feat: 初始化考培练系统项目

- 从服务器拉取完整代码
- 按框架规范整理项目结构
- 配置 Drone CI 测试环境部署
- 包含后端(FastAPI)、前端(Vue3)、管理端

技术栈: Vue3 + TypeScript + FastAPI + MySQL
This commit is contained in:
111
2026-01-24 19:33:28 +08:00
commit 998211c483
1197 changed files with 228429 additions and 0 deletions

215
frontend/src/test/setup.ts Normal file
View File

@@ -0,0 +1,215 @@
/**
* 测试环境设置
*/
import { vi, afterEach } from 'vitest'
import { config } from '@vue/test-utils'
// 全局测试配置
config.global.stubs = {
// Element Plus 组件存根
'el-button': true,
'el-input': true,
'el-form': true,
'el-form-item': true,
'el-message': true,
'el-message-box': true,
'el-notification': true,
'el-loading': true,
'el-table': true,
'el-table-column': true,
'el-pagination': true,
'el-dialog': true,
'el-drawer': true,
'el-card': true,
'el-tag': true,
'el-badge': true,
'el-avatar': true,
'el-tooltip': true,
'el-popover': true,
'el-dropdown': true,
'el-dropdown-menu': true,
'el-dropdown-item': true,
'el-menu': true,
'el-menu-item': true,
'el-submenu': true,
'el-breadcrumb': true,
'el-breadcrumb-item': true,
'el-tabs': true,
'el-tab-pane': true,
'el-collapse': true,
'el-collapse-item': true,
'el-tree': true,
'el-select': true,
'el-option': true,
'el-checkbox': true,
'el-checkbox-group': true,
'el-radio': true,
'el-radio-group': true,
'el-switch': true,
'el-slider': true,
'el-rate': true,
'el-color-picker': true,
'el-transfer': true,
'el-upload': true,
'el-progress': true,
'el-skeleton': true,
'el-empty': true,
'el-result': true,
'el-alert': true,
'el-descriptions': true,
'el-descriptions-item': true,
'el-timeline': true,
'el-timeline-item': true,
'el-divider': true,
'el-backtop': true,
'el-calendar': true,
'el-image': true,
'el-carousel': true,
'el-carousel-item': true,
'el-steps': true,
'el-step': true,
'el-row': true,
'el-col': true,
'el-container': true,
'el-header': true,
'el-aside': true,
'el-main': true,
'el-footer': true,
'el-space': true,
'el-affix': true,
'el-anchor': true,
'el-anchor-link': true,
// Element Plus Icons
'el-icon': true,
// Router
'router-link': true,
'router-view': true
}
// 全局 mocks
config.global.mocks = {
$t: (msg: string) => msg, // 国际化mock
$route: {
path: '/',
query: {},
params: {},
meta: {}
},
$router: {
push: vi.fn(),
replace: vi.fn(),
go: vi.fn(),
back: vi.fn(),
forward: vi.fn()
}
}
// Mock localStorage
Object.defineProperty(window, 'localStorage', {
value: {
store: {} as Record<string, string>,
getItem(key: string) {
return this.store[key] || null
},
setItem(key: string, value: string) {
this.store[key] = String(value)
},
removeItem(key: string) {
delete this.store[key]
},
clear() {
this.store = {}
}
},
writable: true
})
// Mock sessionStorage
Object.defineProperty(window, 'sessionStorage', {
value: {
store: {} as Record<string, string>,
getItem(key: string) {
return this.store[key] || null
},
setItem(key: string, value: string) {
this.store[key] = String(value)
},
removeItem(key: string) {
delete this.store[key]
},
clear() {
this.store = {}
}
},
writable: true
})
// Mock window.location
Object.defineProperty(window, 'location', {
value: {
href: 'http://localhost:3000/',
origin: 'http://localhost:3000',
protocol: 'http:',
host: 'localhost:3000',
hostname: 'localhost',
port: '3000',
pathname: '/',
search: '',
hash: '',
reload: vi.fn(),
assign: vi.fn(),
replace: vi.fn()
},
writable: true
})
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn()
}))
})
// Mock ResizeObserver
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn()
}))
// Mock IntersectionObserver
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn()
}))
// Mock fetch
global.fetch = vi.fn()
// Mock console methods for cleaner test output
global.console = {
...console,
// 在测试中静默某些日志
log: vi.fn(),
warn: vi.fn(),
error: vi.fn()
}
// 清理函数
afterEach(() => {
vi.clearAllMocks()
localStorage.clear()
sessionStorage.clear()
})

