14 changed files with 1520 additions and 35 deletions
Binary file not shown.
Binary file not shown.
@ -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文件使用模块化设计,可以轻松修改: |
|||
- 颜色主题 |
|||
- 布局结构 |
|||
- 动画效果 |
|||
- 响应式断点 |
|||
|
|||
### 扩展功能 |
|||
可以基于现有架构添加: |
|||
- 头像上传 |
|||
- 社交媒体链接 |
|||
- 隐私设置 |
|||
- 通知偏好 |
|||
@ -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; |
|||
|
|||
})(); |
|||
@ -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('- 代码结构更清晰,易于维护'); |
|||
@ -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") |
|||
}) |
|||
} |
|||
@ -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") |
|||
Loading…
Reference in new issue