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
+1
View File
@@ -0,0 +1 @@
module.exports = { root: true, env: { browser: true, es2021: true, node: true }, extends: ['eslint:recommended', 'plugin:vue/vue3-essential'], parserOptions: { ecmaVersion: 2021, sourceType: 'module' } }
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
.env*
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 wenjianzhang
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1
View File
@@ -0,0 +1 @@
module.exports = { presets: ['@vue/cli-plugin-babel/preset'] }
+39
View File
@@ -0,0 +1,39 @@
{
"name": "lexgo-admin",
"version": "0.1.0",
"private": true,
"license": "MIT",
"scripts": {
"dev": "vue-cli-service serve --host 127.0.0.1 --port 5174",
"build": "vue-cli-service build",
"test": "node --test tests/*.test.mjs",
"lint": "eslint --ext .js,.mjs,.vue src"
},
"dependencies": {
"vue": "^3.5.41",
"vue-router": "^4.6.4",
"vuex": "^4.1.0",
"element-plus": "^2.14.4",
"normalize.css": "8.0.1",
"core-js": "^3.50.0"
},
"devDependencies": {
"@babel/core": "7.28.3",
"@vue/cli-plugin-babel": "^5.0.9",
"@vue/cli-service": "^5.0.9",
"@vue/compiler-sfc": "^3.5.41",
"eslint": "7.32.0",
"eslint-plugin-vue": "^9.33.0",
"sass": "^1.102.0",
"sass-loader": "^13.3.3"
},
"packageManager": "pnpm@9.15.1",
"engines": {
"node": ">=22",
"pnpm": ">=9"
},
"browserslist": [
"> 1%",
"last 2 versions"
]
}
+8346
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>LexGo 管理</title></head><body><noscript>请启用 JavaScript。</noscript><div id="app"></div></body></html>
+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>
+205
View File
@@ -0,0 +1,205 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { createSession, normalizeUsername, validPassword } from '../src/session.mjs'
const admin = { id: 1, username: 'fixture.admin', role: 'admin' }
const learner = { id: 2, username: 'fixture.learner', role: 'learner' }
const response = (data, status = 200) => ({ ok: status < 400, status, json: async () => ({ code: status, data, msg: '请求失败' }) })
function setup(fetch) {
const data = new Map()
const storage = { getItem: key => data.get(key), setItem: (key, value) => data.set(key, value), removeItem: key => data.delete(key) }
return { session: createSession({ fetch, storage }), data }
}
test('normalizes account names and enforces UTF-8 password byte limits', () => {
assert.equal(normalizeUsername(' Fixture.Admin '), 'fixture.admin')
assert.equal(validPassword('虚构测试密码'), true)
assert.equal(validPassword('短'), false)
assert.equal(validPassword('虚'.repeat(25)), false)
})
test('learner login is rejected, revoked, and never stored in admin session', async () => {
const requests = []
const { session, data } = setup(async (url, options) => {
requests.push([url, options])
return response(url.endsWith('/login') ? { token: 'fictional-learner-token', user: learner } : null)
})
await assert.rejects(session.login('fixture.learner', 'fictional-password'), /管理权限/)
assert.equal(session.state.user, null)
assert.equal(data.size, 0)
assert.equal(requests[1][0], '/api/v1/logout')
})
test('admin account list sends bearer and clears private data on 401', async () => {
let expire = false
const { session, data } = setup(async (url, options) => {
if (url.endsWith('/login')) return response({ token: 'fictional-admin-token', user: admin })
assert.equal(options.headers.Authorization, 'Bearer fictional-admin-token')
return expire ? response(null, 401) : response({ items: [learner] })
})
await session.login('fixture.admin', 'fictional-password')
await session.loadAccounts()
assert.deepEqual(session.state.accounts, [learner])
expire = true
await assert.rejects(session.loadAccounts())
assert.equal(session.state.user, null)
assert.deepEqual(session.state.accounts, [])
assert.equal(data.size, 0)
})
test('logout discards an in-flight account response', async () => {
let complete
const { session } = setup(async url => {
if (url.endsWith('/login')) return response({ token: 'fictional-admin-token', user: admin })
if (url.endsWith('/accounts')) return new Promise(resolve => { complete = resolve })
return response(null)
})
await session.login('fixture.admin', 'fictional-password')
const pending = session.loadAccounts()
await session.logout()
complete(response({ items: [learner] }))
await assert.rejects(pending, /会话已变化/)
assert.deepEqual(session.state.accounts, [])
})
test('account creation and state/password update use bounded API contract', async () => {
const writes = []
const { session } = setup(async (url, options) => {
if (url.endsWith('/login')) return response({ token: 'fictional-admin-token', user: admin })
writes.push([url, options.method, JSON.parse(options.body)])
return response(learner)
})
await session.login('fixture.admin', 'fictional-password')
await session.createAccount(' Fixture.Learner ', 'fictional-password')
await session.updateAccount(2, { disabled: true })
await session.updateAccount(2, { password: 'fictional-new-password' })
assert.deepEqual(writes, [
['/api/v1/accounts', 'POST', { username: 'fixture.learner', password: 'fictional-password' }],
['/api/v1/accounts/2', 'PATCH', { disabled: true }],
['/api/v1/accounts/2', 'PATCH', { password: 'fictional-new-password' }]
])
})
test('unauthenticated account access does not reach the API', async () => {
const { session } = setup(async () => assert.fail('must not call API'))
await assert.rejects(session.loadAccounts(), /请先登录/)
})
test('old restore failure cannot clear a newly logged in admin', async () => {
let complete
const { session, data } = setup(async url => {
if (url.endsWith('/me')) return new Promise(resolve => { complete = resolve })
if (url.endsWith('/login')) return response({ token: 'fictional-new-token', user: admin })
return response(null)
})
session.state.token = 'fictional-old-token'
const pending = session.restore()
await session.login('fixture.admin', 'fictional-password')
complete(response(null, 401))
await assert.rejects(pending)
assert.equal(session.state.user?.id, admin.id)
assert.equal(data.get('lexgo-admin-token'), 'fictional-new-token')
})
test('restored learner session is revoked and removed', async () => {
let revoked = false
const { session } = setup(async url => {
if (url.endsWith('/logout')) { revoked = true; return response(null) }
return response(learner)
})
session.state.token = 'fictional-learner-token'
await assert.rejects(session.restore(), /管理权限/)
assert.equal(session.state.user, null)
assert.equal(session.state.token, '')
assert.equal(revoked, true)
})
test('duplicate account error remains actionable without ending the admin session', async () => {
const { session } = setup(async url => url.endsWith('/login')
? response({ token: 'fictional-admin-token', user: admin })
: { ok: false, status: 409, json: async () => ({ code: 409, msg: '账号已存在' }) })
await session.login('fixture.admin', 'fictional-password')
await assert.rejects(session.createAccount('fixture.learner', 'fictional-password'), /账号已存在/)
assert.equal(session.state.user.id, admin.id)
})
test('admin self-disable is blocked before issuing a request', async () => {
const { session } = setup(async url => {
assert.equal(url, '/api/v1/login')
return response({ token: 'fictional-admin-token', user: admin })
})
await session.login('fixture.admin', 'fictional-password')
await assert.rejects(session.updateAccount(1, { disabled: true }), /不能停用当前管理员/)
})
test('logout at the response-to-store handoff cannot restore account data', async () => {
const { session } = setup(async url => {
if (url.endsWith('/login')) return response({ token: 'fictional-admin-token', user: admin })
return { ok: true, status: 200, json: async () => {
queueMicrotask(() => queueMicrotask(() => session.clear()))
return { code: 200, data: { items: [learner] } }
} }
})
await session.login('fixture.admin', 'fictional-password')
await assert.rejects(session.loadAccounts(), /会话已变化/)
assert.deepEqual(session.state.accounts, [])
})
test('administrator password reset is blocked before issuing a write', async () => {
const { session } = setup(async url => {
assert.equal(url, '/api/v1/login')
return response({ token: 'fictional-admin-token', user: admin })
})
await session.login('fixture.admin', 'fictional-password')
await assert.rejects(session.updateAccount(1, { password: 'fictional-new-password' }), /管理员/)
})
test('another administrator cannot be enabled, disabled, or reset from account management', async () => {
const otherAdmin = { id: 3, username: 'fixture.otheradmin', role: 'admin', disabled: false }
const { session } = setup(async url => {
if (url.endsWith('/login')) return response({ token: 'fictional-admin-token', user: admin })
assert.equal(url, '/api/v1/accounts')
return response({ items: [otherAdmin] })
})
await session.login('fixture.admin', 'fictional-password')
await session.loadAccounts()
for (const patch of [{ disabled: true }, { disabled: false }, { password: 'fictional-new-password' }]) {
await assert.rejects(session.updateAccount(3, patch), /管理员/)
}
})
test('logout HTTP 503 clears local private state but reports unconfirmed server revocation', async () => {
const { session, data } = setup(async url => {
if (url.endsWith('/login')) return response({ token: 'fictional-admin-token', user: admin })
if (url.endsWith('/accounts')) return response({ items: [learner] })
return response(null, 503)
})
await session.login('fixture.admin', 'fictional-password')
await session.loadAccounts()
await assert.rejects(session.logout(), /服务器尚未确认退出/)
assert.equal(session.state.user, null)
assert.deepEqual(session.state.accounts, [])
assert.equal(data.size, 0)
})
test('account switch stops on unconfirmed revocation of the previous session', async () => {
let loginCount = 0
const { session, data } = setup(async url => {
if (url.endsWith('/login')) {
loginCount++
return response({ token: 'fictional-admin-token', user: admin })
}
return response(null, 503)
})
await session.login('fixture.admin', 'fictional-password')
await assert.rejects(session.login('fixture.otheradmin', 'fictional-password'), /服务器尚未确认退出/)
assert.equal(loginCount, 1)
assert.equal(session.state.user, null)
assert.equal(data.size, 0)
})
test('logout HTTP 401 is accepted because the server session is already invalid', async () => {
const { session } = setup(async url => url.endsWith('/login')
? response({ token: 'fictional-admin-token', user: admin })
: response(null, 401))
await session.login('fixture.admin', 'fictional-password')
await session.logout()
assert.equal(session.state.user, null)
})
test('rejected learner login reports failed server revocation without storing credentials', async () => {
const { session, data } = setup(async url => url.endsWith('/login')
? response({ token: 'fictional-learner-token', user: learner })
: response(null, 503))
await assert.rejects(session.login('fixture.learner', 'fictional-password'), /没有管理权限.*服务器尚未确认退出/)
assert.equal(session.state.user, null)
assert.equal(data.size, 0)
})
+24
View File
@@ -0,0 +1,24 @@
{
"upstream": "https://github.com/go-admin-team/go-admin-ui.git",
"commit": "67d393d713877572fab0b897296a4c1d525fc81d",
"license": "MIT (LICENSE)",
"unchangedFiles": [
"LICENSE",
"src/layout/BasicLayout.vue",
"src/components/Pagination/index.vue",
"src/components/Hamburger/index.vue",
"src/styles/variables.scss",
"src/styles/sidebar.scss",
"src/styles/mixin.scss",
"src/utils/scroll-to.js"
],
"adaptedFiles": {
"src/layout/index.vue": "Retains app-wrapper/sidebar/main-container shell, responsive mixin and styles; removes settings/tags/permissions modules.",
"src/layout/components/AppMain.vue": "Retains app-main section and background; removes keep-alive to avoid private-state retention.",
"src/views/Accounts.vue": "Curated sys-user/index.vue BasicLayout/card/table/pagination/dialog form structure; removes departments, PII, delete and legacy permissions.",
"src/views/Login.vue": "Curated login form fields and submit structure; removes default credentials, registration, captcha and promotional content.",
"src/layout/mixin/ResizeHandler.js": "Retained responsive behavior; removed unused route watcher argument for lint."
},
"dependencyChanges": "Retains Vue CLI, Vuex, Vue Router, Element Plus and source version ranges. Removes unused demo, chart, editor, upload, export, legacy Jest and Unix-only script dependencies. Uses native Node tests. New lockfile resolved for curated package only.",
"excluded": "Source env/config files, Git metadata, node_modules, running data and unrelated admin modules not copied."
}
+1
View File
@@ -0,0 +1 @@
module.exports = { publicPath: '/', productionSourceMap: false, devServer: { proxy: { '/api': { target: 'http://127.0.0.1:8000' } } } }