309
frontend/src/test/utils.ts Normal file
View File

@@ -0,0 +1,309 @@
/**
* 测试工具函数
*/
import { mount, VueWrapper } from '@vue/test-utils'
import { Component } from 'vue'
import { Router, createRouter, createWebHistory } from 'vue-router'
import { createPinia, Pinia } from 'pinia'
import { expect } from 'vitest'
// 测试路由配置
export const createTestRouter = (routes: any[] = []): Router => {
return createRouter({
history: createWebHistory(),
routes: [
{ path: '/', name: 'Home', component: { template: '<div>Home</div>' } },
{ path: '/login', name: 'Login', component: { template: '<div>Login</div>' } },
{ path: '/404', name: 'NotFound', component: { template: '<div>404</div>' } },
...routes
]
})
}
// 创建测试用的 Pinia 实例
export const createTestPinia = (): Pinia => {
return createPinia()
}
// 挂载组件的增强函数
export interface MountOptions {
props?: Record<string, any>
slots?: Record<string, any>
router?: Router
pinia?: Pinia
global?: {
plugins?: any[]
mocks?: Record<string, any>
stubs?: Record<string, any>
provide?: Record<string, any>
}
}
export const mountComponent = (
component: Component,
options: MountOptions = {}
): VueWrapper => {
const { props, slots, router, pinia, global } = options
const plugins = []
const provide: Record<string, any> = {}
if (router) {
plugins.push(router)
}
if (pinia) {
plugins.push(pinia)
}
return mount(component, {
props,
slots,
global: {
plugins,
provide: {
...provide,
...global?.provide
},
mocks: {
...global?.mocks
},
stubs: {
...global?.stubs
}
}
})
}
// 等待 DOM 更新
export const waitForUpdate = async (wrapper: VueWrapper): Promise<void> => {
await wrapper.vm.$nextTick()
}
// 模拟用户交互
export const userInteraction = {
// 点击元素
async click(wrapper: VueWrapper, selector: string): Promise<void> {
const element = wrapper.find(selector)
await element.trigger('click')
await waitForUpdate(wrapper)
},
// 输入文本
async type(wrapper: VueWrapper, selector: string, text: string): Promise<void> {
const input = wrapper.find(selector)
await input.setValue(text)
await waitForUpdate(wrapper)
},
// 提交表单
async submit(wrapper: VueWrapper, selector: string = 'form'): Promise<void> {
const form = wrapper.find(selector)
await form.trigger('submit')
await waitForUpdate(wrapper)
},
// 鼠标悬停
async hover(wrapper: VueWrapper, selector: string): Promise<void> {
const element = wrapper.find(selector)
await element.trigger('mouseenter')
await waitForUpdate(wrapper)
},
// 键盘事件
async keydown(wrapper: VueWrapper, selector: string, key: string): Promise<void> {
const element = wrapper.find(selector)
await element.trigger('keydown', { key })
await waitForUpdate(wrapper)
}
}
// 模拟 API 响应
export const mockApiResponse = {
success: <T>(data: T) => ({
code: 200,
message: 'success',
data
}),
error: (message: string, code: number = 400) => ({
code,
message,
data: null
}),
paginated: <T>(items: T[], total?: number, page: number = 1, size: number = 10) => ({
code: 200,
message: 'success',
data: {
items,
total: total ?? items.length,
page,
size
}
})
}
// 创建模拟用户数据
export const createMockUser = (overrides: Partial<any> = {}) => ({
id: 1,
username: 'testuser',
email: 'test@example.com',
full_name: '测试用户',
role: 'trainee',
is_active: true,
created_at: '2024-01-01T00:00:00.000Z',
updated_at: '2024-01-01T00:00:00.000Z',
...overrides
})
// 创建模拟课程数据
export const createMockCourse = (overrides: Partial<any> = {}) => ({
id: 1,
title: '测试课程',
description: '这是一个测试课程',
coverImage: '',
category: '技术',
difficulty: 'beginner',
duration: 60,
materialCount: 5,
progress: 0,
rating: 4.5,
status: 'published',
tags: ['测试', '课程'],
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
...overrides
})
// 创建模拟考试数据
export const createMockExam = (overrides: Partial<any> = {}) => ({
id: '1',
name: '测试考试',
type: 'practice',
subject: '测试科目',
totalScore: 100,
userScore: 85,
accuracy: 0.85,
duration: 3600,
status: 'completed',
startTime: '2024-01-01T10:00:00.000Z',
endTime: '2024-01-01T11:00:00.000Z',
createdAt: '2024-01-01T10:00:00.000Z',
...overrides
})
// 创建模拟陪练记录数据
export const createMockPracticeRecord = (overrides: Partial<any> = {}) => ({
id: '1',
sceneId: 1,
sceneName: '测试陪练场景',
sceneCategory: '技术面试',
duration: 1800,
messageCount: 20,
overallScore: 85,
result: 'good',
startTime: '2024-01-01T14:00:00.000Z',
endTime: '2024-01-01T14:30:00.000Z',
createdAt: '2024-01-01T14:30:00.000Z',
...overrides
})
// 断言辅助函数
export const assertions = {
// 检查元素是否存在
elementExists(wrapper: VueWrapper, selector: string): void {
expect(wrapper.find(selector).exists()).toBe(true)
},
// 检查元素是否不存在
elementNotExists(wrapper: VueWrapper, selector: string): void {
expect(wrapper.find(selector).exists()).toBe(false)
},
// 检查元素文本
elementText(wrapper: VueWrapper, selector: string, expectedText: string): void {
expect(wrapper.find(selector).text()).toBe(expectedText)
},
// 检查元素是否包含文本
elementContainsText(wrapper: VueWrapper, selector: string, expectedText: string): void {
expect(wrapper.find(selector).text()).toContain(expectedText)
},
// 检查元素是否可见
elementVisible(wrapper: VueWrapper, selector: string): void {
const element = wrapper.find(selector)
expect(element.exists()).toBe(true)
expect(element.isVisible()).toBe(true)
},
// 检查元素是否隐藏
elementHidden(wrapper: VueWrapper, selector: string): void {
const element = wrapper.find(selector)
if (element.exists()) {
expect(element.isVisible()).toBe(false)
}
},
// 检查表单字段值
inputValue(wrapper: VueWrapper, selector: string, expectedValue: string): void {
const input = wrapper.find(selector)
expect((input.element as HTMLInputElement).value).toBe(expectedValue)
},
// 检查路由导航
routePushed(mockRouter: any, expectedRoute: string | object): void {
expect(mockRouter.push).toHaveBeenCalledWith(expectedRoute)
}
}
// 时间相关工具
export const timeUtils = {
// 模拟延时
delay: (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms)),
// 获取当前时间戳
now: (): number => Date.now(),
// 格式化时间
format: (date: Date | string | number): string => {
const d = new Date(date)
return d.toISOString()
}
}
// 随机数据生成器
export const faker = {
// 随机字符串
string: (length: number = 10): string => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
let result = ''
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
return result
},
// 随机数字
number: (min: number = 0, max: number = 100): number => {
return Math.floor(Math.random() * (max - min + 1)) + min
},
// 随机邮箱
email: (): string => {
return `${faker.string(8)}@example.com`
},
// 随机中文姓名
chineseName: (): string => {
const surnames = ['王', '李', '张', '刘', '陈', '杨', '赵', '黄', '周', '吴']
const names = ['伟', '芳', '娜', '敏', '静', '丽', '强', '磊', '军', '洋']
return surnames[Math.floor(Math.random() * surnames.length)] +
names[Math.floor(Math.random() * names.length)]
},
// 随机布尔值
boolean: (): boolean => Math.random() < 0.5
}