菜单顶部栏优化

This commit is contained in:
zhonghua 2026-02-28 06:44:56 +08:00
parent bb3db79758
commit 0d4d9f5768
14 changed files with 400 additions and 97 deletions

View File

@ -1,4 +1,4 @@
DATABASE_URL="file:/Users/retirado/ccProgram/reading-platform-backend/dev.db"
DATABASE_URL="file:./dev.db"
NODE_ENV=development
PORT=3000
JWT_SECRET="your-super-secret-jwt-key"

View File

@ -24,7 +24,7 @@
"@nestjs/platform-express": "^10.4.22",
"@nestjs/schedule": "^6.1.1",
"@nestjs/throttler": "^5.2.0",
"@prisma/client": "^5.8.0",
"@prisma/client": "^5.22.0",
"@types/multer": "^2.0.0",
"ali-oss": "^6.18.1",
"bcrypt": "^5.1.1",
@ -54,7 +54,7 @@
"@typescript-eslint/parser": "^6.18.0",
"eslint": "^8.56.0",
"jest": "^29.7.0",
"prisma": "^5.8.0",
"prisma": "^5.22.0",
"source-map-support": "^0.5.21",
"ts-jest": "^29.1.1",
"ts-loader": "^9.5.1",

View File

@ -1,21 +1,21 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';
import { AppModule } from './app.module';
import * as compression from 'compression';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { NestFactory } from "@nestjs/core";
import { ValidationPipe, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { NestExpressApplication } from "@nestjs/platform-express";
import { join } from "path";
import { AppModule } from "./app.module";
import * as compression from "compression";
import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
logger: ['error', 'warn', 'log', 'debug', 'verbose'],
logger: ["error", "warn", "log", "debug", "verbose"],
});
console.log("bootstrap");
// 增加请求体大小限制(支持上传大文件 base64
// 1GB 文件编码后约 1.33GB,加上其他字段,设置为 1500mb
app.useBodyParser('json', { limit: '1500mb' });
app.useBodyParser('urlencoded', { limit: '1500mb', extended: true });
app.useBodyParser("json", { limit: "1500mb" });
app.useBodyParser("urlencoded", { limit: "1500mb", extended: true });
const configService = app.get(ConfigService);
@ -28,7 +28,7 @@ async function bootstrap() {
transformOptions: {
enableImplicitConversion: true,
},
}),
})
);
// 全局异常过滤器
@ -39,23 +39,23 @@ async function bootstrap() {
// CORS
app.enableCors({
origin: configService.get('FRONTEND_URL') || 'http://localhost:5173',
origin: configService.get("FRONTEND_URL") || "http://localhost:5173",
credentials: true,
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS',
allowedHeaders: 'Content-Type, Accept, Authorization',
methods: "GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS",
allowedHeaders: "Content-Type, Accept, Authorization",
});
// 配置静态文件服务(用于访问上传的文件)
// 使用绝对路径确保在编译后也能正确找到 uploads 目录
const uploadsPath = join(__dirname, '..', '..', 'uploads');
const uploadsPath = join(__dirname, "..", "..", "uploads");
app.useStaticAssets(uploadsPath, {
prefix: '/uploads/',
prefix: "/uploads/",
});
// API前缀
app.setGlobalPrefix('api/v1');
app.setGlobalPrefix("api/v1");
const port = configService.get<number>('PORT') || 3000;
const port = configService.get<number>("PORT") || 3000;
await app.listen(port);
console.log(`

View File

@ -1,18 +0,0 @@
#!/bin/bash
# 后端启动脚本
# 确保从正确目录启动
cd "$(dirname "$0")"
echo "🚀 正在启动后端服务..."
echo "📂 工作目录: $(pwd)"
# 检查 node_modules 是否存在
if [ ! -d "node_modules" ]; then
echo "📦 正在安装依赖..."
npm install
fi
# 启动后端
npm run start:dev

View File

@ -7,7 +7,8 @@
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"test:e2e": "playwright test"
},
"dependencies": {
"@ant-design/icons-vue": "^7.0.1",
@ -27,10 +28,12 @@
"vue-router": "^4.3.0"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@types/lodash-es": "^4.17.12",
"@types/node": "^20.11.28",
"@vitejs/plugin-vue": "^5.0.4",
"@vue/tsconfig": "^0.5.1",
"@playwright/test": "^1.50.0",
"sass-embedded": "^1.97.3",
"typescript": "~5.4.0",
"unplugin-auto-import": "^0.17.5",

View File

@ -0,0 +1,27 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
timeout: 60 * 1000,
expect: {
timeout: 10 * 1000,
},
use: {
baseURL: 'http://localhost:5173',
headless: true,
trace: 'on-first-retry',
},
webServer: {
command: 'npm run dev',
port: 5173,
reuseExistingServer: true,
timeout: 120 * 1000,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});

View File

@ -11,9 +11,14 @@ declare module 'vue' {
AAvatar: typeof import('ant-design-vue/es')['Avatar']
ABadge: typeof import('ant-design-vue/es')['Badge']
AButton: typeof import('ant-design-vue/es')['Button']
AButtonGroup: typeof import('ant-design-vue/es')['ButtonGroup']
ACard: typeof import('ant-design-vue/es')['Card']
ACheckbox: typeof import('ant-design-vue/es')['Checkbox']
ACheckboxGroup: typeof import('ant-design-vue/es')['CheckboxGroup']
ACol: typeof import('ant-design-vue/es')['Col']
ACollapse: typeof import('ant-design-vue/es')['Collapse']
ACollapsePanel: typeof import('ant-design-vue/es')['CollapsePanel']
ADatePicker: typeof import('ant-design-vue/es')['DatePicker']
ADescriptions: typeof import('ant-design-vue/es')['Descriptions']
ADescriptionsItem: typeof import('ant-design-vue/es')['DescriptionsItem']
ADivider: typeof import('ant-design-vue/es')['Divider']
@ -22,6 +27,8 @@ declare module 'vue' {
AEmpty: typeof import('ant-design-vue/es')['Empty']
AForm: typeof import('ant-design-vue/es')['Form']
AFormItem: typeof import('ant-design-vue/es')['FormItem']
AImage: typeof import('ant-design-vue/es')['Image']
AImagePreviewGroup: typeof import('ant-design-vue/es')['ImagePreviewGroup']
AInput: typeof import('ant-design-vue/es')['Input']
AInputNumber: typeof import('ant-design-vue/es')['InputNumber']
AInputPassword: typeof import('ant-design-vue/es')['InputPassword']
@ -30,24 +37,47 @@ declare module 'vue' {
ALayoutContent: typeof import('ant-design-vue/es')['LayoutContent']
ALayoutHeader: typeof import('ant-design-vue/es')['LayoutHeader']
ALayoutSider: typeof import('ant-design-vue/es')['LayoutSider']
AList: typeof import('ant-design-vue/es')['List']
AListItem: typeof import('ant-design-vue/es')['ListItem']
AListItemMeta: typeof import('ant-design-vue/es')['ListItemMeta']
AMenu: typeof import('ant-design-vue/es')['Menu']
AMenuDivider: typeof import('ant-design-vue/es')['MenuDivider']
AMenuItem: typeof import('ant-design-vue/es')['MenuItem']
AModal: typeof import('ant-design-vue/es')['Modal']
APageHeader: typeof import('ant-design-vue/es')['PageHeader']
APagination: typeof import('ant-design-vue/es')['Pagination']
APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
AProgress: typeof import('ant-design-vue/es')['Progress']
ARadio: typeof import('ant-design-vue/es')['Radio']
ARadioButton: typeof import('ant-design-vue/es')['RadioButton']
ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
ARangePicker: typeof import('ant-design-vue/es')['RangePicker']
ARate: typeof import('ant-design-vue/es')['Rate']
AResult: typeof import('ant-design-vue/es')['Result']
ARow: typeof import('ant-design-vue/es')['Row']
ASelect: typeof import('ant-design-vue/es')['Select']
ASelectOptGroup: typeof import('ant-design-vue/es')['SelectOptGroup']
ASelectOption: typeof import('ant-design-vue/es')['SelectOption']
ASkeleton: typeof import('ant-design-vue/es')['Skeleton']
ASpace: typeof import('ant-design-vue/es')['Space']
ASpin: typeof import('ant-design-vue/es')['Spin']
AStatistic: typeof import('ant-design-vue/es')['Statistic']
AStep: typeof import('ant-design-vue/es')['Step']
ASteps: typeof import('ant-design-vue/es')['Steps']
ASubMenu: typeof import('ant-design-vue/es')['SubMenu']
ASwitch: typeof import('ant-design-vue/es')['Switch']
ATable: typeof import('ant-design-vue/es')['Table']
ATabPane: typeof import('ant-design-vue/es')['TabPane']
ATabs: typeof import('ant-design-vue/es')['Tabs']
ATag: typeof import('ant-design-vue/es')['Tag']
ATextarea: typeof import('ant-design-vue/es')['Textarea']
ATimeline: typeof import('ant-design-vue/es')['Timeline']
ATimelineItem: typeof import('ant-design-vue/es')['TimelineItem']
ATimeRangePicker: typeof import('ant-design-vue/es')['TimeRangePicker']
ATooltip: typeof import('ant-design-vue/es')['Tooltip']
ATypographyText: typeof import('ant-design-vue/es')['TypographyText']
AUpload: typeof import('ant-design-vue/es')['Upload']
AUploadDragger: typeof import('ant-design-vue/es')['UploadDragger']
FilePreviewModal: typeof import('./components/FilePreviewModal.vue')['default']
NotificationBell: typeof import('./components/NotificationBell.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']

View File

@ -1,5 +1,5 @@
<template>
<a-layout class="admin-layout">
<a-layout :class="['admin-layout', { 'is-collapsed': collapsed }]">
<a-layout-sider
v-model:collapsed="collapsed"
:trigger="null"
@ -14,6 +14,7 @@
</div>
</div>
<div class="sider-menu-wrapper">
<a-menu
v-model:selectedKeys="selectedKeys"
mode="inline"
@ -57,6 +58,7 @@
<span>系统设置</span>
</a-menu-item>
</a-menu>
</div>
</a-layout-sider>
<a-layout>
@ -200,9 +202,21 @@ $bg-dark: #111827;
.admin-layout {
min-height: 100vh;
background: $bg-light;
position: relative;
padding-left: 200px; //
padding-top: 64px; //
}
.admin-layout.is-collapsed {
padding-left: 80px; //
}
.admin-sider {
position: fixed;
left: 0;
top: 0;
bottom: 0;
z-index: 100;
background: white !important;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.04);
border-right: 1px solid $border-color;
@ -297,6 +311,25 @@ $bg-dark: #111827;
}
}
.sider-menu-wrapper {
height: calc(100vh - 72px);
overflow: auto;
//
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: rgba(148, 163, 184, 0.6);
border-radius: 999px;
}
}
.admin-header {
background: white;
padding: 0 24px;
@ -305,6 +338,11 @@ $bg-dark: #111827;
align-items: center;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
border-bottom: 1px solid $border-color;
position: fixed;
top: 0;
left: 200px;
right: 0;
z-index: 90;
.trigger {
font-size: 18px;
@ -331,6 +369,10 @@ $bg-dark: #111827;
}
}
.admin-layout.is-collapsed .admin-header {
left: 80px;
}
.admin-content {
margin: 20px;
padding: 24px;

View File

@ -71,6 +71,17 @@
登录
</a-button>
</a-form-item>
<a-form-item>
<a-button
size="large"
block
class="quick-test-btn"
@click="handleQuickTest"
>
一键测试{{ currentRoleLabel }}账号
</a-button>
</a-form-item>
</a-form>
<!-- 测试账号 -->
@ -132,6 +143,11 @@ const formState = reactive({
const loading = ref(false);
const currentRoleLabel = computed(() => {
const found = roles.find(r => r.value === formState.role);
return found ? found.label : '';
});
const selectRole = (role: string) => {
formState.role = role;
};
@ -158,6 +174,17 @@ const handleLogin = async () => {
loading.value = false;
}
};
const handleQuickTest = async () => {
const found = testAccounts.find(t => t.role === formState.role);
if (!found) {
message.warning('当前角色暂不支持一键测试');
return;
}
fillAccount(found);
await handleLogin();
};
</script>
<style scoped lang="scss">
@ -380,6 +407,13 @@ $bg-cream: #FEFEFE;
transform: translateY(0);
}
}
.quick-test-btn {
height: 44px;
border-radius: 12px;
font-size: 14px;
margin-top: 4px;
}
}
// -

View File

@ -1,5 +1,5 @@
<template>
<a-layout class="parent-layout">
<a-layout :class="['parent-layout', { 'is-collapsed': collapsed }]">
<!-- 桌面端侧边栏 -->
<a-layout-sider
v-if="!isMobile"
@ -17,6 +17,7 @@
</div>
</div>
<div class="sider-menu-wrapper">
<a-menu
v-model:selectedKeys="selectedKeys"
mode="inline"
@ -46,6 +47,7 @@
<span>成长档案</span>
</a-menu-item>
</a-menu>
</div>
</a-layout-sider>
<!-- 移动端抽屉菜单 -->
@ -307,6 +309,7 @@ $bg-light: #FAFAFA;
.parent-layout {
min-height: 100vh;
background: $bg-light;
position: relative;
}
.main-layout {
@ -315,8 +318,24 @@ $bg-light: #FAFAFA;
min-height: 100vh;
}
@media screen and (min-width: 769px) {
.parent-layout {
padding-left: 220px; //
padding-top: 64px; //
}
.parent-layout.is-collapsed {
padding-left: 80px; //
}
}
//
.parent-sider {
position: fixed;
left: 0;
top: 0;
bottom: 0;
z-index: 100;
background: white !important;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.06);
border-right: 1px solid $border-color;
@ -391,6 +410,25 @@ $bg-light: #FAFAFA;
}
}
.sider-menu-wrapper {
height: calc(100vh - 80px);
overflow: auto;
//
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: rgba(148, 163, 184, 0.6);
border-radius: 999px;
}
}
//
.parent-header {
background: white;
@ -402,6 +440,12 @@ $bg-light: #FAFAFA;
border-bottom: 1px solid $border-color;
height: 64px;
position: fixed;
top: 0;
left: 220px;
right: 0;
z-index: 90;
.trigger {
font-size: 18px;
cursor: pointer;
@ -427,6 +471,12 @@ $bg-light: #FAFAFA;
}
}
@media screen and (min-width: 769px) {
.parent-layout.is-collapsed .parent-header {
left: 80px;
}
}
//
.parent-content {
margin: 20px;

View File

@ -1,5 +1,5 @@
<template>
<a-layout class="school-layout">
<a-layout :class="['school-layout', { 'is-collapsed': collapsed }]">
<a-layout-sider
v-model:collapsed="collapsed"
:trigger="null"
@ -15,6 +15,7 @@
</div>
</div>
<div class="sider-menu-wrapper">
<a-menu
v-model:selectedKeys="selectedKeys"
v-model:openKeys="openKeys"
@ -110,6 +111,7 @@
</a-menu-item>
</a-sub-menu>
</a-menu>
</div>
</a-layout-sider>
<a-layout>
@ -284,9 +286,21 @@ $bg-light: #FAFAFA;
.school-layout {
min-height: 100vh;
background: $bg-light;
position: relative;
padding-left: 200px; //
padding-top: 64px; //
}
.school-layout.is-collapsed {
padding-left: 80px; //
}
.school-sider {
position: fixed;
left: 0;
top: 0;
bottom: 0;
z-index: 100;
background: white !important;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.06);
border-right: 1px solid $border-color;
@ -417,6 +431,25 @@ $bg-light: #FAFAFA;
}
}
.sider-menu-wrapper {
height: calc(100vh - 80px);
overflow: auto;
//
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: rgba(148, 163, 184, 0.6);
border-radius: 999px;
}
}
.school-header {
background: white;
padding: 0 24px;
@ -425,6 +458,11 @@ $bg-light: #FAFAFA;
align-items: center;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
border-bottom: 1px solid $border-color;
position: fixed;
top: 0;
left: 200px;
right: 0;
z-index: 90;
.trigger {
font-size: 18px;
@ -451,6 +489,10 @@ $bg-light: #FAFAFA;
}
}
.school-layout.is-collapsed .school-header {
left: 80px;
}
.school-content {
margin: 20px;
padding: 24px;

View File

@ -1,5 +1,5 @@
<template>
<a-layout class="teacher-layout">
<a-layout :class="['teacher-layout', { 'is-collapsed': collapsed }]">
<a-layout-sider
v-model:collapsed="collapsed"
:trigger="null"
@ -15,6 +15,7 @@
</div>
</div>
<div class="sider-menu-wrapper">
<a-menu
v-model:selectedKeys="selectedKeys"
mode="inline"
@ -56,6 +57,7 @@
<span>成长档案</span>
</a-menu-item>
</a-menu>
</div>
</a-layout-sider>
<a-layout>
@ -200,9 +202,21 @@ $bg-light: #FAFAFA;
.teacher-layout {
min-height: 100vh;
background: $bg-light;
position: relative;
padding-left: 200px; //
padding-top: 64px; //
}
.teacher-layout.is-collapsed {
padding-left: 80px; //
}
.teacher-sider {
position: fixed;
left: 0;
top: 0;
bottom: 0;
z-index: 100;
background: white !important;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.06);
border-right: 1px solid $border-color;
@ -296,6 +310,25 @@ $bg-light: #FAFAFA;
}
}
.sider-menu-wrapper {
height: calc(100vh - 80px);
overflow: auto;
//
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: rgba(148, 163, 184, 0.6);
border-radius: 999px;
}
}
.teacher-header {
background: white;
padding: 0 24px;
@ -304,6 +337,11 @@ $bg-light: #FAFAFA;
align-items: center;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
border-bottom: 1px solid $border-color;
position: fixed;
top: 0;
left: 200px;
right: 0;
z-index: 90;
.trigger {
font-size: 18px;
@ -330,6 +368,10 @@ $bg-light: #FAFAFA;
}
}
.teacher-layout.is-collapsed .teacher-header {
left: 80px;
}
.teacher-content {
margin: 20px;
padding: 24px;

View File

@ -1,18 +0,0 @@
#!/bin/bash
# 前端启动脚本
# 确保从正确目录启动
cd "$(dirname "$0")"
echo "🚀 正在启动前端服务..."
echo "📂 工作目录: $(pwd)"
# 检查 node_modules 是否存在
if [ ! -d "node_modules" ]; then
echo "📦 正在安装依赖..."
npm install
fi
# 启动前端
npm run dev

View File

@ -0,0 +1,69 @@
import { test, expect, Page } from '@playwright/test';
type RoleKey = 'admin' | 'school' | 'teacher' | 'parent';
const ROLE_CONFIG: Record<
RoleKey,
{
label: string;
account: string;
password: string;
dashboardPath: string;
}
> = {
admin: {
label: '超管',
account: 'admin',
password: 'admin123',
dashboardPath: '/admin/dashboard',
},
school: {
label: '学校',
account: 'school',
password: '123456',
dashboardPath: '/school/dashboard',
},
teacher: {
label: '教师',
account: 'teacher1',
password: '123456',
dashboardPath: '/teacher/dashboard',
},
parent: {
label: '家长',
account: 'parent1',
password: '123456',
dashboardPath: '/parent/dashboard',
},
};
async function loginAsRole(page: Page, role: RoleKey) {
const cfg = ROLE_CONFIG[role];
await page.goto('/login');
await page.getByText(cfg.label).click();
await page.getByPlaceholder('请输入账号').fill(cfg.account);
await page.getByPlaceholder('请输入密码').fill(cfg.password);
await page.getByRole('button', { name: '登录' }).click();
await page.waitForURL(`**${cfg.dashboardPath}*`);
await expect(page).toHaveURL(new RegExp(`${cfg.dashboardPath}`));
await expect(page).toHaveTitle(/幼儿阅读教学服务平台/);
}
test.describe('从登录开始的一键全角色流程', () => {
for (const role of Object.keys(ROLE_CONFIG) as RoleKey[]) {
test(`角色:${ROLE_CONFIG[role].label} 登录并进入首页`, async ({ page }) => {
await loginAsRole(page, role);
await expect(page.getByText('数据看板').or(page.getByText('首页'))).toBeVisible({
timeout: 10_000,
});
});
}
});