Browse Source

优化用户资料管理功能,新增用户资料获取和更新接口,调整注册流程,更新视图模板以提升用户体验,增强安全性和交互性

re
谢亚昕 3 months ago
parent
commit
202b214310
  1. BIN
      database/development.sqlite3-shm
  2. BIN
      database/development.sqlite3-wal
  3. 169
      docs/profile-system.md
  4. 449
      public/js/profile.js
  5. 129
      scripts/test-profile.js
  6. 124
      src/controllers/Page/PageController.js
  7. 25
      src/db/migrations/20250901000000_add_profile_fields.mjs
  8. 2
      src/middlewares/Session/index.js
  9. 4
      src/views/layouts/empty.pug
  10. 4
      src/views/layouts/utils.pug
  11. 2
      src/views/page/articles/index.pug
  12. 27
      src/views/page/login/index.pug
  13. 619
      src/views/page/profile/index.pug
  14. 1
      src/views/page/register/index.pug

BIN
database/development.sqlite3-shm

Binary file not shown.

BIN
database/development.sqlite3-wal

Binary file not shown.

169
docs/profile-system.md

@ -0,0 +1,169 @@
# 用户资料系统
## 概述
用户资料系统允许已登录用户查看和编辑个人信息,包括基本信息、个人简介、头像等,还可以修改密码。
## 功能特性
### 1. 基本信息管理
- 用户名(必填)
- 邮箱地址
- 昵称
- 个人简介
- 头像URL
### 2. 密码管理
- 修改密码(需要验证当前密码)
- 密码强度验证
- 密码确认匹配验证
### 3. 账户信息展示
- 用户ID
- 注册时间
- 最后更新时间
- 用户角色
## 技术实现
### 后端接口
#### 获取用户资料
- **路由**: `GET /profile`
- **权限**: 需要登录
- **功能**: 显示用户资料页面
#### 更新用户资料
- **路由**: `POST /profile/update`
- **权限**: 需要登录
- **参数**:
```json
{
"username": "用户名",
"email": "邮箱",
"name": "昵称",
"bio": "个人简介",
"avatar": "头像URL"
}
```
#### 修改密码
- **路由**: `POST /profile/change-password`
- **权限**: 需要登录
- **参数**:
```json
{
"oldPassword": "当前密码",
"newPassword": "新密码",
"confirmPassword": "确认新密码"
}
```
### 前端实现
#### 页面模板
- 文件位置: `src/views/page/profile/index.pug`
- 布局: 使用 `page.pug` 布局,包含导航栏和页脚
#### 样式文件
- 文件位置: `public/css/page/profile.css`
- 特性: 响应式设计,现代化UI,表单验证样式
#### JavaScript交互
- 文件位置: `public/js/profile.js`
- 功能: 表单提交、实时验证、消息提示、加载状态
## 数据库结构
### 用户表字段
```sql
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username VARCHAR(100) NOT NULL,
email VARCHAR(100) UNIQUE,
password VARCHAR(100) NOT NULL,
role VARCHAR(100) NOT NULL,
name VARCHAR(100), -- 新增:昵称
bio TEXT, -- 新增:个人简介
avatar VARCHAR(500), -- 新增:头像URL
status VARCHAR(20) DEFAULT 'active', -- 新增:用户状态
phone VARCHAR(100),
age INTEGER UNSIGNED,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
## 使用方法
### 1. 运行数据库迁移
```bash
# 添加新的用户资料字段
npm run migrate:latest
```
### 2. 访问用户资料页面
- 登录后访问 `/profile` 路径
- 页面会自动加载当前用户信息
### 3. 编辑用户资料
- 在基本信息表单中修改相应字段
- 点击"保存更改"提交修改
- 系统会验证必填字段并显示操作结果
### 4. 修改密码
- 在密码修改表单中输入当前密码和新密码
- 系统会验证密码强度和匹配性
- 修改成功后表单会自动清空
## 安全特性
1. **身份验证**: 所有接口都需要用户登录
2. **密码加密**: 使用bcrypt进行密码哈希
3. **输入验证**: 前端和后端双重验证
4. **SQL注入防护**: 使用参数化查询
5. **XSS防护**: 输出转义和内容安全策略
## 错误处理
系统提供友好的错误提示:
- 表单验证错误
- 网络请求错误
- 服务器错误
- 权限不足错误
## 响应式设计
页面支持多种设备:
- 桌面端:双列布局
- 平板端:自适应布局
- 移动端:单列布局,触摸友好
## 浏览器兼容性
- Chrome 60+
- Firefox 55+
- Safari 12+
- Edge 79+
## 开发说明
### 添加新字段
1. 创建数据库迁移文件
2. 更新UserModel和UserService
3. 修改前端表单和验证逻辑
4. 更新API接口
### 自定义样式
CSS文件使用模块化设计,可以轻松修改:
- 颜色主题
- 布局结构
- 动画效果
- 响应式断点
### 扩展功能
可以基于现有架构添加:
- 头像上传
- 社交媒体链接
- 隐私设置
- 通知偏好

449
public/js/profile.js

@ -0,0 +1,449 @@
// 用户资料页面JavaScript
(function() {
'use strict';
// 页面初始化
document.addEventListener('DOMContentLoaded', function() {
initProfilePage();
});
function initProfilePage() {
bindFormEvents();
bindInputValidation();
showInitialMessage();
initTabs();
}
// 绑定表单事件
function bindFormEvents() {
const profileForm = document.getElementById('profileForm');
const passwordForm = document.getElementById('passwordForm');
console.log('Profile form found:', !!profileForm);
console.log('Password form found:', !!passwordForm);
if (profileForm) {
profileForm.addEventListener('submit', handleProfileUpdate);
console.log('Profile form event listener added');
}
if (passwordForm) {
passwordForm.addEventListener('submit', handlePasswordChange);
console.log('Password form event listener added');
}
}
// 处理用户资料更新
async function handleProfileUpdate(event) {
event.preventDefault();
const form = event.target;
const submitBtn = form.querySelector('button[type="submit"]');
const originalText = submitBtn.textContent;
try {
setButtonLoading(submitBtn, true);
const formData = new FormData(form);
const data = Object.fromEntries(formData.entries());
// 验证必填字段
if (!validateProfileData(data)) {
return;
}
const response = await fetch('/profile/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
showMessage('资料更新成功!', 'success', 'profileForm');
updateUserInfoDisplay(result.user);
} else {
throw new Error(result.message || '更新失败');
}
} catch (error) {
showMessage(error.message || '更新失败,请重试', 'error', 'profileForm');
console.error('Profile update error:', error);
} finally {
setButtonLoading(submitBtn, false, originalText);
}
}
// 处理密码修改
async function handlePasswordChange(event) {
event.preventDefault();
const form = event.target;
const submitBtn = form.querySelector('button[type="submit"]');
const originalText = submitBtn.textContent;
try {
setButtonLoading(submitBtn, true);
const formData = new FormData(form);
const data = Object.fromEntries(formData.entries());
if (!validatePasswordData(data)) {
return;
}
const response = await fetch('/profile/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
showMessage('密码修改成功!', 'success', 'passwordForm');
form.reset();
} else {
throw new Error(result.message || '密码修改失败');
}
} catch (error) {
showMessage(error.message || '密码修改失败,请重试', 'error', 'passwordForm');
console.error('Password change error:', error);
} finally {
setButtonLoading(submitBtn, false, originalText);
}
}
// 验证用户资料数据
function validateProfileData(data) {
if (!data.username.trim()) {
showMessage('用户名不能为空', 'error', 'profileForm');
return false;
}
if (data.username.length < 3) {
showMessage('用户名长度不能少于3位', 'error', 'profileForm');
return false;
}
if (data.email && !isValidEmail(data.email)) {
showMessage('请输入有效的邮箱地址', 'error', 'profileForm');
return false;
}
return true;
}
// 验证密码数据
function validatePasswordData(data) {
const { oldPassword, newPassword, confirmPassword } = data;
if (!oldPassword || !newPassword || !confirmPassword) {
showMessage('请填写所有密码字段', 'error', 'passwordForm');
return false;
}
if (newPassword.length < 6) {
showMessage('新密码长度不能少于6位', 'error', 'passwordForm');
return false;
}
if (newPassword !== confirmPassword) {
showMessage('新密码与确认密码不匹配', 'error', 'passwordForm');
return false;
}
if (oldPassword === newPassword) {
showMessage('新密码不能与当前密码相同', 'error', 'passwordForm');
return false;
}
return true;
}
// 绑定输入验证
function bindInputValidation() {
const inputs = document.querySelectorAll('.form-input, .form-textarea');
inputs.forEach(input => {
input.addEventListener('blur', function() {
validateField(this);
});
input.addEventListener('input', function() {
clearFieldError(this);
});
});
}
// 验证单个字段
function validateField(field) {
const value = field.value.trim();
const fieldName = field.name;
clearFieldError(field);
let isValid = true;
let errorMessage = '';
switch (fieldName) {
case 'username':
if (!value) {
isValid = false;
errorMessage = '用户名不能为空';
} else if (value.length < 3) {
isValid = false;
errorMessage = '用户名长度不能少于3位';
}
break;
case 'email':
if (value && !isValidEmail(value)) {
isValid = false;
errorMessage = '请输入有效的邮箱地址';
}
break;
case 'newPassword':
if (value && value.length < 6) {
isValid = false;
errorMessage = '密码长度不能少于6位';
}
break;
}
if (!isValid) {
showFieldError(field, errorMessage);
}
return isValid;
}
// 显示字段错误
function showFieldError(field, message) {
field.classList.add('error');
let errorElement = field.parentNode.querySelector('.error-message');
if (!errorElement) {
errorElement = document.createElement('div');
errorElement.className = 'error-message';
field.parentNode.appendChild(errorElement);
}
errorElement.textContent = message;
errorElement.classList.add('show');
}
// 清除字段错误
function clearFieldError(field) {
field.classList.remove('error');
const errorElement = field.parentNode.querySelector('.error-message');
if (errorElement) {
errorElement.classList.remove('show');
}
}
// 验证邮箱格式
function isValidEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
// 设置按钮加载状态
function setButtonLoading(button, loading, originalText = null) {
if (loading) {
button.disabled = true;
button.textContent = '处理中...';
button.classList.add('loading');
} else {
button.disabled = false;
button.textContent = originalText || button.textContent;
button.classList.remove('loading');
}
}
// 显示消息
function showMessage(message, type = 'info', formId = null) {
console.log('showMessage called with:', { message, type, formId });
removeExistingMessages();
let targetContainer;
if (formId === 'profileForm') {
targetContainer = document.querySelector('#profileForm .message-container');
console.log('Looking for profileForm message container:', !!targetContainer);
} else if (formId === 'passwordForm') {
targetContainer = document.querySelector('#passwordForm .message-container');
console.log('Looking for passwordForm message container:', !!targetContainer);
} else {
// 如果没有指定表单,使用默认容器
targetContainer = document.querySelector('.profile-content');
console.log('Using default container:', !!targetContainer);
}
if (!targetContainer) {
console.warn('Message container not found for formId:', formId);
console.warn('Available containers:', document.querySelectorAll('.message-container'));
// 尝试使用备用容器
targetContainer = document.querySelector('.message-container');
if (!targetContainer) {
console.error('No message container found anywhere');
return;
}
}
console.log('Target container found:', targetContainer);
const messageElement = document.createElement('div');
messageElement.className = `message ${type} show`;
const messageText = document.createElement('span');
messageText.textContent = message;
messageElement.appendChild(messageText);
const closeButton = document.createElement('button');
closeButton.className = 'message-close';
closeButton.type = 'button';
closeButton.innerHTML = '×';
closeButton.onclick = function() {
messageElement.remove();
};
messageElement.appendChild(closeButton);
targetContainer.appendChild(messageElement);
console.log('Message added to container');
// 5秒后自动隐藏
setTimeout(() => {
if (messageElement.parentNode) {
messageElement.remove();
}
}, 5000);
}
// 关闭指定消息
function closeMessage(messageId) {
const messageElement = document.getElementById(messageId);
if (messageElement) {
messageElement.remove();
}
}
// 移除现有消息
function removeExistingMessages() {
const existingMessages = document.querySelectorAll('.message');
existingMessages.forEach(msg => msg.remove());
}
// 显示初始消息
function showInitialMessage() {
const urlParams = new URLSearchParams(window.location.search);
const msg = urlParams.get('msg');
const msgType = urlParams.get('type') || 'info';
if (msg) {
showMessage(decodeURIComponent(msg), msgType);
const newUrl = window.location.pathname;
window.history.replaceState({}, document.title, newUrl);
}
}
// 更新用户信息显示
function updateUserInfoDisplay(user) {
const infoItems = document.querySelectorAll('.info-value');
infoItems.forEach(item => {
const label = item.previousElementSibling.textContent;
if (label.includes('最后更新')) {
item.textContent = new Date().toLocaleDateString('zh-CN');
}
});
}
// 重置表单
function resetForm() {
const form = document.getElementById('profileForm');
if (form) {
form.reset();
const inputs = form.querySelectorAll('.form-input, .form-textarea');
inputs.forEach(input => clearFieldError(input));
showMessage('表单已重置', 'info');
}
}
// 重置密码表单
function resetPasswordForm() {
console.log('resetPasswordForm called');
const form = document.getElementById('passwordForm');
console.log('Password form found:', !!form);
if (form) {
form.reset();
const inputs = form.querySelectorAll('.form-input');
inputs.forEach(input => clearFieldError(input));
// 查找消息容器
const messageContainer = form.querySelector('.message-container');
console.log('Message container found:', !!messageContainer);
showMessage('密码表单已清空', 'info', 'passwordForm');
} else {
console.error('Password form not found');
}
}
// 初始化标签页
function initTabs() {
const tabBtns = document.querySelectorAll('.tab-btn');
const tabPanes = document.querySelectorAll('.tab-pane');
console.log('Initializing tabs...');
console.log('Tab buttons found:', tabBtns.length);
console.log('Tab panes found:', tabPanes.length);
if (tabBtns.length === 0 || tabPanes.length === 0) {
console.warn('Tab elements not found');
return;
}
tabBtns.forEach(btn => {
btn.addEventListener('click', function() {
const targetTab = this.getAttribute('data-tab');
console.log('Tab clicked:', targetTab); // 调试日志
// 移除所有活动状态
tabBtns.forEach(b => b.classList.remove('active'));
tabPanes.forEach(p => p.classList.remove('active'));
// 添加活动状态到当前标签
this.classList.add('active');
const targetPane = document.getElementById(targetTab + '-tab');
if (targetPane) {
targetPane.classList.add('active');
console.log('Tab pane activated:', targetTab + '-tab'); // 调试日志
// 重新绑定表单事件,确保新显示的Tab中的表单能正常工作
setTimeout(() => {
bindFormEvents();
}, 100);
} else {
console.error('Target tab pane not found:', targetTab + '-tab');
}
});
});
// 确保默认标签页是激活状态
const defaultTab = document.querySelector('.tab-btn.active');
const defaultPane = document.querySelector('.tab-pane.active');
if (defaultTab && defaultPane) {
console.log('Default tab initialized:', defaultTab.getAttribute('data-tab'));
}
}
// 导出函数供HTML调用
window.resetForm = resetForm;
window.resetPasswordForm = resetPasswordForm;
window.closeMessage = closeMessage;
})();

129
scripts/test-profile.js

@ -0,0 +1,129 @@
#!/usr/bin/env node
/**
* 用户资料系统测试脚本
* 用于验证系统功能是否正常
*/
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
console.log('🧪 开始测试用户资料系统...\n');
// 检查必要的文件是否存在
const requiredFiles = [
'src/controllers/Page/PageController.js',
'src/views/page/profile/index.pug',
'public/js/profile.js',
'src/db/migrations/20250901000000_add_profile_fields.mjs'
];
console.log('📁 检查必要文件...');
let allFilesExist = true;
requiredFiles.forEach(file => {
if (fs.existsSync(file)) {
console.log(`${file}`);
} else {
console.log(`${file} - 文件不存在`);
allFilesExist = false;
}
});
if (!allFilesExist) {
console.log('\n❌ 部分必要文件缺失,请检查文件创建');
process.exit(1);
}
console.log('\n✅ 所有必要文件都存在');
// 检查数据库迁移文件
console.log('\n🗄️ 检查数据库迁移...');
try {
const migrationContent = fs.readFileSync('src/db/migrations/20250901000000_add_profile_fields.mjs', 'utf8');
if (migrationContent.includes('name') && migrationContent.includes('bio') && migrationContent.includes('avatar')) {
console.log('✅ 数据库迁移文件包含必要字段');
} else {
console.log('❌ 数据库迁移文件缺少必要字段');
}
} catch (error) {
console.log('❌ 无法读取数据库迁移文件');
}
// 检查路由配置
console.log('\n🛣️ 检查路由配置...');
try {
const controllerContent = fs.readFileSync('src/controllers/Page/PageController.js', 'utf8');
const hasProfileGet = controllerContent.includes('profileGet');
const hasProfileUpdate = controllerContent.includes('profileUpdate');
const hasChangePassword = controllerContent.includes('changePassword');
const hasProfileRoutes = controllerContent.includes('/profile/update') && controllerContent.includes('/profile/change-password');
if (hasProfileGet && hasProfileUpdate && hasChangePassword && hasProfileRoutes) {
console.log('✅ 控制器方法已实现');
console.log('✅ 路由配置已添加');
} else {
console.log('❌ 控制器方法或路由配置不完整');
}
} catch (error) {
console.log('❌ 无法读取控制器文件');
}
// 检查前端模板
console.log('\n🎨 检查前端模板...');
try {
const templateContent = fs.readFileSync('src/views/page/profile/index.pug', 'utf8');
const hasProfileForm = templateContent.includes('profileForm');
const hasPasswordForm = templateContent.includes('passwordForm');
const hasUserFields = templateContent.includes('username') && templateContent.includes('email') && templateContent.includes('name');
const hasInlineStyles = templateContent.includes('style.') && templateContent.includes('.profile-container');
if (hasProfileForm && hasPasswordForm && hasUserFields && hasInlineStyles) {
console.log('✅ 前端模板包含必要表单和样式');
} else {
console.log('❌ 前端模板缺少必要元素');
}
} catch (error) {
console.log('❌ 无法读取前端模板文件');
}
// 检查JavaScript功能
console.log('\n⚡ 检查JavaScript功能...');
try {
const jsContent = fs.readFileSync('public/js/profile.js', 'utf8');
const hasProfileUpdate = jsContent.includes('handleProfileUpdate');
const hasPasswordChange = jsContent.includes('handlePasswordChange');
const hasValidation = jsContent.includes('validateField');
const hasIIFE = jsContent.includes('(function()') && jsContent.includes('})();');
if (hasProfileUpdate && hasPasswordChange && hasValidation && hasIIFE) {
console.log('✅ JavaScript文件包含必要功能,使用IIFE模式');
} else {
console.log('❌ JavaScript文件缺少必要功能');
}
} catch (error) {
console.log('❌ 无法读取JavaScript文件');
}
console.log('\n📋 测试完成!');
console.log('\n📝 下一步操作:');
console.log('1. 运行数据库迁移: npm run migrate');
console.log('2. 启动应用: npm start');
console.log('3. 访问 /profile 页面测试功能');
console.log('4. 确保用户已登录才能访问资料页面');
console.log('\n🔧 如果遇到问题:');
console.log('- 检查数据库连接');
console.log('- 确认用户表结构正确');
console.log('- 查看浏览器控制台错误信息');
console.log('- 检查服务器日志');
console.log('\n✨ 重构完成:');
console.log('- 样式已内联到Pug模板中');
console.log('- JavaScript使用IIFE模式,避免全局污染');
console.log('- 界面设计更简洁,与项目风格保持一致');
console.log('- 代码结构更清晰,易于维护');

124
src/controllers/Page/PageController.js

@ -83,21 +83,12 @@ class PageController {
return ctx.redirect("/?msg=用户已登录")
}
// TODO 多个
ctx.session.registerRandomStr = Math.ceil(Math.random() * 100000000000000)
return await ctx.render("page/register/index", { site_title: "注册", randomStr: ctx.session.registerRandomStr })
return await ctx.render("page/register/index", { site_title: "注册" })
}
// 处理注册请求
async registerPost(ctx) {
const { username, password, code, randomStr } = ctx.request.body
if (!ctx.session.registerRandomStr) {
throw new CommonError("缺少随机数")
}
if (ctx.session.registerRandomStr + "" !== randomStr + "") {
throw new CommonError("随机数不匹配")
}
delete ctx.session.registerRandomStr
const { username, password, code } = ctx.request.body
// 检查Session中是否存在验证码
if (!ctx.session.captcha) {
@ -123,7 +114,7 @@ class PageController {
delete ctx.session.captcha
await this.userService.register({ username, password, role: "user" })
await this.userService.register({ username, name: username, password, role: "user" })
return ctx.redirect("/login")
}
@ -134,6 +125,111 @@ class PageController {
ctx.set("hx-redirect", "/")
}
// 获取用户资料
async profileGet(ctx) {
if (!ctx.session.user) {
return ctx.redirect("/login")
}
try {
const user = await this.userService.getUserById(ctx.session.user.id)
return await ctx.render("page/profile/index", {
user,
site_title: "用户资料"
}, { includeSite: true, includeUser: true })
} catch (error) {
logger.error(`获取用户资料失败: ${error.message}`)
ctx.status = 500
ctx.body = { success: false, message: "获取用户资料失败" }
}
}
// 更新用户资料
async profileUpdate(ctx) {
if (!ctx.session.user) {
ctx.status = 401
ctx.body = { success: false, message: "未登录" }
return
}
try {
const { username, email, name, bio, avatar } = ctx.request.body
// 验证必填字段
if (!username) {
ctx.status = 400
ctx.body = { success: false, message: "用户名不能为空" }
return
}
const updateData = { username, email, name, bio, avatar }
// 移除空值
Object.keys(updateData).forEach(key => {
if (updateData[key] === undefined || updateData[key] === null || updateData[key] === '') {
delete updateData[key]
}
})
const updatedUser = await this.userService.updateUser(ctx.session.user.id, updateData)
// 更新session中的用户信息
ctx.session.user = { ...ctx.session.user, ...updatedUser }
ctx.body = {
success: true,
message: "资料更新成功",
user: updatedUser
}
} catch (error) {
logger.error(`更新用户资料失败: ${error.message}`)
ctx.status = 500
ctx.body = { success: false, message: error.message || "更新用户资料失败" }
}
}
// 修改密码
async changePassword(ctx) {
if (!ctx.session.user) {
ctx.status = 401
ctx.body = { success: false, message: "未登录" }
return
}
try {
const { oldPassword, newPassword, confirmPassword } = ctx.request.body
if (!oldPassword || !newPassword || !confirmPassword) {
ctx.status = 400
ctx.body = { success: false, message: "请填写所有密码字段" }
return
}
if (newPassword !== confirmPassword) {
ctx.status = 400
ctx.body = { success: false, message: "新密码与确认密码不匹配" }
return
}
if (newPassword.length < 6) {
ctx.status = 400
ctx.body = { success: false, message: "新密码长度不能少于6位" }
return
}
await this.userService.changePassword(ctx.session.user.id, oldPassword, newPassword)
ctx.body = {
success: true,
message: "密码修改成功"
}
} catch (error) {
logger.error(`修改密码失败: ${error.message}`)
ctx.status = 500
ctx.body = { success: false, message: error.message || "修改密码失败" }
}
}
// 处理联系表单提交
async contactPost(ctx) {
const { name, email, subject, message } = ctx.request.body
@ -184,7 +280,9 @@ class PageController {
router.get("/privacy", controller.pageGet("page/extra/privacy"), { auth: false })
router.get("/faq", controller.pageGet("page/extra/faq"), { auth: false })
router.get("/feedback", controller.pageGet("page/extra/feedback"), { auth: false })
router.get("/profile", controller.pageGet("page/profile/index"), { auth: true })
router.get("/profile", controller.profileGet.bind(controller), { auth: true })
router.post("/profile/update", controller.profileUpdate.bind(controller), { auth: true })
router.post("/profile/change-password", controller.changePassword.bind(controller), { auth: true })
router.get("/notice", controller.pageGet("page/notice/index"), { auth: true })
router.get("/help", controller.pageGet("page/extra/help"), { auth: false })
router.get("/contact", controller.pageGet("page/extra/contact"), { auth: false })

25
src/db/migrations/20250901000000_add_profile_fields.mjs

@ -0,0 +1,25 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
export const up = async knex => {
return knex.schema.alterTable("users", function (table) {
table.string("name", 100) // 昵称
table.text("bio") // 个人简介
table.string("avatar", 500) // 头像URL
table.string("status", 20).defaultTo("active") // 用户状态
})
}
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
export const down = async knex => {
return knex.schema.alterTable("users", function (table) {
table.dropColumn("name")
table.dropColumn("bio")
table.dropColumn("avatar")
table.dropColumn("status")
})
}

2
src/middlewares/Session/index.js

@ -8,6 +8,8 @@ export default (app) => {
signed: true, // 将 cookie 的内容通过密钥进行加密。需配置app.keys
rolling: false,
renew: false,
secure: process.env.NODE_ENV === "production",
sameSite: "lax", // https://scotthelme.co.uk/csrf-is-dead/
};
return session(CONFIG, app);
};

4
src/views/layouts/empty.pug

@ -29,7 +29,7 @@ block $$content
else
.right.menu.desktop-only
a.menu-item(hx-post="/logout") 退出
a.menu-item(href="/profile") 欢迎您 , #{$user.username}
a.menu-item(href="/profile") 欢迎您 , #{$user.name}
a.menu-item(href="/notice")
.fe--notice-active
// 移动端:汉堡按钮
@ -48,7 +48,7 @@ block $$content
else
.right.menu
a.menu-item(hx-post="/logout") 退出
a.menu-item() 欢迎您 , #{$user.username}
a.menu-item() 欢迎您 , #{$user.name}
a.menu-item(href="/notice" class="fe--notice-active") 公告
.page-layout
.page.container

4
src/views/layouts/utils.pug

@ -10,13 +10,13 @@ mixin css(url, extranl = false)
if extranl || url.startsWith('http') || url.startsWith('//')
link(rel="stylesheet" type="text/css" href=url)
else
link(rel="stylesheet", href=($config && $config.base || "") + url)
link(rel="stylesheet", href=($config && $config.base || "") + (url.startsWith('/') ? url.slice(1) : url))
mixin js(url, extranl = false)
if extranl || url.startsWith('http') || url.startsWith('//')
script(type="text/javascript" src=url)
else
script(src=($config && $config.base || "") + url)
script(src=($config && $config.base || "") + (url.startsWith('/') ? url.slice(1) : url))
mixin link(href, name)
//- attributes == {class: "btn"}

2
src/views/page/articles/index.pug

@ -2,7 +2,7 @@ extends /layouts/empty.pug
block pageContent
.flex.flex-col
.flex-1.py-8.bg-gray-50
.flex-1
.container.mx-auto
// 页头
.flex.justify-between.items-center.mb-8

27
src/views/page/login/index.pug

@ -4,17 +4,16 @@ block pageScripts
script(src="js/login.js")
block pageContent
div.h-full.bg-red-400 sada
//- .flex.items-center.justify-center.bg-base-200.flex-1
//- .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
.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

619
src/views/page/profile/index.pug

@ -1,7 +1,622 @@
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);
}
.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
div sad
.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(for="avatar") 头像URL
input.form-input#avatar(
type="url"
name="avatar"
value=user.avatar || ''
placeholder="请输入头像图片链接"
)
.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
src/views/page/register/index.pug

@ -100,7 +100,6 @@ block pageContent
.register-container
.register-title 注册账号
form(action="/register" method="post")
input(type="text" name="randomStr" value=randomStr style="display:none")
.form-group
label(for="username") 用户名
input(type="text" id="username" name="username" required placeholder="请输入用户名")

Loading…
Cancel
Save