feat: 接入两端用户名登录和独立学习空间 (#2)

This commit is contained in:
ila
2026-09-10 17:39:57 +08:00
parent 596543b0e2
commit 2c8e9b3ca0
82 changed files with 14148 additions and 30 deletions
+7
View File
@@ -0,0 +1,7 @@
<template><router-view /></template>
<script>export default { name: 'LexGoAdmin' }</script>
<style lang="scss">
@use './styles/sidebar.scss';
html, body, #app { height: 100%; margin: 0; font-family: "Microsoft YaHei", sans-serif; color: #303133; }
* { box-sizing: border-box; }
</style>
+45
View File
@@ -0,0 +1,45 @@
<template>
<div style="padding: 0 15px;" @click="toggleClick">
<svg
:class="{'is-active':isActive}"
class="hamburger"
viewBox="0 0 1024 1024"
xmlns="http://www.w3.org/2000/svg"
width="64"
height="64"
>
<path d="M408 442h480c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8H408c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8zm-8 204c0 4.4 3.6 8 8 8h480c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8H408c-4.4 0-8 3.6-8 8v56zm504-486H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zm0 632H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zM142.4 642.1L298.7 519a8.84 8.84 0 0 0 0-13.9L142.4 381.9c-5.8-4.6-14.4-.5-14.4 6.9v246.3a8.9 8.9 0 0 0 14.4 7z" />
</svg>
</div>
</template>
<script>
export default {
name: 'AppHamburger',
emits: ['toggleClick'],
props: {
isActive: {
type: Boolean,
default: false
}
},
methods: {
toggleClick() {
this.$emit('toggleClick')
}
}
}
</script>
<style scoped>
.hamburger {
display: inline-block;
vertical-align: middle;
width: 20px;
height: 20px;
}
.hamburger.is-active {
transform: rotate(180deg);
}
</style>
+102
View File
@@ -0,0 +1,102 @@
<template>
<div :class="{'hidden':hidden}" class="pagination-container">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:background="background"
:layout="layout"
:page-sizes="pageSizes"
:total="total"
v-bind="$attrs"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</template>
<script>
import { scrollTo } from '@/utils/scroll-to'
export default {
name: 'PaginationComponent',
emits: ['update:page', 'update:limit', 'pagination'],
props: {
total: {
required: true,
type: Number
},
page: {
type: Number,
default: 1
},
limit: {
type: Number,
default: 20
},
pageSizes: {
type: Array,
default() {
return [10, 20, 30, 50]
}
},
layout: {
type: String,
default: 'total, sizes, prev, pager, next, jumper'
},
background: {
type: Boolean,
default: true
},
autoScroll: {
type: Boolean,
default: true
},
hidden: {
type: Boolean,
default: false
}
},
computed: {
currentPage: {
get() {
return this.page
},
set(val) {
this.$emit('update:page', val)
}
},
pageSize: {
get() {
return this.limit
},
set(val) {
this.$emit('update:limit', val)
}
}
},
methods: {
handleSizeChange(val) {
this.$emit('pagination', { page: this.currentPage, limit: val })
if (this.autoScroll) {
scrollTo(0, 800)
}
},
handleCurrentChange(val) {
this.$emit('pagination', { page: val, limit: this.pageSize })
if (this.autoScroll) {
scrollTo(0, 800)
}
}
}
}
</script>
<style scoped>
.pagination-container {
background: #fff;
padding: 32px 16px;
}
.pagination-container.hidden {
display: none;
}
</style>
+18
View File
@@ -0,0 +1,18 @@
<template>
<div class="basic-layout">
<slot name="wrapper" />
</div>
</template>
<script>
export default {
name: 'BasicLayout'
}
</script>
<style lang="scss" scoped>
.basic-layout{
padding: 10px;
box-sizing: border-box;
}
</style>
+3
View File
@@ -0,0 +1,3 @@
<template><section class="app-main"><router-view :key="$store.state.generation" /></section></template>
<script>export default { name: 'AppMain' }</script>
<style scoped>.app-main { min-height: calc(100vh - 56px); width: 100%; position: relative; overflow: hidden; background-color: #f0f2f5; }</style>
+51
View File
@@ -0,0 +1,51 @@
<template>
<div :class="classObj" class="app-wrapper">
<div v-if="device === 'mobile' && sidebar.opened" class="drawer-bg" @click="handleClickOutside" />
<aside class="sidebar-container">
<div class="brand">{{ sidebar.opened ? 'LexGo 管理' : 'LG' }}</div>
<el-menu default-active="/accounts" :collapse="!sidebar.opened" router>
<el-menu-item index="/accounts"><span>账号管理</span></el-menu-item>
</el-menu>
</aside>
<div class="main-container">
<header class="navbar">
<button class="menu-toggle" aria-label="切换导航" @click="$store.dispatch('app/toggleSideBar')"><hamburger :is-active="sidebar.opened" /></button>
<span>账号管理</span>
<div class="right-menu"><span>{{ $store.state.user?.username }}</span><el-button @click="logout">退出登录</el-button></div>
</header>
<app-main />
</div>
</div>
</template>
<script>
import { mapState } from 'vuex'
import ResizeMixin from './mixin/ResizeHandler'
import Hamburger from '../components/Hamburger/index.vue'
import AppMain from './components/AppMain.vue'
import { session } from '../store'
import { ElMessage } from 'element-plus'
export default {
name: 'MainLayout', components: { Hamburger, AppMain }, mixins: [ResizeMixin],
computed: {
...mapState({ sidebar: state => state.app.sidebar, device: state => state.app.device }),
classObj() { return { hideSidebar: !this.sidebar.opened, openSidebar: this.sidebar.opened, withoutAnimation: this.sidebar.withoutAnimation, mobile: this.device === 'mobile' } }
},
watch: { '$store.state.user'(user) { if (!user) this.$router.replace('/login') } },
methods: {
handleClickOutside() { this.$store.dispatch('app/closeSideBar', { withoutAnimation: false }) },
async logout() {
try { await session.logout() } catch { ElMessage.warning('本地已退出;服务器尚未确认退出,原会话可能仍有效。') }
this.$router.replace('/login')
}
}
}
</script>
<style lang="scss" scoped>
@use '@/styles/mixin.scss' as *;
.app-wrapper { @include clearfix; position: relative; height: 100%; width: 100%; &.mobile.openSidebar { position: fixed; top: 0; } }
.drawer-bg { background: #000; opacity: .3; width: 100%; top: 0; height: 100%; position: absolute; z-index: 999; }
.navbar { height: 56px; display: flex; align-items: center; gap: 12px; background: white; padding-right: 16px; box-shadow: 0 1px 4px #00152914; }
.right-menu { margin-left: auto; display: flex; align-items: center; gap: 12px; }
.brand { height: 64px; display: flex; align-items: center; justify-content: center; font-size: 18px; color: white; font-weight: 600; }
.menu-toggle { background: transparent; border: 0; cursor: pointer; }
</style>
+45
View File
@@ -0,0 +1,45 @@
import store from '@/store'
const { body } = document
const WIDTH = 992 // refer to Bootstrap's responsive design
export default {
watch: {
$route() {
if (this.device === 'mobile' && this.sidebar.opened) {
store.dispatch('app/closeSideBar', { withoutAnimation: false })
}
}
},
beforeMount() {
window.addEventListener('resize', this.$_resizeHandler)
},
beforeUnmount() {
window.removeEventListener('resize', this.$_resizeHandler)
},
mounted() {
const isMobile = this.$_isMobile()
if (isMobile) {
store.dispatch('app/toggleDevice', 'mobile')
store.dispatch('app/closeSideBar', { withoutAnimation: true })
}
},
methods: {
// use $_ for mixins properties
// https://vuejs.org/v2/style-guide/index.html#Private-property-names-essential
$_isMobile() {
const rect = body.getBoundingClientRect()
return rect.width - 1 < WIDTH
},
$_resizeHandler() {
if (!document.hidden) {
const isMobile = this.$_isMobile()
store.dispatch('app/toggleDevice', isMobile ? 'mobile' : 'desktop')
if (isMobile) {
store.dispatch('app/closeSideBar', { withoutAnimation: true })
}
}
}
}
}
+9
View File
@@ -0,0 +1,9 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import 'normalize.css/normalize.css'
import 'element-plus/dist/index.css'
import App from './App.vue'
import store from './store'
import router from './router'
createApp(App).use(store).use(router).use(ElementPlus, { locale: zhCn }).mount('#app')
+18
View File
@@ -0,0 +1,18 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import { session } from '../store'
import Layout from '../layout/index.vue'
import Login from '../views/Login.vue'
import Accounts from '../views/Accounts.vue'
const router = createRouter({ history: createWebHashHistory(), routes: [
{ path: '/login', component: Login },
{ path: '/', component: Layout, children: [{ path: '', redirect: '/accounts' }, { path: 'accounts', component: Accounts }] },
{ path: '/:pathMatch(.*)*', redirect: '/accounts' }
] })
router.beforeEach(async to => {
if (to.path === '/login') return true
try {
if (!session.state.user && !await session.restore()) return '/login'
if (session.state.user?.role !== 'admin') return '/login'
} catch { return '/login' }
})
export default router
+109
View File
@@ -0,0 +1,109 @@
export function normalizeUsername(value) { return value.trim().toLowerCase() }
export function validPassword(value) {
const bytes = new TextEncoder().encode(value).length
return bytes >= 10 && bytes <= 72
}
export function validUsername(value) { return /^[a-z][a-z0-9_.-]{2,31}$/.test(normalizeUsername(value)) }
export function createSession({ fetch, storage, changed = () => {} }) {
const key = 'lexgo-admin-token'
const state = { token: storage.getItem(key) || '', user: null, accounts: [], generation: 0 }
function clear() {
state.generation++
state.token = ''
state.user = null
state.accounts = []
storage.removeItem(key)
changed(state)
}
function assertCurrent(generation) {
if (generation !== state.generation) throw new Error('会话已变化,请重新操作')
}
async function request(path, method = 'GET', body, token = state.token, generation = state.generation) {
const result = await fetch('/api/v1' + path, {
method,
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}) },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
cache: 'no-store'
})
const payload = await result.json()
assertCurrent(generation)
if (!result.ok) {
if (result.status === 401 || result.status === 403) clear()
throw new Error(result.status === 401 ? '登录已失效,请重新登录' : payload.msg || '请求失败')
}
return payload.data
}
async function revoke(token) {
// Cleanup must never restore a previous identity or expose its response.
if (!token) return
let result
try {
result = await fetch('/api/v1/logout', { method: 'POST', headers: { Authorization: 'Bearer ' + token } })
} catch {
throw new Error('服务器尚未确认退出,原会话可能仍有效')
}
if (!result.ok && result.status !== 401) throw new Error('服务器尚未确认退出,原会话可能仍有效')
}
async function requireAdmin(user, token) {
if (user.role !== 'admin') {
clear()
try { await revoke(token) } catch (error) { throw new Error('此账号没有管理权限;' + error.message) }
throw new Error('此账号没有管理权限')
}
}
function authorized() { if (!state.user || state.user.role !== 'admin') throw new Error('请先登录管理员账号') }
return {
state, clear,
async login(username, password) {
const oldToken = state.token
clear()
const generation = state.generation
if (oldToken) await revoke(oldToken)
assertCurrent(generation)
const data = await request('/login', 'POST', { username: normalizeUsername(username), password }, '', generation)
assertCurrent(generation)
await requireAdmin(data.user, data.token)
assertCurrent(generation)
state.token = data.token
state.user = data.user
storage.setItem(key, data.token)
changed(state)
},
async restore() {
if (!state.token) return false
const generation = state.generation
try {
const user = await request('/me')
assertCurrent(generation)
await requireAdmin(user, state.token)
assertCurrent(generation)
state.user = user
changed(state)
return true
} catch (error) { if (generation === state.generation) clear(); throw error }
},
async logout() { const token = state.token; clear(); await revoke(token) },
async loadAccounts() {
authorized()
const generation = state.generation
const data = await request('/accounts')
assertCurrent(generation)
state.accounts = data.items
changed(state)
},
async createAccount(username, password) {
authorized()
if (!validUsername(username)) throw new Error('账号须为 3–32 位,以字母开头,仅含字母、数字、点、下划线或短横线')
if (!validPassword(password)) throw new Error('密码须为 10–72 字节')
return request('/accounts', 'POST', { username: normalizeUsername(username), password })
},
async updateAccount(id, patch) {
authorized()
if (patch.password !== undefined && !validPassword(patch.password)) throw new Error('密码须为 10–72 字节')
if (id === state.user.id && patch.disabled) throw new Error('不能停用当前管理员')
const account = state.accounts.find(item => item.id === id)
if (id === state.user.id || (account && account.role !== 'learner')) throw new Error('管理员账号不支持此操作')
return request('/accounts/' + id, 'PATCH', patch)
}
}
}
+24
View File
@@ -0,0 +1,24 @@
import { createStore } from 'vuex'
import { createSession } from '../session.mjs'
const store = createStore({
state: { user: null, accounts: [], generation: 0 },
mutations: { session(state, next) { state.user = next.user; state.accounts = next.accounts; state.generation = next.generation } },
modules: {
app: {
namespaced: true,
state: () => ({ sidebar: { opened: true, withoutAnimation: false }, device: 'desktop' }),
mutations: {
close(state, value) { state.sidebar.opened = false; state.sidebar.withoutAnimation = value.withoutAnimation },
toggle(state) { state.sidebar.opened = !state.sidebar.opened },
device(state, value) { state.device = value }
},
actions: {
closeSideBar({ commit }, value) { commit('close', value) },
toggleSideBar({ commit }) { commit('toggle') },
toggleDevice({ commit }, value) { commit('device', value) }
}
}
}
})
export const session = createSession({ fetch: window.fetch.bind(window), storage: window.sessionStorage, changed: state => store.commit('session', state) })
export default store
+66
View File
@@ -0,0 +1,66 @@
@mixin clearfix {
&:after {
content: "";
display: table;
clear: both;
}
}
@mixin scrollBar {
&::-webkit-scrollbar-track-piece {
background: #d3dce6;
}
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: #99a9bf;
border-radius: 20px;
}
}
@mixin relative {
position: relative;
width: 100%;
height: 100%;
}
@mixin pct($pct) {
width: #{$pct};
position: relative;
margin: 0 auto;
}
@mixin triangle($width, $height, $color, $direction) {
$width: $width*0.5;
$color-border-style: $height solid $color;
$transparent-border-style: $width solid transparent;
height: 0;
width: 0;
@if $direction==up {
border-bottom: $color-border-style;
border-left: $transparent-border-style;
border-right: $transparent-border-style;
}
@else if $direction==right {
border-left: $color-border-style;
border-top: $transparent-border-style;
border-bottom: $transparent-border-style;
}
@else if $direction==down {
border-top: $color-border-style;
border-left: $transparent-border-style;
border-right: $transparent-border-style;
}
@else if $direction==left {
border-right: $color-border-style;
border-top: $transparent-border-style;
border-bottom: $transparent-border-style;
}
}
+263
View File
@@ -0,0 +1,263 @@
@use './variables.scss' as *;
#app {
.main-container {
min-height: 100%;
transition: margin-left .28s;
margin-left: $sideBarWidth;
position: relative;
}
.sidebar-container {
-webkit-transition: width .28s;
transition: width 0.28s;
width: $sideBarWidth !important;
background-color: $menuBg;
height: 100%;
position: fixed;
font-size: 0px;
top: 0;
bottom: 0;
left: 0;
z-index: 1001;
overflow: hidden;
-webkit-box-shadow: 2px 0 6px rgba(0, 21, 41, .35);
box-shadow: 2px 0 6px rgba(0, 21, 41, .35);
.horizontal-collapse-transition {
transition: 0s width ease-in-out, 0s padding-left ease-in-out, 0s padding-right ease-in-out;
}
.scrollbar-wrapper {
overflow-x: hidden !important;
}
.el-scrollbar__bar.is-vertical {
right: 0px;
}
.el-scrollbar {
height: 100%;
}
&.has-logo {
.el-scrollbar {
height: calc(100% - 64px);
}
}
.is-horizontal {
display: none;
}
a {
display: inline-block;
width: 100%;
overflow: hidden;
}
.svg-icon {
margin-right: 16px;
}
.el-menu {
border: none;
height: 100%;
width: 100% !important;
--el-menu-bg-color: #{$menuBg};
background-color: $menuBg !important;
border-right: none !important;
}
// 统一菜单项高度(Element Plus 2.x 默认 56px 偏大,收紧至 50px)
.el-menu-item,
.el-sub-menu__title,
.el-submenu__title {
height: 50px !important;
line-height: 50px !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
color: #fff !important;
}
// 顶级 el-menu-item(如"首页")必须设置深色背景,否则白字不可见
.el-menu-item {
background-color: $menuBg !important;
&:hover {
background-color: $menuHover !important;
}
&.is-active {
background-color: $subMenuBg !important;
color: $menuActiveText !important;
}
}
// 展开的子菜单 el-menu-item 强制暗色背景 + 白色文字
.el-sub-menu .el-menu-item,
.el-submenu .el-menu-item {
background-color: $subMenuBg !important;
color: #fff !important;
min-width: $sideBarWidth !important;
&:hover {
background-color: $subMenuHover !important;
}
&.is-active {
color: $menuActiveText !important;
}
}
// 嵌套子菜单标题
.nest-menu .el-sub-menu > .el-sub-menu__title,
.nest-menu .el-submenu > .el-submenu__title {
background-color: $subMenuBg !important;
color: #fff !important;
min-width: $sideBarWidth !important;
&:hover {
background-color: $subMenuHover !important;
}
}
// 父级菜单标题 hover 效果
.submenu-title-noDropdown,
.el-sub-menu__title,
.el-submenu__title {
&:hover {
background-color: $menuHover !important;
}
}
.is-active > .el-sub-menu__title,
.is-active > .el-submenu__title {
color: $subMenuActiveText !important;
}
}
.hideSidebar {
.sidebar-container {
width: 54px !important;
}
.main-container {
margin-left: 54px;
}
.submenu-title-noDropdown {
padding: 0 !important;
position: relative;
.el-tooltip {
padding: 0 !important;
.svg-icon {
margin-left: 20px;
}
}
}
.el-sub-menu,
.el-submenu {
overflow: hidden;
> .el-sub-menu__title,
> .el-submenu__title {
padding: 0 !important;
.svg-icon {
margin-left: 20px;
}
}
}
.el-menu--collapse {
.el-sub-menu,
.el-submenu {
> .el-sub-menu__title,
> .el-submenu__title {
> span {
height: 0;
width: 0;
overflow: hidden;
visibility: hidden;
display: inline-block;
}
}
}
}
}
.el-menu--collapse .el-menu .el-sub-menu,
.el-menu--collapse .el-menu .el-submenu {
min-width: $sideBarWidth !important;
}
// mobile responsive
.mobile {
.main-container {
margin-left: 0px;
}
.sidebar-container {
transition: transform .28s;
width: $sideBarWidth !important;
}
&.hideSidebar {
.sidebar-container {
pointer-events: none;
transition-duration: 0.3s;
transform: translate3d(-$sideBarWidth, 0, 0);
}
}
}
.withoutAnimation {
.main-container,
.sidebar-container {
transition: none;
}
}
}
// when menu collapsed
.el-menu--vertical {
> .el-menu {
.svg-icon {
margin-right: 16px;
}
}
.nest-menu .el-sub-menu > .el-sub-menu__title,
.nest-menu .el-submenu > .el-submenu__title,
.el-menu-item {
&:hover {
background-color: rgba(0, 0, 0, 0.06) !important;
}
}
// the scroll bar appears when the subMenu is too long
> .el-menu--popup {
max-height: 100vh;
overflow-y: auto;
&::-webkit-scrollbar-track-piece {
background: #d3dce6;
}
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: #99a9bf;
border-radius: 20px;
}
}
}
+43
View File
@@ -0,0 +1,43 @@
// base color
$blue:#324157;
$light-blue:#3A71A8;
$red:#C03639;
$pink: #E65D6E;
$green: #30B08F;
$tiffany: #4AB7BD;
$yellow:#FEC171;
$panGreen: #30B08F;
// sidebar — Ant Design Pro 经典深蓝配色
$menuActiveText:#1677ff;
$subMenuActiveText:#69b1ff;
$menuBg:#001529;
$menuHover:#000c17;
$sidebarTitle: #ffffff;
$menuLightBg:#ffffff;
$menuLightHover:#e6f4ff;
$sidebarLightTitle: #001d66;
$subMenuBg:#000c17;
$subMenuHover:#001528;
$sideBarWidth: 210px;
// the :export directive is the magic sauce for webpack
// https://www.bluematador.com/blog/how-to-share-variables-between-js-and-sass
:export {
// menuText: $menuText;
menuActiveText: $menuActiveText;
subMenuActiveText: $subMenuActiveText;
menuBg: $menuBg;
menuHover: $menuHover;
menuLightBg: $menuLightBg;
menuLightHover: $menuLightHover;
subMenuBg: $subMenuBg;
subMenuHover: $subMenuHover;
sideBarWidth: $sideBarWidth;
sidebarTitle: $sidebarTitle;
sidebarLightTitle: $sidebarLightTitle
}
+58
View File
@@ -0,0 +1,58 @@
Math.easeInOutQuad = function(t, b, c, d) {
t /= d / 2
if (t < 1) {
return c / 2 * t * t + b
}
t--
return -c / 2 * (t * (t - 2) - 1) + b
}
// requestAnimationFrame for Smart Animating http://goo.gl/sx5sts
var requestAnimFrame = (function() {
return window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || function(callback) { window.setTimeout(callback, 1000 / 60) }
})()
/**
* Because it's so fucking difficult to detect the scrolling element, just move them all
* @param {number} amount
*/
function move(amount) {
document.documentElement.scrollTop = amount
document.body.parentNode.scrollTop = amount
document.body.scrollTop = amount
}
function position() {
return document.documentElement.scrollTop || document.body.parentNode.scrollTop || document.body.scrollTop
}
/**
* @param {number} to
* @param {number} duration
* @param {Function} callback
*/
export function scrollTo(to, duration, callback) {
const start = position()
const change = to - start
const increment = 20
let currentTime = 0
duration = (typeof (duration) === 'undefined') ? 500 : duration
var animateScroll = function() {
// increment the time
currentTime += increment
// find the value with the quadratic in-out easing function
var val = Math.easeInOutQuad(currentTime, start, change, duration)
// move the document.body
move(val)
// do the animation unless its over
if (currentTime < duration) {
requestAnimFrame(animateScroll)
} else {
if (callback && typeof (callback) === 'function') {
// the animation is done so lets callback
callback()
}
}
}
animateScroll()
}
+72
View File
@@ -0,0 +1,72 @@
<template>
<basic-layout><template #wrapper><el-card class="box-card">
<div class="toolbar"><h1>账号管理</h1><el-button type="primary" @click="openCreate">新增学习账号</el-button><el-button :loading="loading" @click="load">刷新</el-button></div>
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
<el-table v-loading="loading" :data="pageItems" border empty-text="暂无账号">
<el-table-column label="编号" width="75" prop="id" />
<el-table-column label="账号" prop="username" min-width="170" :show-overflow-tooltip="true" />
<el-table-column label="角色" width="110"><template #default="scope">{{ scope.row.role === 'admin' ? '管理员' : '学习者' }}</template></el-table-column>
<el-table-column label="状态" width="90"><template #default="scope"><el-tag :type="scope.row.disabled ? 'info' : 'success'">{{ scope.row.disabled ? '已停用' : '正常' }}</el-tag></template></el-table-column>
<el-table-column label="操作" min-width="220"><template #default="scope">
<el-button link type="primary" :disabled="busy || scope.row.role !== 'learner'" @click="changeStatus(scope.row)">{{ scope.row.disabled ? '启用' : '停用' }}</el-button>
<el-button link type="primary" :disabled="busy || scope.row.role !== 'learner'" @click="openReset(scope.row)">重置密码</el-button>
</template></el-table-column>
</el-table>
<pagination v-show="accounts.length" v-model:page="page" v-model:limit="limit" :total="accounts.length" :auto-scroll="false" layout="total, prev, pager, next" />
<el-dialog v-model="open" :title="target ? '重置密码 · ' + target.username : '新增学习账号'" width="min(460px, 92vw)" :close-on-click-modal="false" :close-on-press-escape="!busy" :show-close="!busy" @closed="clearForm">
<el-alert v-if="formError" :title="formError" type="error" :closable="false" />
<el-form :model="form" label-position="top" @submit.prevent="save">
<el-form-item v-if="!target" label="账号"><el-input v-model="form.username" autocomplete="off" maxlength="32" /><div class="hint">3–32 位,以字母开头;可含数字、点、下划线、短横线</div></el-form-item>
<el-form-item label="新密码"><el-input v-model="form.password" type="password" show-password autocomplete="new-password" /><div class="hint">10–72 字节;中文等字符会占多个字节</div></el-form-item>
<div class="dialog-footer"><el-button :disabled="busy" @click="open = false">取消</el-button><el-button type="primary" native-type="submit" :loading="busy">保存</el-button></div>
</el-form>
</el-dialog>
</el-card></template></basic-layout>
</template>
<script>
import BasicLayout from '../layout/BasicLayout.vue'
import Pagination from '../components/Pagination/index.vue'
import { session } from '../store'
import { ElMessage, ElMessageBox } from 'element-plus'
export default {
name: 'AccountManagement', components: { BasicLayout, Pagination },
data: () => ({ loading: false, busy: false, error: '', formError: '', open: false, target: null, form: { username: '', password: '' }, page: 1, limit: 20 }),
computed: {
accounts() { return this.$store.state.accounts },
pageItems() { return this.accounts.slice((this.page - 1) * this.limit, this.page * this.limit) }
},
mounted() { this.load() },
methods: {
async load() {
this.loading = true; this.error = ''
try { await session.loadAccounts() } catch (error) { this.error = error.message }
finally { this.loading = false }
},
clearForm() { this.form = { username: '', password: '' }; this.formError = ''; this.target = null },
openCreate() { this.clearForm(); this.open = true },
openReset(account) { if (account.role !== 'learner') return; this.clearForm(); this.target = account; this.open = true },
async save() {
if (this.busy) return
this.busy = true; this.formError = ''
try {
if (this.target) await session.updateAccount(this.target.id, { password: this.form.password })
else await session.createAccount(this.form.username, this.form.password)
this.open = false; this.clearForm(); ElMessage.success('已保存'); await this.load()
} catch (error) { this.formError = error.message }
finally { this.busy = false }
},
async changeStatus(account) {
if (account.role !== 'learner' || this.busy) return
const generation = session.state.generation
try { await ElMessageBox.confirm('确认' + (account.disabled ? '启用' : '停用') + '账号 ' + account.username + '?', '账号状态', { confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' }) }
catch { return }
if (generation !== session.state.generation) return
this.busy = true; this.error = ''
try { await session.updateAccount(account.id, { disabled: !account.disabled }); await this.load() }
catch (error) { this.error = error.message }
finally { this.busy = false }
}
}
}
</script>
<style scoped>.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 18px; flex-wrap: wrap; } h1 { font-size: 20px; margin: 0 auto 0 0; } .hint { color: #606266; font-size: 12px; } .dialog-footer { display: flex; justify-content: flex-end; } .el-alert { margin-bottom: 16px; }</style>
+31
View File
@@ -0,0 +1,31 @@
<template>
<main class="login-page"><div class="form-box">
<h1>LexGo 管理</h1><p>账号登录</p>
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
<el-form :model="form" label-position="top" class="login-form" @submit.prevent="login">
<el-form-item label="账号"><el-input v-model="form.username" name="username" autocomplete="username" autofocus /></el-form-item>
<el-form-item label="密码"><el-input v-model="form.password" name="password" type="password" show-password autocomplete="current-password" /></el-form-item>
<el-button native-type="submit" type="primary" :loading="loading" :disabled="!form.username.trim() || !form.password">登录</el-button>
</el-form>
</div></main>
</template>
<script>
import { session } from '../store'
export default {
name: 'AdminLogin', data: () => ({ form: { username: '', password: '' }, loading: false, error: '' }),
methods: {
async login() {
if (this.loading) return
this.loading = true; this.error = ''
try { await session.login(this.form.username, this.form.password); this.$router.replace('/accounts') }
catch (error) { this.error = error.message }
finally { this.form.password = ''; this.loading = false }
}
}
}
</script>
<style scoped>
.login-page { min-height: 100vh; display: grid; place-items: center; background: #f0f2f5; padding: 24px; }
.form-box { background: white; width: min(420px, 100%); padding: 36px; border-radius: 8px; box-shadow: 0 6px 24px #00152910; }
h1 { font-size: 24px; } p { color: #606266; } .login-form { margin-top: 24px; } .login-form .el-button { width: 100%; }
</style>