From 202b214310a713a34df8baa28b2c9e36a2a4ae52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B0=A2=E4=BA=9A=E6=98=95?= <1549469775@qq.com> Date: Tue, 2 Sep 2025 14:46:19 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=94=A8=E6=88=B7=E8=B5=84?= =?UTF-8?q?=E6=96=99=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E7=94=A8=E6=88=B7=E8=B5=84=E6=96=99=E8=8E=B7=E5=8F=96?= =?UTF-8?q?=E5=92=8C=E6=9B=B4=E6=96=B0=E6=8E=A5=E5=8F=A3=EF=BC=8C=E8=B0=83?= =?UTF-8?q?=E6=95=B4=E6=B3=A8=E5=86=8C=E6=B5=81=E7=A8=8B=EF=BC=8C=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E8=A7=86=E5=9B=BE=E6=A8=A1=E6=9D=BF=E4=BB=A5=E6=8F=90?= =?UTF-8?q?=E5=8D=87=E7=94=A8=E6=88=B7=E4=BD=93=E9=AA=8C=EF=BC=8C=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E5=AE=89=E5=85=A8=E6=80=A7=E5=92=8C=E4=BA=A4=E4=BA=92?= =?UTF-8?q?=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- database/development.sqlite3-shm | Bin 32768 -> 32768 bytes database/development.sqlite3-wal | Bin 259592 -> 638632 bytes docs/profile-system.md | 169 ++++++ public/js/profile.js | 449 +++++++++++++++ scripts/test-profile.js | 129 +++++ src/controllers/Page/PageController.js | 124 ++++- .../20250901000000_add_profile_fields.mjs | 25 + src/middlewares/Session/index.js | 2 + src/views/layouts/empty.pug | 4 +- src/views/layouts/utils.pug | 4 +- src/views/page/articles/index.pug | 2 +- src/views/page/login/index.pug | 27 +- src/views/page/profile/index.pug | 619 ++++++++++++++++++++- src/views/page/register/index.pug | 1 - 14 files changed, 1520 insertions(+), 35 deletions(-) create mode 100644 docs/profile-system.md create mode 100644 public/js/profile.js create mode 100644 scripts/test-profile.js create mode 100644 src/db/migrations/20250901000000_add_profile_fields.mjs diff --git a/database/development.sqlite3-shm b/database/development.sqlite3-shm index 416781c875984978aa56bf8ee1d89690d63c6f98..264d1055f96c6b329035f41a4b409796adfd97bf 100644 GIT binary patch delta 644 zcmb7-+cT9>7{-5VZTtJe-o`e}V54DQ)Fk89rI1pnl+=z&PDzCwo#op(q|#ygd5OF4x!wD`^ z%OIb^CT%nFPe74qGH>F<9O@;`BTYt7|`c-c6mXR=6^yTO^+{R>Df$vh!r=E9= Isv|RQ7j&SHfdBvi delta 305 zcmZo@U}|V!s+V}A%K!t63=9JHK#mX)PoBV#s%XA<<^3Ph^WVHbpng8`(+uBvCd#C$ zhnWpB_dgPViZe0TZ){{{-28=!%V8690psL@1h`#&2M6v uAS_l91|Np zY|B)w?TGyphoX&jYqiU?{l-pfU0UmcmBqE%sbjS+Gxxri?LopQqfY&PMqhr(&AH1t z=YP&!{+IfTp!s%4%|d)3v5n+idkVua5x0)`y!_F~f41xhys+7c@_=O=un7a* z;2_us+Q6nZxxfHic^q#{I9P%KgwaBgogz3CE`~jU zd6_kwHm12@Srd89l^*0R4BUd6{Q`nX%mtRP^!Z2kxs!g|hpc1t(PKalMThoi%NZYSt_I61q@t(V?GT=`;g%- zy~c=403VC@KkaHhnGl|Sys?HF?CAu5@It?uUV|GqQ(|8yEKKv3hs$CN5Nj|H$G^;< zOsr{3ofAa3);;6oJYc{8d~sUa;pbEF?56{>OXVfX?7(ulMo}QwJSn88P*|9wQfCM1 zl$AR8Sza#J$u(9HDI=oQ$#r_IwOB-j2IeaBeYyhPo3cxSW7$5S*LM{i(z&Qvml#GJI zLa!`&Zf;hYMxCcBR%Yd?l*PH)u#!S8fxqT_IXt=TPJ=7%|98Ll&yEd!_0HXehZyG? zK0Js&0gVPtX$GLWrbIL1(}v5&Yz;0Dcv17#9r~mPz4_zaGQ|11;A1Z2eV_7i!{~O-&oY8T=L)$;we( z$@?-Ti5cJCofC%m@x2Lh6kb%StjsD=C&b$PT@ybE`9gk=D zTr9v>EG|;ctx#)nwK>J=e0ENe%A%jnJCT+~92H`;WJt zUHbLW3Ob*m)Q<9*CwvI_F!_x0JI`l6JRT@Y!dEWoBm|3AY67a&QXl0dGK0)d0h~4MYtH9kQPL z+8bMbG*=9N8&zyQ>O#)CS$N4(@hh+s90!+R#dm?>w~vW*u)wdqdh%;Or~k z0xU?0{U&11!g;%9Asyfg`Nzw6>oEBX48RUD!03L0?B@Ae-3Gn(`fh#3n~LB5!txqj z8@@dPUNf%ECWevzY3J8pym3xRd&dUr4ZOFxW9Ai}tA?QZ~S&=ZTM?+P-(7fS}lrqm9NB%17s9Er~6k*K?XfdD# zyTKZ86v9yf48@H^5Y98KXe35(oc&1|ZjjdyejM(TWE%V!MFxc;JJFG2ZqR^Z`v1Yl zUt}6=z(4~y37dS~IBX^HB7lt@*gy<$v*t$Uo&{XGKx~yjUGU*!8S@u%{vY3#q>g2} z*p1O08~~T$m^TAMXB#1y%@dHpc-N{wcWyD9UH;Cwl{L<&pmXtS9ebL(URu)mvVn^V zy4!Yj?^@fjYgzZMmr*yswwv4DWzW3V_Gt*y%?B7b06qXefQ!bIZNzRcn}@RQj0wpd zI~qG%-lyB|T3XZ9w6%Np>a%-SaJ}Ia$+X|yvc7xy;?5OoP#17PV%5asd0#KL4aZuT zF4ke-EchDi0PCP8%Yos>w}eNv)egJ?q!_mYFJ}kkTnFW76ewj|?0LffL_qwfGv^{d z;%-YY_}`0Mgd{M`=yZgeg(Zib9JOUdM$uu?Sayi0C-Fx)utM;spk}P))PcFWGt$(MoyeXX&}`lEHABCy z2IE}dmJg@5d_=zz6%YC6+~qq-p7s)rrZO`h?P)(%i80DoJ{!o=R5#AugrhIkl3?kwi|@XY}4M z!i*>uvyc!RO_wp-3)n5(H(D{U-u+Oa|ILkjSEF2mp9hgWm%2_QhND#lOVKCB)owiU z>k9#lq6!QsprSM32GSpCXx=~uSTyXlK{IV4@3YFWHv48;<;gRWlLDv5&9tw2>z9d( zO6obAQP|_)JO<7~E!shpFBy*a8gVZhG@lr=FYt#ZDPw~y3}nIDY0w5kST^uAd%0JO z55@-Zj%=WnYvuOsp#95rqjIZ$1)%Q=e~lrp)-?35_DAU}+JgRcL zdG%G__pf$R?V8I1V%6vUtDQCWjQ-l5GmZVLeQ`5hw0C}R;a%5e#oz01Hnh)Lb?gfU zd$Tbx8)9!d#NIH7y`h%)vtSRNN<9qtO=P7VChM3(&@udum!9@-*|)mAf2J5!xW46w z?*`P-rr>vD{BCH96a4B0euTi*6t#L&wtYCZsAi|&-nJ;v>B_Xx;o&;1I83JqD=7)f zQI+P0l`3`Nx`J`4+?eF)Q)h%pDX~zJM~R{&xe-)e6g-L(ij@+0sFM>R6-J0;A!Sf9 zBeyK1B1RMu6;co*lu#jaightDGAcw7BMi}K;ahH*2ELcYQ1;{h?;o{h-=TQ#_0Iug zI1N9)C+@)!_44N9 z#zF1!{yE}Ydfe^(i9K!58JBmz z)V;E~yJ>CbhjpD>Kj_@MsAI?K?o}_HsafFK`Cct_TIYhzQ0{C^twpq}ZV@ATcFDV) z##SiGa%uJb53YM~Y2nWTdEK?E-Tj4wopb74XzCKmG5`w*veS zBKlqdll@)+4`WSC<2Dpu;GWq!7WGK*1rURLL2IiMzq*zn0XEo7l)zsH>C|Ot@oHKH zMdnsWWRv5i#g&-}vdoEbQi-TAM=wp7BhOc>k|s$DXH3aUO3u)x$W_y4OsYuIs^r43 zo{QgzXpuNtD7Br1Ohwg9=KHUOLN#P^dN0f$UMN&QsCnitLt!A|;KGM5z3e>is2c(^ z0Xi|zNw2uS2V%6zARS_w#WGx2dTV>`clrc|W$@A6xU%eU(1A}7;LxW2G2j^H@G;;Z z;2I)BO1{4oya)k~rgO}Ii|!k6EL!K;uMWO@@XmK3CWILwt@Gw%LIox>yL=HeG>cGK zS#Vlde<$;X#Rb0?Ld7~!20*0DTR*EYhRS&IKb|Rl`{3mMRC8RSx|**tadN(Wb8>Lz zl0=JH)W4}H&d0*ZITo7WZQE6!Z!i5ML=d_gPR`eclk;J5a^ChhIWPO1oM#VCPQd2m&=Iwj+(De&h#i{_w9YQ? zq}d(%ganG9<5nvt$9=>&Io-D}pD#P5=b9&1sLcOs7UsnF_3@Vu|Ba>^==tJ5Wno?| zt@oBPkg1kZ{c{*JoN z%H=*R7KX)%99xmOPdmwZAjCR)p&0ih7t^1yR-=Y|#0kg{9BF3FTrhFnR&YMv+n!R- zN?eS>W?K4pow!W2^pfbG4>ZMUhTiKG?%tD^b})q_UYhr6QShtJ4cwAtY}X>j%#ER< zM?(tE@qE@}J1Q|=sUnhb+?C;>@3C&#+hiajpx2yVxYqh zUq5{*asQu8eDEWP?x*n)tZbfqb?BMNJ~9#^)5NL6Y~I>hdH?cwY@htQZSd1aKvYDT zKBrivEl}o8m1~OX%iXOszT+IygT`my8=QX1Yx|hOzo!5D%;&^9=DXX>hlp)@^gwrc8*h}(PEh`gdsvv5p!>$ zH=9Hs^XcYpm?GZdc>(1zbaxBfG#&Gevw!M>O3@J?I!Lx$P09b@^P@9cT)i2DRbim& zF~S}?!fHudl)4Ak0EC&Wip~8UbF*qJHIBInJohmN$|Uc4Vdez@#5^tLNXd3@H7em5?R3y>S!>;M1& delta 15 WcmZ4SSFPg>e?tpn3)2>6j&A@uZ3d43 diff --git a/docs/profile-system.md b/docs/profile-system.md new file mode 100644 index 0000000..99eef37 --- /dev/null +++ b/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文件使用模块化设计,可以轻松修改: +- 颜色主题 +- 布局结构 +- 动画效果 +- 响应式断点 + +### 扩展功能 +可以基于现有架构添加: +- 头像上传 +- 社交媒体链接 +- 隐私设置 +- 通知偏好 diff --git a/public/js/profile.js b/public/js/profile.js new file mode 100644 index 0000000..116296d --- /dev/null +++ b/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; + +})(); diff --git a/scripts/test-profile.js b/scripts/test-profile.js new file mode 100644 index 0000000..5f30e81 --- /dev/null +++ b/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('- 代码结构更清晰,易于维护'); diff --git a/src/controllers/Page/PageController.js b/src/controllers/Page/PageController.js index 3c7f94f..2eabc2a 100644 --- a/src/controllers/Page/PageController.js +++ b/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 }) diff --git a/src/db/migrations/20250901000000_add_profile_fields.mjs b/src/db/migrations/20250901000000_add_profile_fields.mjs new file mode 100644 index 0000000..3f27c22 --- /dev/null +++ b/src/db/migrations/20250901000000_add_profile_fields.mjs @@ -0,0 +1,25 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +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 } + */ +export const down = async knex => { + return knex.schema.alterTable("users", function (table) { + table.dropColumn("name") + table.dropColumn("bio") + table.dropColumn("avatar") + table.dropColumn("status") + }) +} diff --git a/src/middlewares/Session/index.js b/src/middlewares/Session/index.js index 9a3f254..fcc90fb 100644 --- a/src/middlewares/Session/index.js +++ b/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); }; diff --git a/src/views/layouts/empty.pug b/src/views/layouts/empty.pug index ccc9c3c..2a97747 100644 --- a/src/views/layouts/empty.pug +++ b/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 diff --git a/src/views/layouts/utils.pug b/src/views/layouts/utils.pug index aa2c266..7cc90a7 100644 --- a/src/views/layouts/utils.pug +++ b/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"} diff --git a/src/views/page/articles/index.pug b/src/views/page/articles/index.pug index 1bf9cd6..5c4cfeb 100644 --- a/src/views/page/articles/index.pug +++ b/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 diff --git a/src/views/page/login/index.pug b/src/views/page/login/index.pug index acb7428..796f94f 100644 --- a/src/views/page/login/index.pug +++ b/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 diff --git a/src/views/page/profile/index.pug b/src/views/page/profile/index.pug index b46cbad..33fd665 100644 --- a/src/views/page/profile/index.pug +++ b/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 \ No newline at end of file + .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") \ No newline at end of file diff --git a/src/views/page/register/index.pug b/src/views/page/register/index.pug index 72b3d67..1af0613 100644 --- a/src/views/page/register/index.pug +++ b/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="请输入用户名")