优化构架

This commit is contained in:
2026-08-03 18:49:18 +08:00
parent 1241d11c01
commit 44c33a6280
133 changed files with 651 additions and 603 deletions
+167
View File
@@ -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()
})
})
+86
View File
@@ -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>