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; |
|||
flex-wrap: wrap; |
|||
/* 首页样式 */ |
|||
|
|||
&.blog { |
|||
.hero-section { |
|||
position: relative; |
|||
overflow: hidden; |
|||
} |
|||
|
|||
>* { |
|||
width: calc(25% - 15px * 3 / 4); |
|||
} |
|||
.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; |
|||
} |
|||
|
|||
/* ≥1024px 默认4列;介于768px-1023px 显示3列 */ |
|||
@media (max-width: 1023px) { |
|||
>* { |
|||
width: calc(33.3333% - 15px * 2 / 3); |
|||
} |
|||
} |
|||
.hero-content { |
|||
position: relative; |
|||
z-index: 1; |
|||
} |
|||
|
|||
/* 介于640px-767px 显示2列 */ |
|||
@media (max-width: 767px) { |
|||
>* { |
|||
width: calc(50% - 15px * 1 / 2); |
|||
} |
|||
.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; |
|||
} |
|||
|
|||
/* <640px 显示1列,并优化间距与字号 */ |
|||
@media (max-width: 639px) { |
|||
gap: 12px; |
|||
.hero-content h1 { |
|||
font-size: 2.5rem; |
|||
} |
|||
|
|||
>* { |
|||
width: 100%; |
|||
.features-grid { |
|||
grid-template-columns: 1fr; |
|||
} |
|||
|
|||
.article-card { |
|||
padding: 14px; |
|||
.stats-grid { |
|||
grid-template-columns: 1fr 1fr; |
|||
} |
|||
|
|||
.article-title { |
|||
font-size: 16px; |
|||
.user-info { |
|||
text-align: center; |
|||
margin-bottom: 1.5rem; |
|||
} |
|||
|
|||
.article-meta { |
|||
font-size: 12px; |
|||
.user-actions { |
|||
justify-content: center; |
|||
} |
|||
} |
|||
|
|||
.article-desc { |
|||
font-size: 14px; |
|||
@media (max-width: 480px) { |
|||
.hero-content h1 { |
|||
font-size: 2rem; |
|||
} |
|||
|
|||
.hero-content p { |
|||
font-size: 1rem; |
|||
} |
|||
|
|||
.stats-grid { |
|||
grid-template-columns: 1fr; |
|||
} |
|||
} |
|||
|
|||
.list a:hover { |
|||
text-decoration: underline; |
|||
} |
|||
.hero-actions { |
|||
flex-direction: column; |
|||
gap: 1rem; |
|||
} |
|||
|
|||
.material-symbols-light--info-rounded { |
|||
display: inline-block; |
|||
width: 24px; |
|||
height: 24px; |
|||
--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); |
|||
mask-image: var(--svg); |
|||
-webkit-mask-repeat: no-repeat; |
|||
mask-repeat: no-repeat; |
|||
-webkit-mask-size: 100% 100%; |
|||
mask-size: 100% 100%; |
|||
.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" |
|||
// 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) { |
|||
const accept = ctx.accepts("json", "html", "text") |
|||
const isDev = process.env.NODE_ENV === "development" |
|||
|
|||
// 确保状态码在合理范围内
|
|||
status = status >= 100 && status < 600 ? status : 500 |
|||
|
|||
if (accept === "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") { |
|||
ctx.type = "html" |
|||
await ctx.render("error/index", { status, message, stack, isDev }) |
|||
} else { |
|||
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 |
|||
} |
|||
|
|||
export default function errorHandler() { |
|||
/** |
|||
* 错误处理中间件 |
|||
* @returns {Function} Koa中间件函数 |
|||
*/ |
|||
export default function () { |
|||
return async (ctx, next) => { |
|||
// 拦截 Chrome DevTools 探测请求,直接返回 204
|
|||
if (ctx.path === "/.well-known/appspecific/com.chrome.devtools.json") { |
|||
ctx.status = 204 |
|||
ctx.body = "" |
|||
return |
|||
} |
|||
try { |
|||
await next() |
|||
if (ctx.status === 404) { |
|||
// 处理404情况 - 只有在没有设置body且状态码为404时才处理
|
|||
if (ctx.status === 404 && !ctx.body) { |
|||
await formatError(ctx, 404, "Resource not found") |
|||
} |
|||
} 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" |
|||
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" |
|||
|
|||
export default class CommonError extends BaseError { |
|||
constructor(message, status = CommonError.BAD_REQUEST) { |
|||
constructor(message, status = CommonError.ERR_CODE.BAD_REQUEST) { |
|||
super(message, status) |
|||
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 |
|||
+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 |
|||
.home-hero |
|||
.avatar-container |
|||
.author #{$site.site_author} |
|||
img.avatar(src=$site.site_author_avatar, alt="") |
|||
.card |
|||
div 人生轨迹 |
|||
+include() |
|||
- 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 |
|||
:markdown-it(linkify langPrefix='highlight-') |
|||
# Markdown |
|||
|
|||
Markdown document with http://links.com and |
|||
|
|||
```js |
|||
var codeBlocks; |
|||
``` |
|||
@ -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