新增账号,超管体系

This commit is contained in:
eafonyang
2026-06-18 17:37:08 +08:00
parent 481261b334
commit 476da9b459
27 changed files with 1439 additions and 84 deletions
+11
View File
@@ -10,3 +10,14 @@ export function login(data) {
}
})
}
export function changePassword(data) {
return request({
url: '/auth/password',
method: 'put',
data: {
old_password: data.oldPassword,
new_password: data.newPassword
}
})
}
+39
View File
@@ -0,0 +1,39 @@
import request from '@/utils/request'
export function getUsers(params) {
return request({
url: '/users/',
method: 'get',
params
})
}
export function createUser(data) {
return request({
url: '/users/',
method: 'post',
data
})
}
export function updateUser(id, data) {
return request({
url: `/users/${id}`,
method: 'put',
data
})
}
export function deleteUser(id) {
return request({
url: `/users/${id}`,
method: 'delete'
})
}
export function getCurrentUser() {
return request({
url: '/auth/me',
method: 'get'
})
}
@@ -0,0 +1,127 @@
<template>
<el-dialog
v-model="visible"
title="修改密码"
width="480px"
@close="resetForm"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="100px"
>
<el-form-item label="原密码" prop="oldPassword">
<el-input
v-model="form.oldPassword"
type="password"
show-password
autocomplete="current-password"
placeholder="请输入当前密码"
/>
</el-form-item>
<el-form-item label="新密码" prop="newPassword">
<el-input
v-model="form.newPassword"
type="password"
show-password
autocomplete="new-password"
placeholder="至少 6 位"
/>
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input
v-model="form.confirmPassword"
type="password"
show-password
autocomplete="new-password"
placeholder="再次输入新密码"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">
确定
</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { computed, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { changePassword } from '@/api/auth'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue'])
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const loading = ref(false)
const formRef = ref(null)
const form = reactive({
oldPassword: '',
newPassword: '',
confirmPassword: ''
})
const validateConfirm = (_rule, value, callback) => {
if (!value) {
callback(new Error('请再次输入新密码'))
return
}
if (value !== form.newPassword) {
callback(new Error('两次输入的密码不一致'))
return
}
callback()
}
const rules = {
oldPassword: [{ required: true, message: '请输入原密码', trigger: 'blur' }],
newPassword: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, message: '新密码至少 6 位', trigger: 'blur' }
],
confirmPassword: [
{ required: true, validator: validateConfirm, trigger: 'blur' }
]
}
function resetForm() {
form.oldPassword = ''
form.newPassword = ''
form.confirmPassword = ''
formRef.value?.resetFields?.()
}
async function handleSubmit() {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
loading.value = true
try {
const res = await changePassword({
oldPassword: form.oldPassword,
newPassword: form.newPassword
})
if (res.code === 1) {
ElMessage.success('密码已修改')
visible.value = false
}
} finally {
loading.value = false
}
})
}
</script>
+7 -8
View File
@@ -55,10 +55,8 @@
<script setup>
import { computed, markRaw, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Document, List, Upload, Collection, House } from '@element-plus/icons-vue'
const STORAGE_KEY = 'dashboard_tabs_v1'
const HOME_PATH = '/brand'
import { Document, List, Upload, Collection, House, User } from '@element-plus/icons-vue'
import { TABS_STORAGE_KEY, HOME_PATH } from '@/utils/tabs'
const router = useRouter()
const route = useRoute()
@@ -68,7 +66,8 @@ const iconMap = markRaw({
list: List,
upload: Upload,
collection: Collection,
home: House
home: House,
user: User
})
function resolveTabIcon(iconKey) {
@@ -108,7 +107,7 @@ function defaultHomeTab() {
key: HOME_PATH,
fullPath: HOME_PATH,
path: HOME_PATH,
name: '',
name: 'Home',
title: '首页',
icon: 'home',
closable: false
@@ -137,13 +136,13 @@ function persist() {
closable: t.closable
}))
}
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(data))
sessionStorage.setItem(TABS_STORAGE_KEY, JSON.stringify(data))
} catch {}
}
function restore() {
try {
const raw = sessionStorage.getItem(STORAGE_KEY)
const raw = sessionStorage.getItem(TABS_STORAGE_KEY)
if (!raw) return false
const parsed = JSON.parse(raw)
const list = Array.isArray(parsed?.tabs) ? parsed.tabs : null
+60 -12
View File
@@ -92,16 +92,41 @@
<el-breadcrumb-item v-if="breadcrumbSection">{{ breadcrumbSection }}</el-breadcrumb-item>
<el-breadcrumb-item v-if="breadcrumbTitle">{{ breadcrumbTitle }}</el-breadcrumb-item>
</el-breadcrumb>
<el-button
circle
type="primary"
class="lux-header-power-btn"
title="退出登录"
aria-label="退出登录"
@click="handleLogout"
<el-dropdown
trigger="click"
placement="bottom-end"
popper-class="lux-user-dropdown"
@command="handleUserMenuCommand"
>
<el-icon :size="18"><SwitchButton /></el-icon>
</el-button>
<el-button
circle
type="primary"
class="lux-header-user-btn"
title="用户菜单"
aria-label="用户菜单"
>
<el-icon :size="18"><User /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-if="currentUsername" disabled class="lux-user-dropdown-name">
{{ currentUsername }}
</el-dropdown-item>
<el-dropdown-item v-if="isSuperAdminUser" command="users" :divided="!!currentUsername">
账号管理
</el-dropdown-item>
<el-dropdown-item
command="password"
:divided="!!currentUsername && !isSuperAdminUser"
>
修改密码
</el-dropdown-item>
<el-dropdown-item command="logout">
退出登录
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-header>
<el-main class="lux-main">
<TabsView />
@@ -111,6 +136,8 @@
</el-footer>
</el-container>
</el-container>
<ChangePasswordDialog v-model="changePasswordVisible" />
</div>
</template>
@@ -118,10 +145,11 @@
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Headset, Collection, List, Upload, Document, SwitchButton, Share } from '@element-plus/icons-vue'
import { clearToken } from '@/utils/auth'
import { Headset, Collection, List, Upload, Document, Share, User } from '@element-plus/icons-vue'
import { clearAuth, getUser, isSuperAdmin } from '@/utils/auth'
import TabsView from '@/components/TabsView.vue'
import SidebarLogo from '@/components/SidebarLogo.vue'
import ChangePasswordDialog from '@/components/ChangePasswordDialog.vue'
const route = useRoute()
const router = useRouter()
@@ -131,6 +159,10 @@ const isSidebarCollapsed = ref(true)
const openedMenus = ref([])
const isSuperAdminUser = computed(() => isSuperAdmin())
const currentUsername = computed(() => getUser()?.username || '')
const changePasswordVisible = ref(false)
function resolveOpenedMenus(path) {
if (path.startsWith('/brand') || path.startsWith('/model')) return ['1']
if (path.startsWith('/ota')) return ['upgrade']
@@ -154,6 +186,7 @@ const railNavItems = [
]
const breadcrumbTitle = computed(() => {
if (route.path === '/home') return ''
const t = route.meta?.title
return typeof t === 'string' ? t : ''
})
@@ -163,14 +196,29 @@ const breadcrumbSection = computed(() => {
if (path.startsWith('/brand') || path.startsWith('/model')) return '耳机管理'
if (path.startsWith('/ota')) return '升级管理'
if (path.startsWith('/share-code')) return '分享码'
if (path.startsWith('/system')) return '系统管理'
return ''
})
const handleLogout = () => {
clearToken()
clearAuth()
ElMessage.success('已退出登录')
router.push('/login')
}
function handleUserMenuCommand(command) {
if (command === 'logout') {
handleLogout()
return
}
if (command === 'users') {
router.push('/system/users')
return
}
if (command === 'password') {
changePasswordVisible.value = true
}
}
</script>
<style scoped>
+23 -5
View File
@@ -1,6 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router'
import Layout from '@/layout/index.vue'
import { getToken, clearToken, isTokenExpired } from '@/utils/auth'
import { getToken, clearAuth, isTokenExpired, isSuperAdmin } from '@/utils/auth'
import { HOME_PATH } from '@/utils/tabs'
const routes = [
{
@@ -13,8 +14,14 @@ const routes = [
path: '/',
component: Layout,
meta: { requiresAuth: true },
redirect: '/brand',
redirect: HOME_PATH,
children: [
{
path: HOME_PATH,
name: 'Home',
component: () => import('@/views/home/index.vue'),
meta: { title: '首页', icon: 'home' }
},
{
path: '/brand',
name: 'Brand',
@@ -38,6 +45,12 @@ const routes = [
name: 'ShareCodeLog',
component: () => import('@/views/share-code/log.vue'),
meta: { title: '分享日志', icon: 'document' }
},
{
path: '/system/users',
name: 'SystemUsers',
component: () => import('@/views/system/users/index.vue'),
meta: { title: '账号管理', icon: 'user', requiresSuperAdmin: true }
}
]
}
@@ -54,15 +67,20 @@ router.beforeEach((to, _from, next) => {
if (needAuth) {
if (!token || isTokenExpired(token)) {
if (token) clearToken()
if (token) clearAuth()
next({ path: '/login', query: { redirect: to.fullPath } })
return
}
const needSuperAdmin = to.matched.some((r) => r.meta && r.meta.requiresSuperAdmin === true)
if (needSuperAdmin && !isSuperAdmin()) {
next({ path: HOME_PATH })
return
}
}
if (to.path === '/login' && token && !isTokenExpired(token)) {
const redirect = to.query.redirect
next(typeof redirect === 'string' && redirect ? redirect : '/')
next({ path: HOME_PATH })
return
}
+21 -5
View File
@@ -208,19 +208,19 @@ body {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.lux-shell .lux-header-power-btn {
.lux-shell .lux-header-user-btn {
width: 34px !important;
height: 34px !important;
padding: 0 !important;
box-shadow: 0 4px 12px rgba(56, 189, 248, 0.22) !important;
}
.lux-shell .lux-header-power-btn .el-icon {
.lux-shell .lux-header-user-btn .el-icon {
margin: 0;
}
.lux-shell .lux-header-power-btn:hover,
.lux-shell .lux-header-power-btn:focus {
.lux-shell .lux-header-user-btn:hover,
.lux-shell .lux-header-user-btn:focus {
box-shadow: 0 6px 16px rgba(56, 189, 248, 0.3) !important;
}
@@ -428,11 +428,27 @@ body {
}
/* 下拉菜单深色 */
.lux-shell .el-dropdown-menu {
.lux-shell .el-dropdown-menu,
.lux-user-dropdown.el-popper {
background: #1e293b !important;
border-color: rgba(148, 163, 184, 0.12) !important;
}
.lux-user-dropdown .el-dropdown-menu__item {
color: #cbd5e1;
}
.lux-user-dropdown .el-dropdown-menu__item:not(.is-disabled):hover {
background: rgba(56, 189, 248, 0.12);
color: #e0f2fe;
}
.lux-user-dropdown .lux-user-dropdown-name {
color: #94a3b8 !important;
font-size: 12px;
cursor: default;
}
/* 消息框深色 */
.el-message-box {
background: #1e293b !important;
+58
View File
@@ -1,4 +1,5 @@
const TOKEN_KEY = 'dashboard_access_token'
const USER_KEY = 'dashboard_user'
export function getToken() {
return localStorage.getItem(TOKEN_KEY)
@@ -16,6 +17,63 @@ export function clearToken() {
localStorage.removeItem(TOKEN_KEY)
}
export function setUser(user) {
if (user) {
localStorage.setItem(USER_KEY, JSON.stringify(user))
} else {
localStorage.removeItem(USER_KEY)
}
}
export function getUser() {
const raw = localStorage.getItem(USER_KEY)
if (raw) {
try {
return JSON.parse(raw)
} catch {
localStorage.removeItem(USER_KEY)
}
}
return getUserFromToken()
}
export function clearUser() {
localStorage.removeItem(USER_KEY)
}
import { clearTabsStorage } from '@/utils/tabs'
export function clearAuth() {
clearToken()
clearUser()
clearTabsStorage()
}
/** 从 JWT 解析用户信息(兜底) */
export function getUserFromToken(token = getToken()) {
if (!token) return null
try {
const part = token.split('.')[1]
if (!part) return null
let base64 = part.replace(/-/g, '+').replace(/_/g, '/')
const pad = base64.length % 4
if (pad) base64 += '='.repeat(4 - pad)
const payload = JSON.parse(atob(base64))
if (payload.sub == null) return null
return {
id: payload.sub,
username: payload.username || '',
is_super_admin: !!payload.is_super_admin
}
} catch {
return null
}
}
export function isSuperAdmin(user = getUser()) {
return !!user?.is_super_admin
}
/** 解析 JWT 过期时间(毫秒时间戳),失败返回 null */
export function getTokenExpiresAt(token) {
if (!token) return null
+7 -2
View File
@@ -1,6 +1,6 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { getToken, clearToken } from '@/utils/auth'
import { getToken, clearAuth } from '@/utils/auth'
// 创建 axios 实例
const request = axios.create({
@@ -44,7 +44,7 @@ request.interceptors.response.use(
error => {
const status = error.response?.status
if (status === 401) {
clearToken()
clearAuth()
const detail = error.response?.data?.detail
if (detail) {
ElMessage.warning(typeof detail === 'string' ? detail : '登录已失效')
@@ -57,6 +57,11 @@ request.interceptors.response.use(
}
return Promise.reject(error)
}
if (status === 403) {
const detail = error.response?.data?.detail
ElMessage.warning(typeof detail === 'string' ? detail : '无权限执行此操作')
return Promise.reject(error)
}
console.error('响应错误:', error)
ElMessage.error(error.message || '网络错误')
return Promise.reject(error)
+8
View File
@@ -0,0 +1,8 @@
export const TABS_STORAGE_KEY = 'dashboard_tabs_v1'
export const HOME_PATH = '/home'
export function clearTabsStorage() {
try {
sessionStorage.removeItem(TABS_STORAGE_KEY)
} catch {}
}
+140
View File
@@ -0,0 +1,140 @@
<template>
<div class="home-page">
<el-card class="home-hero">
<div class="home-hero-inner">
<div class="home-hero-icon">
<el-icon :size="28"><House /></el-icon>
</div>
<div>
<h1 class="home-title">欢迎回来{{ username ? `,${username}` : '' }}</h1>
<p class="home-subtitle">Luxsin CMS · 从左侧菜单或下方快捷入口开始操作</p>
</div>
</div>
</el-card>
<div class="home-links">
<el-card
v-for="item in quickLinks"
:key="item.path"
class="home-link-card"
shadow="hover"
@click="go(item.path)"
>
<div class="home-link-inner">
<el-icon class="home-link-icon"><component :is="item.icon" /></el-icon>
<div>
<div class="home-link-title">{{ item.title }}</div>
<div class="home-link-desc">{{ item.desc }}</div>
</div>
</div>
</el-card>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { House, Collection, List, Document, Share } from '@element-plus/icons-vue'
import { getUser } from '@/utils/auth'
defineOptions({ name: 'Home' })
const router = useRouter()
const username = computed(() => getUser()?.username || '')
const quickLinks = [
{ path: '/brand', title: '耳机品牌管理', desc: '维护耳机品牌信息', icon: Collection },
{ path: '/model', title: '耳机型号管理', desc: '维护耳机型号与配置', icon: List },
{ path: '/ota', title: 'OTA 管理', desc: '固件升级包管理', icon: Document },
{ path: '/share-code/log', title: '分享日志', desc: '查看分享码使用记录', icon: Share }
]
function go(path) {
router.push(path)
}
</script>
<style scoped>
.home-page {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 100%;
}
.home-hero :deep(.el-card__body) {
padding: 28px 32px;
}
.home-hero-inner {
display: flex;
align-items: center;
gap: 20px;
}
.home-hero-icon {
width: 56px;
height: 56px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
color: #38bdf8;
background: linear-gradient(145deg, rgba(15, 23, 42, 0.9), rgba(30, 41, 59, 0.9));
box-shadow: 0 0 0 1px rgba(56, 189, 248, 0.2) inset;
}
.home-title {
margin: 0;
font-size: 24px;
font-weight: 700;
color: #e2e8f0;
}
.home-subtitle {
margin: 8px 0 0;
font-size: 14px;
color: #94a3b8;
}
.home-links {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 16px;
}
.home-link-card {
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.home-link-card:hover {
transform: translateY(-2px);
}
.home-link-inner {
display: flex;
align-items: flex-start;
gap: 14px;
}
.home-link-icon {
font-size: 22px;
color: #7dd3fc;
margin-top: 2px;
}
.home-link-title {
font-size: 15px;
font-weight: 600;
color: #e2e8f0;
}
.home-link-desc {
margin-top: 6px;
font-size: 13px;
color: #94a3b8;
line-height: 1.5;
}
</style>
+96 -27
View File
@@ -33,10 +33,14 @@
<div class="login-card">
<div class="logo-ring">
<el-icon :size="26"><Headset /></el-icon>
<svg xmlns="http://www.w3.org/2000/svg" width="2em" height="2em" viewBox="0 0 26 26">
<path d="M0 0h26v26H0z" fill="none" />
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20.607 4C21 4.617 21 5.413 21 7.004v9.994c0 1.591 0 2.387-.393 3.002q-.105.165-.235.312c-.483.546-1.249.765-2.78 1.202c-1.533.438-2.3.657-2.856.329a1.5 1.5 0 0 1-.267-.202C14 21.196 14 20.4 14 18.803V5.197c0-1.596 0-2.393.469-2.837q.121-.116.267-.202c.555-.328 1.323-.11 2.857.329c1.53.437 2.296.656 2.78 1.202q.129.147.234.312M14 4h-2.017C10.08 4 9.13 4 8.54 4.586C8.21 4.912 8.064 5.35 8 6m6 14h-2.017c-1.902 0-2.853 0-3.443-.586c-.33-.326-.476-.764-.54-1.414m2-6H3m5.5-2.5S11 11.34 11 12s-2.5 2.5-2.5 2.5" />
</svg>
</div>
<h1 class="title">欢迎回来</h1>
<p class="subtitle">耳机管理平台 · 控制台登录</p>
<p class="subtitle">Luxsin CMS</p>
<el-form
ref="formRef"
@@ -95,13 +99,13 @@
<script setup>
import { reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { User, Lock, Headset, Key } from '@element-plus/icons-vue'
import { login } from '@/api/auth'
import { setToken } from '@/utils/auth'
import { setToken, setUser } from '@/utils/auth'
import { clearTabsStorage, HOME_PATH } from '@/utils/tabs'
const route = useRoute()
const router = useRouter()
const formRef = ref(null)
const loading = ref(false)
@@ -128,9 +132,12 @@ const handleSubmit = async () => {
})
if (res.code === 1 && res.data?.access_token) {
setToken(res.data.access_token)
if (res.data.user) {
setUser(res.data.user)
}
ElMessage.success('登录成功')
const redirect = route.query.redirect
router.replace(typeof redirect === 'string' && redirect ? redirect : '/')
clearTabsStorage()
router.replace(HOME_PATH)
} else {
ElMessage.error(res.msg || '登录失败')
}
@@ -217,11 +224,47 @@ const handleSubmit = async () => {
max-width: 420px;
padding: 44px 40px 36px;
border-radius: 24px;
background: #1e293b;
border: 1px solid rgba(148, 163, 184, 0.12);
overflow: hidden;
background: rgba(30, 41, 59, 0.38);
backdrop-filter: blur(24px) saturate(1.5);
-webkit-backdrop-filter: blur(24px) saturate(1.5);
border: 1px solid rgba(255, 255, 255, 0.14);
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.2),
0 25px 50px -12px rgba(0, 0, 0, 0.5);
0 8px 32px rgba(0, 0, 0, 0.28),
0 0 0 1px rgba(255, 255, 255, 0.06) inset,
0 1px 0 rgba(255, 255, 255, 0.1) inset;
}
.login-card::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(
145deg,
rgba(255, 255, 255, 0.14) 0%,
rgba(255, 255, 255, 0.04) 28%,
transparent 52%
);
pointer-events: none;
}
.login-card::after {
content: '';
position: absolute;
inset: 1px;
border-radius: 23px;
background: linear-gradient(
180deg,
rgba(56, 189, 248, 0.06) 0%,
transparent 36%
);
pointer-events: none;
}
.login-card > * {
position: relative;
z-index: 1;
}
.logo-ring {
@@ -233,10 +276,13 @@ const handleSubmit = async () => {
justify-content: center;
border-radius: 16px;
color: #38bdf8;
background: linear-gradient(145deg, #0f172a 0%, #1e293b 100%);
background: rgba(15, 23, 42, 0.35);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow:
0 2px 8px rgba(56, 189, 248, 0.15),
0 0 0 1px rgba(56, 189, 248, 0.2) inset;
0 2px 12px rgba(56, 189, 248, 0.18),
0 0 0 1px rgba(255, 255, 255, 0.1) inset,
0 0 0 1px rgba(56, 189, 248, 0.22);
}
.title {
@@ -283,9 +329,13 @@ const handleSubmit = async () => {
.glass-input :deep(.el-input__wrapper) {
border-radius: 14px;
background-color: #0f172a !important;
box-shadow: 0 0 0 1px rgba(148, 163, 184, 0.15) inset !important;
border: 1px solid transparent;
background-color: rgba(15, 23, 42, 0.32) !important;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.08) inset,
0 1px 0 rgba(255, 255, 255, 0.04) inset !important;
border: 1px solid rgba(255, 255, 255, 0.06);
transition:
box-shadow 0.2s ease,
border-color 0.2s ease,
@@ -293,14 +343,19 @@ const handleSubmit = async () => {
}
.glass-input :deep(.el-input__wrapper:hover) {
background-color: #1e293b !important;
box-shadow: 0 0 0 1px rgba(148, 163, 184, 0.25) inset !important;
background-color: rgba(30, 41, 59, 0.42) !important;
border-color: rgba(255, 255, 255, 0.12);
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.12) inset,
0 1px 0 rgba(255, 255, 255, 0.06) inset !important;
}
.glass-input :deep(.el-input__wrapper.is-focus) {
background-color: #0f172a !important;
border-color: rgba(56, 189, 248, 0.45);
box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.22) !important;
background-color: rgba(15, 23, 42, 0.45) !important;
border-color: rgba(56, 189, 248, 0.5);
box-shadow:
0 0 0 3px rgba(56, 189, 248, 0.18),
0 0 0 1px rgba(56, 189, 248, 0.35) inset !important;
}
.glass-input :deep(.el-input__inner) {
@@ -328,14 +383,22 @@ const handleSubmit = async () => {
width: 100%;
height: 52px;
margin-top: 4px;
border: none;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 999px;
font-size: 15px;
font-weight: 700;
letter-spacing: 0.12em;
color: #fff !important;
background: #38bdf8 !important;
box-shadow: 0 4px 16px rgba(56, 189, 248, 0.3);
background: linear-gradient(
135deg,
rgba(56, 189, 248, 0.92) 0%,
rgba(34, 211, 238, 0.88) 100%
) !important;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
box-shadow:
0 4px 20px rgba(56, 189, 248, 0.32),
0 1px 0 rgba(255, 255, 255, 0.2) inset;
transition:
transform 0.15s ease,
box-shadow 0.2s ease,
@@ -343,8 +406,14 @@ const handleSubmit = async () => {
}
.login-btn:hover {
background: #22d3ee !important;
box-shadow: 0 6px 20px rgba(56, 189, 248, 0.4);
background: linear-gradient(
135deg,
rgba(34, 211, 238, 0.95) 0%,
rgba(56, 189, 248, 0.92) 100%
) !important;
box-shadow:
0 6px 24px rgba(56, 189, 248, 0.42),
0 1px 0 rgba(255, 255, 255, 0.25) inset;
}
.login-btn:active {
+425
View File
@@ -0,0 +1,425 @@
<template>
<div class="users-container">
<el-card class="search-card">
<el-form :inline="true" :model="searchForm" class="search-form">
<el-form-item label="用户名">
<el-input
v-model="searchForm.username"
placeholder="模糊查询"
clearable
style="width: 200px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">
<el-icon><Refresh /></el-icon>
重置
</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="table-card">
<div class="card-header">
<span class="title">账号列表</span>
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
新建账号
</el-button>
</div>
<el-table v-loading="loading" :data="tableData" border stripe style="width: 100%">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="username" label="用户名" min-width="140" />
<el-table-column prop="is_super_admin" label="超级管理员" width="120">
<template #default="{ row }">
<el-tag v-if="row.is_super_admin" type="warning" size="small">是</el-tag>
<el-tag v-else type="info" size="small">否</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag v-if="row.status === 1" type="success" size="small">启用</el-tag>
<el-tag v-else type="danger" size="small">禁用</el-tag>
</template>
</el-table-column>
<el-table-column prop="last_login_at" label="最近登录" min-width="170">
<template #default="{ row }">
{{ formatDateTime(row.last_login_at) }}
</template>
</el-table-column>
<el-table-column prop="create_at" label="创建时间" min-width="170">
<template #default="{ row }">
{{ formatDateTime(row.create_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button
v-if="row.status === 1"
type="warning"
size="small"
@click="handleToggleStatus(row, 0)"
>
禁用
</el-button>
<el-button
v-else
type="success"
size="small"
@click="handleToggleStatus(row, 1)"
>
启用
</el-button>
<el-button type="primary" size="small" @click="handleChangePassword(row)">
改密
</el-button>
<el-button type="danger" size="small" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
</el-card>
<el-dialog
v-model="createDialogVisible"
title="新建账号"
width="480px"
@close="resetCreateForm"
>
<el-form
ref="createFormRef"
:model="createForm"
:rules="createRules"
label-width="110px"
>
<el-form-item label="用户名" prop="username">
<el-input v-model="createForm.username" maxlength="64" placeholder="登录账号" />
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="createForm.password"
type="password"
show-password
placeholder="至少 6 位"
/>
</el-form-item>
<el-form-item label="超级管理员">
<el-switch v-model="createForm.is_super_admin" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleCreateSubmit">
确定
</el-button>
</template>
</el-dialog>
<el-dialog
v-model="passwordDialogVisible"
:title="`修改密码 - ${passwordForm.username}`"
width="480px"
@close="resetPasswordForm"
>
<el-form
ref="passwordFormRef"
:model="passwordForm"
:rules="passwordRules"
label-width="110px"
>
<el-form-item label="新密码" prop="password">
<el-input
v-model="passwordForm.password"
type="password"
show-password
placeholder="至少 6 位"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="passwordDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handlePasswordSubmit">
确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh, Plus } from '@element-plus/icons-vue'
import { getUsers, createUser, updateUser, deleteUser } from '@/api/user'
const loading = ref(false)
const submitLoading = ref(false)
const tableData = ref([])
const searchForm = reactive({
username: ''
})
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0
})
const createDialogVisible = ref(false)
const createFormRef = ref(null)
const createForm = reactive({
username: '',
password: '',
is_super_admin: false
})
const passwordDialogVisible = ref(false)
const passwordFormRef = ref(null)
const passwordForm = reactive({
id: null,
username: '',
password: ''
})
const createRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 1, max: 64, message: '长度 1-64 个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码至少 6 位', trigger: 'blur' }
]
}
const passwordRules = {
password: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, message: '密码至少 6 位', trigger: 'blur' }
]
}
function formatDateTime(value) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const pad = (n) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
async function loadData() {
loading.value = true
try {
const res = await getUsers({
skip: (pagination.page - 1) * pagination.pageSize,
limit: pagination.pageSize,
username: searchForm.username || undefined
})
if (res.code === 1 && res.data) {
tableData.value = res.data.items || []
pagination.total = res.data.total || 0
} else if (res.code === 2) {
tableData.value = []
pagination.total = 0
}
} catch (e) {
console.error(e)
ElMessage.error('加载失败')
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadData()
}
function handleReset() {
searchForm.username = ''
pagination.page = 1
loadData()
}
function handleSizeChange() {
loadData()
}
function handlePageChange() {
loadData()
}
function handleAdd() {
createDialogVisible.value = true
}
function resetCreateForm() {
createForm.username = ''
createForm.password = ''
createForm.is_super_admin = false
createFormRef.value?.resetFields?.()
}
async function handleCreateSubmit() {
if (!createFormRef.value) return
await createFormRef.value.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
const res = await createUser({
username: createForm.username.trim(),
password: createForm.password,
is_super_admin: createForm.is_super_admin
})
if (res.code === 1) {
ElMessage.success('创建成功')
createDialogVisible.value = false
loadData()
} else {
ElMessage.error(res.msg || '创建失败')
}
} catch (e) {
console.error(e)
} finally {
submitLoading.value = false
}
})
}
function handleChangePassword(row) {
passwordForm.id = row.id
passwordForm.username = row.username
passwordForm.password = ''
passwordDialogVisible.value = true
}
function resetPasswordForm() {
passwordForm.id = null
passwordForm.username = ''
passwordForm.password = ''
passwordFormRef.value?.resetFields?.()
}
async function handlePasswordSubmit() {
if (!passwordFormRef.value) return
await passwordFormRef.value.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
const res = await updateUser(passwordForm.id, { password: passwordForm.password })
if (res.code === 1) {
ElMessage.success('密码已更新')
passwordDialogVisible.value = false
} else {
ElMessage.error(res.msg || '更新失败')
}
} catch (e) {
console.error(e)
} finally {
submitLoading.value = false
}
})
}
function handleToggleStatus(row, status) {
const action = status === 1 ? '启用' : '禁用'
ElMessageBox.confirm(`确定要${action}账号「${row.username}」吗?`, '提示', {
type: 'warning',
confirmButtonText: '确定',
cancelButtonText: '取消'
})
.then(async () => {
try {
const res = await updateUser(row.id, { status })
if (res.code === 1) {
ElMessage.success(`${action}成功`)
loadData()
} else {
ElMessage.error(res.msg || `${action}失败`)
}
} catch (e) {
console.error(e)
}
})
.catch(() => {})
}
function handleDelete(row) {
ElMessageBox.confirm(`确定要删除账号「${row.username}」吗?此操作不可恢复。`, '警告', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消'
})
.then(async () => {
try {
const res = await deleteUser(row.id)
if (res.code === 1) {
ElMessage.success('删除成功')
loadData()
} else {
ElMessage.error(res.msg || '删除失败')
}
} catch (e) {
console.error(e)
}
})
.catch(() => {})
}
onMounted(() => {
loadData()
})
</script>
<style scoped>
.users-container {
height: 100%;
}
.search-form {
display: flex;
flex-wrap: wrap;
}
.table-card {
min-height: 500px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.title {
font-size: 18px;
font-weight: 600;
color: #e2e8f0;
}
.pagination {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>