优化构架
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
import LoginView from './LoginView.vue'
|
||||
|
||||
const loginSource = readFileSync(resolve(process.cwd(), 'src/modules/auth/pages/LoginView.vue'), 'utf8')
|
||||
const formStub = {
|
||||
template: '<form><slot /></form>',
|
||||
setup(_: unknown, { expose }: { expose: (exposed: { validate: () => Promise<boolean> }) => void }) {
|
||||
expose({ validate: async () => true })
|
||||
},
|
||||
}
|
||||
|
||||
function createMemoryStorage(): Storage {
|
||||
const values = new Map<string, string>()
|
||||
return {
|
||||
get length() { return values.size },
|
||||
clear: () => values.clear(),
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
key: (index) => [...values.keys()][index] ?? null,
|
||||
removeItem: (key) => values.delete(key),
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
}
|
||||
}
|
||||
|
||||
describe('LoginView authentication lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('localStorage', createMemoryStorage())
|
||||
})
|
||||
|
||||
it('always renders a blank login form without demo credentials or hints', () => {
|
||||
expect(loginSource).toContain("account: ''")
|
||||
expect(loginSource).toContain("password: ''")
|
||||
expect(loginSource).not.toContain('import.meta.env.DEV')
|
||||
expect(loginSource).not.toContain('platform_admin')
|
||||
expect(loginSource).not.toContain('ChangeMe123!')
|
||||
expect(loginSource).not.toContain('demo-tip')
|
||||
})
|
||||
|
||||
it('initializes preview access after login and before redirecting', async () => {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const auth = useAuthStore()
|
||||
const access = useAccessPreviewStore()
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/login', component: LoginView },
|
||||
{ path: '/', component: { template: '<p>home</p>' } },
|
||||
],
|
||||
})
|
||||
await router.push('/login')
|
||||
await router.isReady()
|
||||
|
||||
const calls: string[] = []
|
||||
vi.spyOn(auth, 'login').mockImplementation(async () => { calls.push('login') })
|
||||
vi.spyOn(access, 'initializeAfterLogin').mockImplementation(() => { calls.push('access') })
|
||||
vi.spyOn(router, 'replace').mockImplementation(async () => { calls.push('replace') })
|
||||
const wrapper = mount(LoginView, {
|
||||
global: {
|
||||
plugins: [pinia, router],
|
||||
stubs: {
|
||||
'el-form': formStub,
|
||||
'el-form-item': { template: '<label><slot /></label>' },
|
||||
'el-input': { props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />' },
|
||||
'el-button': { emits: ['click'], template: '<button type="button" @click="$emit(\'click\')"><slot /></button>' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const inputs = wrapper.findAll('input')
|
||||
await inputs[0]!.setValue('test_admin')
|
||||
await inputs[1]!.setValue('password')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(calls).toEqual(['login', 'access', 'replace'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['/customers?page=2', '/customers?page=2'],
|
||||
['/customers/reviews', '/customers/reviews'],
|
||||
['//example.com', '/403'],
|
||||
['https://example.com', '/403'],
|
||||
[['/customers', '/orders/first-leg'], '/403'],
|
||||
[null, '/403'],
|
||||
])('sanitizes the post-login redirect %j to %s', async (redirect, expected) => {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const auth = useAuthStore()
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/login', component: LoginView },
|
||||
{ path: '/', component: { template: '<p>home</p>' } },
|
||||
],
|
||||
})
|
||||
await router.push({ path: '/login', query: { redirect } })
|
||||
await router.isReady()
|
||||
vi.spyOn(auth, 'login').mockResolvedValue()
|
||||
const replace = vi.spyOn(router, 'replace').mockResolvedValue()
|
||||
const wrapper = mount(LoginView, {
|
||||
global: {
|
||||
plugins: [pinia, router],
|
||||
stubs: {
|
||||
'el-form': formStub,
|
||||
'el-form-item': { template: '<label><slot /></label>' },
|
||||
'el-input': { props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />' },
|
||||
'el-button': { emits: ['click'], template: '<button type="button" @click="$emit(\'click\')"><slot /></button>' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const inputs = wrapper.findAll('input')
|
||||
await inputs[0]!.setValue('test_admin')
|
||||
await inputs[1]!.setValue('password')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(replace).toHaveBeenCalledWith(expected)
|
||||
})
|
||||
|
||||
it('preserves the form and reports a recoverable error when login fails', async () => {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const auth = useAuthStore()
|
||||
const access = useAccessPreviewStore()
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/login', component: LoginView },
|
||||
{ path: '/customers', component: { template: '<p>customers</p>' } },
|
||||
],
|
||||
})
|
||||
await router.push('/login')
|
||||
await router.isReady()
|
||||
vi.spyOn(auth, 'login').mockRejectedValue(new Error('network unavailable'))
|
||||
const initialize = vi.spyOn(access, 'initializeAfterLogin')
|
||||
const replace = vi.spyOn(router, 'replace')
|
||||
const wrapper = mount(LoginView, {
|
||||
global: {
|
||||
plugins: [pinia, router],
|
||||
stubs: {
|
||||
'el-form': formStub,
|
||||
'el-form-item': { template: '<label><slot /></label>' },
|
||||
'el-input': { props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />' },
|
||||
'el-button': { emits: ['click'], template: '<button type="button" @click="$emit(\'click\')"><slot /></button>' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const inputs = wrapper.findAll('input')
|
||||
await inputs[0]!.setValue('test_admin')
|
||||
await inputs[1]!.setValue('password')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('[role="alert"]').text()).toBe('network unavailable')
|
||||
expect(initialize).not.toHaveBeenCalled()
|
||||
expect(replace).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { ArrowRight, LockKeyhole, ShieldCheck, UserRound } from '@lucide/vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import baiyueLogoWhite from '@/assets/brand/baiyue-logo-white.png'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import { sanitizeInternalPath } from '@/core/routing/accessDecision'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const access = useAccessPreviewStore()
|
||||
const loading = ref(false)
|
||||
const loginError = ref<ApiErrorDetails | null>(null)
|
||||
const formRef = ref<FormInstance>()
|
||||
const form = reactive({
|
||||
account: '',
|
||||
password: '',
|
||||
})
|
||||
const rules: FormRules<typeof form> = {
|
||||
account: [{ required: true, message: '请输入账号', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
loginError.value = null
|
||||
const isValid = await formRef.value?.validate().catch(() => false)
|
||||
if (!isValid) return
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.login(form.account, form.password)
|
||||
access.initializeAfterLogin()
|
||||
ElMessage.success('登录成功')
|
||||
const requestedPath = sanitizeInternalPath(route.query.redirect)
|
||||
const targetPath = requestedPath === '/'
|
||||
? access.firstAccessiblePath ?? '/403'
|
||||
: requestedPath
|
||||
await router.replace(targetPath)
|
||||
} catch (error) {
|
||||
loginError.value = getApiErrorDetails(error, '登录失败,请检查账号和密码后重试')
|
||||
ElMessage.error(loginError.value.message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<section class="login-intro">
|
||||
<div class="login-brand"><img class="login-brand-logo" :src="baiyueLogoWhite" alt="柏悦物流" /></div>
|
||||
<div>
|
||||
<h1>让跨境物流管理<br />清晰、可靠、可追踪</h1>
|
||||
<p class="intro-copy">统一管理企业入驻、内部权限和头程订单,让每一次操作都有边界、有记录。</p>
|
||||
</div>
|
||||
<div class="security-note">
|
||||
<ShieldCheck :size="22" />
|
||||
<span>平台内部系统,请使用授权账号登录</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="login-panel">
|
||||
<div class="login-card">
|
||||
<div class="login-card-icon"><LockKeyhole :size="24" /></div>
|
||||
<h2>登录管理后台</h2>
|
||||
<p>使用平台内部员工账号登录</p>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" size="large" @submit.prevent="submit">
|
||||
<el-form-item label="账号" prop="account" required>
|
||||
<el-input v-model.trim="form.account" placeholder="请输入账号" :prefix-icon="UserRound" autocomplete="username" maxlength="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" prop="password" required>
|
||||
<el-input v-model="form.password" type="password" show-password placeholder="请输入密码" :prefix-icon="LockKeyhole" autocomplete="current-password" maxlength="128" />
|
||||
</el-form-item>
|
||||
<el-button class="login-submit" native-type="submit" type="primary" :loading="loading">
|
||||
登录 <ArrowRight :size="17" />
|
||||
</el-button>
|
||||
</el-form>
|
||||
<div v-if="loginError" class="login-error" role="alert"><p>{{ loginError.message }}</p><ApiErrorMeta :details="loginError" /></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user