feat: 接入两端用户名登录和独立学习空间 (#2)
This commit is contained in:
@@ -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' } }
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env*
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = { presets: ['@vue/cli-plugin-babel/preset'] }
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Generated
+8346
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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."
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = { publicPath: '/', productionSourceMap: false, devServer: { proxy: { '/api': { target: 'http://127.0.0.1:8000' } } } }
|
||||
Reference in New Issue
Block a user