feat: 添加管理员账户信息修改功能

- 新增右上角用户菜单,包含修改账户信息和退出登录选项
- 实现修改用户名和密码功能,支持独立修改或同时修改
- 添加密码强度验证(最少8位)和确认密码验证
- 修改后自动退出登录,确保安全性
- 同步更新Redis和data/init.json文件,保持数据一致性
- 优化用户体验,提供实时反馈和错误提示

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
shaw
2025-07-16 17:16:29 +08:00
parent 5a0271d536
commit 156bfa9b58
3 changed files with 335 additions and 6 deletions

View File

@@ -146,6 +146,112 @@ router.post('/auth/logout', async (req, res) => {
} }
}); });
// 🔑 修改账户信息
router.post('/auth/change-password', async (req, res) => {
try {
const token = req.headers['authorization']?.replace('Bearer ', '') || req.cookies?.adminToken;
if (!token) {
return res.status(401).json({
error: 'No token provided',
message: 'Authentication required'
});
}
const { newUsername, currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword) {
return res.status(400).json({
error: 'Missing required fields',
message: 'Current password and new password are required'
});
}
// 验证新密码长度
if (newPassword.length < 8) {
return res.status(400).json({
error: 'Password too short',
message: 'New password must be at least 8 characters long'
});
}
// 获取当前会话
const sessionData = await redis.getSession(token);
if (!sessionData) {
return res.status(401).json({
error: 'Invalid token',
message: 'Session expired or invalid'
});
}
// 获取当前管理员信息
const adminData = await redis.getSession('admin_credentials');
if (!adminData) {
return res.status(500).json({
error: 'Admin data not found',
message: 'Administrator credentials not found'
});
}
// 验证当前密码
const isValidPassword = await bcrypt.compare(currentPassword, adminData.passwordHash);
if (!isValidPassword) {
logger.security(`🔒 Invalid current password attempt for user: ${sessionData.username}`);
return res.status(401).json({
error: 'Invalid current password',
message: 'Current password is incorrect'
});
}
// 准备更新的数据
const saltRounds = 10;
const newPasswordHash = await bcrypt.hash(newPassword, saltRounds);
const updatedAdminData = {
...adminData,
passwordHash: newPasswordHash,
updatedAt: new Date().toISOString()
};
// 如果提供了新用户名,则更新用户名
if (newUsername && newUsername.trim() && newUsername !== adminData.username) {
updatedAdminData.username = newUsername.trim();
}
// 更新Redis中的管理员凭据
await redis.setSession('admin_credentials', updatedAdminData);
// 更新data/init.json文件
const initFilePath = path.join(__dirname, '../../data/init.json');
if (fs.existsSync(initFilePath)) {
const initData = JSON.parse(fs.readFileSync(initFilePath, 'utf8'));
initData.adminUsername = updatedAdminData.username;
initData.adminPassword = newPassword; // 保存明文密码到init.json
initData.updatedAt = new Date().toISOString();
fs.writeFileSync(initFilePath, JSON.stringify(initData, null, 2));
}
// 清除当前会话(强制用户重新登录)
await redis.deleteSession(token);
logger.success(`🔐 Admin password changed successfully for user: ${updatedAdminData.username}`);
res.json({
success: true,
message: 'Password changed successfully. Please login again.',
newUsername: updatedAdminData.username
});
} catch (error) {
logger.error('❌ Change password error:', error);
res.status(500).json({
error: 'Change password failed',
message: 'Internal server error'
});
}
});
// 🔄 刷新token // 🔄 刷新token
router.post('/auth/refresh', async (req, res) => { router.post('/auth/refresh', async (req, res) => {
try { try {

View File

@@ -186,6 +186,20 @@ const app = createApp({
callbackUrl: '' callbackUrl: ''
}, },
// 用户菜单和账户修改相关
userMenuOpen: false,
currentUser: {
username: ''
},
showChangePasswordModal: false,
changePasswordLoading: false,
changePasswordForm: {
newUsername: '',
currentPassword: '',
newPassword: '',
confirmPassword: ''
},
} }
}, },
@@ -202,6 +216,15 @@ const app = createApp({
// 初始化防抖函数 // 初始化防抖函数
this.setTrendPeriod = this.debounce(this._setTrendPeriod, 300); this.setTrendPeriod = this.debounce(this._setTrendPeriod, 300);
// 添加全局点击事件监听器,用于关闭用户菜单
document.addEventListener('click', (event) => {
// 检查点击是否在用户菜单外部
const userMenuButton = event.target.closest('.relative');
if (!userMenuButton || !userMenuButton.querySelector('button[\@click*="userMenuOpen"]')) {
this.userMenuOpen = false;
}
});
if (this.authToken) { if (this.authToken) {
this.isLoggedIn = true; this.isLoggedIn = true;
@@ -784,6 +807,10 @@ const app = createApp({
this.authToken = data.token; this.authToken = data.token;
localStorage.setItem('authToken', this.authToken); localStorage.setItem('authToken', this.authToken);
this.isLoggedIn = true; this.isLoggedIn = true;
// 记录当前用户名
this.currentUser.username = this.loginForm.username;
this.loadDashboard(); this.loadDashboard();
} else { } else {
this.loginError = data.message; this.loginError = data.message;
@@ -796,6 +823,75 @@ const app = createApp({
} }
}, },
// 用户菜单相关方法
openChangePasswordModal() {
this.userMenuOpen = false;
this.showChangePasswordModal = true;
},
closeChangePasswordModal() {
this.showChangePasswordModal = false;
this.changePasswordForm = {
newUsername: '',
currentPassword: '',
newPassword: '',
confirmPassword: ''
};
},
async changePassword() {
// 验证表单
if (this.changePasswordForm.newPassword !== this.changePasswordForm.confirmPassword) {
this.showToast('新密码和确认密码不一致', 'error');
return;
}
if (this.changePasswordForm.newPassword.length < 8) {
this.showToast('新密码长度至少8位', 'error');
return;
}
this.changePasswordLoading = true;
try {
const response = await fetch('/web/auth/change-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + this.authToken
},
body: JSON.stringify({
newUsername: this.changePasswordForm.newUsername || undefined,
currentPassword: this.changePasswordForm.currentPassword,
newPassword: this.changePasswordForm.newPassword
})
});
const result = await response.json();
if (result.success) {
this.showToast('账户信息修改成功,即将退出登录', 'success');
this.closeChangePasswordModal();
// 将新的用户名更新到本地状态
if (this.changePasswordForm.newUsername) {
this.currentUser.username = this.changePasswordForm.newUsername;
}
// 延迟2秒后自动退出登录
setTimeout(() => {
this.logout();
}, 2000);
} else {
this.showToast(result.message || '修改失败', 'error');
}
} catch (error) {
console.error('Change password error:', error);
this.showToast('网络错误,请稍后再试', 'error');
} finally {
this.changePasswordLoading = false;
}
},
async logout() { async logout() {
if (this.authToken) { if (this.authToken) {
try { try {

View File

@@ -83,14 +83,44 @@
<p class="text-gray-600 text-sm leading-tight mt-0.5">管理后台</p> <p class="text-gray-600 text-sm leading-tight mt-0.5">管理后台</p>
</div> </div>
</div> </div>
<!-- 用户菜单 -->
<div class="relative">
<button
@click="userMenuOpen = !userMenuOpen"
class="btn btn-primary px-4 py-3 flex items-center gap-2 relative"
>
<i class="fas fa-user-circle"></i>
<span>{{ currentUser.username || 'Admin' }}</span>
<i class="fas fa-chevron-down text-xs transition-transform duration-200" :class="{ 'rotate-180': userMenuOpen }"></i>
</button>
<!-- 悬浮菜单 -->
<div
v-if="userMenuOpen"
class="absolute right-0 top-full mt-2 w-48 bg-white rounded-xl shadow-xl border border-gray-200 py-2 z-50"
@click.stop
>
<button
@click="openChangePasswordModal"
class="w-full px-4 py-3 text-left text-gray-700 hover:bg-gray-50 transition-colors flex items-center gap-3"
>
<i class="fas fa-key text-blue-500"></i>
<span>修改账户信息</span>
</button>
<hr class="my-2 border-gray-200">
<button <button
@click="logout" @click="logout"
class="btn btn-danger px-6 py-3 flex items-center gap-2" class="w-full px-4 py-3 text-left text-red-600 hover:bg-red-50 transition-colors flex items-center gap-3"
> >
<i class="fas fa-sign-out-alt"></i>退出登录 <i class="fas fa-sign-out-alt"></i>
<span>退出登录</span>
</button> </button>
</div> </div>
</div> </div>
</div>
</div>
<!-- 主内容区域 --> <!-- 主内容区域 -->
<div class="glass-strong rounded-3xl p-6 shadow-xl"> <div class="glass-strong rounded-3xl p-6 shadow-xl">
@@ -2385,6 +2415,103 @@
</div> </div>
</div> </div>
<!-- 修改账户信息模态框 -->
<div v-if="showChangePasswordModal" class="fixed inset-0 modal z-50 flex items-center justify-center p-4">
<div class="modal-content w-full max-w-md p-8 mx-auto">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl flex items-center justify-center">
<i class="fas fa-key text-white"></i>
</div>
<h3 class="text-xl font-bold text-gray-900">修改账户信息</h3>
</div>
<button
@click="closeChangePasswordModal"
class="text-gray-400 hover:text-gray-600 transition-colors"
>
<i class="fas fa-times text-xl"></i>
</button>
</div>
<form @submit.prevent="changePassword" class="space-y-6">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">当前用户名</label>
<input
:value="currentUser.username || 'Admin'"
type="text"
disabled
class="form-input w-full bg-gray-100 cursor-not-allowed"
>
<p class="text-xs text-gray-500 mt-2">当前用户名,输入新用户名以修改</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">新用户名</label>
<input
v-model="changePasswordForm.newUsername"
type="text"
class="form-input w-full"
placeholder="输入新用户名(留空保持不变)"
>
<p class="text-xs text-gray-500 mt-2">留空表示不修改用户名</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">当前密码</label>
<input
v-model="changePasswordForm.currentPassword"
type="password"
required
class="form-input w-full"
placeholder="请输入当前密码"
>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">新密码</label>
<input
v-model="changePasswordForm.newPassword"
type="password"
required
class="form-input w-full"
placeholder="请输入新密码"
>
<p class="text-xs text-gray-500 mt-2">密码长度至少8位</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-3">确认新密码</label>
<input
v-model="changePasswordForm.confirmPassword"
type="password"
required
class="form-input w-full"
placeholder="请再次输入新密码"
>
</div>
<div class="flex gap-3 pt-4">
<button
type="button"
@click="closeChangePasswordModal"
class="flex-1 px-6 py-3 bg-gray-100 text-gray-700 rounded-xl font-semibold hover:bg-gray-200 transition-colors"
>
取消
</button>
<button
type="submit"
:disabled="changePasswordLoading"
class="btn btn-primary flex-1 py-3 px-6 font-semibold"
>
<div v-if="changePasswordLoading" class="loading-spinner mr-2"></div>
<i v-else class="fas fa-save mr-2"></i>
{{ changePasswordLoading ? '保存中...' : '保存修改' }}
</button>
</div>
</form>
</div>
</div>
<!-- Toast 通知组件 --> <!-- Toast 通知组件 -->
<div v-for="(toast, index) in toasts" :key="toast.id" <div v-for="(toast, index) in toasts" :key="toast.id"
:class="['toast rounded-2xl p-4 shadow-2xl backdrop-blur-sm', :class="['toast rounded-2xl p-4 shadow-2xl backdrop-blur-sm',