feat: 打造若依 (RuoYi) 规范的高品质 RBAC 用户权限体系,内置 admin/admin123 超级管理员与测试读者,支持登录注册、个人中心与用户管理
Build and Deploy Blog / deploy (push) Successful in 38s

This commit is contained in:
2026-10-22 17:22:47 +08:00
parent 9ad9923d61
commit 3bee3f5663
17 changed files with 1474 additions and 22 deletions
+640 -17
View File
@@ -299,13 +299,69 @@
<span>{{ backendConnected ? 'Spring Boot 3 + MySQL 8.0 在线' : '本地安全模式' }}</span>
</div>
<!-- 写文章按钮 (唤起现代化 Markdown 写作台) -->
<button @click="openPublishModal"
<!-- 写文章按钮 (仅超级管理员可见) -->
<button v-if="isAdmin" @click="openPublishModal"
class="inline-flex items-center gap-1.5 px-3.5 py-1.5 rounded-xl text-sm font-semibold bg-gradient-to-r from-sky-600 to-indigo-600 hover:from-sky-700 hover:to-indigo-700 text-white shadow-md shadow-sky-600/25 transition-all hover:shadow active:scale-95">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>
<span class="hidden sm:inline">写新文章</span>
</button>
<!-- 若依风格用户系统入口 -->
<div v-if="isLoggedIn" class="relative">
<button @click="showUserDropdown = !showUserDropdown"
class="flex items-center gap-2 px-3 py-1.5 rounded-xl bg-white dark:bg-slate-900 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors border border-slate-200 dark:border-slate-800 shadow-sm">
<span class="text-base">{{ currentUser.avatar || '👨‍💻' }}</span>
<span class="text-xs font-bold text-slate-800 dark:text-slate-200 max-w-[80px] sm:max-w-[120px] truncate">{{ currentUser.nickname }}</span>
<span :class="isAdmin ? 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300 border-amber-200' : 'bg-sky-100 text-sky-800 dark:bg-sky-950 dark:text-sky-300 border-sky-200'"
class="px-2 py-0.5 text-[10px] font-bold rounded-full border">
{{ isAdmin ? '👑 管理员' : '👤 读者' }}
</span>
<svg class="w-3 h-3 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
</button>
<!-- 下拉菜单抽屉 -->
<div v-show="showUserDropdown" @click.outside="showUserDropdown = false"
class="absolute right-0 mt-2 w-56 rounded-2xl bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 shadow-2xl py-2 z-50">
<div class="px-4 py-2.5 border-b border-slate-100 dark:border-slate-800">
<div class="font-bold text-xs text-slate-800 dark:text-slate-100 flex items-center gap-1.5">
<span>{{ currentUser.avatar || '👨‍💻' }}</span>
<span>{{ currentUser.nickname }}</span>
</div>
<div class="text-[11px] text-slate-400 font-mono">@{{ currentUser.username }}</div>
<div class="text-[10px] text-slate-400 mt-0.5 truncate">{{ currentUser.email || '未绑定邮箱' }}</div>
</div>
<button v-if="isAdmin" @click="showUserDropdown = false; openPublishModal()"
class="w-full text-left px-4 py-2 text-xs text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 flex items-center gap-2">
<span>✍️</span> <span>写新文章</span>
</button>
<button v-if="isAdmin" @click="showUserDropdown = false; openUserMgmtModal()"
class="w-full text-left px-4 py-2 text-xs text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 flex items-center gap-2">
<span>👥</span> <span>用户管理中心 (RuoYi RBAC)</span>
</button>
<button @click="showUserDropdown = false; openProfileModal()"
class="w-full text-left px-4 py-2 text-xs text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 flex items-center gap-2">
<span>👤</span> <span>个人中心设置</span>
</button>
<div class="my-1 border-t border-slate-100 dark:border-slate-800"></div>
<button @click="showUserDropdown = false; handleLogout()"
class="w-full text-left px-4 py-2 text-xs text-rose-600 hover:bg-rose-50 dark:hover:bg-rose-950/40 flex items-center gap-2 font-medium">
<span>🚪</span> <span>退出登录</span>
</button>
</div>
</div>
<!-- 未登录状态展示登录/注册按钮 -->
<button v-else @click="openAuthModal('login')"
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-semibold bg-sky-50 hover:bg-sky-100 dark:bg-sky-950/60 dark:hover:bg-sky-900/60 text-sky-700 dark:text-sky-300 border border-sky-200 dark:border-sky-800 transition-all shadow-sm active:scale-95">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"></path></svg>
<span>登录 / 注册</span>
</button>
<!-- 深色/浅色模式切换 -->
<button @click="toggleDarkMode"
class="p-2 rounded-xl text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-100 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors border border-transparent dark:border-slate-800"
@@ -608,10 +664,25 @@
<!-- 撰写评论卡片 -->
<div class="bg-slate-50/80 dark:bg-slate-800/40 p-4 sm:p-5 rounded-2xl border border-slate-200/60 dark:border-slate-700/60 mb-6 space-y-3">
<div class="flex items-center gap-3">
<input v-model="newComment.author" type="text" placeholder="你的技术昵称 (默认: 访客)"
class="px-3.5 py-1.5 rounded-xl bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-700 text-xs w-48 focus:outline-none focus:ring-2 focus:ring-sky-500">
<span class="text-xs text-slate-400">评论将实时持久化写入 MySQL 8.0</span>
<div class="flex items-center justify-between text-xs">
<div v-if="isLoggedIn" class="flex items-center gap-2">
<span class="text-base">{{ currentUser.avatar || '👨‍💻' }}</span>
<span class="text-slate-600 dark:text-slate-300">正在以</span>
<strong class="text-slate-900 dark:text-white">{{ currentUser.nickname }}</strong>
<span :class="isAdmin ? 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300' : 'bg-sky-100 text-sky-800 dark:bg-sky-950 dark:text-sky-300'"
class="px-1.5 py-0.2 text-[10px] font-bold rounded-full">
{{ isAdmin ? '👑 博主' : '👤 读者' }}
</span>
</div>
<div v-else class="flex items-center gap-2">
<input v-model="newComment.author" type="text" placeholder="你的昵称 (默认: 访客)"
class="px-3 py-1.5 rounded-xl bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-700 text-xs w-40 focus:outline-none focus:ring-2 focus:ring-sky-500">
<span class="text-slate-400">或</span>
<button @click="openAuthModal('login')" class="text-sky-600 dark:text-sky-400 font-bold hover:underline">
👉 登录若依账号
</button>
</div>
<span class="text-slate-400 hidden sm:inline font-mono">直写 MySQL 8.0</span>
</div>
<textarea v-model="newComment.content" rows="3" placeholder="写下你的技术思考、疑问或独到见解..."
class="w-full px-3.5 py-2.5 rounded-xl bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-700 text-xs sm:text-sm focus:outline-none focus:ring-2 focus:ring-sky-500"></textarea>
@@ -630,14 +701,20 @@
class="p-4 rounded-2xl bg-slate-50/60 dark:bg-slate-800/30 border border-slate-100 dark:border-slate-800 text-xs sm:text-sm">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<div class="w-6 h-6 rounded-full bg-sky-500/20 text-sky-600 dark:text-sky-400 font-bold flex items-center justify-center text-xs">
{{ c.author ? c.author.charAt(0).toUpperCase() : 'U' }}
<div class="w-7 h-7 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-sm shadow-sm">
{{ c.avatar || (c.role === 'ADMIN' ? '👑' : '👨‍💻') }}
</div>
<span class="font-bold text-slate-900 dark:text-slate-100">{{ c.author || '匿名访客' }}</span>
<span v-if="c.role === 'ADMIN'" class="px-1.5 py-0.5 rounded text-[10px] font-bold bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300 border border-amber-200 dark:border-amber-800">
👑 博主/管理员
</span>
<span v-else-if="c.role === 'USER'" class="px-1.5 py-0.5 rounded text-[10px] font-medium bg-sky-100 text-sky-800 dark:bg-sky-950 dark:text-sky-300 border border-sky-200 dark:border-sky-800">
读者
</span>
</div>
<span class="text-xs text-slate-400 font-mono">{{ formatDate(c.createdAt) }}</span>
</div>
<p class="text-slate-700 dark:text-slate-300 leading-relaxed pl-8">{{ c.content }}</p>
<p class="text-slate-700 dark:text-slate-300 leading-relaxed pl-9">{{ c.content }}</p>
</div>
</div>
<div v-else class="text-center py-8 text-xs text-slate-400">
@@ -949,6 +1026,249 @@
</div>
</div>
<!-- ======================= 若依风格 1:用户认证登录/注册中心模态框 ======================= -->
<div v-if="showAuthModal"
class="fixed inset-0 z-50 overflow-y-auto bg-slate-900/70 backdrop-blur-sm flex items-center justify-center p-4"
@click.self="showAuthModal = false">
<div class="w-full max-w-md bg-white dark:bg-slate-900 rounded-3xl shadow-2xl border border-slate-200 dark:border-slate-800 overflow-hidden animate-in fade-in zoom-in-95 duration-200">
<!-- 头部 -->
<div class="px-6 pt-6 pb-4 text-center border-b border-slate-100 dark:border-slate-800 relative">
<button @click="showAuthModal = false" class="absolute right-4 top-4 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 text-lg">✕</button>
<div class="w-12 h-12 mx-auto rounded-2xl bg-gradient-to-tr from-sky-500 via-indigo-500 to-purple-600 flex items-center justify-center text-white text-xl font-black shadow-md shadow-sky-500/20 mb-3">
RY
</div>
<h3 class="text-xl font-black text-slate-900 dark:text-white">小东全栈博客 · 认证中心</h3>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-1">若依 (RuoYi) 规范 RBAC 权限与用户体系</p>
<!-- 登录/注册 Tab 切换 -->
<div class="flex mt-4 bg-slate-100 dark:bg-slate-800 p-1 rounded-xl text-xs font-bold">
<button @click="authTab = 'login'" :class="authTab === 'login' ? 'bg-white dark:bg-slate-700 text-sky-600 dark:text-sky-400 shadow-sm' : 'text-slate-500'" class="flex-1 py-1.5 rounded-lg transition-all">
账号登录
</button>
<button @click="authTab = 'register'" :class="authTab === 'register' ? 'bg-white dark:bg-slate-700 text-sky-600 dark:text-sky-400 shadow-sm' : 'text-slate-500'" class="flex-1 py-1.5 rounded-lg transition-all">
读者注册
</button>
</div>
</div>
<!-- 登录表单 -->
<div v-if="authTab === 'login'" class="p-6 space-y-4 text-xs">
<div>
<label class="block font-bold mb-1 text-slate-700 dark:text-slate-300">用户名 *</label>
<input v-model="loginForm.username" type="text" placeholder="输入账号 (如 admin)"
class="w-full px-3.5 py-2.5 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 focus:ring-2 focus:ring-sky-500 focus:outline-none text-sm font-medium">
</div>
<div>
<label class="block font-bold mb-1 text-slate-700 dark:text-slate-300">密码 *</label>
<input v-model="loginForm.password" type="password" placeholder="输入密码 (如 admin123)" @keyup.enter="handleLogin"
class="w-full px-3.5 py-2.5 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 focus:ring-2 focus:ring-sky-500 focus:outline-none text-sm font-medium">
</div>
<!-- 快捷一键填入账号 (若依经典便民测试功能) -->
<div class="pt-2">
<div class="text-[11px] text-slate-400 mb-2 font-mono">⚡ 快捷填入内置测试账号:</div>
<div class="flex gap-2">
<button @click="quickFill('admin', 'admin123')"
class="flex-1 py-1.5 px-2 rounded-xl bg-amber-50 hover:bg-amber-100 dark:bg-amber-950/40 dark:hover:bg-amber-950/70 text-amber-700 dark:text-amber-300 border border-amber-200 dark:border-amber-800 font-semibold text-[11px] transition-colors">
👑 超级管理员 (admin)
</button>
<button @click="quickFill('ry_user', 'user123')"
class="flex-1 py-1.5 px-2 rounded-xl bg-sky-50 hover:bg-sky-100 dark:bg-sky-950/40 dark:hover:bg-sky-950/70 text-sky-700 dark:text-sky-300 border border-sky-200 dark:border-sky-800 font-semibold text-[11px] transition-colors">
👤 测试读者 (ry_user)
</button>
</div>
</div>
<button @click="handleLogin" :disabled="authLoading"
class="w-full mt-4 py-2.5 rounded-xl bg-gradient-to-r from-sky-600 to-indigo-600 hover:from-sky-700 hover:to-indigo-700 disabled:opacity-50 text-white font-bold text-sm shadow-md shadow-sky-600/30 flex items-center justify-center gap-2">
<span v-if="authLoading" class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
<span>{{ authLoading ? '验证登录中...' : '立即登录' }}</span>
</button>
</div>
<!-- 注册表单 -->
<div v-else class="p-6 space-y-3.5 text-xs">
<div>
<label class="block font-bold mb-1 text-slate-700 dark:text-slate-300">用户名 *</label>
<input v-model="registerForm.username" type="text" placeholder="3-20 位字符,用于登录"
class="w-full px-3.5 py-2 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 focus:ring-2 focus:ring-sky-500 focus:outline-none">
</div>
<div>
<label class="block font-bold mb-1 text-slate-700 dark:text-slate-300">个性昵称</label>
<input v-model="registerForm.nickname" type="text" placeholder="例如: 极客小明"
class="w-full px-3.5 py-2 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 focus:ring-2 focus:ring-sky-500 focus:outline-none">
</div>
<div>
<label class="block font-bold mb-1 text-slate-700 dark:text-slate-300">登录密码 *</label>
<input v-model="registerForm.password" type="password" placeholder="不少于 5 位"
class="w-full px-3.5 py-2 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 focus:ring-2 focus:ring-sky-500 focus:outline-none">
</div>
<div>
<label class="block font-bold mb-1 text-slate-700 dark:text-slate-300">联系邮箱</label>
<input v-model="registerForm.email" type="email" placeholder="name@example.com"
class="w-full px-3.5 py-2 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 focus:ring-2 focus:ring-sky-500 focus:outline-none">
</div>
<button @click="handleRegister" :disabled="authLoading"
class="w-full mt-4 py-2.5 rounded-xl bg-gradient-to-r from-sky-600 to-indigo-600 hover:from-sky-700 hover:to-indigo-700 disabled:opacity-50 text-white font-bold text-sm shadow-md shadow-sky-600/30 flex items-center justify-center gap-2">
<span v-if="authLoading" class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
<span>{{ authLoading ? '创建账号中...' : '立即注册读者账号' }}</span>
</button>
</div>
</div>
</div>
<!-- ======================= 若依风格 2:用户权限管理中心模态框 (RBAC 后台) ======================= -->
<div v-if="showUserMgmtModal"
class="fixed inset-0 z-50 overflow-y-auto bg-slate-900/70 backdrop-blur-sm flex items-center justify-center p-3 sm:p-6"
@click.self="showUserMgmtModal = false">
<div class="w-full max-w-4xl bg-white dark:bg-slate-900 rounded-3xl shadow-2xl border border-slate-200 dark:border-slate-800 overflow-hidden flex flex-col max-h-[90vh] animate-in fade-in zoom-in-95 duration-200">
<div class="px-6 py-4 border-b border-slate-100 dark:border-slate-800 flex items-center justify-between shrink-0">
<div class="flex items-center gap-3">
<span class="text-2xl">👥</span>
<div>
<h3 class="font-bold text-base text-slate-900 dark:text-white">若依风格 · 用户权限管理中心 (RBAC)</h3>
<p class="text-xs text-slate-400">管理全站注册用户、启用/停用状态及权限角色</p>
</div>
</div>
<button @click="showUserMgmtModal = false" class="text-slate-400 hover:text-slate-600 text-lg">✕</button>
</div>
<!-- 用户表格内容区 -->
<div class="p-6 overflow-y-auto flex-1">
<div class="flex items-center justify-between mb-4">
<div class="text-xs text-slate-500 font-mono">共 {{ userList.length }} 位注册用户</div>
<button @click="fetchUserList" class="px-3 py-1.5 rounded-xl bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-xs font-semibold flex items-center gap-1.5 transition-colors">
<span>🔄</span> <span>刷新列表</span>
</button>
</div>
<div class="overflow-x-auto rounded-2xl border border-slate-200 dark:border-slate-800">
<table class="w-full text-left text-xs border-collapse">
<thead>
<tr class="bg-slate-50 dark:bg-slate-800/60 border-b border-slate-200 dark:border-slate-800 text-slate-500 dark:text-slate-400 font-bold">
<th class="p-3.5">ID</th>
<th class="p-3.5">用户名</th>
<th class="p-3.5">昵称</th>
<th class="p-3.5">角色权限</th>
<th class="p-3.5">账号状态</th>
<th class="p-3.5">最近登录</th>
<th class="p-3.5 text-right">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-800">
<tr v-for="u in userList" :key="u.id" class="hover:bg-slate-50/50 dark:hover:bg-slate-800/40 transition-colors">
<td class="p-3.5 font-mono">{{ u.id }}</td>
<td class="p-3.5 font-bold text-slate-900 dark:text-slate-100">{{ u.username }}</td>
<td class="p-3.5 flex items-center gap-1.5">
<span>{{ u.avatar || '👨‍💻' }}</span>
<span>{{ u.nickname }}</span>
</td>
<td class="p-3.5">
<span :class="u.role === 'ADMIN' ? 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300 border-amber-200' : 'bg-sky-100 text-sky-800 dark:bg-sky-950 dark:text-sky-300 border-sky-200'"
class="px-2 py-0.5 rounded-full font-bold text-[10px] border">
{{ u.role === 'ADMIN' ? '👑 超级管理员' : '👤 访客读者' }}
</span>
</td>
<td class="p-3.5">
<span :class="u.status === '0' ? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-400 border-emerald-200' : 'bg-rose-100 text-rose-800 dark:bg-rose-950 dark:text-rose-400 border-rose-200'"
class="px-2 py-0.5 rounded-full font-bold text-[10px] border">
{{ u.status === '0' ? '正常' : '已停用' }}
</span>
</td>
<td class="p-3.5 text-slate-400 font-mono">{{ u.loginDate ? formatDate(u.loginDate) : '未登录' }}</td>
<td class="p-3.5 text-right font-medium">
<template v-if="u.username !== 'admin'">
<button @click="toggleUserStatus(u)" class="text-sky-600 hover:underline mr-3">
{{ u.status === '0' ? '停用' : '启用' }}
</button>
<button @click="deleteUser(u)" class="text-rose-600 hover:underline">
删除
</button>
</template>
<span v-else class="text-[11px] text-slate-400 font-mono">系统保护</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- ======================= 若依风格 3:个人中心设置模态框 ======================= -->
<div v-if="showProfileModal"
class="fixed inset-0 z-50 overflow-y-auto bg-slate-900/70 backdrop-blur-sm flex items-center justify-center p-4"
@click.self="showProfileModal = false">
<div class="w-full max-w-md bg-white dark:bg-slate-900 rounded-3xl shadow-2xl border border-slate-200 dark:border-slate-800 overflow-hidden animate-in fade-in zoom-in-95 duration-200">
<div class="px-6 py-4 border-b border-slate-100 dark:border-slate-800 flex items-center justify-between">
<h3 class="font-bold text-base text-slate-900 dark:text-white flex items-center gap-2">
<span>👤 个人中心设置</span>
</h3>
<button @click="showProfileModal = false" class="text-slate-400 hover:text-slate-600">✕</button>
</div>
<!-- Tabs -->
<div class="flex border-b border-slate-100 dark:border-slate-800 text-xs font-bold">
<button @click="profileTab = 'info'" :class="profileTab === 'info' ? 'border-b-2 border-sky-500 text-sky-600 dark:text-sky-400' : 'text-slate-400'" class="flex-1 py-3 text-center transition-colors">
基本资料
</button>
<button @click="profileTab = 'pwd'" :class="profileTab === 'pwd' ? 'border-b-2 border-sky-500 text-sky-600 dark:text-sky-400' : 'text-slate-400'" class="flex-1 py-3 text-center transition-colors">
修改密码
</button>
</div>
<!-- 修改基本资料 -->
<div v-if="profileTab === 'info'" class="p-6 space-y-4 text-xs">
<div>
<label class="block font-bold mb-1 text-slate-400">用户名</label>
<input :value="currentUser.username" disabled class="w-full px-3.5 py-2 rounded-xl bg-slate-100 dark:bg-slate-800/80 border border-slate-200 dark:border-slate-700 text-slate-400 cursor-not-allowed font-mono">
</div>
<div>
<label class="block font-bold mb-1 text-slate-700 dark:text-slate-300">修改昵称</label>
<input v-model="profileForm.nickname" type="text" class="w-full px-3.5 py-2 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 focus:ring-2 focus:ring-sky-500">
</div>
<div>
<label class="block font-bold mb-1 text-slate-700 dark:text-slate-300">联系邮箱</label>
<input v-model="profileForm.email" type="email" class="w-full px-3.5 py-2 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 focus:ring-2 focus:ring-sky-500">
</div>
<div>
<label class="block font-bold mb-1.5 text-slate-700 dark:text-slate-300">选择头像 Emoji</label>
<div class="flex flex-wrap gap-2">
<button v-for="av in ['👨‍💻', '🚀', '💻', '🧠', '⚡', '🐬', '🌟', '🛡️']" :key="av"
@click="profileForm.avatar = av"
:class="profileForm.avatar === av ? 'ring-2 ring-sky-500 scale-110' : 'opacity-70 hover:opacity-100'"
class="w-9 h-9 rounded-xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-lg transition-all">
{{ av }}
</button>
</div>
</div>
<button @click="handleUpdateProfile" class="w-full mt-2 py-2.5 rounded-xl bg-sky-600 hover:bg-sky-700 text-white font-bold text-xs shadow-md shadow-sky-600/30 transition-all">
保存资料修改
</button>
</div>
<!-- 修改密码 -->
<div v-else class="p-6 space-y-4 text-xs">
<div>
<label class="block font-bold mb-1 text-slate-700 dark:text-slate-300">原密码 *</label>
<input v-model="pwdForm.oldPassword" type="password" placeholder="输入当前使用的密码" class="w-full px-3.5 py-2 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 focus:ring-2 focus:ring-sky-500">
</div>
<div>
<label class="block font-bold mb-1 text-slate-700 dark:text-slate-300">新密码 *</label>
<input v-model="pwdForm.newPassword" type="password" placeholder="不少于 5 位" class="w-full px-3.5 py-2 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 focus:ring-2 focus:ring-sky-500">
</div>
<div>
<label class="block font-bold mb-1 text-slate-700 dark:text-slate-300">确认新密码 *</label>
<input v-model="pwdForm.confirmPassword" type="password" placeholder="再次输入新密码" class="w-full px-3.5 py-2 rounded-xl bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 focus:ring-2 focus:ring-sky-500">
</div>
<button @click="handleUpdatePwd" class="w-full mt-2 py-2.5 rounded-xl bg-sky-600 hover:bg-sky-700 text-white font-bold text-xs shadow-md shadow-sky-600/30 transition-all">
确认修改密码
</button>
</div>
</div>
</div>
<!-- 返回顶部浮动胶囊 -->
<button v-show="showScrollTop" @click="scrollToTop"
class="fixed bottom-6 right-6 z-40 p-3 rounded-2xl bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 text-slate-700 dark:text-slate-300 shadow-xl hover:scale-110 active:scale-95 transition-all"
@@ -1004,6 +1324,253 @@
const showScrollTop = ref(false);
const toasts = ref([]);
// ============ 若依 (RuoYi) 规范 RBAC 用户认证与权限体系 ============
const token = ref(localStorage.getItem('ry_token') || '');
const currentUser = ref(null);
const roles = ref([]);
const permissions = ref([]);
const showUserDropdown = ref(false);
const showAuthModal = ref(false);
const authTab = ref('login'); // 'login' | 'register'
const authLoading = ref(false);
const loginForm = ref({ username: '', password: '' });
const registerForm = ref({ username: '', password: '', nickname: '', email: '' });
const showUserMgmtModal = ref(false);
const userList = ref([]);
const showProfileModal = ref(false);
const profileTab = ref('info'); // 'info' | 'pwd'
const profileForm = ref({ nickname: '', email: '', avatar: '👨‍💻' });
const pwdForm = ref({ oldPassword: '', newPassword: '', confirmPassword: '' });
const isLoggedIn = computed(() => !!token.value && !!currentUser.value);
const isAdmin = computed(() => currentUser.value && currentUser.value.role === 'ADMIN');
const authHeaders = computed(() => {
return token.value ? { 'Authorization': 'Bearer ' + token.value } : {};
});
function openAuthModal(tab = 'login') {
authTab.value = tab;
loginForm.value = { username: '', password: '' };
registerForm.value = { username: '', password: '', nickname: '', email: '' };
showAuthModal.value = true;
}
function quickFill(u, p) {
loginForm.value.username = u;
loginForm.value.password = p;
}
async function handleLogin() {
if (!loginForm.value.username.trim() || !loginForm.value.password.trim()) {
showToast('请输入用户名和密码', 'error');
return;
}
authLoading.value = true;
try {
const res = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(loginForm.value)
});
const result = await res.json();
if (res.ok && result.code === 200) {
token.value = result.data.token;
localStorage.setItem('ry_token', result.data.token);
currentUser.value = result.data.user;
roles.value = result.data.roles;
permissions.value = result.data.permissions;
showAuthModal.value = false;
showToast(`欢迎回来,${currentUser.value.nickname}!`, 'success');
} else {
showToast(result.msg || '登录失败,请检查账号密码', 'error');
}
} catch (e) {
showToast('登录服务不可用,请稍后重试', 'error');
}
authLoading.value = false;
}
async function handleRegister() {
if (!registerForm.value.username.trim() || !registerForm.value.password.trim()) {
showToast('用户名和密码不能为空', 'error');
return;
}
authLoading.value = true;
try {
const res = await fetch(`${API_BASE}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(registerForm.value)
});
const result = await res.json();
if (res.ok && result.code === 200) {
showToast('注册成功!正在为您自动登录...', 'success');
loginForm.value.username = registerForm.value.username;
loginForm.value.password = registerForm.value.password;
await handleLogin();
} else {
showToast(result.msg || '注册失败', 'error');
}
} catch (e) {
showToast('注册请求失败', 'error');
}
authLoading.value = false;
}
function handleLogout() {
token.value = '';
currentUser.value = null;
roles.value = [];
permissions.value = [];
localStorage.removeItem('ry_token');
showToast('已安全退出登录', 'info');
}
async function fetchUserInfo() {
if (!token.value) return;
try {
const res = await fetch(`${API_BASE}/auth/info`, {
headers: { ...authHeaders.value }
});
if (res.ok) {
const result = await res.json();
if (result.code === 200) {
currentUser.value = result.data.user;
roles.value = result.data.roles;
permissions.value = result.data.permissions;
return;
}
}
} catch (e) {}
localStorage.removeItem('ry_token');
token.value = '';
currentUser.value = null;
}
// 用户管理中心
function openUserMgmtModal() {
showUserMgmtModal.value = true;
fetchUserList();
}
async function fetchUserList() {
try {
const res = await fetch(`${API_BASE}/users`, {
headers: { ...authHeaders.value }
});
if (res.ok) {
const result = await res.json();
userList.value = result.data || [];
}
} catch (e) {
showToast('获取用户列表失败', 'error');
}
}
async function toggleUserStatus(u) {
const newStatus = u.status === '0' ? '1' : '0';
try {
const res = await fetch(`${API_BASE}/users/${u.id}/status?status=${newStatus}`, {
method: 'PUT',
headers: { ...authHeaders.value }
});
const result = await res.json();
if (res.ok && result.code === 200) {
u.status = newStatus;
showToast(`已${newStatus === '1' ? '停用' : '启用'}用户 [${u.username}]`, 'success');
} else {
showToast(result.msg || '操作失败', 'error');
}
} catch (e) {
showToast('网络请求失败', 'error');
}
}
async function deleteUser(u) {
if (!confirm(`确定要永久删除用户 [${u.username}] 吗?`)) return;
try {
const res = await fetch(`${API_BASE}/users/${u.id}`, {
method: 'DELETE',
headers: { ...authHeaders.value }
});
const result = await res.json();
if (res.ok && result.code === 200) {
showToast(`用户 [${u.username}] 已被删除`, 'success');
fetchUserList();
} else {
showToast(result.msg || '删除失败', 'error');
}
} catch (e) {
showToast('网络请求失败', 'error');
}
}
// 个人中心
function openProfileModal() {
profileTab.value = 'info';
profileForm.value = {
nickname: currentUser.value.nickname,
email: currentUser.value.email || '',
avatar: currentUser.value.avatar || '👨‍💻'
};
pwdForm.value = { oldPassword: '', newPassword: '', confirmPassword: '' };
showProfileModal.value = true;
}
async function handleUpdateProfile() {
try {
const res = await fetch(`${API_BASE}/users/profile`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...authHeaders.value },
body: JSON.stringify(profileForm.value)
});
const result = await res.json();
if (res.ok && result.code === 200) {
currentUser.value.nickname = profileForm.value.nickname;
currentUser.value.email = profileForm.value.email;
currentUser.value.avatar = profileForm.value.avatar;
showToast('个人资料已成功更新', 'success');
showProfileModal.value = false;
} else {
showToast(result.msg || '修改失败', 'error');
}
} catch (e) {
showToast('请求失败', 'error');
}
}
async function handleUpdatePwd() {
if (!pwdForm.value.oldPassword || !pwdForm.value.newPassword) {
showToast('旧密码和新密码不能为空', 'error');
return;
}
if (pwdForm.value.newPassword !== pwdForm.value.confirmPassword) {
showToast('两次输入的新密码不一致', 'error');
return;
}
try {
const res = await fetch(`${API_BASE}/users/password`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...authHeaders.value },
body: JSON.stringify(pwdForm.value)
});
const result = await res.json();
if (res.ok && result.code === 200) {
showToast('密码修改成功,请使用新密码重新登录', 'success');
handleLogout();
showProfileModal.value = false;
openAuthModal('login');
} else {
showToast(result.msg || '修改密码失败', 'error');
}
} catch (e) {
showToast('请求失败', 'error');
}
}
const categories = ref(['全部']);
const categoryList = ref([]);
const availableCategories = computed(() => categories.value.filter(c => c !== '全部'));
@@ -1284,17 +1851,26 @@
showToast('评论内容不能为空', 'error');
return;
}
if (isLoggedIn.value && currentUser.value) {
newComment.value.author = currentUser.value.nickname;
}
submittingComment.value = true;
try {
const res = await fetch(`${API_BASE}/posts/${activePost.value.id}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...authHeaders.value
},
body: JSON.stringify(newComment.value)
});
if (res.ok) {
const result = await res.json();
if (res.ok && result.code === 200) {
newComment.value.content = '';
showToast('评论已持久化写入 MySQL 8.0', 'success');
fetchComments(activePost.value.id);
} else {
showToast(result.msg || '评论发表失败', 'error');
}
} catch (e) {
showToast('评论提交失败,请检查网络连接', 'error');
@@ -1304,16 +1880,26 @@
// 删除文章
async function deletePost(post) {
if (!isAdmin.value) {
showToast('无权删除:仅超级管理员可用', 'error');
return;
}
if (!confirm(`确定要永久删除文章《${post.title}》吗?`)) return;
try {
const res = await fetch(`${API_BASE}/posts/${post.id}`, { method: 'DELETE' });
if (res.ok) {
const res = await fetch(`${API_BASE}/posts/${post.id}`, {
method: 'DELETE',
headers: { ...authHeaders.value }
});
const result = await res.json();
if (res.ok && result.code === 200) {
showToast('文章已从 MySQL 数据库中移除', 'success');
navigateView('home');
fetchPosts();
} else {
showToast(result.msg || '删除失败', 'error');
}
} catch (e) {
showToast('删除失败', 'error');
showToast('删除失败,权限不足或服务器错误', 'error');
}
}
@@ -1372,6 +1958,10 @@
// 持久化发布文章至 MySQL
async function publishPost() {
if (!isAdmin.value) {
showToast('无权发布:请先使用超级管理员账号登录', 'error');
return;
}
if (!newPost.value.title.trim() || !newPost.value.content.trim()) {
showToast('文章标题和正文内容不能为空!', 'error');
return;
@@ -1381,15 +1971,19 @@
try {
const res = await fetch(`${API_BASE}/posts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...authHeaders.value
},
body: JSON.stringify(newPost.value)
});
if (res.ok) {
const result = await res.json();
if (res.ok && result.code === 200) {
showToast('文章已成功持久化写入 MySQL 8.0!', 'success');
showPublishModal.value = false;
fetchPosts();
} else {
showToast('发布失败,请稍后重试', 'error');
showToast(result.msg || '发布失败,请稍后重试', 'error');
}
} catch (e) {
showToast('发布失败,无法连接服务器', 'error');
@@ -1500,6 +2094,7 @@
window.addEventListener('scroll', handleScroll);
window.addEventListener('hashchange', handleHashRoute);
await fetchUserInfo();
await fetchCategories();
await fetchPosts();
handleHashRoute();
@@ -1516,6 +2111,34 @@
backendConnected,
currentView,
navigateView,
token,
currentUser,
isLoggedIn,
isAdmin,
showUserDropdown,
showAuthModal,
authTab,
authLoading,
loginForm,
registerForm,
openAuthModal,
quickFill,
handleLogin,
handleRegister,
handleLogout,
showUserMgmtModal,
userList,
openUserMgmtModal,
fetchUserList,
toggleUserStatus,
deleteUser,
showProfileModal,
profileTab,
profileForm,
pwdForm,
openProfileModal,
handleUpdateProfile,
handleUpdatePwd,
posts,
stats,
categories,