Browse Source
- 修改 jsconfig.json,更新模块和目标版本为 ESNext 和 ES2020,增加模块解析和类型检查选项 - 在 knexfile.mjs 中优化 SQLite 性能设置,确保连接创建后只调用一次 done() - 更新公共样式,增强响应式设计,改善用户界面体验 - 在 logger.js 中移除错误日志记录,简化日志配置 - 在 main.js 中重构插件注册逻辑,确保中间件异步加载 - 在 BaseController.js 中新增用户登录状态检查和获取用户ID的方法 - 在 JobController.js 中为路由添加认证中间件 - 在 CommonController.js 中提供全局数据,优化首页渲染逻辑 - 在 install.js 中增强中间件功能,提供全局配置数据 - 在 Auth 中间件中优化用户验证逻辑,确保状态管理一致性 - 在 errorHandler 中增强错误响应格式,提升开发环境调试体验 - 更新路由和视图文件,确保数据传递和渲染逻辑一致性pure
48 changed files with 3321 additions and 1637 deletions
Binary file not shown.
@ -1,69 +1,146 @@ |
|||||
.list { |
/* 首页样式 */ |
||||
display: flex; |
|
||||
gap: 15px; |
.hero-section { |
||||
flex-wrap: wrap; |
position: relative; |
||||
|
overflow: hidden; |
||||
&.blog { |
} |
||||
|
|
||||
>* { |
.hero-section::before { |
||||
width: calc(25% - 15px * 3 / 4); |
content: ""; |
||||
} |
position: absolute; |
||||
|
top: 0; |
||||
/* ≥1024px 默认4列;介于768px-1023px 显示3列 */ |
left: 0; |
||||
@media (max-width: 1023px) { |
right: 0; |
||||
>* { |
bottom: 0; |
||||
width: calc(33.3333% - 15px * 2 / 3); |
background: url('/images/hero-bg.svg') no-repeat center center; |
||||
} |
background-size: cover; |
||||
} |
opacity: 0.1; |
||||
|
z-index: 0; |
||||
/* 介于640px-767px 显示2列 */ |
} |
||||
@media (max-width: 767px) { |
|
||||
>* { |
.hero-content { |
||||
width: calc(50% - 15px * 1 / 2); |
position: relative; |
||||
} |
z-index: 1; |
||||
} |
} |
||||
|
|
||||
/* <640px 显示1列,并优化间距与字号 */ |
.feature-card { |
||||
@media (max-width: 639px) { |
transition: all 0.3s ease; |
||||
gap: 12px; |
} |
||||
|
|
||||
>* { |
.feature-card:hover { |
||||
width: 100%; |
transform: translateY(-5px); |
||||
} |
} |
||||
|
|
||||
.article-card { |
.feature-card .material-symbols-light--article, |
||||
padding: 14px; |
.feature-card .material-symbols-light--bookmark, |
||||
} |
.feature-card .material-symbols-light--person { |
||||
|
transition: all 0.3s ease; |
||||
.article-title { |
} |
||||
font-size: 16px; |
|
||||
} |
.feature-card:hover .material-symbols-light--article, |
||||
|
.feature-card:hover .material-symbols-light--bookmark, |
||||
.article-meta { |
.feature-card:hover .material-symbols-light--person { |
||||
font-size: 12px; |
transform: scale(1.1); |
||||
} |
} |
||||
|
|
||||
.article-desc { |
.stats-section { |
||||
font-size: 14px; |
position: relative; |
||||
} |
} |
||||
} |
|
||||
} |
.stats-section::before { |
||||
} |
content: ""; |
||||
|
position: absolute; |
||||
.list a:hover { |
top: 0; |
||||
text-decoration: underline; |
left: 0; |
||||
} |
right: 0; |
||||
|
bottom: 0; |
||||
.material-symbols-light--info-rounded { |
background: url('/images/stats-bg.svg') no-repeat center center; |
||||
display: inline-block; |
background-size: cover; |
||||
width: 24px; |
opacity: 0.05; |
||||
height: 24px; |
z-index: 0; |
||||
--svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M12 16.5q.214 0 .357-.144T12.5 16v-4.5q0-.213-.144-.356T11.999 11t-.356.144t-.143.356V16q0 .213.144.356t.357.144M12 9.577q.262 0 .439-.177t.176-.438t-.177-.439T12 8.346t-.438.177t-.177.439t.177.438t.438.177M12.003 21q-1.867 0-3.51-.708q-1.643-.709-2.859-1.924t-1.925-2.856T3 12.003t.709-3.51Q4.417 6.85 5.63 5.634t2.857-1.925T11.997 3t3.51.709q1.643.708 2.859 1.922t1.925 2.857t.709 3.509t-.708 3.51t-1.924 2.859t-2.856 1.925t-3.509.709'/%3E%3C/svg%3E"); |
} |
||||
background-color: currentColor; |
|
||||
-webkit-mask-image: var(--svg); |
.stat-item { |
||||
mask-image: var(--svg); |
transition: all 0.3s ease; |
||||
-webkit-mask-repeat: no-repeat; |
} |
||||
mask-repeat: no-repeat; |
|
||||
-webkit-mask-size: 100% 100%; |
.stat-item:hover { |
||||
mask-size: 100% 100%; |
transform: scale(1.05); |
||||
|
} |
||||
|
|
||||
|
.user-dashboard { |
||||
|
position: relative; |
||||
|
} |
||||
|
|
||||
|
.user-dashboard::before { |
||||
|
content: ""; |
||||
|
position: absolute; |
||||
|
top: 0; |
||||
|
left: 0; |
||||
|
right: 0; |
||||
|
bottom: 0; |
||||
|
background: url('/images/dashboard-bg.svg') no-repeat center center; |
||||
|
background-size: cover; |
||||
|
opacity: 0.03; |
||||
|
z-index: 0; |
||||
|
} |
||||
|
|
||||
|
.avatar { |
||||
|
transition: all 0.3s ease; |
||||
|
} |
||||
|
|
||||
|
.avatar:hover { |
||||
|
transform: scale(1.05); |
||||
|
} |
||||
|
|
||||
|
/* 响应式设计 */ |
||||
|
@media (max-width: 768px) { |
||||
|
.hero-section { |
||||
|
padding: 4rem 0; |
||||
|
} |
||||
|
|
||||
|
.hero-content h1 { |
||||
|
font-size: 2.5rem; |
||||
|
} |
||||
|
|
||||
|
.features-grid { |
||||
|
grid-template-columns: 1fr; |
||||
|
} |
||||
|
|
||||
|
.stats-grid { |
||||
|
grid-template-columns: 1fr 1fr; |
||||
|
} |
||||
|
|
||||
|
.user-info { |
||||
|
text-align: center; |
||||
|
margin-bottom: 1.5rem; |
||||
|
} |
||||
|
|
||||
|
.user-actions { |
||||
|
justify-content: center; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@media (max-width: 480px) { |
||||
|
.hero-content h1 { |
||||
|
font-size: 2rem; |
||||
|
} |
||||
|
|
||||
|
.hero-content p { |
||||
|
font-size: 1rem; |
||||
|
} |
||||
|
|
||||
|
.stats-grid { |
||||
|
grid-template-columns: 1fr; |
||||
|
} |
||||
|
|
||||
|
.hero-actions { |
||||
|
flex-direction: column; |
||||
|
gap: 1rem; |
||||
|
} |
||||
|
|
||||
|
.hero-actions a { |
||||
|
width: 100%; |
||||
|
text-align: center; |
||||
|
} |
||||
} |
} |
||||
|
After Width: | Height: | Size: 139 B |
|
After Width: | Height: | Size: 135 B |
|
After Width: | Height: | Size: 132 B |
@ -1,43 +1,102 @@ |
|||||
import { logger } from "@/logger" |
import { logger } from "@/logger" |
||||
// src/plugins/errorHandler.js
|
import AuthError from "@/utils/error/AuthError" |
||||
// 错误处理中间件插件
|
import BaseError from "@/utils/error/BaseError.js" |
||||
|
import CommonError from "@/utils/error/CommonError.js" |
||||
|
|
||||
|
/** |
||||
|
* 格式化错误响应 |
||||
|
* @param {Object} ctx - Koa上下文 |
||||
|
* @param {number} status - HTTP状态码 |
||||
|
* @param {string} message - 错误消息 |
||||
|
* @param {string} stack - 错误堆栈(仅开发环境) |
||||
|
* @returns {Promise<void>} |
||||
|
*/ |
||||
async function formatError(ctx, status, message, stack) { |
async function formatError(ctx, status, message, stack) { |
||||
const accept = ctx.accepts("json", "html", "text") |
const accept = ctx.accepts("json", "html", "text") |
||||
const isDev = process.env.NODE_ENV === "development" |
const isDev = process.env.NODE_ENV === "development" |
||||
|
|
||||
|
// 确保状态码在合理范围内
|
||||
|
status = status >= 100 && status < 600 ? status : 500 |
||||
|
|
||||
if (accept === "json") { |
if (accept === "json") { |
||||
ctx.type = "application/json" |
ctx.type = "application/json" |
||||
ctx.body = isDev && stack ? { success: false, error: message, stack } : { success: false, error: message } |
ctx.body = isDev && stack ? |
||||
|
{ success: false, error: message, stack, status } : |
||||
|
{ success: false, error: message, status } |
||||
} else if (accept === "html") { |
} else if (accept === "html") { |
||||
ctx.type = "html" |
ctx.type = "html" |
||||
await ctx.render("error/index", { status, message, stack, isDev }) |
await ctx.render("error/index", { status, message, stack, isDev }) |
||||
} else { |
} else { |
||||
ctx.type = "text" |
ctx.type = "text" |
||||
ctx.body = isDev && stack ? `${status} - ${message}\n${stack}` : `${status} - ${message}` |
ctx.body = isDev && stack ? |
||||
|
`${status} - ${message}\n${stack}` : |
||||
|
`${status} - ${message}` |
||||
} |
} |
||||
ctx.status = status |
ctx.status = status |
||||
} |
} |
||||
|
|
||||
export default function errorHandler() { |
/** |
||||
|
* 错误处理中间件 |
||||
|
* @returns {Function} Koa中间件函数 |
||||
|
*/ |
||||
|
export default function () { |
||||
return async (ctx, next) => { |
return async (ctx, next) => { |
||||
// 拦截 Chrome DevTools 探测请求,直接返回 204
|
|
||||
if (ctx.path === "/.well-known/appspecific/com.chrome.devtools.json") { |
|
||||
ctx.status = 204 |
|
||||
ctx.body = "" |
|
||||
return |
|
||||
} |
|
||||
try { |
try { |
||||
await next() |
await next() |
||||
if (ctx.status === 404) { |
// 处理404情况 - 只有在没有设置body且状态码为404时才处理
|
||||
|
if (ctx.status === 404 && !ctx.body) { |
||||
await formatError(ctx, 404, "Resource not found") |
await formatError(ctx, 404, "Resource not found") |
||||
} |
} |
||||
} catch (err) { |
} catch (err) { |
||||
logger.error(err) |
if (err instanceof AuthError) { |
||||
|
ctx.redirect('/no-auth?from=' + err.ctx.url) |
||||
|
return |
||||
|
} |
||||
|
// 记录错误日志,包含更多上下文信息
|
||||
|
logger.error({ |
||||
|
message: "Unhandled error occurred", |
||||
|
error: err.message, |
||||
|
stack: err.stack, |
||||
|
url: ctx.url, |
||||
|
method: ctx.method, |
||||
|
ip: ctx.ip, |
||||
|
userAgent: ctx.headers['user-agent'] |
||||
|
}) |
||||
|
|
||||
const isDev = process.env.NODE_ENV === "development" |
const isDev = process.env.NODE_ENV === "development" |
||||
if (isDev && err.stack) { |
|
||||
console.error(err.stack) |
// 开发环境下在控制台输出错误堆栈
|
||||
|
// if (isDev && err.stack) {
|
||||
|
// console.error("\x1b[31m%s\x1b[0m", err.stack)
|
||||
|
// }
|
||||
|
|
||||
|
// 根据错误类型设置适当的状态码和消息
|
||||
|
let status = 500 |
||||
|
let message = "Internal server error" |
||||
|
|
||||
|
// 处理自定义错误类型
|
||||
|
if (err instanceof BaseError) { |
||||
|
status = err.statusCode || 500 |
||||
|
message = err.message || message |
||||
|
} else if (err.status) { |
||||
|
// 处理Koa内置错误对象
|
||||
|
status = err.status |
||||
|
message = err.message || message |
||||
|
} else if (err.statusCode) { |
||||
|
// 处理其他带有状态码的错误对象
|
||||
|
status = err.statusCode |
||||
|
message = err.message || message |
||||
} |
} |
||||
await formatError(ctx, err.statusCode || 500, err.message || err || "Internal server error", isDev ? err.stack : undefined) |
|
||||
|
// 确保状态码在合理范围内
|
||||
|
status = status >= 100 && status < 600 ? status : 500 |
||||
|
|
||||
|
await formatError( |
||||
|
ctx, |
||||
|
status, |
||||
|
message, |
||||
|
isDev ? err.stack : undefined |
||||
|
) |
||||
} |
} |
||||
} |
} |
||||
} |
} |
||||
@ -0,0 +1,563 @@ |
|||||
|
import ArticleModel from "../db/models/ArticleModel.js" |
||||
|
import { logger } from "../logger.js" |
||||
|
|
||||
|
/** |
||||
|
* 文章服务类 |
||||
|
* 提供文章相关的业务逻辑 |
||||
|
*/ |
||||
|
class ArticleService { |
||||
|
/** |
||||
|
* 创建新文章 |
||||
|
* @param {Object} articleData - 文章数据 |
||||
|
* @returns {Promise<Object>} 创建的文章信息 |
||||
|
*/ |
||||
|
static async createArticle(articleData) { |
||||
|
try { |
||||
|
// 数据验证
|
||||
|
this.validateArticleData(articleData) |
||||
|
|
||||
|
// 创建文章
|
||||
|
const article = await ArticleModel.create(articleData) |
||||
|
|
||||
|
logger.info(`文章创建成功: ${article.title} (ID: ${article.id})`) |
||||
|
return this.formatArticleResponse(article) |
||||
|
} catch (error) { |
||||
|
logger.error(`创建文章失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 根据ID获取文章 |
||||
|
* @param {number} id - 文章ID |
||||
|
* @param {boolean} incrementView - 是否增加浏览量 |
||||
|
* @returns {Promise<Object|null>} 文章信息 |
||||
|
*/ |
||||
|
static async getArticleById(id, incrementView = false) { |
||||
|
try { |
||||
|
const article = await ArticleModel.findById(id) |
||||
|
if (!article) { |
||||
|
return null |
||||
|
} |
||||
|
|
||||
|
// 如果需要增加浏览量
|
||||
|
if (incrementView) { |
||||
|
await ArticleModel.incrementViewCount(id) |
||||
|
article.view_count = (article.view_count || 0) + 1 |
||||
|
} |
||||
|
|
||||
|
return this.formatArticleResponse(article) |
||||
|
} catch (error) { |
||||
|
logger.error(`获取文章失败 (ID: ${id}):`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 根据slug获取文章 |
||||
|
* @param {string} slug - 文章slug |
||||
|
* @param {boolean} incrementView - 是否增加浏览量 |
||||
|
* @returns {Promise<Object|null>} 文章信息 |
||||
|
*/ |
||||
|
static async getArticleBySlug(slug, incrementView = false) { |
||||
|
try { |
||||
|
const article = await ArticleModel.findBySlug(slug) |
||||
|
if (!article) { |
||||
|
return null |
||||
|
} |
||||
|
|
||||
|
// 如果需要增加浏览量
|
||||
|
if (incrementView) { |
||||
|
await ArticleModel.incrementViewCount(article.id) |
||||
|
article.view_count = (article.view_count || 0) + 1 |
||||
|
} |
||||
|
|
||||
|
return this.formatArticleResponse(article) |
||||
|
} catch (error) { |
||||
|
logger.error(`根据slug获取文章失败 (${slug}):`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 更新文章 |
||||
|
* @param {number} id - 文章ID |
||||
|
* @param {Object} updateData - 更新数据 |
||||
|
* @returns {Promise<Object>} 更新后的文章信息 |
||||
|
*/ |
||||
|
static async updateArticle(id, updateData) { |
||||
|
try { |
||||
|
// 验证文章是否存在
|
||||
|
const existingArticle = await ArticleModel.findById(id) |
||||
|
if (!existingArticle) { |
||||
|
throw new Error("文章不存在") |
||||
|
} |
||||
|
|
||||
|
// 数据验证
|
||||
|
this.validateArticleUpdateData(updateData) |
||||
|
|
||||
|
// 更新文章
|
||||
|
const article = await ArticleModel.update(id, updateData) |
||||
|
|
||||
|
logger.info(`文章更新成功: ${article.title} (ID: ${id})`) |
||||
|
return this.formatArticleResponse(article) |
||||
|
} catch (error) { |
||||
|
logger.error(`更新文章失败 (ID: ${id}):`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 删除文章 |
||||
|
* @param {number} id - 文章ID |
||||
|
* @returns {Promise<boolean>} 删除结果 |
||||
|
*/ |
||||
|
static async deleteArticle(id) { |
||||
|
try { |
||||
|
const article = await ArticleModel.findById(id) |
||||
|
if (!article) { |
||||
|
throw new Error("文章不存在") |
||||
|
} |
||||
|
|
||||
|
const result = await ArticleModel.delete(id) |
||||
|
|
||||
|
logger.info(`文章删除成功: ${article.title} (ID: ${id})`) |
||||
|
return result > 0 |
||||
|
} catch (error) { |
||||
|
logger.error(`删除文章失败 (ID: ${id}):`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 发布文章 |
||||
|
* @param {number} id - 文章ID |
||||
|
* @returns {Promise<Object>} 发布后的文章信息 |
||||
|
*/ |
||||
|
static async publishArticle(id) { |
||||
|
try { |
||||
|
const article = await ArticleModel.publish(id) |
||||
|
logger.info(`文章发布成功: ${article.title} (ID: ${id})`) |
||||
|
return this.formatArticleResponse(article) |
||||
|
} catch (error) { |
||||
|
logger.error(`发布文章失败 (ID: ${id}):`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 取消发布文章 |
||||
|
* @param {number} id - 文章ID |
||||
|
* @returns {Promise<Object>} 取消发布后的文章信息 |
||||
|
*/ |
||||
|
static async unpublishArticle(id) { |
||||
|
try { |
||||
|
const article = await ArticleModel.unpublish(id) |
||||
|
logger.info(`文章取消发布成功: ${article.title} (ID: ${id})`) |
||||
|
return this.formatArticleResponse(article) |
||||
|
} catch (error) { |
||||
|
logger.error(`取消发布文章失败 (ID: ${id}):`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取文章列表 |
||||
|
* @param {Object} options - 查询选项 |
||||
|
* @returns {Promise<Object>} 文章列表和分页信息 |
||||
|
*/ |
||||
|
static async getArticleList(options = {}) { |
||||
|
try { |
||||
|
const { |
||||
|
page = 1, |
||||
|
limit = 10, |
||||
|
search = "", |
||||
|
category = null, |
||||
|
status = null, |
||||
|
author = null, |
||||
|
orderBy = "created_at", |
||||
|
order = "desc" |
||||
|
} = options |
||||
|
|
||||
|
const where = {} |
||||
|
if (category) where.category = category |
||||
|
if (status) where.status = status |
||||
|
if (author) where.author = author |
||||
|
|
||||
|
const result = await ArticleModel.paginate({ |
||||
|
page, |
||||
|
limit, |
||||
|
where, |
||||
|
search, |
||||
|
searchFields: ArticleModel.searchableFields, |
||||
|
orderBy, |
||||
|
order |
||||
|
}) |
||||
|
|
||||
|
return { |
||||
|
articles: result.data.map(article => this.formatArticleResponse(article)), |
||||
|
pagination: result.pagination |
||||
|
} |
||||
|
} catch (error) { |
||||
|
logger.error(`获取文章列表失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取已发布文章列表 |
||||
|
* @param {Object} options - 查询选项 |
||||
|
* @returns {Promise<Object>} 文章列表和分页信息 |
||||
|
*/ |
||||
|
static async getPublishedArticles(options = {}) { |
||||
|
try { |
||||
|
const { |
||||
|
page = 1, |
||||
|
limit = 10, |
||||
|
search = "", |
||||
|
category = null, |
||||
|
author = null, |
||||
|
orderBy = "published_at", |
||||
|
order = "desc" |
||||
|
} = options |
||||
|
|
||||
|
const where = { status: "published" } |
||||
|
if (category) where.category = category |
||||
|
if (author) where.author = author |
||||
|
|
||||
|
const result = await ArticleModel.paginate({ |
||||
|
page, |
||||
|
limit, |
||||
|
where, |
||||
|
search, |
||||
|
searchFields: ArticleModel.searchableFields, |
||||
|
orderBy, |
||||
|
order |
||||
|
}) |
||||
|
|
||||
|
return { |
||||
|
articles: result.data.map(article => this.formatArticleResponse(article)), |
||||
|
pagination: result.pagination |
||||
|
} |
||||
|
} catch (error) { |
||||
|
logger.error(`获取已发布文章列表失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取作者文章列表 |
||||
|
* @param {string} author - 作者用户名 |
||||
|
* @param {Object} options - 查询选项 |
||||
|
* @returns {Promise<Object>} 文章列表和分页信息 |
||||
|
*/ |
||||
|
static async getAuthorArticles(author, options = {}) { |
||||
|
try { |
||||
|
const { |
||||
|
page = 1, |
||||
|
limit = 10, |
||||
|
status = null, |
||||
|
search = "", |
||||
|
orderBy = "updated_at", |
||||
|
order = "desc" |
||||
|
} = options |
||||
|
|
||||
|
const where = { author } |
||||
|
if (status) where.status = status |
||||
|
|
||||
|
const result = await ArticleModel.paginate({ |
||||
|
page, |
||||
|
limit, |
||||
|
where, |
||||
|
search, |
||||
|
searchFields: ArticleModel.searchableFields, |
||||
|
orderBy, |
||||
|
order |
||||
|
}) |
||||
|
|
||||
|
return { |
||||
|
articles: result.data.map(article => this.formatArticleResponse(article)), |
||||
|
pagination: result.pagination |
||||
|
} |
||||
|
} catch (error) { |
||||
|
logger.error(`获取作者文章列表失败 (${author}):`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 根据分类获取文章 |
||||
|
* @param {string} category - 分类 |
||||
|
* @param {Object} options - 查询选项 |
||||
|
* @returns {Promise<Array>} 文章列表 |
||||
|
*/ |
||||
|
static async getArticlesByCategory(category, options = {}) { |
||||
|
try { |
||||
|
const { limit = 20 } = options |
||||
|
const articles = await ArticleModel.findByCategoryWithAuthor(category, limit) |
||||
|
return articles.map(article => this.formatArticleResponse(article)) |
||||
|
} catch (error) { |
||||
|
logger.error(`根据分类获取文章失败 (${category}):`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 根据标签获取文章 |
||||
|
* @param {string} tags - 标签(逗号分隔) |
||||
|
* @param {Object} options - 查询选项 |
||||
|
* @returns {Promise<Array>} 文章列表 |
||||
|
*/ |
||||
|
static async getArticlesByTags(tags, options = {}) { |
||||
|
try { |
||||
|
const articles = await ArticleModel.findByTags(tags) |
||||
|
return articles.map(article => this.formatArticleResponse(article)) |
||||
|
} catch (error) { |
||||
|
logger.error(`根据标签获取文章失败 (${tags}):`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 搜索文章 |
||||
|
* @param {string} keyword - 搜索关键词 |
||||
|
* @param {Object} options - 搜索选项 |
||||
|
* @returns {Promise<Array>} 搜索结果 |
||||
|
*/ |
||||
|
static async searchArticles(keyword, options = {}) { |
||||
|
try { |
||||
|
const { limit = 20 } = options |
||||
|
const articles = await ArticleModel.searchWithAuthor(keyword, limit) |
||||
|
return articles.map(article => this.formatArticleResponse(article)) |
||||
|
} catch (error) { |
||||
|
logger.error(`搜索文章失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取最新文章 |
||||
|
* @param {number} limit - 数量限制 |
||||
|
* @returns {Promise<Array>} 最新文章列表 |
||||
|
*/ |
||||
|
static async getRecentArticles(limit = 10) { |
||||
|
try { |
||||
|
const articles = await ArticleModel.getRecentArticlesWithAuthor(limit) |
||||
|
return articles.map(article => this.formatArticleResponse(article)) |
||||
|
} catch (error) { |
||||
|
logger.error(`获取最新文章失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取热门文章 |
||||
|
* @param {number} limit - 数量限制 |
||||
|
* @returns {Promise<Array>} 热门文章列表 |
||||
|
*/ |
||||
|
static async getPopularArticles(limit = 10) { |
||||
|
try { |
||||
|
const articles = await ArticleModel.getPopularArticlesWithAuthor(limit) |
||||
|
return articles.map(article => this.formatArticleResponse(article)) |
||||
|
} catch (error) { |
||||
|
logger.error(`获取热门文章失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取精选文章 |
||||
|
* @param {number} limit - 数量限制 |
||||
|
* @returns {Promise<Array>} 精选文章列表 |
||||
|
*/ |
||||
|
static async getFeaturedArticles(limit = 5) { |
||||
|
try { |
||||
|
const articles = await ArticleModel.getFeaturedArticlesWithAuthor(limit) |
||||
|
return articles.map(article => this.formatArticleResponse(article)) |
||||
|
} catch (error) { |
||||
|
logger.error(`获取精选文章失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取相关文章 |
||||
|
* @param {number} articleId - 文章ID |
||||
|
* @param {number} limit - 数量限制 |
||||
|
* @returns {Promise<Array>} 相关文章列表 |
||||
|
*/ |
||||
|
static async getRelatedArticles(articleId, limit = 5) { |
||||
|
try { |
||||
|
const articles = await ArticleModel.getRelatedArticles(articleId, limit) |
||||
|
return articles.map(article => this.formatArticleResponse(article)) |
||||
|
} catch (error) { |
||||
|
logger.error(`获取相关文章失败 (ID: ${articleId}):`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取文章统计信息 |
||||
|
* @returns {Promise<Object>} 统计信息 |
||||
|
*/ |
||||
|
static async getArticleStats() { |
||||
|
try { |
||||
|
const [ |
||||
|
total, |
||||
|
published, |
||||
|
drafts, |
||||
|
byCategory, |
||||
|
byStatus |
||||
|
] = await Promise.all([ |
||||
|
ArticleModel.getArticleCount(), |
||||
|
ArticleModel.getPublishedArticleCount(), |
||||
|
ArticleModel.count({ status: "draft" }), |
||||
|
ArticleModel.getArticleCountByCategory(), |
||||
|
ArticleModel.getArticleCountByStatus() |
||||
|
]) |
||||
|
|
||||
|
return { |
||||
|
total, |
||||
|
published, |
||||
|
drafts, |
||||
|
byCategory, |
||||
|
byStatus |
||||
|
} |
||||
|
} catch (error) { |
||||
|
logger.error(`获取文章统计失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 验证文章数据 |
||||
|
* @param {Object} articleData - 文章数据 |
||||
|
*/ |
||||
|
static validateArticleData(articleData) { |
||||
|
if (!articleData.title) { |
||||
|
throw new Error("文章标题不能为空") |
||||
|
} |
||||
|
if (!articleData.content) { |
||||
|
throw new Error("文章内容不能为空") |
||||
|
} |
||||
|
if (!articleData.author) { |
||||
|
throw new Error("文章作者不能为空") |
||||
|
} |
||||
|
|
||||
|
// 标题长度验证
|
||||
|
if (articleData.title.length > 200) { |
||||
|
throw new Error("文章标题不能超过200个字符") |
||||
|
} |
||||
|
|
||||
|
// 内容长度验证
|
||||
|
if (articleData.content.length < 10) { |
||||
|
throw new Error("文章内容不能少于10个字符") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 验证文章更新数据 |
||||
|
* @param {Object} updateData - 更新数据 |
||||
|
*/ |
||||
|
static validateArticleUpdateData(updateData) { |
||||
|
if (updateData.title && updateData.title.length > 200) { |
||||
|
throw new Error("文章标题不能超过200个字符") |
||||
|
} |
||||
|
|
||||
|
if (updateData.content && updateData.content.length < 10) { |
||||
|
throw new Error("文章内容不能少于10个字符") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 格式化文章响应数据 |
||||
|
* @param {Object} article - 文章数据 |
||||
|
* @returns {Object} 格式化后的文章数据 |
||||
|
*/ |
||||
|
static formatArticleResponse(article) { |
||||
|
return { |
||||
|
...article, |
||||
|
// 确保数字字段为数字类型
|
||||
|
id: parseInt(article.id), |
||||
|
view_count: parseInt(article.view_count) || 0, |
||||
|
reading_time: parseInt(article.reading_time) || 0, |
||||
|
// 格式化日期字段
|
||||
|
created_at: article.created_at, |
||||
|
updated_at: article.updated_at, |
||||
|
published_at: article.published_at |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 批量更新文章状态 |
||||
|
* @param {Array} ids - 文章ID数组 |
||||
|
* @param {string} status - 新状态 |
||||
|
* @returns {Promise<number>} 更新数量 |
||||
|
*/ |
||||
|
static async batchUpdateStatus(ids, status) { |
||||
|
try { |
||||
|
if (!Array.isArray(ids) || ids.length === 0) { |
||||
|
throw new Error("文章ID数组不能为空") |
||||
|
} |
||||
|
|
||||
|
if (!["draft", "published", "archived"].includes(status)) { |
||||
|
throw new Error("无效的文章状态") |
||||
|
} |
||||
|
|
||||
|
const result = await ArticleModel.updateMany( |
||||
|
{ id: ids }, |
||||
|
{ status } |
||||
|
) |
||||
|
|
||||
|
logger.info(`批量更新文章状态成功: ${ids.length} 篇文章状态更新为 ${status}`) |
||||
|
return result |
||||
|
} catch (error) { |
||||
|
logger.error(`批量更新文章状态失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取文章分类统计 |
||||
|
* @returns {Promise<Array>} 分类统计 |
||||
|
*/ |
||||
|
static async getCategoryStats() { |
||||
|
try { |
||||
|
return await ArticleModel.getArticleCountByCategory() |
||||
|
} catch (error) { |
||||
|
logger.error(`获取文章分类统计失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取文章标签列表 |
||||
|
* @returns {Promise<Array>} 标签列表 |
||||
|
*/ |
||||
|
static async getTagList() { |
||||
|
try { |
||||
|
const articles = await ArticleModel.findWhere( |
||||
|
{ status: "published" }, |
||||
|
{ select: ["tags"] } |
||||
|
) |
||||
|
|
||||
|
const tagSet = new Set() |
||||
|
articles.forEach(article => { |
||||
|
if (article.tags) { |
||||
|
const tags = article.tags.split(",").map(tag => tag.trim()) |
||||
|
tags.forEach(tag => { |
||||
|
if (tag) tagSet.add(tag) |
||||
|
}) |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
return Array.from(tagSet).sort() |
||||
|
} catch (error) { |
||||
|
logger.error(`获取文章标签列表失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export default ArticleService |
||||
|
export { ArticleService } |
||||
@ -0,0 +1,492 @@ |
|||||
|
import BookmarkModel from "../db/models/BookmarkModel.js" |
||||
|
import { logger } from "../logger.js" |
||||
|
|
||||
|
/** |
||||
|
* 书签服务类 |
||||
|
* 提供书签相关的业务逻辑 |
||||
|
*/ |
||||
|
class BookmarkService { |
||||
|
/** |
||||
|
* 创建新书签 |
||||
|
* @param {Object} bookmarkData - 书签数据 |
||||
|
* @returns {Promise<Object>} 创建的书签信息 |
||||
|
*/ |
||||
|
static async createBookmark(bookmarkData) { |
||||
|
try { |
||||
|
// 数据验证
|
||||
|
this.validateBookmarkData(bookmarkData) |
||||
|
|
||||
|
// 创建书签
|
||||
|
const bookmark = await BookmarkModel.create(bookmarkData) |
||||
|
|
||||
|
logger.info(`书签创建成功: ${bookmark.title} (ID: ${bookmark.id})`) |
||||
|
return this.formatBookmarkResponse(bookmark) |
||||
|
} catch (error) { |
||||
|
logger.error(`创建书签失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 根据ID获取书签 |
||||
|
* @param {number} id - 书签ID |
||||
|
* @returns {Promise<Object|null>} 书签信息 |
||||
|
*/ |
||||
|
static async getBookmarkById(id) { |
||||
|
try { |
||||
|
const bookmark = await BookmarkModel.findById(id) |
||||
|
return bookmark ? this.formatBookmarkResponse(bookmark) : null |
||||
|
} catch (error) { |
||||
|
logger.error(`获取书签失败 (ID: ${id}):`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 更新书签 |
||||
|
* @param {number} id - 书签ID |
||||
|
* @param {Object} updateData - 更新数据 |
||||
|
* @returns {Promise<Object>} 更新后的书签信息 |
||||
|
*/ |
||||
|
static async updateBookmark(id, updateData) { |
||||
|
try { |
||||
|
// 验证书签是否存在
|
||||
|
const existingBookmark = await BookmarkModel.findById(id) |
||||
|
if (!existingBookmark) { |
||||
|
throw new Error("书签不存在") |
||||
|
} |
||||
|
|
||||
|
// 数据验证
|
||||
|
this.validateBookmarkUpdateData(updateData) |
||||
|
|
||||
|
// 更新书签
|
||||
|
const bookmark = await BookmarkModel.update(id, updateData) |
||||
|
|
||||
|
logger.info(`书签更新成功: ${bookmark.title} (ID: ${id})`) |
||||
|
return this.formatBookmarkResponse(bookmark) |
||||
|
} catch (error) { |
||||
|
logger.error(`更新书签失败 (ID: ${id}):`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 删除书签 |
||||
|
* @param {number} id - 书签ID |
||||
|
* @returns {Promise<boolean>} 删除结果 |
||||
|
*/ |
||||
|
static async deleteBookmark(id) { |
||||
|
try { |
||||
|
const bookmark = await BookmarkModel.findById(id) |
||||
|
if (!bookmark) { |
||||
|
throw new Error("书签不存在") |
||||
|
} |
||||
|
|
||||
|
const result = await BookmarkModel.delete(id) |
||||
|
|
||||
|
logger.info(`书签删除成功: ${bookmark.title} (ID: ${id})`) |
||||
|
return result > 0 |
||||
|
} catch (error) { |
||||
|
logger.error(`删除书签失败 (ID: ${id}):`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取用户书签列表 |
||||
|
* @param {number} userId - 用户ID |
||||
|
* @param {Object} options - 查询选项 |
||||
|
* @returns {Promise<Object>} 书签列表和分页信息 |
||||
|
*/ |
||||
|
static async getUserBookmarks(userId, options = {}) { |
||||
|
try { |
||||
|
const { |
||||
|
page = 1, |
||||
|
limit = 20, |
||||
|
search = "", |
||||
|
orderBy = "created_at", |
||||
|
order = "desc" |
||||
|
} = options |
||||
|
|
||||
|
const result = await BookmarkModel.findByUserWithPagination(userId, { |
||||
|
page, |
||||
|
limit, |
||||
|
search, |
||||
|
searchFields: BookmarkModel.searchableFields, |
||||
|
orderBy, |
||||
|
order |
||||
|
}) |
||||
|
|
||||
|
return { |
||||
|
bookmarks: result.data.map(bookmark => this.formatBookmarkResponse(bookmark)), |
||||
|
pagination: result.pagination |
||||
|
} |
||||
|
} catch (error) { |
||||
|
logger.error(`获取用户书签列表失败 (用户ID: ${userId}):`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取所有书签列表(管理员) |
||||
|
* @param {Object} options - 查询选项 |
||||
|
* @returns {Promise<Object>} 书签列表和分页信息 |
||||
|
*/ |
||||
|
static async getAllBookmarks(options = {}) { |
||||
|
try { |
||||
|
const { |
||||
|
page = 1, |
||||
|
limit = 20, |
||||
|
search = "", |
||||
|
userId = null, |
||||
|
orderBy = "created_at", |
||||
|
order = "desc" |
||||
|
} = options |
||||
|
|
||||
|
const where = {} |
||||
|
if (userId) where.user_id = userId |
||||
|
|
||||
|
const result = await BookmarkModel.paginate({ |
||||
|
page, |
||||
|
limit, |
||||
|
where, |
||||
|
search, |
||||
|
searchFields: BookmarkModel.searchableFields, |
||||
|
orderBy, |
||||
|
order |
||||
|
}) |
||||
|
|
||||
|
return { |
||||
|
bookmarks: result.data.map(bookmark => this.formatBookmarkResponse(bookmark)), |
||||
|
pagination: result.pagination |
||||
|
} |
||||
|
} catch (error) { |
||||
|
logger.error(`获取所有书签列表失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取书签及其用户信息 |
||||
|
* @param {Object} options - 查询选项 |
||||
|
* @returns {Promise<Array>} 书签列表 |
||||
|
*/ |
||||
|
static async getBookmarksWithUsers(options = {}) { |
||||
|
try { |
||||
|
const { limit = 50, orderBy = "created_at", order = "desc" } = options |
||||
|
const bookmarks = await BookmarkModel.findAllWithUsers({ limit, orderBy, order }) |
||||
|
return bookmarks.map(bookmark => this.formatBookmarkResponse(bookmark)) |
||||
|
} catch (error) { |
||||
|
logger.error(`获取书签及用户信息失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取热门书签 |
||||
|
* @param {number} limit - 数量限制 |
||||
|
* @returns {Promise<Array>} 热门书签列表 |
||||
|
*/ |
||||
|
static async getPopularBookmarks(limit = 10) { |
||||
|
try { |
||||
|
const bookmarks = await BookmarkModel.getPopularBookmarks(limit) |
||||
|
return bookmarks.map(bookmark => ({ |
||||
|
url: bookmark.url, |
||||
|
title: bookmark.title, |
||||
|
bookmark_count: parseInt(bookmark.bookmark_count), |
||||
|
latest_bookmark: bookmark.latest_bookmark |
||||
|
})) |
||||
|
} catch (error) { |
||||
|
logger.error(`获取热门书签失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 搜索书签 |
||||
|
* @param {string} keyword - 搜索关键词 |
||||
|
* @param {Object} options - 搜索选项 |
||||
|
* @returns {Promise<Array>} 搜索结果 |
||||
|
*/ |
||||
|
static async searchBookmarks(keyword, options = {}) { |
||||
|
try { |
||||
|
const { |
||||
|
userId = null, |
||||
|
limit = 20, |
||||
|
orderBy = "created_at", |
||||
|
order = "desc" |
||||
|
} = options |
||||
|
|
||||
|
const where = {} |
||||
|
if (userId) where.user_id = userId |
||||
|
|
||||
|
const bookmarks = await BookmarkModel.findWhere(where, { |
||||
|
search: keyword, |
||||
|
searchFields: BookmarkModel.searchableFields, |
||||
|
limit, |
||||
|
orderBy, |
||||
|
order |
||||
|
}) |
||||
|
|
||||
|
return bookmarks.map(bookmark => this.formatBookmarkResponse(bookmark)) |
||||
|
} catch (error) { |
||||
|
logger.error(`搜索书签失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 检查书签是否存在 |
||||
|
* @param {number} userId - 用户ID |
||||
|
* @param {string} url - URL |
||||
|
* @returns {Promise<boolean>} 是否存在 |
||||
|
*/ |
||||
|
static async checkBookmarkExists(userId, url) { |
||||
|
try { |
||||
|
const bookmark = await BookmarkModel.findByUserAndUrl(userId, url) |
||||
|
return !!bookmark |
||||
|
} catch (error) { |
||||
|
logger.error(`检查书签是否存在失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取用户书签统计 |
||||
|
* @param {number} userId - 用户ID |
||||
|
* @returns {Promise<Object>} 统计信息 |
||||
|
*/ |
||||
|
static async getUserBookmarkStats(userId) { |
||||
|
try { |
||||
|
return await BookmarkModel.getUserBookmarkStats(userId) |
||||
|
} catch (error) { |
||||
|
logger.error(`获取用户书签统计失败 (用户ID: ${userId}):`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 批量删除书签 |
||||
|
* @param {Array} ids - 书签ID数组 |
||||
|
* @param {number} userId - 用户ID(可选,用于权限验证) |
||||
|
* @returns {Promise<number>} 删除数量 |
||||
|
*/ |
||||
|
static async batchDeleteBookmarks(ids, userId = null) { |
||||
|
try { |
||||
|
if (!Array.isArray(ids) || ids.length === 0) { |
||||
|
throw new Error("书签ID数组不能为空") |
||||
|
} |
||||
|
|
||||
|
// 如果提供了用户ID,验证书签是否属于该用户
|
||||
|
if (userId) { |
||||
|
const bookmarks = await BookmarkModel.findWhere({ id: ids, user_id: userId }) |
||||
|
if (bookmarks.length !== ids.length) { |
||||
|
throw new Error("部分书签不存在或无权限删除") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const result = await BookmarkModel.deleteWhere({ id: ids }) |
||||
|
|
||||
|
logger.info(`批量删除书签成功: ${result} 个书签`) |
||||
|
return result |
||||
|
} catch (error) { |
||||
|
logger.error(`批量删除书签失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 批量创建书签 |
||||
|
* @param {Array} bookmarksData - 书签数据数组 |
||||
|
* @returns {Promise<Object>} 创建结果 |
||||
|
*/ |
||||
|
static async batchCreateBookmarks(bookmarksData) { |
||||
|
try { |
||||
|
const results = [] |
||||
|
const errors = [] |
||||
|
|
||||
|
for (let i = 0; i < bookmarksData.length; i++) { |
||||
|
try { |
||||
|
const bookmarkData = bookmarksData[i] |
||||
|
this.validateBookmarkData(bookmarkData) |
||||
|
|
||||
|
const bookmark = await BookmarkModel.create(bookmarkData) |
||||
|
results.push(this.formatBookmarkResponse(bookmark)) |
||||
|
} catch (error) { |
||||
|
errors.push({ |
||||
|
index: i, |
||||
|
data: bookmarksData[i], |
||||
|
error: error.message |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
success: results, |
||||
|
errors, |
||||
|
summary: { |
||||
|
total: bookmarksData.length, |
||||
|
success: results.length, |
||||
|
failed: errors.length |
||||
|
} |
||||
|
} |
||||
|
} catch (error) { |
||||
|
logger.error(`批量创建书签失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取书签分类统计 |
||||
|
* @param {number} userId - 用户ID(可选) |
||||
|
* @returns {Promise<Array>} 分类统计 |
||||
|
*/ |
||||
|
static async getCategoryStats(userId = null) { |
||||
|
try { |
||||
|
const where = userId ? { user_id: userId } : {} |
||||
|
const bookmarks = await BookmarkModel.findWhere(where, { select: ["category"] }) |
||||
|
|
||||
|
const categoryStats = {} |
||||
|
bookmarks.forEach(bookmark => { |
||||
|
const category = bookmark.category || "未分类" |
||||
|
categoryStats[category] = (categoryStats[category] || 0) + 1 |
||||
|
}) |
||||
|
|
||||
|
return Object.entries(categoryStats) |
||||
|
.map(([category, count]) => ({ category, count })) |
||||
|
.sort((a, b) => b.count - a.count) |
||||
|
} catch (error) { |
||||
|
logger.error(`获取书签分类统计失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 验证书签数据 |
||||
|
* @param {Object} bookmarkData - 书签数据 |
||||
|
*/ |
||||
|
static validateBookmarkData(bookmarkData) { |
||||
|
if (!bookmarkData.user_id) { |
||||
|
throw new Error("用户ID不能为空") |
||||
|
} |
||||
|
if (!bookmarkData.url) { |
||||
|
throw new Error("URL不能为空") |
||||
|
} |
||||
|
if (!bookmarkData.title) { |
||||
|
throw new Error("书签标题不能为空") |
||||
|
} |
||||
|
|
||||
|
// URL格式验证
|
||||
|
const urlRegex = /^https?:\/\/.+\..+/ |
||||
|
if (!urlRegex.test(bookmarkData.url)) { |
||||
|
throw new Error("URL格式不正确") |
||||
|
} |
||||
|
|
||||
|
// 标题长度验证
|
||||
|
if (bookmarkData.title.length > 200) { |
||||
|
throw new Error("书签标题不能超过200个字符") |
||||
|
} |
||||
|
|
||||
|
// 描述长度验证
|
||||
|
if (bookmarkData.description && bookmarkData.description.length > 500) { |
||||
|
throw new Error("书签描述不能超过500个字符") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 验证书签更新数据 |
||||
|
* @param {Object} updateData - 更新数据 |
||||
|
*/ |
||||
|
static validateBookmarkUpdateData(updateData) { |
||||
|
if (updateData.url) { |
||||
|
const urlRegex = /^https?:\/\/.+\..+/ |
||||
|
if (!urlRegex.test(updateData.url)) { |
||||
|
throw new Error("URL格式不正确") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (updateData.title && updateData.title.length > 200) { |
||||
|
throw new Error("书签标题不能超过200个字符") |
||||
|
} |
||||
|
|
||||
|
if (updateData.description && updateData.description.length > 500) { |
||||
|
throw new Error("书签描述不能超过500个字符") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 格式化书签响应数据 |
||||
|
* @param {Object} bookmark - 书签数据 |
||||
|
* @returns {Object} 格式化后的书签数据 |
||||
|
*/ |
||||
|
static formatBookmarkResponse(bookmark) { |
||||
|
return { |
||||
|
...bookmark, |
||||
|
// 确保数字字段为数字类型
|
||||
|
id: parseInt(bookmark.id), |
||||
|
user_id: parseInt(bookmark.user_id), |
||||
|
// 格式化日期字段
|
||||
|
created_at: bookmark.created_at, |
||||
|
updated_at: bookmark.updated_at |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 导入书签 |
||||
|
* @param {number} userId - 用户ID |
||||
|
* @param {Array} bookmarksData - 书签数据数组 |
||||
|
* @returns {Promise<Object>} 导入结果 |
||||
|
*/ |
||||
|
static async importBookmarks(userId, bookmarksData) { |
||||
|
try { |
||||
|
const results = [] |
||||
|
const errors = [] |
||||
|
const skipped = [] |
||||
|
|
||||
|
for (let i = 0; i < bookmarksData.length; i++) { |
||||
|
try { |
||||
|
const bookmarkData = { ...bookmarksData[i], user_id: userId } |
||||
|
this.validateBookmarkData(bookmarkData) |
||||
|
|
||||
|
// 检查是否已存在
|
||||
|
const exists = await this.checkBookmarkExists(userId, bookmarkData.url) |
||||
|
if (exists) { |
||||
|
skipped.push({ |
||||
|
index: i, |
||||
|
data: bookmarkData, |
||||
|
reason: "书签已存在" |
||||
|
}) |
||||
|
continue |
||||
|
} |
||||
|
|
||||
|
const bookmark = await BookmarkModel.create(bookmarkData) |
||||
|
results.push(this.formatBookmarkResponse(bookmark)) |
||||
|
} catch (error) { |
||||
|
errors.push({ |
||||
|
index: i, |
||||
|
data: bookmarksData[i], |
||||
|
error: error.message |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
success: results, |
||||
|
errors, |
||||
|
skipped, |
||||
|
summary: { |
||||
|
total: bookmarksData.length, |
||||
|
success: results.length, |
||||
|
failed: errors.length, |
||||
|
skipped: skipped.length |
||||
|
} |
||||
|
} |
||||
|
} catch (error) { |
||||
|
logger.error(`导入书签失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export default BookmarkService |
||||
|
export { BookmarkService } |
||||
@ -0,0 +1,511 @@ |
|||||
|
import ContactModel from "../db/models/ContactModel.js" |
||||
|
import { logger } from "../logger.js" |
||||
|
|
||||
|
/** |
||||
|
* 联系信息服务类 |
||||
|
* 提供联系信息相关的业务逻辑 |
||||
|
*/ |
||||
|
class ContactService { |
||||
|
/** |
||||
|
* 创建新联系信息 |
||||
|
* @param {Object} contactData - 联系信息数据 |
||||
|
* @returns {Promise<Object>} 创建的联系信息 |
||||
|
*/ |
||||
|
static async createContact(contactData) { |
||||
|
try { |
||||
|
// 数据验证
|
||||
|
this.validateContactData(contactData) |
||||
|
|
||||
|
// 创建联系信息
|
||||
|
const contact = await ContactModel.create(contactData) |
||||
|
|
||||
|
logger.info(`联系信息创建成功: ${contact.name} (ID: ${contact.id})`) |
||||
|
return this.formatContactResponse(contact) |
||||
|
} catch (error) { |
||||
|
logger.error(`创建联系信息失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 根据ID获取联系信息 |
||||
|
* @param {number} id - 联系信息ID |
||||
|
* @returns {Promise<Object|null>} 联系信息 |
||||
|
*/ |
||||
|
static async getContactById(id) { |
||||
|
try { |
||||
|
const contact = await ContactModel.findById(id) |
||||
|
return contact ? this.formatContactResponse(contact) : null |
||||
|
} catch (error) { |
||||
|
logger.error(`获取联系信息失败 (ID: ${id}):`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 更新联系信息 |
||||
|
* @param {number} id - 联系信息ID |
||||
|
* @param {Object} updateData - 更新数据 |
||||
|
* @returns {Promise<Object>} 更新后的联系信息 |
||||
|
*/ |
||||
|
static async updateContact(id, updateData) { |
||||
|
try { |
||||
|
// 验证联系信息是否存在
|
||||
|
const existingContact = await ContactModel.findById(id) |
||||
|
if (!existingContact) { |
||||
|
throw new Error("联系信息不存在") |
||||
|
} |
||||
|
|
||||
|
// 数据验证
|
||||
|
this.validateContactUpdateData(updateData) |
||||
|
|
||||
|
// 更新联系信息
|
||||
|
const contact = await ContactModel.update(id, updateData) |
||||
|
|
||||
|
logger.info(`联系信息更新成功: ${contact.name} (ID: ${id})`) |
||||
|
return this.formatContactResponse(contact) |
||||
|
} catch (error) { |
||||
|
logger.error(`更新联系信息失败 (ID: ${id}):`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 删除联系信息 |
||||
|
* @param {number} id - 联系信息ID |
||||
|
* @returns {Promise<boolean>} 删除结果 |
||||
|
*/ |
||||
|
static async deleteContact(id) { |
||||
|
try { |
||||
|
const contact = await ContactModel.findById(id) |
||||
|
if (!contact) { |
||||
|
throw new Error("联系信息不存在") |
||||
|
} |
||||
|
|
||||
|
const result = await ContactModel.delete(id) |
||||
|
|
||||
|
logger.info(`联系信息删除成功: ${contact.name} (ID: ${id})`) |
||||
|
return result > 0 |
||||
|
} catch (error) { |
||||
|
logger.error(`删除联系信息失败 (ID: ${id}):`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取联系信息列表 |
||||
|
* @param {Object} options - 查询选项 |
||||
|
* @returns {Promise<Object>} 联系信息列表和分页信息 |
||||
|
*/ |
||||
|
static async getContactList(options = {}) { |
||||
|
try { |
||||
|
const { |
||||
|
page = 1, |
||||
|
limit = 20, |
||||
|
search = "", |
||||
|
status = null, |
||||
|
orderBy = "created_at", |
||||
|
order = "desc" |
||||
|
} = options |
||||
|
|
||||
|
const where = {} |
||||
|
if (status) where.status = status |
||||
|
|
||||
|
const result = await ContactModel.paginate({ |
||||
|
page, |
||||
|
limit, |
||||
|
where, |
||||
|
search, |
||||
|
searchFields: ContactModel.searchableFields, |
||||
|
orderBy, |
||||
|
order |
||||
|
}) |
||||
|
|
||||
|
return { |
||||
|
contacts: result.data.map(contact => this.formatContactResponse(contact)), |
||||
|
pagination: result.pagination |
||||
|
} |
||||
|
} catch (error) { |
||||
|
logger.error(`获取联系信息列表失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 根据邮箱获取联系信息 |
||||
|
* @param {string} email - 邮箱 |
||||
|
* @returns {Promise<Array>} 联系信息列表 |
||||
|
*/ |
||||
|
static async getContactsByEmail(email) { |
||||
|
try { |
||||
|
const contacts = await ContactModel.findByEmail(email) |
||||
|
return contacts.map(contact => this.formatContactResponse(contact)) |
||||
|
} catch (error) { |
||||
|
logger.error(`根据邮箱获取联系信息失败 (${email}):`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 根据状态获取联系信息 |
||||
|
* @param {string} status - 状态 |
||||
|
* @returns {Promise<Array>} 联系信息列表 |
||||
|
*/ |
||||
|
static async getContactsByStatus(status) { |
||||
|
try { |
||||
|
const contacts = await ContactModel.findByStatus(status) |
||||
|
return contacts.map(contact => this.formatContactResponse(contact)) |
||||
|
} catch (error) { |
||||
|
logger.error(`根据状态获取联系信息失败 (${status}):`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 根据日期范围获取联系信息 |
||||
|
* @param {string} startDate - 开始日期 |
||||
|
* @param {string} endDate - 结束日期 |
||||
|
* @returns {Promise<Array>} 联系信息列表 |
||||
|
*/ |
||||
|
static async getContactsByDateRange(startDate, endDate) { |
||||
|
try { |
||||
|
const contacts = await ContactModel.findByDateRange(startDate, endDate) |
||||
|
return contacts.map(contact => this.formatContactResponse(contact)) |
||||
|
} catch (error) { |
||||
|
logger.error(`根据日期范围获取联系信息失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 标记为已读 |
||||
|
* @param {number} id - 联系信息ID |
||||
|
* @returns {Promise<Object>} 更新后的联系信息 |
||||
|
*/ |
||||
|
static async markAsRead(id) { |
||||
|
try { |
||||
|
const contact = await ContactModel.markAsRead(id) |
||||
|
logger.info(`联系信息标记为已读成功 (ID: ${id})`) |
||||
|
return this.formatContactResponse(contact) |
||||
|
} catch (error) { |
||||
|
logger.error(`标记联系信息为已读失败 (ID: ${id}):`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 标记为已回复 |
||||
|
* @param {number} id - 联系信息ID |
||||
|
* @returns {Promise<Object>} 更新后的联系信息 |
||||
|
*/ |
||||
|
static async markAsReplied(id) { |
||||
|
try { |
||||
|
const contact = await ContactModel.markAsReplied(id) |
||||
|
logger.info(`联系信息标记为已回复成功 (ID: ${id})`) |
||||
|
return this.formatContactResponse(contact) |
||||
|
} catch (error) { |
||||
|
logger.error(`标记联系信息为已回复失败 (ID: ${id}):`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 批量更新状态 |
||||
|
* @param {Array} ids - 联系信息ID数组 |
||||
|
* @param {string} status - 新状态 |
||||
|
* @returns {Promise<number>} 更新数量 |
||||
|
*/ |
||||
|
static async batchUpdateStatus(ids, status) { |
||||
|
try { |
||||
|
if (!Array.isArray(ids) || ids.length === 0) { |
||||
|
throw new Error("联系信息ID数组不能为空") |
||||
|
} |
||||
|
|
||||
|
if (!["unread", "read", "replied"].includes(status)) { |
||||
|
throw new Error("无效的联系信息状态") |
||||
|
} |
||||
|
|
||||
|
const result = await ContactModel.updateStatusBatchByIds(ids, status) |
||||
|
|
||||
|
logger.info(`批量更新联系信息状态成功: ${ids.length} 条记录状态更新为 ${status}`) |
||||
|
return result |
||||
|
} catch (error) { |
||||
|
logger.error(`批量更新联系信息状态失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 批量删除联系信息 |
||||
|
* @param {Array} ids - 联系信息ID数组 |
||||
|
* @returns {Promise<number>} 删除数量 |
||||
|
*/ |
||||
|
static async batchDeleteContacts(ids) { |
||||
|
try { |
||||
|
if (!Array.isArray(ids) || ids.length === 0) { |
||||
|
throw new Error("联系信息ID数组不能为空") |
||||
|
} |
||||
|
|
||||
|
const result = await ContactModel.deleteWhere({ id: ids }) |
||||
|
|
||||
|
logger.info(`批量删除联系信息成功: ${result} 条记录`) |
||||
|
return result |
||||
|
} catch (error) { |
||||
|
logger.error(`批量删除联系信息失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取联系信息统计 |
||||
|
* @returns {Promise<Object>} 统计信息 |
||||
|
*/ |
||||
|
static async getContactStats() { |
||||
|
try { |
||||
|
const stats = await ContactModel.getStats() |
||||
|
const todayCount = await ContactModel.getTodayCount() |
||||
|
|
||||
|
return { |
||||
|
...stats, |
||||
|
today: todayCount |
||||
|
} |
||||
|
} catch (error) { |
||||
|
logger.error(`获取联系信息统计失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取今日新联系数量 |
||||
|
* @returns {Promise<number>} 今日新联系数量 |
||||
|
*/ |
||||
|
static async getTodayContactCount() { |
||||
|
try { |
||||
|
return await ContactModel.getTodayCount() |
||||
|
} catch (error) { |
||||
|
logger.error(`获取今日新联系数量失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 搜索联系信息 |
||||
|
* @param {string} keyword - 搜索关键词 |
||||
|
* @param {Object} options - 搜索选项 |
||||
|
* @returns {Promise<Array>} 搜索结果 |
||||
|
*/ |
||||
|
static async searchContacts(keyword, options = {}) { |
||||
|
try { |
||||
|
const { |
||||
|
status = null, |
||||
|
limit = 20, |
||||
|
orderBy = "created_at", |
||||
|
order = "desc" |
||||
|
} = options |
||||
|
|
||||
|
const where = {} |
||||
|
if (status) where.status = status |
||||
|
|
||||
|
const contacts = await ContactModel.findWhere(where, { |
||||
|
search: keyword, |
||||
|
searchFields: ContactModel.searchableFields, |
||||
|
limit, |
||||
|
orderBy, |
||||
|
order |
||||
|
}) |
||||
|
|
||||
|
return contacts.map(contact => this.formatContactResponse(contact)) |
||||
|
} catch (error) { |
||||
|
logger.error(`搜索联系信息失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取未读联系信息数量 |
||||
|
* @returns {Promise<number>} 未读数量 |
||||
|
*/ |
||||
|
static async getUnreadCount() { |
||||
|
try { |
||||
|
return await ContactModel.count({ status: "unread" }) |
||||
|
} catch (error) { |
||||
|
logger.error(`获取未读联系信息数量失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取最近联系信息 |
||||
|
* @param {number} limit - 数量限制 |
||||
|
* @returns {Promise<Array>} 最近联系信息列表 |
||||
|
*/ |
||||
|
static async getRecentContacts(limit = 10) { |
||||
|
try { |
||||
|
const contacts = await ContactModel.findWhere( |
||||
|
{}, |
||||
|
{ orderBy: "created_at", order: "desc", limit } |
||||
|
) |
||||
|
return contacts.map(contact => this.formatContactResponse(contact)) |
||||
|
} catch (error) { |
||||
|
logger.error(`获取最近联系信息失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 验证联系信息数据 |
||||
|
* @param {Object} contactData - 联系信息数据 |
||||
|
*/ |
||||
|
static validateContactData(contactData) { |
||||
|
if (!contactData.name) { |
||||
|
throw new Error("姓名不能为空") |
||||
|
} |
||||
|
if (!contactData.email) { |
||||
|
throw new Error("邮箱不能为空") |
||||
|
} |
||||
|
if (!contactData.subject) { |
||||
|
throw new Error("主题不能为空") |
||||
|
} |
||||
|
if (!contactData.message) { |
||||
|
throw new Error("消息内容不能为空") |
||||
|
} |
||||
|
|
||||
|
// 邮箱格式验证
|
||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ |
||||
|
if (!emailRegex.test(contactData.email)) { |
||||
|
throw new Error("邮箱格式不正确") |
||||
|
} |
||||
|
|
||||
|
// 姓名长度验证
|
||||
|
if (contactData.name.length > 100) { |
||||
|
throw new Error("姓名不能超过100个字符") |
||||
|
} |
||||
|
|
||||
|
// 主题长度验证
|
||||
|
if (contactData.subject.length > 200) { |
||||
|
throw new Error("主题不能超过200个字符") |
||||
|
} |
||||
|
|
||||
|
// 消息长度验证
|
||||
|
if (contactData.message.length > 2000) { |
||||
|
throw new Error("消息内容不能超过2000个字符") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 验证联系信息更新数据 |
||||
|
* @param {Object} updateData - 更新数据 |
||||
|
*/ |
||||
|
static validateContactUpdateData(updateData) { |
||||
|
if (updateData.email) { |
||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ |
||||
|
if (!emailRegex.test(updateData.email)) { |
||||
|
throw new Error("邮箱格式不正确") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (updateData.name && updateData.name.length > 100) { |
||||
|
throw new Error("姓名不能超过100个字符") |
||||
|
} |
||||
|
|
||||
|
if (updateData.subject && updateData.subject.length > 200) { |
||||
|
throw new Error("主题不能超过200个字符") |
||||
|
} |
||||
|
|
||||
|
if (updateData.message && updateData.message.length > 2000) { |
||||
|
throw new Error("消息内容不能超过2000个字符") |
||||
|
} |
||||
|
|
||||
|
if (updateData.status && !["unread", "read", "replied"].includes(updateData.status)) { |
||||
|
throw new Error("无效的状态值") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 格式化联系信息响应数据 |
||||
|
* @param {Object} contact - 联系信息数据 |
||||
|
* @returns {Object} 格式化后的联系信息数据 |
||||
|
*/ |
||||
|
static formatContactResponse(contact) { |
||||
|
return { |
||||
|
...contact, |
||||
|
// 确保数字字段为数字类型
|
||||
|
id: parseInt(contact.id), |
||||
|
// 格式化日期字段
|
||||
|
created_at: contact.created_at, |
||||
|
updated_at: contact.updated_at |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 导出联系信息 |
||||
|
* @param {Object} options - 导出选项 |
||||
|
* @returns {Promise<Array>} 导出的联系信息 |
||||
|
*/ |
||||
|
static async exportContacts(options = {}) { |
||||
|
try { |
||||
|
const { |
||||
|
status = null, |
||||
|
startDate = null, |
||||
|
endDate = null, |
||||
|
limit = 1000 |
||||
|
} = options |
||||
|
|
||||
|
let where = {} |
||||
|
if (status) where.status = status |
||||
|
|
||||
|
let contacts |
||||
|
if (startDate && endDate) { |
||||
|
contacts = await ContactModel.findByDateRange(startDate, endDate) |
||||
|
} else { |
||||
|
contacts = await ContactModel.findWhere(where, { |
||||
|
orderBy: "created_at", |
||||
|
order: "desc", |
||||
|
limit |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
return contacts.map(contact => this.formatContactResponse(contact)) |
||||
|
} catch (error) { |
||||
|
logger.error(`导出联系信息失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取联系信息趋势数据 |
||||
|
* @param {number} days - 天数 |
||||
|
* @returns {Promise<Array>} 趋势数据 |
||||
|
*/ |
||||
|
static async getContactTrends(days = 30) { |
||||
|
try { |
||||
|
const endDate = new Date() |
||||
|
const startDate = new Date() |
||||
|
startDate.setDate(startDate.getDate() - days) |
||||
|
|
||||
|
const contacts = await ContactModel.findByDateRange( |
||||
|
startDate.toISOString().split('T')[0], |
||||
|
endDate.toISOString().split('T')[0] |
||||
|
) |
||||
|
|
||||
|
// 按日期分组统计
|
||||
|
const trends = {} |
||||
|
contacts.forEach(contact => { |
||||
|
const date = contact.created_at.split('T')[0] |
||||
|
if (!trends[date]) { |
||||
|
trends[date] = { date, count: 0 } |
||||
|
} |
||||
|
trends[date].count++ |
||||
|
}) |
||||
|
|
||||
|
return Object.values(trends).sort((a, b) => a.date.localeCompare(b.date)) |
||||
|
} catch (error) { |
||||
|
logger.error(`获取联系信息趋势数据失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export default ContactService |
||||
|
export { ContactService } |
||||
|
|
||||
@ -0,0 +1,513 @@ |
|||||
|
import SiteConfigModel from "../db/models/SiteConfigModel.js" |
||||
|
import { logger } from "../logger.js" |
||||
|
|
||||
|
/** |
||||
|
* 站点配置服务类 |
||||
|
* 提供站点配置相关的业务逻辑 |
||||
|
*/ |
||||
|
class SiteConfigService { |
||||
|
/** |
||||
|
* 获取配置值 |
||||
|
* @param {string} key - 配置键 |
||||
|
* @param {*} defaultValue - 默认值 |
||||
|
* @returns {Promise<*>} 配置值 |
||||
|
*/ |
||||
|
static async get(key, defaultValue = null) { |
||||
|
try { |
||||
|
const value = await SiteConfigModel.get(key) |
||||
|
return value !== null ? value : defaultValue |
||||
|
} catch (error) { |
||||
|
logger.error(`获取配置失败 (${key}):`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 设置配置值 |
||||
|
* @param {string} key - 配置键 |
||||
|
* @param {*} value - 配置值 |
||||
|
* @returns {Promise<Object>} 配置对象 |
||||
|
*/ |
||||
|
static async set(key, value) { |
||||
|
try { |
||||
|
// 验证配置键
|
||||
|
this.validateConfigKey(key) |
||||
|
|
||||
|
// 序列化值
|
||||
|
const serializedValue = this.serializeValue(value) |
||||
|
|
||||
|
const config = await SiteConfigModel.set(key, serializedValue) |
||||
|
|
||||
|
logger.info(`配置设置成功: ${key}`) |
||||
|
return this.formatConfigResponse(config) |
||||
|
} catch (error) { |
||||
|
logger.error(`设置配置失败 (${key}):`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 批量获取配置 |
||||
|
* @param {Array} keys - 配置键数组 |
||||
|
* @returns {Promise<Object>} 配置对象 |
||||
|
*/ |
||||
|
static async getMany(keys) { |
||||
|
try { |
||||
|
if (!Array.isArray(keys) || keys.length === 0) { |
||||
|
return {} |
||||
|
} |
||||
|
|
||||
|
const configs = await SiteConfigModel.getMany(keys) |
||||
|
|
||||
|
// 反序列化值
|
||||
|
const result = {} |
||||
|
for (const [key, value] of Object.entries(configs)) { |
||||
|
result[key] = this.deserializeValue(value) |
||||
|
} |
||||
|
|
||||
|
return result |
||||
|
} catch (error) { |
||||
|
logger.error(`批量获取配置失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取所有配置 |
||||
|
* @returns {Promise<Object>} 所有配置 |
||||
|
*/ |
||||
|
static async getAll() { |
||||
|
try { |
||||
|
const configs = await SiteConfigModel.getAll() |
||||
|
|
||||
|
// 反序列化值
|
||||
|
const result = {} |
||||
|
for (const [key, value] of Object.entries(configs)) { |
||||
|
result[key] = this.deserializeValue(value) |
||||
|
} |
||||
|
|
||||
|
return result |
||||
|
} catch (error) { |
||||
|
logger.error(`获取所有配置失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 批量设置配置 |
||||
|
* @param {Object} configs - 配置对象 |
||||
|
* @returns {Promise<Array>} 设置结果 |
||||
|
*/ |
||||
|
static async setMany(configs) { |
||||
|
try { |
||||
|
if (!configs || typeof configs !== 'object') { |
||||
|
throw new Error("配置对象不能为空") |
||||
|
} |
||||
|
|
||||
|
const results = [] |
||||
|
for (const [key, value] of Object.entries(configs)) { |
||||
|
try { |
||||
|
this.validateConfigKey(key) |
||||
|
const serializedValue = this.serializeValue(value) |
||||
|
const config = await SiteConfigModel.set(key, serializedValue) |
||||
|
results.push(this.formatConfigResponse(config)) |
||||
|
} catch (error) { |
||||
|
logger.error(`设置配置失败 (${key}):`, error) |
||||
|
results.push({ key, error: error.message }) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
logger.info(`批量设置配置完成: ${Object.keys(configs).length} 个配置`) |
||||
|
return results |
||||
|
} catch (error) { |
||||
|
logger.error(`批量设置配置失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 删除配置 |
||||
|
* @param {string} key - 配置键 |
||||
|
* @returns {Promise<boolean>} 删除结果 |
||||
|
*/ |
||||
|
static async delete(key) { |
||||
|
try { |
||||
|
const result = await SiteConfigModel.deleteByKey(key) |
||||
|
|
||||
|
logger.info(`配置删除成功: ${key}`) |
||||
|
return result > 0 |
||||
|
} catch (error) { |
||||
|
logger.error(`删除配置失败 (${key}):`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 检查配置是否存在 |
||||
|
* @param {string} key - 配置键 |
||||
|
* @returns {Promise<boolean>} 是否存在 |
||||
|
*/ |
||||
|
static async has(key) { |
||||
|
try { |
||||
|
return await SiteConfigModel.hasKey(key) |
||||
|
} catch (error) { |
||||
|
logger.error(`检查配置是否存在失败 (${key}):`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取配置统计 |
||||
|
* @returns {Promise<Object>} 统计信息 |
||||
|
*/ |
||||
|
static async getStats() { |
||||
|
try { |
||||
|
return await SiteConfigModel.getConfigStats() |
||||
|
} catch (error) { |
||||
|
logger.error(`获取配置统计失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取站点基本信息配置 |
||||
|
* @returns {Promise<Object>} 站点基本信息 |
||||
|
*/ |
||||
|
static async getSiteInfo() { |
||||
|
try { |
||||
|
const keys = [ |
||||
|
'site_name', |
||||
|
'site_description', |
||||
|
'site_keywords', |
||||
|
'site_author', |
||||
|
'site_url', |
||||
|
'site_logo', |
||||
|
'site_favicon', |
||||
|
'site_theme', |
||||
|
'site_language', |
||||
|
'site_timezone' |
||||
|
] |
||||
|
|
||||
|
const configs = await this.getMany(keys) |
||||
|
|
||||
|
return { |
||||
|
name: configs.site_name || '我的网站', |
||||
|
description: configs.site_description || '', |
||||
|
keywords: configs.site_keywords || '', |
||||
|
author: configs.site_author || '', |
||||
|
url: configs.site_url || '', |
||||
|
logo: configs.site_logo || '', |
||||
|
favicon: configs.site_favicon || '', |
||||
|
theme: configs.site_theme || 'default', |
||||
|
language: configs.site_language || 'zh-CN', |
||||
|
timezone: configs.site_timezone || 'Asia/Shanghai' |
||||
|
} |
||||
|
} catch (error) { |
||||
|
logger.error(`获取站点基本信息失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 设置站点基本信息配置 |
||||
|
* @param {Object} siteInfo - 站点信息 |
||||
|
* @returns {Promise<Object>} 设置结果 |
||||
|
*/ |
||||
|
static async setSiteInfo(siteInfo) { |
||||
|
try { |
||||
|
const configs = {} |
||||
|
|
||||
|
if (siteInfo.name) configs.site_name = siteInfo.name |
||||
|
if (siteInfo.description) configs.site_description = siteInfo.description |
||||
|
if (siteInfo.keywords) configs.site_keywords = siteInfo.keywords |
||||
|
if (siteInfo.author) configs.site_author = siteInfo.author |
||||
|
if (siteInfo.url) configs.site_url = siteInfo.url |
||||
|
if (siteInfo.logo) configs.site_logo = siteInfo.logo |
||||
|
if (siteInfo.favicon) configs.site_favicon = siteInfo.favicon |
||||
|
if (siteInfo.theme) configs.site_theme = siteInfo.theme |
||||
|
if (siteInfo.language) configs.site_language = siteInfo.language |
||||
|
if (siteInfo.timezone) configs.site_timezone = siteInfo.timezone |
||||
|
|
||||
|
return await this.setMany(configs) |
||||
|
} catch (error) { |
||||
|
logger.error(`设置站点基本信息失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取邮件配置 |
||||
|
* @returns {Promise<Object>} 邮件配置 |
||||
|
*/ |
||||
|
static async getEmailConfig() { |
||||
|
try { |
||||
|
const keys = [ |
||||
|
'email_host', |
||||
|
'email_port', |
||||
|
'email_secure', |
||||
|
'email_user', |
||||
|
'email_password', |
||||
|
'email_from', |
||||
|
'email_name' |
||||
|
] |
||||
|
|
||||
|
const configs = await this.getMany(keys) |
||||
|
|
||||
|
return { |
||||
|
host: configs.email_host || '', |
||||
|
port: parseInt(configs.email_port) || 587, |
||||
|
secure: configs.email_secure === 'true', |
||||
|
user: configs.email_user || '', |
||||
|
password: configs.email_password || '', |
||||
|
from: configs.email_from || '', |
||||
|
name: configs.email_name || '' |
||||
|
} |
||||
|
} catch (error) { |
||||
|
logger.error(`获取邮件配置失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 设置邮件配置 |
||||
|
* @param {Object} emailConfig - 邮件配置 |
||||
|
* @returns {Promise<Object>} 设置结果 |
||||
|
*/ |
||||
|
static async setEmailConfig(emailConfig) { |
||||
|
try { |
||||
|
const configs = {} |
||||
|
|
||||
|
if (emailConfig.host) configs.email_host = emailConfig.host |
||||
|
if (emailConfig.port) configs.email_port = emailConfig.port.toString() |
||||
|
if (emailConfig.secure !== undefined) configs.email_secure = emailConfig.secure.toString() |
||||
|
if (emailConfig.user) configs.email_user = emailConfig.user |
||||
|
if (emailConfig.password) configs.email_password = emailConfig.password |
||||
|
if (emailConfig.from) configs.email_from = emailConfig.from |
||||
|
if (emailConfig.name) configs.email_name = emailConfig.name |
||||
|
|
||||
|
return await this.setMany(configs) |
||||
|
} catch (error) { |
||||
|
logger.error(`设置邮件配置失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取系统配置 |
||||
|
* @returns {Promise<Object>} 系统配置 |
||||
|
*/ |
||||
|
static async getSystemConfig() { |
||||
|
try { |
||||
|
const keys = [ |
||||
|
'maintenance_mode', |
||||
|
'registration_enabled', |
||||
|
'email_verification_required', |
||||
|
'max_upload_size', |
||||
|
'allowed_file_types', |
||||
|
'session_timeout', |
||||
|
'password_min_length', |
||||
|
'login_attempts_limit' |
||||
|
] |
||||
|
|
||||
|
const configs = await this.getMany(keys) |
||||
|
|
||||
|
return { |
||||
|
maintenanceMode: configs.maintenance_mode === 'true', |
||||
|
registrationEnabled: configs.registration_enabled !== 'false', |
||||
|
emailVerificationRequired: configs.email_verification_required === 'true', |
||||
|
maxUploadSize: parseInt(configs.max_upload_size) || 10485760, // 10MB
|
||||
|
allowedFileTypes: configs.allowed_file_types ? configs.allowed_file_types.split(',') : ['jpg', 'jpeg', 'png', 'gif', 'pdf'], |
||||
|
sessionTimeout: parseInt(configs.session_timeout) || 3600, // 1小时
|
||||
|
passwordMinLength: parseInt(configs.password_min_length) || 6, |
||||
|
loginAttemptsLimit: parseInt(configs.login_attempts_limit) || 5 |
||||
|
} |
||||
|
} catch (error) { |
||||
|
logger.error(`获取系统配置失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 设置系统配置 |
||||
|
* @param {Object} systemConfig - 系统配置 |
||||
|
* @returns {Promise<Object>} 设置结果 |
||||
|
*/ |
||||
|
static async setSystemConfig(systemConfig) { |
||||
|
try { |
||||
|
const configs = {} |
||||
|
|
||||
|
if (systemConfig.maintenanceMode !== undefined) configs.maintenance_mode = systemConfig.maintenanceMode.toString() |
||||
|
if (systemConfig.registrationEnabled !== undefined) configs.registration_enabled = systemConfig.registrationEnabled.toString() |
||||
|
if (systemConfig.emailVerificationRequired !== undefined) configs.email_verification_required = systemConfig.emailVerificationRequired.toString() |
||||
|
if (systemConfig.maxUploadSize) configs.max_upload_size = systemConfig.maxUploadSize.toString() |
||||
|
if (systemConfig.allowedFileTypes) configs.allowed_file_types = Array.isArray(systemConfig.allowedFileTypes) ? systemConfig.allowedFileTypes.join(',') : systemConfig.allowedFileTypes |
||||
|
if (systemConfig.sessionTimeout) configs.session_timeout = systemConfig.sessionTimeout.toString() |
||||
|
if (systemConfig.passwordMinLength) configs.password_min_length = systemConfig.passwordMinLength.toString() |
||||
|
if (systemConfig.loginAttemptsLimit) configs.login_attempts_limit = systemConfig.loginAttemptsLimit.toString() |
||||
|
|
||||
|
return await this.setMany(configs) |
||||
|
} catch (error) { |
||||
|
logger.error(`设置系统配置失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 重置配置为默认值 |
||||
|
* @param {Array} keys - 要重置的配置键数组(可选,默认重置所有) |
||||
|
* @returns {Promise<Object>} 重置结果 |
||||
|
*/ |
||||
|
static async resetToDefaults(keys = null) { |
||||
|
try { |
||||
|
const defaultConfigs = { |
||||
|
site_name: '我的网站', |
||||
|
site_description: '欢迎来到我的网站', |
||||
|
site_keywords: '网站,博客,个人网站', |
||||
|
site_author: '网站管理员', |
||||
|
site_url: 'http://localhost:3000', |
||||
|
site_theme: 'default', |
||||
|
site_language: 'zh-CN', |
||||
|
site_timezone: 'Asia/Shanghai', |
||||
|
maintenance_mode: 'false', |
||||
|
registration_enabled: 'true', |
||||
|
email_verification_required: 'false', |
||||
|
max_upload_size: '10485760', |
||||
|
allowed_file_types: 'jpg,jpeg,png,gif,pdf', |
||||
|
session_timeout: '3600', |
||||
|
password_min_length: '6', |
||||
|
login_attempts_limit: '5' |
||||
|
} |
||||
|
|
||||
|
const configsToReset = keys ? |
||||
|
Object.fromEntries(keys.filter(key => defaultConfigs[key]).map(key => [key, defaultConfigs[key]])) : |
||||
|
defaultConfigs |
||||
|
|
||||
|
return await this.setMany(configsToReset) |
||||
|
} catch (error) { |
||||
|
logger.error(`重置配置为默认值失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 验证配置键 |
||||
|
* @param {string} key - 配置键 |
||||
|
*/ |
||||
|
static validateConfigKey(key) { |
||||
|
if (!key || typeof key !== 'string') { |
||||
|
throw new Error("配置键不能为空") |
||||
|
} |
||||
|
|
||||
|
if (key.length > 100) { |
||||
|
throw new Error("配置键长度不能超过100个字符") |
||||
|
} |
||||
|
|
||||
|
if (!/^[a-zA-Z0-9_]+$/.test(key)) { |
||||
|
throw new Error("配置键只能包含字母、数字和下划线") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 序列化值 |
||||
|
* @param {*} value - 要序列化的值 |
||||
|
* @returns {string} 序列化后的字符串 |
||||
|
*/ |
||||
|
static serializeValue(value) { |
||||
|
if (value === null || value === undefined) { |
||||
|
return '' |
||||
|
} |
||||
|
|
||||
|
if (typeof value === 'string') { |
||||
|
return value |
||||
|
} |
||||
|
|
||||
|
if (typeof value === 'number' || typeof value === 'boolean') { |
||||
|
return value.toString() |
||||
|
} |
||||
|
|
||||
|
return JSON.stringify(value) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 反序列化值 |
||||
|
* @param {string} value - 要反序列化的字符串 |
||||
|
* @returns {*} 反序列化后的值 |
||||
|
*/ |
||||
|
static deserializeValue(value) { |
||||
|
if (value === null || value === undefined || value === '') { |
||||
|
return null |
||||
|
} |
||||
|
|
||||
|
// 尝试解析为JSON
|
||||
|
try { |
||||
|
return JSON.parse(value) |
||||
|
} catch (e) { |
||||
|
// 如果不是有效的JSON,返回原字符串
|
||||
|
return value |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 格式化配置响应数据 |
||||
|
* @param {Object} config - 配置数据 |
||||
|
* @returns {Object} 格式化后的配置数据 |
||||
|
*/ |
||||
|
static formatConfigResponse(config) { |
||||
|
return { |
||||
|
...config, |
||||
|
// 确保数字字段为数字类型
|
||||
|
id: parseInt(config.id), |
||||
|
// 反序列化值
|
||||
|
value: this.deserializeValue(config.value), |
||||
|
// 格式化日期字段
|
||||
|
created_at: config.created_at, |
||||
|
updated_at: config.updated_at |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 导出配置 |
||||
|
* @returns {Promise<Object>} 导出的配置 |
||||
|
*/ |
||||
|
static async exportConfig() { |
||||
|
try { |
||||
|
const configs = await this.getAll() |
||||
|
return { |
||||
|
exported_at: new Date().toISOString(), |
||||
|
configs |
||||
|
} |
||||
|
} catch (error) { |
||||
|
logger.error(`导出配置失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 导入配置 |
||||
|
* @param {Object} configData - 配置数据 |
||||
|
* @returns {Promise<Object>} 导入结果 |
||||
|
*/ |
||||
|
static async importConfig(configData) { |
||||
|
try { |
||||
|
if (!configData || !configData.configs) { |
||||
|
throw new Error("无效的配置数据") |
||||
|
} |
||||
|
|
||||
|
const results = await this.setMany(configData.configs) |
||||
|
|
||||
|
logger.info(`配置导入完成: ${Object.keys(configData.configs).length} 个配置`) |
||||
|
return { |
||||
|
success: results.filter(r => !r.error).length, |
||||
|
failed: results.filter(r => r.error).length, |
||||
|
results |
||||
|
} |
||||
|
} catch (error) { |
||||
|
logger.error(`导入配置失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export default SiteConfigService |
||||
|
export { SiteConfigService } |
||||
|
|
||||
@ -0,0 +1,415 @@ |
|||||
|
import UserModel from "../db/models/UserModel.js" |
||||
|
import { logger } from "../logger.js" |
||||
|
|
||||
|
/** |
||||
|
* 用户服务类 |
||||
|
* 提供用户相关的业务逻辑 |
||||
|
*/ |
||||
|
class UserService { |
||||
|
/** |
||||
|
* 创建新用户 |
||||
|
* @param {Object} userData - 用户数据 |
||||
|
* @returns {Promise<Object>} 创建的用户信息 |
||||
|
*/ |
||||
|
static async createUser(userData) { |
||||
|
try { |
||||
|
// 数据验证
|
||||
|
this.validateUserData(userData) |
||||
|
|
||||
|
// 检查用户名和邮箱唯一性
|
||||
|
await this.checkUniqueConstraints(userData) |
||||
|
|
||||
|
// 创建用户
|
||||
|
const user = await UserModel.create(userData) |
||||
|
|
||||
|
logger.info(`用户创建成功: ${user.username} (ID: ${user.id})`) |
||||
|
return this.formatUserResponse(user) |
||||
|
} catch (error) { |
||||
|
logger.error(`创建用户失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 根据ID获取用户 |
||||
|
* @param {number} id - 用户ID |
||||
|
* @returns {Promise<Object|null>} 用户信息 |
||||
|
*/ |
||||
|
static async getUserById(id) { |
||||
|
try { |
||||
|
const user = await UserModel.findById(id) |
||||
|
return user ? this.formatUserResponse(user) : null |
||||
|
} catch (error) { |
||||
|
logger.error(`获取用户失败 (ID: ${id}):`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 根据用户名获取用户 |
||||
|
* @param {string} username - 用户名 |
||||
|
* @returns {Promise<Object|null>} 用户信息 |
||||
|
*/ |
||||
|
static async getUserByUsername(username) { |
||||
|
try { |
||||
|
const user = await UserModel.findByUsername(username) |
||||
|
return user ? this.formatUserResponse(user) : null |
||||
|
} catch (error) { |
||||
|
logger.error(`根据用户名获取用户失败 (${username}):`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 根据邮箱获取用户 |
||||
|
* @param {string} email - 邮箱 |
||||
|
* @returns {Promise<Object|null>} 用户信息 |
||||
|
*/ |
||||
|
static async getUserByEmail(email) { |
||||
|
try { |
||||
|
const user = await UserModel.findByEmail(email) |
||||
|
return user ? this.formatUserResponse(user) : null |
||||
|
} catch (error) { |
||||
|
logger.error(`根据邮箱获取用户失败 (${email}):`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 更新用户信息 |
||||
|
* @param {number} id - 用户ID |
||||
|
* @param {Object} updateData - 更新数据 |
||||
|
* @returns {Promise<Object>} 更新后的用户信息 |
||||
|
*/ |
||||
|
static async updateUser(id, updateData) { |
||||
|
try { |
||||
|
// 验证用户是否存在
|
||||
|
const existingUser = await UserModel.findById(id) |
||||
|
if (!existingUser) { |
||||
|
throw new Error("用户不存在") |
||||
|
} |
||||
|
|
||||
|
// 数据验证
|
||||
|
this.validateUserUpdateData(updateData) |
||||
|
|
||||
|
// 检查唯一性约束
|
||||
|
await this.checkUniqueConstraintsForUpdate(id, updateData) |
||||
|
|
||||
|
// 更新用户
|
||||
|
const user = await UserModel.update(id, updateData) |
||||
|
|
||||
|
logger.info(`用户更新成功: ${user.username} (ID: ${id})`) |
||||
|
return this.formatUserResponse(user) |
||||
|
} catch (error) { |
||||
|
logger.error(`更新用户失败 (ID: ${id}):`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 删除用户 |
||||
|
* @param {number} id - 用户ID |
||||
|
* @returns {Promise<boolean>} 删除结果 |
||||
|
*/ |
||||
|
static async deleteUser(id) { |
||||
|
try { |
||||
|
const user = await UserModel.findById(id) |
||||
|
if (!user) { |
||||
|
throw new Error("用户不存在") |
||||
|
} |
||||
|
|
||||
|
const result = await UserModel.delete(id) |
||||
|
|
||||
|
logger.info(`用户删除成功: ${user.username} (ID: ${id})`) |
||||
|
return result > 0 |
||||
|
} catch (error) { |
||||
|
logger.error(`删除用户失败 (ID: ${id}):`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取用户列表 |
||||
|
* @param {Object} options - 查询选项 |
||||
|
* @returns {Promise<Object>} 用户列表和分页信息 |
||||
|
*/ |
||||
|
static async getUserList(options = {}) { |
||||
|
try { |
||||
|
const { |
||||
|
page = 1, |
||||
|
limit = 10, |
||||
|
search = "", |
||||
|
role = null, |
||||
|
status = null, |
||||
|
orderBy = "created_at", |
||||
|
order = "desc" |
||||
|
} = options |
||||
|
|
||||
|
const where = {} |
||||
|
if (role) where.role = role |
||||
|
if (status) where.status = status |
||||
|
|
||||
|
const result = await UserModel.paginate({ |
||||
|
page, |
||||
|
limit, |
||||
|
where, |
||||
|
search, |
||||
|
searchFields: UserModel.searchableFields, |
||||
|
orderBy, |
||||
|
order |
||||
|
}) |
||||
|
|
||||
|
return { |
||||
|
users: result.data.map(user => this.formatUserResponse(user)), |
||||
|
pagination: result.pagination |
||||
|
} |
||||
|
} catch (error) { |
||||
|
logger.error(`获取用户列表失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 激活用户 |
||||
|
* @param {number} id - 用户ID |
||||
|
* @returns {Promise<Object>} 更新后的用户信息 |
||||
|
*/ |
||||
|
static async activateUser(id) { |
||||
|
try { |
||||
|
const user = await UserModel.activate(id) |
||||
|
logger.info(`用户激活成功: ${user.username} (ID: ${id})`) |
||||
|
return this.formatUserResponse(user) |
||||
|
} catch (error) { |
||||
|
logger.error(`激活用户失败 (ID: ${id}):`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 停用用户 |
||||
|
* @param {number} id - 用户ID |
||||
|
* @returns {Promise<Object>} 更新后的用户信息 |
||||
|
*/ |
||||
|
static async deactivateUser(id) { |
||||
|
try { |
||||
|
const user = await UserModel.deactivate(id) |
||||
|
logger.info(`用户停用成功: ${user.username} (ID: ${id})`) |
||||
|
return this.formatUserResponse(user) |
||||
|
} catch (error) { |
||||
|
logger.error(`停用用户失败 (ID: ${id}):`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 根据角色获取用户 |
||||
|
* @param {string} role - 角色 |
||||
|
* @returns {Promise<Array>} 用户列表 |
||||
|
*/ |
||||
|
static async getUsersByRole(role) { |
||||
|
try { |
||||
|
const users = await UserModel.findByRole(role) |
||||
|
return users.map(user => this.formatUserResponse(user)) |
||||
|
} catch (error) { |
||||
|
logger.error(`根据角色获取用户失败 (${role}):`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取用户统计信息 |
||||
|
* @returns {Promise<Object>} 统计信息 |
||||
|
*/ |
||||
|
static async getUserStats() { |
||||
|
try { |
||||
|
return await UserModel.getUserStats() |
||||
|
} catch (error) { |
||||
|
logger.error(`获取用户统计失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 验证用户数据 |
||||
|
* @param {Object} userData - 用户数据 |
||||
|
*/ |
||||
|
static validateUserData(userData) { |
||||
|
if (!userData.username) { |
||||
|
throw new Error("用户名不能为空") |
||||
|
} |
||||
|
if (!userData.email) { |
||||
|
throw new Error("邮箱不能为空") |
||||
|
} |
||||
|
if (!userData.password) { |
||||
|
throw new Error("密码不能为空") |
||||
|
} |
||||
|
|
||||
|
// 邮箱格式验证
|
||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ |
||||
|
if (!emailRegex.test(userData.email)) { |
||||
|
throw new Error("邮箱格式不正确") |
||||
|
} |
||||
|
|
||||
|
// 用户名长度验证
|
||||
|
if (userData.username.length < 3 || userData.username.length > 20) { |
||||
|
throw new Error("用户名长度必须在3-20个字符之间") |
||||
|
} |
||||
|
|
||||
|
// 密码强度验证
|
||||
|
if (userData.password.length < 6) { |
||||
|
throw new Error("密码长度不能少于6个字符") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 验证用户更新数据 |
||||
|
* @param {Object} updateData - 更新数据 |
||||
|
*/ |
||||
|
static validateUserUpdateData(updateData) { |
||||
|
if (updateData.email) { |
||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ |
||||
|
if (!emailRegex.test(updateData.email)) { |
||||
|
throw new Error("邮箱格式不正确") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (updateData.username) { |
||||
|
if (updateData.username.length < 3 || updateData.username.length > 20) { |
||||
|
throw new Error("用户名长度必须在3-20个字符之间") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (updateData.password && updateData.password.length < 6) { |
||||
|
throw new Error("密码长度不能少于6个字符") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 检查唯一性约束 |
||||
|
* @param {Object} userData - 用户数据 |
||||
|
*/ |
||||
|
static async checkUniqueConstraints(userData) { |
||||
|
if (userData.username) { |
||||
|
const existingUser = await UserModel.findByUsername(userData.username) |
||||
|
if (existingUser) { |
||||
|
throw new Error("用户名已存在") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (userData.email) { |
||||
|
const existingEmail = await UserModel.findByEmail(userData.email) |
||||
|
if (existingEmail) { |
||||
|
throw new Error("邮箱已存在") |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 检查更新时的唯一性约束 |
||||
|
* @param {number} id - 用户ID |
||||
|
* @param {Object} updateData - 更新数据 |
||||
|
*/ |
||||
|
static async checkUniqueConstraintsForUpdate(id, updateData) { |
||||
|
if (updateData.username) { |
||||
|
const existingUser = await UserModel.findByUsername(updateData.username) |
||||
|
if (existingUser && existingUser.id !== parseInt(id)) { |
||||
|
throw new Error("用户名已存在") |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (updateData.email) { |
||||
|
const existingEmail = await UserModel.findByEmail(updateData.email) |
||||
|
if (existingEmail && existingEmail.id !== parseInt(id)) { |
||||
|
throw new Error("邮箱已存在") |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 格式化用户响应数据 |
||||
|
* @param {Object} user - 用户数据 |
||||
|
* @returns {Object} 格式化后的用户数据 |
||||
|
*/ |
||||
|
static formatUserResponse(user) { |
||||
|
const { password, ...userWithoutPassword } = user |
||||
|
return userWithoutPassword |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 批量创建用户 |
||||
|
* @param {Array} usersData - 用户数据数组 |
||||
|
* @returns {Promise<Array>} 创建结果 |
||||
|
*/ |
||||
|
static async createUsersBatch(usersData) { |
||||
|
try { |
||||
|
const results = [] |
||||
|
const errors = [] |
||||
|
|
||||
|
for (let i = 0; i < usersData.length; i++) { |
||||
|
try { |
||||
|
const userData = usersData[i] |
||||
|
this.validateUserData(userData) |
||||
|
await this.checkUniqueConstraints(userData) |
||||
|
|
||||
|
const user = await UserModel.create(userData) |
||||
|
results.push(this.formatUserResponse(user)) |
||||
|
} catch (error) { |
||||
|
errors.push({ |
||||
|
index: i, |
||||
|
data: usersData[i], |
||||
|
error: error.message |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return { |
||||
|
success: results, |
||||
|
errors, |
||||
|
summary: { |
||||
|
total: usersData.length, |
||||
|
success: results.length, |
||||
|
failed: errors.length |
||||
|
} |
||||
|
} |
||||
|
} catch (error) { |
||||
|
logger.error(`批量创建用户失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 搜索用户 |
||||
|
* @param {string} keyword - 搜索关键词 |
||||
|
* @param {Object} options - 搜索选项 |
||||
|
* @returns {Promise<Array>} 搜索结果 |
||||
|
*/ |
||||
|
static async searchUsers(keyword, options = {}) { |
||||
|
try { |
||||
|
const { |
||||
|
limit = 20, |
||||
|
role = null, |
||||
|
status = null |
||||
|
} = options |
||||
|
|
||||
|
const where = {} |
||||
|
if (role) where.role = role |
||||
|
if (status) where.status = status |
||||
|
|
||||
|
const users = await UserModel.findWhere(where, { |
||||
|
search: keyword, |
||||
|
searchFields: UserModel.searchableFields, |
||||
|
limit, |
||||
|
orderBy: "created_at", |
||||
|
order: "desc" |
||||
|
}) |
||||
|
|
||||
|
return users.map(user => this.formatUserResponse(user)) |
||||
|
} catch (error) { |
||||
|
logger.error(`搜索用户失败:`, error) |
||||
|
throw error |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
export default UserService |
||||
|
export { UserService } |
||||
@ -0,0 +1,84 @@ |
|||||
|
/** |
||||
|
* 服务层统一导出 |
||||
|
* 提供所有业务服务的统一访问入口 |
||||
|
*/ |
||||
|
|
||||
|
import UserService from "./UserService.js" |
||||
|
import ArticleService from "./ArticleService.js" |
||||
|
import BookmarkService from "./BookmarkService.js" |
||||
|
import ContactService from "./ContactService.js" |
||||
|
import SiteConfigService from "./SiteConfigService.js" |
||||
|
import JobService from "./JobService.js" |
||||
|
|
||||
|
/** |
||||
|
* 服务层统一管理类 |
||||
|
* 提供所有业务服务的统一访问和管理 |
||||
|
*/ |
||||
|
class ServiceManager { |
||||
|
constructor() { |
||||
|
this.services = { |
||||
|
user: UserService, |
||||
|
article: ArticleService, |
||||
|
bookmark: BookmarkService, |
||||
|
contact: ContactService, |
||||
|
siteConfig: SiteConfigService, |
||||
|
job: JobService |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取指定服务 |
||||
|
* @param {string} serviceName - 服务名称 |
||||
|
* @returns {Object} 服务实例 |
||||
|
*/ |
||||
|
getService(serviceName) { |
||||
|
const service = this.services[serviceName] |
||||
|
if (!service) { |
||||
|
throw new Error(`服务 ${serviceName} 不存在`) |
||||
|
} |
||||
|
return service |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取所有服务列表 |
||||
|
* @returns {Array} 服务名称列表 |
||||
|
*/ |
||||
|
getServiceList() { |
||||
|
return Object.keys(this.services) |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 检查服务是否存在 |
||||
|
* @param {string} serviceName - 服务名称 |
||||
|
* @returns {boolean} 是否存在 |
||||
|
*/ |
||||
|
hasService(serviceName) { |
||||
|
return serviceName in this.services |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 创建全局服务管理器实例
|
||||
|
const serviceManager = new ServiceManager() |
||||
|
|
||||
|
// 导出所有服务
|
||||
|
export { |
||||
|
UserService, |
||||
|
ArticleService, |
||||
|
BookmarkService, |
||||
|
ContactService, |
||||
|
SiteConfigService, |
||||
|
JobService, |
||||
|
ServiceManager |
||||
|
} |
||||
|
|
||||
|
// 导出服务管理器实例
|
||||
|
export default serviceManager |
||||
|
|
||||
|
// 便捷访问方法
|
||||
|
export const getUserService = () => serviceManager.getService('user') |
||||
|
export const getArticleService = () => serviceManager.getService('article') |
||||
|
export const getBookmarkService = () => serviceManager.getService('bookmark') |
||||
|
export const getContactService = () => serviceManager.getService('contact') |
||||
|
export const getSiteConfigService = () => serviceManager.getService('siteConfig') |
||||
|
export const getJobService = () => serviceManager.getService('job') |
||||
|
|
||||
@ -0,0 +1,10 @@ |
|||||
|
import app from "@/global.js" |
||||
|
import BaseError from "./BaseError.js" |
||||
|
|
||||
|
export default class AuthError extends BaseError { |
||||
|
constructor(message, status = AuthError.ERR_CODE.UNAUTHORIZED) { |
||||
|
super(message, status) |
||||
|
this.name = "AuthError" |
||||
|
this.ctx = app.currentContext |
||||
|
} |
||||
|
} |
||||
@ -1,8 +1,10 @@ |
|||||
|
import app from "@/global.js" |
||||
import BaseError from "./BaseError.js" |
import BaseError from "./BaseError.js" |
||||
|
|
||||
export default class CommonError extends BaseError { |
export default class CommonError extends BaseError { |
||||
constructor(message, status = CommonError.BAD_REQUEST) { |
constructor(message, status = CommonError.ERR_CODE.BAD_REQUEST) { |
||||
super(message, status) |
super(message, status) |
||||
this.name = "CommonError" |
this.name = "CommonError" |
||||
|
this.ctx = app.currentContext |
||||
} |
} |
||||
} |
} |
||||
|
|||||
@ -1,20 +0,0 @@ |
|||||
extends /layouts/bg-page.pug |
|
||||
|
|
||||
block pageContent |
|
||||
.about-container.card |
|
||||
h1 关于我们 |
|
||||
p 我们致力于打造一个基于 Koa3 的现代 Web 示例项目,帮助开发者快速上手高效、可扩展的 Web 应用开发。 |
|
||||
.about-section |
|
||||
h2 我们的愿景 |
|
||||
p 推动 Node.js 生态下的现代 Web 技术发展,降低开发门槛,提升开发体验。 |
|
||||
.about-section |
|
||||
h2 技术栈 |
|
||||
ul |
|
||||
li Koa3 |
|
||||
li Pug 模板引擎 |
|
||||
li 现代前端技术(如 ES6+、CSS3) |
|
||||
.about-section |
|
||||
h2 联系我们 |
|
||||
p 如有建议或合作意向,欢迎通过 |
|
||||
a(href="mailto:1549469775@qq.com") 联系方式 |
|
||||
| 与我们取得联系。 |
|
||||
@ -1,70 +0,0 @@ |
|||||
extends /layouts/empty.pug |
|
||||
|
|
||||
block pageContent |
|
||||
.container.mx-auto.px-4.py-8 |
|
||||
article.max-w-4xl.mx-auto |
|
||||
header.mb-8 |
|
||||
h1.text-4xl.font-bold.mb-4= article.title |
|
||||
.flex.flex-wrap.items-center.text-gray-600.mb-4 |
|
||||
span.mr-4 |
|
||||
i.fas.fa-calendar-alt.mr-1 |
|
||||
= new Date(article.published_at).toLocaleDateString() |
|
||||
span.mr-4 |
|
||||
i.fas.fa-eye.mr-1 |
|
||||
= article.view_count + " 阅读" |
|
||||
if article.reading_time |
|
||||
span.mr-4 |
|
||||
i.fas.fa-clock.mr-1 |
|
||||
= article.reading_time + " 分钟阅读" |
|
||||
if article.category |
|
||||
a.text-blue-600.mr-4(href=`/articles/category/${article.category}` class="hover:text-blue-800") |
|
||||
i.fas.fa-folder.mr-1 |
|
||||
= article.category |
|
||||
if article.status === "draft" |
|
||||
span(class="ml-2 px-2 py-0.5 text-xs bg-yellow-200 text-yellow-800 rounded align-middle") 未发布 |
|
||||
|
|
||||
if article.tags |
|
||||
.flex.flex-wrap.gap-2.mb-4 |
|
||||
each tag in article.tags.split(',') |
|
||||
a.bg-gray-100.text-gray-700.px-3.py-1.rounded-full.text-sm(href=`/articles/tag/${tag.trim()}` class="hover:bg-gray-200") |
|
||||
i.fas.fa-tag.mr-1 |
|
||||
= tag.trim() |
|
||||
|
|
||||
if article.featured_image |
|
||||
.mb-8 |
|
||||
img.w-full.rounded-lg.shadow-lg(src=article.featured_image alt=article.title) |
|
||||
|
|
||||
.prose.prose-lg.max-w-none.mb-8.markdown-content(class="prose-pre:bg-gray-100 prose-pre:p-4 prose-pre:rounded-lg prose-code:text-blue-600 prose-blockquote:border-l-4 prose-blockquote:border-gray-300 prose-blockquote:pl-4 prose-blockquote:italic prose-img:rounded-lg prose-img:shadow-md") |
|
||||
!= article.content |
|
||||
|
|
||||
if article.keywords || article.excerpt |
|
||||
.bg-gray-50.rounded-lg.p-6.mb-8 |
|
||||
if article.keywords |
|
||||
.mb-4 |
|
||||
h3.text-lg.font-semibold.mb-2 关键词 |
|
||||
.flex.flex-wrap.gap-2 |
|
||||
each keyword in article.keywords.split(',') |
|
||||
span.bg-white.px-3.py-1.rounded-full.text-sm= keyword.trim() |
|
||||
if article.excerpt |
|
||||
h3.text-lg.font-semibold.mb-2 摘要 |
|
||||
p.text-gray-600= article.excerpt |
|
||||
|
|
||||
if relatedArticles && relatedArticles.length |
|
||||
section.border-t.pt-8.mt-8 |
|
||||
h2.text-2xl.font-bold.mb-6 相关文章 |
|
||||
.grid.grid-cols-1.gap-6(class="md:grid-cols-2") |
|
||||
each related in relatedArticles |
|
||||
.bg-white.shadow-md.rounded-lg.overflow-hidden |
|
||||
if related.featured_image |
|
||||
img.w-full.h-48.object-cover(src=related.featured_image alt=related.title) |
|
||||
.p-6 |
|
||||
h3.text-xl.font-semibold.mb-2 |
|
||||
a(href=`/articles/${related.slug}` class="hover:text-blue-600")= related.title |
|
||||
if related.excerpt |
|
||||
p.text-gray-600.text-sm.mb-4= related.excerpt |
|
||||
.flex.justify-between.items-center.text-sm.text-gray-500 |
|
||||
span |
|
||||
i.fas.fa-calendar-alt.mr-1 |
|
||||
= new Date(related.published_at).toLocaleDateString() |
|
||||
if related.category |
|
||||
a.text-blue-600(href=`/articles/category/${related.category}` class="hover:text-blue-800")= related.category |
|
||||
@ -1,29 +0,0 @@ |
|||||
extends /layouts/empty.pug |
|
||||
|
|
||||
block pageContent |
|
||||
.container.mx-auto.py-8 |
|
||||
h1.text-3xl.font-bold.mb-8 |
|
||||
span.text-gray-600 分类: |
|
||||
= category |
|
||||
|
|
||||
.grid.grid-cols-1.gap-6(class="md:grid-cols-2 lg:grid-cols-3") |
|
||||
each article in articles |
|
||||
.bg-white.shadow-md.rounded-lg.overflow-hidden |
|
||||
if article.featured_image |
|
||||
img.w-full.h-48.object-cover(src=article.featured_image alt=article.title) |
|
||||
.p-6 |
|
||||
h2.text-xl.font-semibold.mb-2 |
|
||||
a(href=`/articles/${article.slug}` class="hover:text-blue-600")= article.title |
|
||||
if article.excerpt |
|
||||
p.text-gray-600.mb-4= article.excerpt |
|
||||
.flex.justify-between.items-center.text-sm.text-gray-500 |
|
||||
span |
|
||||
i.fas.fa-calendar-alt.mr-1 |
|
||||
= new Date(article.published_at).toLocaleDateString() |
|
||||
span |
|
||||
i.fas.fa-eye.mr-1 |
|
||||
= article.view_count + " 阅读" |
|
||||
|
|
||||
if !articles.length |
|
||||
.text-center.py-8 |
|
||||
p.text-gray-500 该分类下暂无文章 |
|
||||
@ -1,134 +0,0 @@ |
|||||
extends /layouts/empty.pug |
|
||||
|
|
||||
block pageContent |
|
||||
.flex.flex-col |
|
||||
.flex-1 |
|
||||
.container.mx-auto |
|
||||
// 页头 |
|
||||
.flex.justify-between.items-center.mb-8 |
|
||||
h1.text-2xl.font-bold 文章列表 |
|
||||
.flex.gap-4 |
|
||||
// 搜索框 |
|
||||
.relative |
|
||||
input#searchInput.w-64.pl-10.pr-4.py-2.border.rounded-lg( |
|
||||
type="text" |
|
||||
placeholder="搜索文章..." |
|
||||
hx-get="/articles/search" |
|
||||
hx-trigger="keyup changed delay:500ms" |
|
||||
hx-target="#articleList" |
|
||||
hx-swap="outerHTML" |
|
||||
name="q" |
|
||||
class="focus:outline-none focus:ring-blue-500 focus:ring-2" |
|
||||
) |
|
||||
i.fas.fa-search.absolute.left-3.top-3.text-gray-400 |
|
||||
|
|
||||
// 视图切换按钮 |
|
||||
//- .flex.items-center.gap-2.bg-white.p-1.rounded-lg.border |
|
||||
//- button.p-2.rounded( |
|
||||
//- class="hover:bg-gray-100" |
|
||||
//- hx-get="/articles?view=grid" |
|
||||
//- hx-target="#articleList" |
|
||||
//- ) |
|
||||
//- i.fas.fa-th-large |
|
||||
//- button.p-2.rounded( |
|
||||
//- class="hover:bg-gray-100" |
|
||||
//- hx-get="/articles?view=list" |
|
||||
//- hx-target="#articleList" |
|
||||
//- ) |
|
||||
//- i.fas.fa-list |
|
||||
|
|
||||
// 筛选栏 |
|
||||
.bg-white.rounded-lg.shadow-sm.p-4.mb-6 |
|
||||
.flex.flex-wrap.gap-4 |
|
||||
if categories && categories.length |
|
||||
.flex.items-center.gap-2 |
|
||||
span.text-gray-600 分类: |
|
||||
each cat in categories |
|
||||
a.px-3.py-1.rounded-full( |
|
||||
class="hover:bg-blue-50 hover:text-blue-600" + (cat === currentCategory ? " bg-blue-100 text-blue-600" : "") |
|
||||
href=`/articles/category/${cat}` |
|
||||
)= cat |
|
||||
|
|
||||
if tags && tags.length |
|
||||
.flex.items-center.gap-2 |
|
||||
span.text-gray-600 标签: |
|
||||
each tag in tags |
|
||||
a.px-3.py-1.rounded-full( |
|
||||
class="hover:bg-blue-50 hover:text-blue-600" + (tag === currentTag ? " bg-blue-100 text-blue-600" : "") |
|
||||
href=`/articles/tag/${tag}` |
|
||||
)= tag |
|
||||
|
|
||||
// 文章列表 |
|
||||
#articleList.grid.grid-cols-1.gap-6(class="md:grid-cols-2 lg:grid-cols-3") |
|
||||
each article in articles |
|
||||
.bg-white.rounded-lg.shadow-sm.overflow-hidden.transition.duration-300.transform(class="hover:-translate-y-1 hover:shadow-md") |
|
||||
if article.featured_image |
|
||||
.relative.h-48 |
|
||||
img.w-full.h-full.object-cover(src=article.featured_image alt=article.title) |
|
||||
if article.category |
|
||||
a.absolute.top-3.right-3.px-3.py-1.bg-blue-600.text-white.text-sm.rounded-full.opacity-90( |
|
||||
href=`/articles/category/${article.category}` |
|
||||
class="hover:opacity-100" |
|
||||
)= article.category |
|
||||
.p-6 |
|
||||
h2.text-xl.font-bold.mb-3 |
|
||||
a(href=`/articles/${article.slug}` class="hover:text-blue-600")= article.title |
|
||||
if article.excerpt |
|
||||
p.text-gray-600.text-sm.mb-4.line-clamp-2= article.excerpt |
|
||||
|
|
||||
.flex.flex-wrap.gap-2.mb-4 |
|
||||
if article.tags |
|
||||
each tag in article.tags.split(',') |
|
||||
a.text-sm.text-gray-500( |
|
||||
href=`/articles/tag/${tag.trim()}` |
|
||||
class="hover:text-blue-600" |
|
||||
) |
|
||||
i.fas.fa-tag.mr-1 |
|
||||
= tag.trim() |
|
||||
|
|
||||
.flex.justify-between.items-center.text-sm.text-gray-500 |
|
||||
.flex.items-center.gap-4 |
|
||||
span |
|
||||
i.far.fa-calendar.mr-1 |
|
||||
= new Date(article.published_at).toLocaleDateString() |
|
||||
if article.reading_time |
|
||||
span |
|
||||
i.far.fa-clock.mr-1 |
|
||||
= article.reading_time + "分钟" |
|
||||
span |
|
||||
i.far.fa-eye.mr-1 |
|
||||
= article.view_count + " 阅读" |
|
||||
|
|
||||
if !articles.length |
|
||||
.col-span-full.py-16.text-center |
|
||||
.text-gray-400.mb-4 |
|
||||
i.fas.fa-inbox.text-6xl |
|
||||
p.text-gray-500 暂无文章 |
|
||||
|
|
||||
// 分页 |
|
||||
if totalPages > 1 |
|
||||
.flex.justify-center.mt-8 |
|
||||
nav.flex.items-center.gap-1(aria-label="Pagination") |
|
||||
// 上一页 |
|
||||
if currentPage > 1 |
|
||||
a.px-3.py-1.rounded-md.bg-white.border( |
|
||||
href=`/articles?page=${currentPage - 1}` |
|
||||
class="text-gray-500 hover:text-gray-700 hover:bg-gray-50" |
|
||||
) 上一页 |
|
||||
|
|
||||
// 页码 |
|
||||
each page in Array.from({length: totalPages}, (_, i) => i + 1) |
|
||||
if page === currentPage |
|
||||
span.px-3.py-1.rounded-md.bg-blue-50.text-blue-600.border.border-blue-200= page |
|
||||
else |
|
||||
a.px-3.py-1.rounded-md.bg-white.border( |
|
||||
href=`/articles?page=${page}` |
|
||||
class="text-gray-500 hover:text-gray-700 hover:bg-gray-50" |
|
||||
)= page |
|
||||
|
|
||||
// 下一页 |
|
||||
if currentPage < totalPages |
|
||||
a.px-3.py-1.rounded-md.bg-white.border( |
|
||||
href=`/articles?page=${currentPage + 1}` |
|
||||
class="text-gray-500 hover:text-gray-700 hover:bg-gray-50" |
|
||||
) 下一页 |
|
||||
@ -1,34 +0,0 @@ |
|||||
//- extends /layouts/empty.pug |
|
||||
|
|
||||
//- block pageContent |
|
||||
#articleList.container.mx-auto.px-4.py-8 |
|
||||
.mb-8 |
|
||||
h1.text-3xl.font-bold.mb-4 |
|
||||
span.text-gray-600 搜索结果: |
|
||||
= keyword |
|
||||
p.text-gray-500 找到 #{articles.length} 篇相关文章 |
|
||||
|
|
||||
.grid.grid-cols-1.gap-6(class="md:grid-cols-2 lg:grid-cols-3") |
|
||||
each article in articles |
|
||||
.bg-white.shadow-md.rounded-lg.overflow-hidden |
|
||||
if article.featured_image |
|
||||
img.w-full.h-48.object-cover(src=article.featured_image alt=article.title) |
|
||||
.p-6 |
|
||||
h2.text-xl.font-semibold.mb-2 |
|
||||
a(href=`/articles/${article.slug}` class="hover:text-blue-600")= article.title |
|
||||
if article.excerpt |
|
||||
p.text-gray-600.mb-4= article.excerpt |
|
||||
.flex.justify-between.items-center |
|
||||
.text-sm.text-gray-500 |
|
||||
span.mr-4 |
|
||||
i.fas.fa-calendar-alt.mr-1 |
|
||||
= new Date(article.published_at).toLocaleDateString() |
|
||||
span |
|
||||
i.fas.fa-eye.mr-1 |
|
||||
= article.view_count + " 阅读" |
|
||||
if article.category |
|
||||
a.text-sm.bg-blue-100.text-blue-600.px-3.py-1.rounded-full(href=`/articles/category/${article.category}` class="hover:bg-blue-200")= article.category |
|
||||
|
|
||||
if !articles.length |
|
||||
.text-center.py-8 |
|
||||
p.text-gray-500 未找到相关文章 |
|
||||
@ -1,32 +0,0 @@ |
|||||
extends /layouts/empty.pug |
|
||||
|
|
||||
block pageContent |
|
||||
.container.mx-auto.py-8 |
|
||||
h1.text-3xl.font-bold.mb-8 |
|
||||
span.text-gray-600 标签: |
|
||||
= tag |
|
||||
|
|
||||
.grid.grid-cols-1.gap-6(class="md:grid-cols-2 lg:grid-cols-3") |
|
||||
each article in articles |
|
||||
.bg-white.shadow-md.rounded-lg.overflow-hidden |
|
||||
if article.featured_image |
|
||||
img.w-full.h-48.object-cover(src=article.featured_image alt=article.title) |
|
||||
.p-6 |
|
||||
h2.text-xl.font-semibold.mb-2 |
|
||||
a(href=`/articles/${article.slug}` class="hover:text-blue-600")= article.title |
|
||||
if article.excerpt |
|
||||
p.text-gray-600.mb-4= article.excerpt |
|
||||
.flex.justify-between.items-center |
|
||||
.text-sm.text-gray-500 |
|
||||
span.mr-4 |
|
||||
i.fas.fa-calendar-alt.mr-1 |
|
||||
= new Date(article.published_at).toLocaleDateString() |
|
||||
span |
|
||||
i.fas.fa-eye.mr-1 |
|
||||
= article.view_count + " 阅读" |
|
||||
if article.category |
|
||||
a.text-sm.bg-blue-100.text-blue-600.px-3.py-1.rounded-full(href=`/articles/category/${article.category}` class="hover:bg-blue-200")= article.category |
|
||||
|
|
||||
if !articles.length |
|
||||
.text-center.py-8 |
|
||||
p.text-gray-500 该标签下暂无文章 |
|
||||
@ -1,10 +0,0 @@ |
|||||
extends /layouts/page.pug |
|
||||
|
|
||||
block pageHead |
|
||||
+css("css/page/index.css") |
|
||||
|
|
||||
block pageContent |
|
||||
.card.home-hero |
|
||||
h1 #{$site.site_title} |
|
||||
p.subtitle #{$site.site_description} |
|
||||
|
|
||||
@ -1,11 +0,0 @@ |
|||||
extends /layouts/bg-page.pug |
|
||||
|
|
||||
block pageHead |
|
||||
+css("css/page/index.css") |
|
||||
|
|
||||
block pageContent |
|
||||
div(class="mt-[20px]") |
|
||||
+include() |
|
||||
include /htmx/navbar.pug |
|
||||
.card(class="mt-[20px]") |
|
||||
img(src="/static/bg2.webp" alt="bg") |
|
||||
@ -1,69 +0,0 @@ |
|||||
extends /layouts/empty.pug |
|
||||
|
|
||||
block pageHead |
|
||||
+css('css/page/index.css') |
|
||||
+css('https://unpkg.com/tippy.js@5/dist/backdrop.css') |
|
||||
+js("https://unpkg.com/popper.js@1") |
|
||||
+js("https://unpkg.com/tippy.js@5") |
|
||||
|
|
||||
mixin item(url, desc) |
|
||||
a(href=url target="_blank" class="inline-flex items-center text-[16px] p-[10px] rounded-[10px] shadow") |
|
||||
block |
|
||||
.material-symbols-light--info-rounded(data-tippy-content=desc) |
|
||||
|
|
||||
mixin card(blog) |
|
||||
.article-card(class="bg-white rounded-[12px] shadow p-6 transition hover:shadow-lg border border-gray-100") |
|
||||
h3.article-title(class="text-lg font-semibold text-gray-900 mb-2") |
|
||||
div(class="transition-colors duration-200") #{blog.title} |
|
||||
if blog.status === "draft" |
|
||||
span(class="ml-2 px-2 py-0.5 text-xs bg-yellow-200 text-yellow-800 rounded align-middle") 未发布 |
|
||||
p.article-meta(class="text-sm text-gray-400 mb-3 flex") |
|
||||
span(class="mr-2 line-clamp-1" title=blog.author) |
|
||||
span 作者: |
|
||||
span(class="transition-colors duration-200") #{blog.author} |
|
||||
span(class="mr-2 whitespace-nowrap") |
|
||||
span | |
|
||||
span(class="transition-colors duration-200") #{blog.updated_at.slice(0, 10)} |
|
||||
span(class="mr-2 whitespace-nowrap") |
|
||||
span | 分类: |
|
||||
a(href=`/articles/category/${blog.category}` class="hover:text-blue-600 transition-colors duration-200") #{blog.category} |
|
||||
p.article-desc( |
|
||||
class="text-gray-600 text-base mb-4 line-clamp-2" |
|
||||
style="height: 2.8em; overflow: hidden;" |
|
||||
) |
|
||||
| #{blog.excerpt} |
|
||||
a(href="/articles/"+blog.slug class="inline-block text-sm text-blue-600 hover:underline transition-colors duration-200") 阅读全文 → |
|
||||
|
|
||||
mixin empty() |
|
||||
.div-placeholder(class="h-[100px] w-full bg-gray-100 text-center flex items-center justify-center text-[16px]") |
|
||||
block |
|
||||
|
|
||||
block pageContent |
|
||||
div |
|
||||
h2(class="text-[20px] font-bold mb-[10px]") 接口列表 |
|
||||
if apiList && apiList.length > 0 |
|
||||
.api.list |
|
||||
each api in apiList |
|
||||
+item(api.url, api.desc) #{api.name} |
|
||||
else |
|
||||
+empty() 空 |
|
||||
div(class="mt-[20px]") |
|
||||
h2(class="text-[20px] font-bold mb-[10px]") 文章列表 |
|
||||
if blogs && blogs.length > 0 |
|
||||
.blog.list |
|
||||
each blog in blogs |
|
||||
+card(blog) |
|
||||
else |
|
||||
+empty() 文章数据为空 |
|
||||
div(class="mt-[20px]") |
|
||||
h2(class="text-[20px] font-bold mb-[10px]") 收藏列表 |
|
||||
if collections && collections.length > 0 |
|
||||
.blog.list |
|
||||
each collection in collections |
|
||||
+card(collection) |
|
||||
else |
|
||||
+empty() 收藏列表数据为空 |
|
||||
|
|
||||
block pageScripts |
|
||||
script. |
|
||||
tippy('[data-tippy-content]'); |
|
||||
@ -1,17 +1,22 @@ |
|||||
extends /layouts/pure.pug |
extends /layouts/empty.pug |
||||
|
|
||||
block pageHead |
block pageHead |
||||
+css("css/page/index.css") |
style |
||||
|
:scss(includePaths=["D:/@code/demo/koa3-demo/src/views/page/index", "D:/@code/demo/koa3-demo/src/views"]) |
||||
|
//- process.env.SASS_PATH = "D:/@code/demo/koa3-demo/src/views/page/index" |
||||
|
@import "./index.scss"; |
||||
|
$color: red; |
||||
|
* { |
||||
|
color: $color; |
||||
|
} |
||||
|
|
||||
|
|
||||
block pageContent |
block pageContent |
||||
.home-hero |
:markdown-it(linkify langPrefix='highlight-') |
||||
.avatar-container |
# Markdown |
||||
.author #{$site.site_author} |
|
||||
img.avatar(src=$site.site_author_avatar, alt="") |
Markdown document with http://links.com and |
||||
.card |
|
||||
div 人生轨迹 |
```js |
||||
+include() |
var codeBlocks; |
||||
- var timeLine = [{icon: "第一份工作",title: "???", desc: `做游戏的。`, } ] |
``` |
||||
include /htmx/timeline.pug |
|
||||
//- div(hx-get="/htmx/timeline" hx-trigger="load") |
|
||||
//- div(style="text-align:center;color:white") Loading |
|
||||
@ -1 +1,19 @@ |
|||||
div sada |
extends /layouts/empty.pug |
||||
|
|
||||
|
block pageHead |
||||
|
style |
||||
|
|
||||
|
|
||||
|
|
||||
|
block pageContent |
||||
|
:my-own-filter(addStart addEnd) |
||||
|
Filter |
||||
|
Body |
||||
|
:markdown-it(linkify langPrefix='highlight-') |
||||
|
# Markdown |
||||
|
|
||||
|
Markdown document with http://links.com and |
||||
|
|
||||
|
```js |
||||
|
var codeBlocks; |
||||
|
``` |
||||
@ -0,0 +1,146 @@ |
|||||
|
/* 首页样式 */ |
||||
|
|
||||
|
.hero-section { |
||||
|
position: relative; |
||||
|
overflow: hidden; |
||||
|
} |
||||
|
|
||||
|
.hero-section::before { |
||||
|
content: ""; |
||||
|
position: absolute; |
||||
|
top: 0; |
||||
|
left: 0; |
||||
|
right: 0; |
||||
|
bottom: 0; |
||||
|
background: url('/images/hero-bg.svg') no-repeat center center; |
||||
|
background-size: cover; |
||||
|
opacity: 0.1; |
||||
|
z-index: 0; |
||||
|
} |
||||
|
|
||||
|
.hero-content { |
||||
|
position: relative; |
||||
|
z-index: 1; |
||||
|
} |
||||
|
|
||||
|
.feature-card { |
||||
|
transition: all 0.3s ease; |
||||
|
} |
||||
|
|
||||
|
.feature-card:hover { |
||||
|
transform: translateY(-5px); |
||||
|
} |
||||
|
|
||||
|
.feature-card .material-symbols-light--article, |
||||
|
.feature-card .material-symbols-light--bookmark, |
||||
|
.feature-card .material-symbols-light--person { |
||||
|
transition: all 0.3s ease; |
||||
|
} |
||||
|
|
||||
|
.feature-card:hover .material-symbols-light--article, |
||||
|
.feature-card:hover .material-symbols-light--bookmark, |
||||
|
.feature-card:hover .material-symbols-light--person { |
||||
|
transform: scale(1.1); |
||||
|
} |
||||
|
|
||||
|
.stats-section { |
||||
|
position: relative; |
||||
|
} |
||||
|
|
||||
|
.stats-section::before { |
||||
|
content: ""; |
||||
|
position: absolute; |
||||
|
top: 0; |
||||
|
left: 0; |
||||
|
right: 0; |
||||
|
bottom: 0; |
||||
|
background: url('/images/stats-bg.svg') no-repeat center center; |
||||
|
background-size: cover; |
||||
|
opacity: 0.05; |
||||
|
z-index: 0; |
||||
|
} |
||||
|
|
||||
|
.stat-item { |
||||
|
transition: all 0.3s ease; |
||||
|
} |
||||
|
|
||||
|
.stat-item:hover { |
||||
|
transform: scale(1.05); |
||||
|
} |
||||
|
|
||||
|
.user-dashboard { |
||||
|
position: relative; |
||||
|
} |
||||
|
|
||||
|
.user-dashboard::before { |
||||
|
content: ""; |
||||
|
position: absolute; |
||||
|
top: 0; |
||||
|
left: 0; |
||||
|
right: 0; |
||||
|
bottom: 0; |
||||
|
background: url('/images/dashboard-bg.svg') no-repeat center center; |
||||
|
background-size: cover; |
||||
|
opacity: 0.03; |
||||
|
z-index: 0; |
||||
|
} |
||||
|
|
||||
|
.avatar { |
||||
|
transition: all 0.3s ease; |
||||
|
} |
||||
|
|
||||
|
.avatar:hover { |
||||
|
transform: scale(1.05); |
||||
|
} |
||||
|
|
||||
|
/* 响应式设计 */ |
||||
|
@media (max-width: 768px) { |
||||
|
.hero-section { |
||||
|
padding: 4rem 0; |
||||
|
} |
||||
|
|
||||
|
.hero-content h1 { |
||||
|
font-size: 2.5rem; |
||||
|
} |
||||
|
|
||||
|
.features-grid { |
||||
|
grid-template-columns: 1fr; |
||||
|
} |
||||
|
|
||||
|
.stats-grid { |
||||
|
grid-template-columns: 1fr 1fr; |
||||
|
} |
||||
|
|
||||
|
.user-info { |
||||
|
text-align: center; |
||||
|
margin-bottom: 1.5rem; |
||||
|
} |
||||
|
|
||||
|
.user-actions { |
||||
|
justify-content: center; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@media (max-width: 480px) { |
||||
|
.hero-content h1 { |
||||
|
font-size: 2rem; |
||||
|
} |
||||
|
|
||||
|
.hero-content p { |
||||
|
font-size: 1rem; |
||||
|
} |
||||
|
|
||||
|
.stats-grid { |
||||
|
grid-template-columns: 1fr; |
||||
|
} |
||||
|
|
||||
|
.hero-actions { |
||||
|
flex-direction: column; |
||||
|
gap: 1rem; |
||||
|
} |
||||
|
|
||||
|
.hero-actions a { |
||||
|
width: 100%; |
||||
|
text-align: center; |
||||
|
} |
||||
|
} |
||||
@ -1,19 +0,0 @@ |
|||||
extends /layouts/empty.pug |
|
||||
|
|
||||
block pageScripts |
|
||||
script(src="js/login.js") |
|
||||
|
|
||||
block pageContent |
|
||||
.flex.items-center.justify-center.bg-base-200.h-full |
|
||||
.w-full.max-w-md.bg-base-100.shadow-xl.rounded-xl.p-8 |
|
||||
h2.text-2xl.font-bold.text-center.mb-6.text-base-content 登录 |
|
||||
form#login-form(action="/login" method="post" class="space-y-5") |
|
||||
.form-group |
|
||||
label(for="username" class="block mb-1 text-base-content") 用户名 |
|
||||
input#username(type="text" name="username" placeholder="请输入用户名" required class="input input-bordered w-full") |
|
||||
.form-group |
|
||||
label(for="password" class="block mb-1 text-base-content") 密码 |
|
||||
input#password(type="password" name="password" placeholder="请输入密码" required class="input input-bordered w-full") |
|
||||
button.login-btn(type="submit" class="btn btn-primary w-full") 登录 |
|
||||
if error |
|
||||
.login-error.mt-4.text-error.text-center= error |
|
||||
@ -1,7 +0,0 @@ |
|||||
extends /layouts/empty.pug |
|
||||
|
|
||||
block pageHead |
|
||||
|
|
||||
|
|
||||
block pageContent |
|
||||
div 这里是通知界面 |
|
||||
@ -1,752 +0,0 @@ |
|||||
extends /layouts/empty.pug |
|
||||
|
|
||||
block pageHead |
|
||||
style. |
|
||||
.profile-container { |
|
||||
max-width: 1200px; |
|
||||
margin: 20px auto; |
|
||||
background: #fff; |
|
||||
border-radius: 16px; |
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.1); |
|
||||
overflow: hidden; |
|
||||
display: flex; |
|
||||
min-height: 600px; |
|
||||
} |
|
||||
|
|
||||
.profile-sidebar { |
|
||||
width: 320px; |
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
|
||||
color: white; |
|
||||
padding: 40px 24px; |
|
||||
display: flex; |
|
||||
flex-direction: column; |
|
||||
align-items: center; |
|
||||
text-align: center; |
|
||||
} |
|
||||
|
|
||||
.profile-avatar { |
|
||||
width: 120px; |
|
||||
height: 120px; |
|
||||
border-radius: 50%; |
|
||||
background: rgba(255,255,255,0.2); |
|
||||
display: flex; |
|
||||
align-items: center; |
|
||||
justify-content: center; |
|
||||
margin-bottom: 24px; |
|
||||
border: 4px solid rgba(255,255,255,0.3); |
|
||||
overflow: hidden; |
|
||||
} |
|
||||
|
|
||||
.profile-avatar img { |
|
||||
width: 100%; |
|
||||
height: 100%; |
|
||||
object-fit: cover; |
|
||||
border-radius: 50%; |
|
||||
} |
|
||||
|
|
||||
.profile-avatar .avatar-placeholder { |
|
||||
font-size: 3rem; |
|
||||
color: rgba(255,255,255,0.8); |
|
||||
} |
|
||||
|
|
||||
// 头像上传相关样式 |
|
||||
.avatar-upload-section { |
|
||||
position: relative; |
|
||||
margin-bottom: 20px; |
|
||||
} |
|
||||
|
|
||||
.avatar-preview { |
|
||||
width: 120px; |
|
||||
height: 120px; |
|
||||
border-radius: 50%; |
|
||||
border: 3px dashed #d1d5db; |
|
||||
background: #f9fafb; |
|
||||
display: flex; |
|
||||
align-items: center; |
|
||||
justify-content: center; |
|
||||
margin: 0 auto 16px; |
|
||||
cursor: pointer; |
|
||||
transition: all 0.3s ease; |
|
||||
position: relative; |
|
||||
overflow: hidden; |
|
||||
} |
|
||||
|
|
||||
.avatar-preview:hover { |
|
||||
border-color: #667eea; |
|
||||
background: #f0f4ff; |
|
||||
} |
|
||||
|
|
||||
.avatar-preview img { |
|
||||
width: 100%; |
|
||||
height: 100%; |
|
||||
object-fit: cover; |
|
||||
border-radius: 50%; |
|
||||
} |
|
||||
|
|
||||
.avatar-preview .avatar-upload-placeholder { |
|
||||
text-align: center; |
|
||||
color: #9ca3af; |
|
||||
font-size: 0.875rem; |
|
||||
} |
|
||||
|
|
||||
.avatar-preview .upload-icon { |
|
||||
font-size: 2rem; |
|
||||
margin-bottom: 8px; |
|
||||
display: block; |
|
||||
} |
|
||||
|
|
||||
.avatar-preview .upload-overlay { |
|
||||
position: absolute; |
|
||||
top: 0; |
|
||||
left: 0; |
|
||||
right: 0; |
|
||||
bottom: 0; |
|
||||
background: rgba(0,0,0,0.6); |
|
||||
color: white; |
|
||||
display: flex; |
|
||||
align-items: center; |
|
||||
justify-content: center; |
|
||||
opacity: 0; |
|
||||
transition: opacity 0.3s ease; |
|
||||
border-radius: 50%; |
|
||||
font-size: 0.875rem; |
|
||||
text-align: center; |
|
||||
} |
|
||||
|
|
||||
.avatar-preview:hover .upload-overlay { |
|
||||
opacity: 1; |
|
||||
} |
|
||||
|
|
||||
.file-input-hidden { |
|
||||
position: absolute; |
|
||||
opacity: 0; |
|
||||
width: 0; |
|
||||
height: 0; |
|
||||
overflow: hidden; |
|
||||
} |
|
||||
|
|
||||
.avatar-upload-info { |
|
||||
text-align: center; |
|
||||
font-size: 0.8rem; |
|
||||
color: #6b7280; |
|
||||
margin-bottom: 16px; |
|
||||
} |
|
||||
|
|
||||
.upload-progress { |
|
||||
width: 100%; |
|
||||
height: 4px; |
|
||||
background: #e5e7eb; |
|
||||
border-radius: 2px; |
|
||||
overflow: hidden; |
|
||||
margin-top: 8px; |
|
||||
display: none; |
|
||||
} |
|
||||
|
|
||||
.upload-progress-bar { |
|
||||
height: 100%; |
|
||||
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); |
|
||||
width: 0; |
|
||||
transition: width 0.3s ease; |
|
||||
} |
|
||||
|
|
||||
.profile-name { |
|
||||
font-size: 1.5rem; |
|
||||
font-weight: 600; |
|
||||
margin: 0 0 8px 0; |
|
||||
} |
|
||||
|
|
||||
.profile-username { |
|
||||
font-size: 1rem; |
|
||||
opacity: 0.9; |
|
||||
margin: 0 0 16px 0; |
|
||||
background: rgba(255,255,255,0.2); |
|
||||
padding: 6px 16px; |
|
||||
border-radius: 20px; |
|
||||
} |
|
||||
|
|
||||
.profile-bio { |
|
||||
font-size: 0.9rem; |
|
||||
opacity: 0.8; |
|
||||
line-height: 1.5; |
|
||||
margin: 0; |
|
||||
max-width: 250px; |
|
||||
} |
|
||||
|
|
||||
.profile-stats { |
|
||||
margin-top: 32px; |
|
||||
width: 100%; |
|
||||
} |
|
||||
|
|
||||
.stat-item { |
|
||||
display: flex; |
|
||||
justify-content: space-between; |
|
||||
align-items: center; |
|
||||
padding: 12px 0; |
|
||||
border-bottom: 1px solid rgba(255,255,255,0.2); |
|
||||
} |
|
||||
|
|
||||
.stat-item:last-child { |
|
||||
border-bottom: none; |
|
||||
} |
|
||||
|
|
||||
.stat-label { |
|
||||
font-size: 0.85rem; |
|
||||
opacity: 0.8; |
|
||||
} |
|
||||
|
|
||||
.stat-value { |
|
||||
font-weight: 600; |
|
||||
font-size: 0.9rem; |
|
||||
} |
|
||||
|
|
||||
.profile-main { |
|
||||
flex: 1; |
|
||||
padding: 40px 32px; |
|
||||
background: #f8fafc; |
|
||||
} |
|
||||
|
|
||||
.profile-header { |
|
||||
margin-bottom: 32px; |
|
||||
} |
|
||||
|
|
||||
.main-title { |
|
||||
font-size: 2rem; |
|
||||
font-weight: 700; |
|
||||
color: #1e293b; |
|
||||
margin: 0 0 8px 0; |
|
||||
} |
|
||||
|
|
||||
.main-subtitle { |
|
||||
color: #64748b; |
|
||||
font-size: 1rem; |
|
||||
margin: 0; |
|
||||
} |
|
||||
|
|
||||
// 标签页样式 |
|
||||
.profile-tabs { |
|
||||
background: white; |
|
||||
border-radius: 12px; |
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.05); |
|
||||
border: 1px solid #e2e8f0; |
|
||||
overflow: hidden; |
|
||||
} |
|
||||
|
|
||||
.tab-nav { |
|
||||
display: flex; |
|
||||
background: #f8fafc; |
|
||||
border-bottom: 1px solid #e2e8f0; |
|
||||
} |
|
||||
|
|
||||
.tab-btn { |
|
||||
flex: 1; |
|
||||
padding: 16px 24px; |
|
||||
background: none; |
|
||||
border: none; |
|
||||
font-size: 1rem; |
|
||||
font-weight: 500; |
|
||||
color: #64748b; |
|
||||
cursor: pointer; |
|
||||
transition: all 0.2s ease; |
|
||||
position: relative; |
|
||||
} |
|
||||
|
|
||||
.tab-btn:hover { |
|
||||
background: #f1f5f9; |
|
||||
color: #334155; |
|
||||
} |
|
||||
|
|
||||
.tab-btn.active { |
|
||||
background: white; |
|
||||
color: #1e293b; |
|
||||
font-weight: 600; |
|
||||
} |
|
||||
|
|
||||
.tab-btn.active::after { |
|
||||
content: ''; |
|
||||
position: absolute; |
|
||||
bottom: 0; |
|
||||
left: 0; |
|
||||
right: 0; |
|
||||
height: 3px; |
|
||||
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); |
|
||||
} |
|
||||
|
|
||||
.tab-content { |
|
||||
padding: 32px; |
|
||||
} |
|
||||
|
|
||||
.tab-pane { |
|
||||
display: none; |
|
||||
} |
|
||||
|
|
||||
.tab-pane.active { |
|
||||
display: block; |
|
||||
} |
|
||||
|
|
||||
.profile-content { |
|
||||
display: grid; |
|
||||
grid-template-columns: 1fr 1fr; |
|
||||
gap: 32px; |
|
||||
} |
|
||||
|
|
||||
.profile-section { |
|
||||
background: white; |
|
||||
border-radius: 12px; |
|
||||
padding: 28px; |
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.05); |
|
||||
border: 1px solid #e2e8f0; |
|
||||
} |
|
||||
|
|
||||
.section-title { |
|
||||
font-size: 1.25rem; |
|
||||
font-weight: 600; |
|
||||
color: #1e293b; |
|
||||
margin-bottom: 24px; |
|
||||
padding-bottom: 16px; |
|
||||
border-bottom: 2px solid #e2e8f0; |
|
||||
position: relative; |
|
||||
display: flex; |
|
||||
align-items: center; |
|
||||
} |
|
||||
|
|
||||
.section-title::before { |
|
||||
content: ''; |
|
||||
width: 4px; |
|
||||
height: 20px; |
|
||||
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); |
|
||||
border-radius: 2px; |
|
||||
margin-right: 12px; |
|
||||
} |
|
||||
|
|
||||
.form-group { |
|
||||
margin-bottom: 20px; |
|
||||
} |
|
||||
|
|
||||
.form-group:last-child { |
|
||||
margin-bottom: 0; |
|
||||
} |
|
||||
|
|
||||
.form-label { |
|
||||
display: block; |
|
||||
margin-bottom: 8px; |
|
||||
color: #374151; |
|
||||
font-size: 0.9rem; |
|
||||
font-weight: 500; |
|
||||
} |
|
||||
|
|
||||
.form-input, |
|
||||
.form-textarea { |
|
||||
width: 100%; |
|
||||
padding: 12px 16px; |
|
||||
border: 2px solid #d1d5db; |
|
||||
border-radius: 8px; |
|
||||
font-size: 0.95rem; |
|
||||
background: #f9fafb; |
|
||||
transition: all 0.2s ease; |
|
||||
box-sizing: border-box; |
|
||||
} |
|
||||
|
|
||||
.form-input:focus, |
|
||||
.form-textarea:focus { |
|
||||
border-color: #667eea; |
|
||||
outline: none; |
|
||||
background: #fff; |
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); |
|
||||
} |
|
||||
|
|
||||
.form-textarea { |
|
||||
resize: vertical; |
|
||||
min-height: 100px; |
|
||||
font-family: inherit; |
|
||||
} |
|
||||
|
|
||||
.form-actions { |
|
||||
display: flex; |
|
||||
gap: 12px; |
|
||||
margin-top: 24px; |
|
||||
padding-top: 20px; |
|
||||
border-top: 1px solid #e5e7eb; |
|
||||
} |
|
||||
|
|
||||
.btn { |
|
||||
padding: 10px 20px; |
|
||||
border: none; |
|
||||
border-radius: 8px; |
|
||||
font-size: 0.9rem; |
|
||||
font-weight: 500; |
|
||||
cursor: pointer; |
|
||||
transition: all 0.2s ease; |
|
||||
text-decoration: none; |
|
||||
display: inline-flex; |
|
||||
align-items: center; |
|
||||
justify-content: center; |
|
||||
min-width: 100px; |
|
||||
} |
|
||||
|
|
||||
.btn-primary { |
|
||||
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); |
|
||||
color: white; |
|
||||
} |
|
||||
|
|
||||
.btn-primary:hover { |
|
||||
transform: translateY(-1px); |
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3); |
|
||||
} |
|
||||
|
|
||||
.btn-secondary { |
|
||||
background: #6b7280; |
|
||||
color: white; |
|
||||
} |
|
||||
|
|
||||
.btn-secondary:hover { |
|
||||
background: #4b5563; |
|
||||
transform: translateY(-1px); |
|
||||
} |
|
||||
|
|
||||
.info-grid { |
|
||||
display: grid; |
|
||||
grid-template-columns: 1fr; |
|
||||
gap: 16px; |
|
||||
margin-top: 20px; |
|
||||
} |
|
||||
|
|
||||
.info-item { |
|
||||
background: #f8fafc; |
|
||||
padding: 16px; |
|
||||
border-radius: 8px; |
|
||||
border: 1px solid #e2e8f0; |
|
||||
display: flex; |
|
||||
justify-content: space-between; |
|
||||
align-items: center; |
|
||||
} |
|
||||
|
|
||||
.info-label { |
|
||||
font-size: 0.875rem; |
|
||||
color: #64748b; |
|
||||
} |
|
||||
|
|
||||
.info-value { |
|
||||
font-size: 0.9rem; |
|
||||
color: #1e293b; |
|
||||
font-weight: 500; |
|
||||
} |
|
||||
|
|
||||
.message { |
|
||||
padding: 12px 16px; |
|
||||
border-radius: 8px; |
|
||||
margin-bottom: 16px; |
|
||||
font-weight: 500; |
|
||||
display: none; |
|
||||
} |
|
||||
|
|
||||
.message.show { |
|
||||
display: block !important; |
|
||||
} |
|
||||
|
|
||||
.message-container { |
|
||||
margin-bottom: 16px; |
|
||||
} |
|
||||
|
|
||||
.message.success { |
|
||||
background-color: #d1fae5; |
|
||||
color: #065f46; |
|
||||
border: 1px solid #a7f3d0; |
|
||||
} |
|
||||
|
|
||||
.message.error { |
|
||||
background-color: #fee2e2; |
|
||||
color: #991b1b; |
|
||||
border: 1px solid #fecaca; |
|
||||
} |
|
||||
|
|
||||
.message.info { |
|
||||
background-color: #dbeafe; |
|
||||
color: #1e40af; |
|
||||
border: 1px solid #bfdbfe; |
|
||||
} |
|
||||
|
|
||||
.loading { |
|
||||
opacity: 0.6; |
|
||||
pointer-events: none; |
|
||||
} |
|
||||
|
|
||||
.loading::after { |
|
||||
content: ''; |
|
||||
position: absolute; |
|
||||
top: 50%; |
|
||||
left: 50%; |
|
||||
width: 20px; |
|
||||
height: 20px; |
|
||||
margin: -10px 0 0 -10px; |
|
||||
border: 2px solid #f3f3f3; |
|
||||
border-top: 2px solid #667eea; |
|
||||
border-radius: 50%; |
|
||||
animation: spin 1s linear infinite; |
|
||||
} |
|
||||
|
|
||||
@keyframes spin { |
|
||||
0% { transform: rotate(0deg); } |
|
||||
100% { transform: rotate(360deg); } |
|
||||
} |
|
||||
|
|
||||
.form-input.error, |
|
||||
.form-textarea.error { |
|
||||
border-color: #ef4444; |
|
||||
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1); |
|
||||
} |
|
||||
|
|
||||
.error-message { |
|
||||
color: #ef4444; |
|
||||
font-size: 0.8rem; |
|
||||
margin-top: 6px; |
|
||||
display: none; |
|
||||
} |
|
||||
|
|
||||
.error-message.show { |
|
||||
display: block; |
|
||||
} |
|
||||
|
|
||||
@media (max-width: 1024px) { |
|
||||
.profile-container { |
|
||||
flex-direction: column; |
|
||||
margin: 20px; |
|
||||
} |
|
||||
|
|
||||
.profile-sidebar { |
|
||||
width: 100%; |
|
||||
padding: 32px 24px; |
|
||||
} |
|
||||
|
|
||||
.profile-content { |
|
||||
grid-template-columns: 1fr; |
|
||||
gap: 24px; |
|
||||
} |
|
||||
|
|
||||
.profile-main { |
|
||||
padding: 32px 24px; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
@media (max-width: 768px) { |
|
||||
.profile-container { |
|
||||
margin: 16px; |
|
||||
border-radius: 12px; |
|
||||
} |
|
||||
|
|
||||
.profile-sidebar { |
|
||||
padding: 24px 20px; |
|
||||
} |
|
||||
|
|
||||
.profile-main { |
|
||||
padding: 24px 20px; |
|
||||
} |
|
||||
|
|
||||
.profile-content { |
|
||||
gap: 20px; |
|
||||
} |
|
||||
|
|
||||
.profile-section { |
|
||||
padding: 24px 20px; |
|
||||
} |
|
||||
|
|
||||
.form-actions { |
|
||||
flex-direction: column; |
|
||||
} |
|
||||
|
|
||||
.btn { |
|
||||
width: 100%; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
block pageContent |
|
||||
.profile-container |
|
||||
.profile-sidebar |
|
||||
.profile-avatar |
|
||||
if user.avatar |
|
||||
img(src=user.avatar alt="用户头像") |
|
||||
else |
|
||||
.avatar-placeholder 👤 |
|
||||
|
|
||||
h2.profile-name #{user.name || user.username || '用户'} |
|
||||
.profile-username @#{user.username || 'username'} |
|
||||
|
|
||||
if user.bio |
|
||||
p.profile-bio #{user.bio} |
|
||||
else |
|
||||
p.profile-bio 这个人很懒,还没有写个人简介... |
|
||||
|
|
||||
.profile-stats |
|
||||
.stat-item |
|
||||
span.stat-label 用户ID |
|
||||
span.stat-value #{user.id || 'N/A'} |
|
||||
|
|
||||
.stat-item |
|
||||
span.stat-label 注册时间 |
|
||||
span.stat-value #{user.created_at ? new Date(user.created_at).toLocaleDateString('zh-CN') : 'N/A'} |
|
||||
|
|
||||
.stat-item |
|
||||
span.stat-label 用户角色 |
|
||||
span.stat-value #{user.role || 'user'} |
|
||||
|
|
||||
.profile-main |
|
||||
.profile-header |
|
||||
h1.main-title 个人资料设置 |
|
||||
p.main-subtitle 管理您的个人信息和账户安全 |
|
||||
|
|
||||
.profile-tabs |
|
||||
.tab-nav |
|
||||
button.tab-btn.active(data-tab="basic") 基本信息 |
|
||||
button.tab-btn(data-tab="security") 账户安全 |
|
||||
|
|
||||
.tab-content |
|
||||
// 基本信息标签页 |
|
||||
.tab-pane.active#basic-tab |
|
||||
.profile-section |
|
||||
h2.section-title 基本信息 |
|
||||
form#profileForm(action="/profile/update", method="POST") |
|
||||
// 消息提示区域 |
|
||||
.message-container |
|
||||
.message.success#profileMessage |
|
||||
span 资料更新成功! |
|
||||
button.message-close(type="button" onclick="closeMessage('profileMessage')") × |
|
||||
.message.error#profileError |
|
||||
span#profileErrorMessage 更新失败,请重试 |
|
||||
button.message-close(type="button" onclick="closeMessage('profileError')") × |
|
||||
|
|
||||
.form-group |
|
||||
label.form-label(for="username") 用户名 * |
|
||||
input.form-input#username( |
|
||||
type="text" |
|
||||
name="username" |
|
||||
value=user.username || '' |
|
||||
required |
|
||||
placeholder="请输入用户名" |
|
||||
) |
|
||||
.error-message#username-error |
|
||||
|
|
||||
.form-group |
|
||||
label.form-label(for="name") 昵称 |
|
||||
input.form-input#name( |
|
||||
type="text" |
|
||||
name="name" |
|
||||
value=user.name || '' |
|
||||
placeholder="请输入昵称" |
|
||||
) |
|
||||
|
|
||||
.form-group |
|
||||
label.form-label(for="email") 邮箱 |
|
||||
input.form-input#email( |
|
||||
type="email" |
|
||||
name="email" |
|
||||
value=user.email || '' |
|
||||
placeholder="请输入邮箱地址" |
|
||||
) |
|
||||
.error-message#email-error |
|
||||
|
|
||||
.form-group |
|
||||
label.form-label(for="bio") 个人简介 |
|
||||
textarea.form-textarea#bio( |
|
||||
name="bio" |
|
||||
placeholder="介绍一下自己..." |
|
||||
)= user.bio || '' |
|
||||
|
|
||||
// 头像上传区域 |
|
||||
.form-group |
|
||||
label.form-label 头像设置 |
|
||||
.avatar-upload-section |
|
||||
.avatar-preview(onclick="document.getElementById('avatarFile').click()") |
|
||||
if user.avatar |
|
||||
img(src=user.avatar alt="当前头像" id="avatarPreviewImg") |
|
||||
.upload-overlay |
|
||||
div |
|
||||
div 📷 |
|
||||
div 点击更换头像 |
|
||||
else |
|
||||
.avatar-upload-placeholder |
|
||||
span.upload-icon 📷 |
|
||||
div 点击上传头像 |
|
||||
|
|
||||
input.file-input-hidden#avatarFile( |
|
||||
type="file" |
|
||||
name="avatarFile" |
|
||||
accept="image/*" |
|
||||
onchange="handleAvatarSelect(this)" |
|
||||
) |
|
||||
|
|
||||
.avatar-upload-info |
|
||||
| 支持 JPG、PNG、GIF 格式,文件大小不超过 5MB |
|
||||
|
|
||||
.upload-progress#uploadProgress |
|
||||
.upload-progress-bar#uploadProgressBar |
|
||||
|
|
||||
.form-group |
|
||||
label.form-label(for="avatar") 头像URL(可选) |
|
||||
input.form-input#avatar( |
|
||||
type="text" |
|
||||
name="avatar" |
|
||||
value=user.avatar || '' |
|
||||
placeholder="或直接输入头像图片链接" |
|
||||
) |
|
||||
.error-message#avatar-error |
|
||||
|
|
||||
.form-actions |
|
||||
button.btn.btn-primary(type="submit") 保存更改 |
|
||||
button.btn.btn-secondary(type="button" onclick="resetForm()") 重置 |
|
||||
|
|
||||
// 账户安全标签页 |
|
||||
.tab-pane#security-tab |
|
||||
.profile-section |
|
||||
h2.section-title 账户安全 |
|
||||
|
|
||||
// 修改密码 |
|
||||
form#passwordForm(action="/profile/change-password", method="POST") |
|
||||
// 消息提示区域 |
|
||||
.message-container |
|
||||
.message.success#passwordMessage |
|
||||
span 密码修改成功! |
|
||||
button.message-close(type="button" onclick="closeMessage('passwordMessage')") × |
|
||||
.message.error#passwordError |
|
||||
span#passwordErrorMessage 密码修改失败,请重试 |
|
||||
button.message-close(type="button" onclick="closeMessage('passwordError')") × |
|
||||
|
|
||||
.form-group |
|
||||
label.form-label(for="oldPassword") 当前密码 * |
|
||||
input.form-input#oldPassword( |
|
||||
type="password" |
|
||||
name="oldPassword" |
|
||||
required |
|
||||
placeholder="请输入当前密码" |
|
||||
) |
|
||||
|
|
||||
.form-group |
|
||||
label.form-label(for="newPassword") 新密码 * |
|
||||
input.form-input#newPassword( |
|
||||
type="password" |
|
||||
name="newPassword" |
|
||||
required |
|
||||
placeholder="请输入新密码(至少6位)" |
|
||||
minlength="6" |
|
||||
) |
|
||||
|
|
||||
.form-group |
|
||||
label.form-label(for="confirmPassword") 确认新密码 * |
|
||||
input.form-input#confirmPassword( |
|
||||
type="password" |
|
||||
name="confirmPassword" |
|
||||
required |
|
||||
placeholder="请再次输入新密码" |
|
||||
minlength="6" |
|
||||
) |
|
||||
|
|
||||
.form-actions |
|
||||
button.btn.btn-primary(type="submit") 修改密码 |
|
||||
button.btn.btn-secondary(type="button" onclick="resetPasswordForm()") 清空 |
|
||||
|
|
||||
// 账户信息 |
|
||||
.info-grid |
|
||||
.info-item |
|
||||
span.info-label 最后更新 |
|
||||
span.info-value #{user.updated_at ? new Date(user.updated_at).toLocaleDateString('zh-CN') : 'N/A'} |
|
||||
|
|
||||
block pageScripts |
|
||||
script(src="/js/profile.js") |
|
||||
@ -1,119 +0,0 @@ |
|||||
extends /layouts/empty.pug |
|
||||
|
|
||||
block pageHead |
|
||||
style. |
|
||||
body { |
|
||||
background: #f5f7fa; |
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; |
|
||||
} |
|
||||
.register-container { |
|
||||
max-width: 400px; |
|
||||
margin: 60px auto; |
|
||||
background: #fff; |
|
||||
border-radius: 10px; |
|
||||
box-shadow: 0 2px 16px rgba(0,0,0,0.08); |
|
||||
padding: 32px 28px 24px 28px; |
|
||||
} |
|
||||
.register-title { |
|
||||
text-align: center; |
|
||||
font-size: 2rem; |
|
||||
margin-bottom: 24px; |
|
||||
color: #333; |
|
||||
font-weight: 600; |
|
||||
} |
|
||||
.form-group { |
|
||||
margin-bottom: 18px; |
|
||||
} |
|
||||
label { |
|
||||
display: block; |
|
||||
margin-bottom: 6px; |
|
||||
color: #555; |
|
||||
font-size: 1rem; |
|
||||
} |
|
||||
input[type="text"], |
|
||||
input[type="email"], |
|
||||
input[type="password"] { |
|
||||
width: 100%; |
|
||||
padding: 10px 12px; |
|
||||
border: 1px solid #d1d5db; |
|
||||
border-radius: 6px; |
|
||||
font-size: 1rem; |
|
||||
background: #f9fafb; |
|
||||
transition: border 0.2s; |
|
||||
box-sizing: border-box; |
|
||||
} |
|
||||
input:focus { |
|
||||
border-color: #409eff; |
|
||||
outline: none; |
|
||||
} |
|
||||
.register-btn { |
|
||||
width: 100%; |
|
||||
padding: 12px 0; |
|
||||
background: linear-gradient(90deg, #409eff 0%, #66b1ff 100%); |
|
||||
color: #fff; |
|
||||
border: none; |
|
||||
border-radius: 6px; |
|
||||
font-size: 1.1rem; |
|
||||
font-weight: 600; |
|
||||
cursor: pointer; |
|
||||
margin-top: 10px; |
|
||||
transition: background 0.2s; |
|
||||
} |
|
||||
.register-btn:hover { |
|
||||
background: linear-gradient(90deg, #66b1ff 0%, #409eff 100%); |
|
||||
} |
|
||||
.login-link { |
|
||||
display: block; |
|
||||
text-align: right; |
|
||||
margin-top: 14px; |
|
||||
color: #409eff; |
|
||||
text-decoration: none; |
|
||||
font-size: 0.95rem; |
|
||||
} |
|
||||
.login-link:hover { |
|
||||
text-decoration: underline; |
|
||||
} |
|
||||
.captcha-container { |
|
||||
display: flex; |
|
||||
align-items: center; |
|
||||
gap: 12px; |
|
||||
margin-bottom: 8px; |
|
||||
} |
|
||||
.captcha-container img { |
|
||||
width: 100px; |
|
||||
height: 30px; |
|
||||
border: 1px solid #d1d5db; |
|
||||
border-radius: 4px; |
|
||||
cursor: pointer; |
|
||||
transition: all 0.2s ease; |
|
||||
} |
|
||||
.captcha-container img:hover { |
|
||||
border-color: #409eff; |
|
||||
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.1); |
|
||||
} |
|
||||
.captcha-container input { |
|
||||
flex: 1; |
|
||||
margin-bottom: 0; |
|
||||
} |
|
||||
|
|
||||
block pageContent |
|
||||
.register-container |
|
||||
.register-title 注册账号 |
|
||||
form(action="/register" method="post") |
|
||||
.form-group |
|
||||
label(for="username") 用户名 |
|
||||
input(type="text" id="username" name="username" required placeholder="请输入用户名") |
|
||||
.form-group |
|
||||
label(for="password") 密码 |
|
||||
input(type="password" id="password" name="password" required placeholder="请输入密码") |
|
||||
.form-group |
|
||||
label(for="confirm_password") 确认密码 |
|
||||
input(type="password" id="confirm_password" name="confirm_password" required placeholder="请再次输入密码") |
|
||||
.form-group |
|
||||
label(for="code") 验证码 |
|
||||
.captcha-container |
|
||||
img#captcha-img(src="/captcha", alt="验证码" title="点击刷新验证码") |
|
||||
input(type="text" id="code" name="code" required placeholder="请输入验证码") |
|
||||
script(src="/js/register.js") |
|
||||
button.register-btn(type="submit") 注册 |
|
||||
a.login-link(href="/login") 已有账号?去登录 |
|
||||
Loading…
Reference in new issue