重构代码,优化组件加载和样式,移除不必要的依赖,增强首屏渲染性能

This commit is contained in:
Sunwuyuan
2026-03-03 21:16:32 +08:00
parent 97c70f6706
commit cf3412db6a
16 changed files with 424 additions and 302 deletions
+5
View File
@@ -32,6 +32,11 @@ onMounted(() => {
});
</script>
<style>
/* 全局样式(从 index.vue 迁移,确保全局可用且仅加载一次) */
@import "@/styles/index.scss";
@import "@/styles/transitions.scss";
@import "@/styles/global.scss";
.md3-enter-active,
.md3-leave-active {
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1),
@@ -242,7 +242,6 @@
</template>
<script>
import { pinyin } from "pinyin-pro";
import { useDisplay } from "vuetify";
import { getSetting } from "@/utils/settings";
@@ -328,11 +327,7 @@ export default {
return Array.from(surnameMap.entries())
.map(([name, count]) => ({ name, count }))
.sort((a, b) => {
const pinyinA = pinyin(a.name, { toneType: "none" });
const pinyinB = pinyin(b.name, { toneType: "none" });
return pinyinA.localeCompare(pinyinB);
});
.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
},
},
methods: {
@@ -0,0 +1,21 @@
<template>
<div class="async-loading-placeholder d-flex justify-center align-center" :style="{ minHeight: height }">
<v-progress-circular indeterminate color="primary" size="28" width="2" />
</div>
</template>
<script setup>
defineProps({
height: {
type: String,
default: '120px'
}
})
</script>
<style scoped>
.async-loading-placeholder {
width: 100%;
opacity: 0.6;
}
</style>
+71
View File
@@ -0,0 +1,71 @@
<template>
<!-- 纯 CSS 骨架屏,避免使用 VSkeletonLoader 引起的渲染冲突 -->
<v-container class="main-window" fluid>
<div class="skeleton-grid">
<div
v-for="n in cardCount"
:key="n"
class="skeleton-card"
>
<div class="skeleton-heading skeleton-pulse" />
<div class="skeleton-line skeleton-pulse" />
<div class="skeleton-line skeleton-line--short skeleton-pulse" />
</div>
</div>
<div class="d-flex justify-center mt-6 ga-3">
<div class="skeleton-btn skeleton-pulse" />
<div class="skeleton-btn skeleton-pulse" />
</div>
</v-container>
</template>
<script setup>
import { computed } from 'vue'
import { useDisplay } from 'vuetify'
const { mobile } = useDisplay()
const cardCount = computed(() => mobile.value ? 3 : 6)
</script>
<style scoped>
.skeleton-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.skeleton-card {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity, 0.12));
border-radius: 12px;
padding: 16px;
}
.skeleton-heading {
height: 24px;
width: 60%;
background: rgba(var(--v-theme-on-surface), 0.08);
border-radius: 4px;
margin-bottom: 12px;
}
.skeleton-line {
height: 14px;
width: 100%;
background: rgba(var(--v-theme-on-surface), 0.08);
border-radius: 4px;
margin-bottom: 8px;
}
.skeleton-line--short {
width: 40%;
}
.skeleton-btn {
height: 36px;
width: 100px;
background: rgba(var(--v-theme-on-surface), 0.08);
border-radius: 8px;
}
.skeleton-pulse {
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
</style>
+13 -4
View File
@@ -218,8 +218,16 @@
<script>
import UnsavedWarning from "../common/UnsavedWarning.vue";
import "@/styles/warnings.scss";
import {pinyin} from "pinyin-pro";
import dataProvider from "@/utils/dataProvider";
// pinyin-pro (~100KB) 按需动态加载
let _pinyin = null;
async function loadPinyin() {
if (!_pinyin) {
_pinyin = (await import('pinyin-pro')).pinyin;
}
return _pinyin;
}
import {getSetting} from "@/utils/settings";
export default {
@@ -440,10 +448,11 @@ export default {
}
},
sortStudentsByPinyin() {
async sortStudentsByPinyin() {
const pinyinFn = await loadPinyin();
const sorted = [...this.modelValue.list].sort((a, b) => {
const pinyinA = pinyin(a.name, {toneType: "none"});
const pinyinB = pinyin(b.name, {toneType: "none"});
const pinyinA = pinyinFn(a.name, {toneType: "none"});
const pinyinB = pinyinFn(b.name, {toneType: "none"});
return pinyinA.localeCompare(pinyinB);
});
sorted.forEach((s, i) => s.id = i + 1);
@@ -21,8 +21,16 @@
</template>
<script>
import Typewriter from "typewriter-effect/dist/core";
import quotes from "@/data/echoChamber.json";
// typewriter-effect 按需动态加载
let TypewriterClass = null;
async function getTypewriter() {
if (!TypewriterClass) {
TypewriterClass = (await import('typewriter-effect/dist/core')).default;
}
return TypewriterClass;
}
import SettingsCard from "@/components/SettingsCard.vue";
const INITIAL_STATE = {
@@ -50,7 +58,8 @@ export default {
},
methods: {
initTypewriters() {
async initTypewriters() {
const Typewriter = await getTypewriter();
this.typewriter = new Typewriter(this.$refs.typewriter, TYPEWRITER_CONFIG.main);
this.sourceWriter = new Typewriter(this.$refs.sourceWriter, TYPEWRITER_CONFIG.source);
this.typeQuote(INITIAL_STATE);
+3 -5
View File
@@ -1,9 +1,7 @@
<template>
<v-app>
<v-main>
<router-view/>
</v-main>
</v-app>
<v-main>
<router-view />
</v-main>
</template>
<script setup>
+32 -178
View File
@@ -1,207 +1,61 @@
/**
* main.js
*
* Bootstraps Vuetify and other plugins then mounts the App`
* 精简启动流水线:快速挂载 Vue app,重型依赖(Sentry/Clarity)异步加载
*/
// Plugins
import {registerPlugins} from '@/plugins'
import {createPinia} from 'pinia'
import router from './router'
const pinia = createPinia()
// 核心插件(Vuetify / Router / Pinia)
import { registerPlugins } from '@/plugins'
// Components
import App from './App.vue'
import GlobalMessage from '@/components/GlobalMessage.vue'
// Composables
import {createApp} from 'vue'
//import TDesign from 'tdesign-vue-next'
//import 'tdesign-vue-next/es/style/index.css'
//import '@examaware-cs/player/dist/player.css'
import { createApp } from 'vue'
import messageService from './utils/message';
import { getVisitorId } from './utils/visitorId';
import * as Sentry from "@sentry/vue";
import messageService from './utils/message'
const app = createApp(App)
// 保存 feedback integration 实例的引用
let feedbackIntegration = null;
// 初始化 Sentry,但暂不启用 Replay
Sentry.init({
app,
dsn: "https://dc34ab47426f49c0925445f0d87b7007@report.houlang.cloud/6",
// Setting this option to true will send default PII data to Sentry.
// For example, automatic IP address collection on events
sendDefaultPii: true,
integrations: [
Sentry.browserTracingIntegration({ router }),
Sentry.replayIntegration({
// 默认不自动录制,只在手动触发或发生错误时录制
maskAllText: false,
blockAllMedia: false,
}),
feedbackIntegration = Sentry.feedbackIntegration({
// 自动注入反馈按钮,但我们会手动触发
autoInject: false,
colorScheme: 'system',
showBranding: false,
showName: true,
showEmail: true,
isNameRequired: false,
isEmailRequired: false,
useSentryUser: {
name: 'username',
email: 'email',
},
themeDark: {
submitBackground: '#6200EA',
submitBackgroundHover: '#7C4DFF',
},
themeLight: {
submitBackground: '#6200EA',
submitBackgroundHover: '#7C4DFF',
},
})
],
// Tracing
tracesSampleRate: 1.0, // Capture 100% of the transactions
// Set 'tracePropagationTargets' to control for which URLs distributed tracing should be enabled
tracePropagationTargets: ["localhost", /^https?:\/\/cs\.(houlang\.cloud|houlangs\.com)/],
// Session Replay - 关闭自动录制
replaysSessionSampleRate: 0, // 不自动录制会话
replaysOnErrorSampleRate: 0, // 不在错误时自动录制(改为手动触发)
// Logs
enableLogs: true,
// 在初始化时设置用户识别的钩子
beforeSend(event) {
return event;
}
});
// 异步设置用户 fingerprint
getVisitorId().then(visitorId => {
Sentry.setUser({
id: visitorId,
username: visitorId,
});
Sentry.setTag('fingerprint', visitorId);
console.log('Sentry 用户标识已设置:', visitorId);
}).catch(error => {
console.warn('设置 Sentry 用户标识失败:', error);
});
// 导出用于手动打开反馈表单的函数
window.openSentryFeedback = () => {
try {
if (!feedbackIntegration) {
console.warn('Sentry Feedback integration 未初始化');
return false;
}
if (typeof feedbackIntegration.createWidget === 'function') {
const widget = feedbackIntegration.createWidget();
if (widget && typeof widget.open === 'function') {
widget.open();
console.log('Sentry Feedback 对话框已打开');
return true;
}
}
if (typeof feedbackIntegration.openDialog === 'function') {
feedbackIntegration.openDialog();
console.log('Sentry Feedback 对话框已打开');
return true;
}
console.warn('无法找到打开 Feedback 的方法');
console.log('可用方法:', Object.keys(feedbackIntegration));
return false;
} catch (error) {
console.error('打开 Sentry Feedback 时出错:', error);
return false;
}
};
// 导出用于手动启动录制的函数
window.startSentryReplay = () => {
try {
const client = Sentry.getClient();
if (!client) {
console.warn('Sentry 客户端未初始化');
return false;
}
// 获取 Replay integration 实例
const integrations = client.getOptions().integrations || [];
const replayIntegration = integrations.find(
integration => integration && integration.name === 'Replay'
);
if (replayIntegration && typeof replayIntegration.start === 'function') {
replayIntegration.start();
console.log('Sentry Replay 已手动启动');
return true;
}
console.warn('无法找到 Sentry Replay integration');
return false;
} catch (error) {
console.error('启动 Sentry Replay 时出错:', error);
return false;
}
};
registerPlugins(app)
//app.use(TDesign)
app.use(messageService);
app.use(pinia)
app.use(messageService)
app.component('GlobalMessage', GlobalMessage)
// 挂载 Vue app(首要目标:尽快渲染首屏)
app.mount('#app')
// 异步加载 Clarity 以提升初始加载速度
if (document.readyState === 'complete') {
loadClarity();
} else {
window.addEventListener('load', loadClarity, { once: true });
}
// ====== 以下全部异步,不阻塞首屏渲染 ======
async function loadClarity() {
// 异步初始化 Sentry(延迟到首帧渲染完成后,防止 errorHandler 与渲染周期冲突)
setTimeout(() => {
import('./utils/sentry').then(({ initSentry }) => {
const router = app.config.globalProperties.$router
initSentry(app, router)
}).catch((err) => {
console.warn('Sentry 初始化失败:', err)
})
}, 1000)
// 异步加载 Clarity(在页面完全加载后)
const loadClarity = async () => {
try {
const Clarity = (await import('@microsoft/clarity')).default;
const projectId = "rhp8uqoc3l";
Clarity.init(projectId);
const { getVisitorId } = await import('./utils/visitorId')
const Clarity = (await import('@microsoft/clarity')).default
Clarity.init('rhp8uqoc3l')
// 获取并设置访客标识
const visitorId = await getVisitorId();
console.log('Visitor ID:', visitorId);
Clarity.identify(visitorId);
Clarity.setTag('fingerprintjs', visitorId);
const visitorId = await getVisitorId()
console.log('Visitor ID:', visitorId)
Clarity.identify(visitorId)
Clarity.setTag('fingerprintjs', visitorId)
} catch (error) {
console.warn('Clarity 加载或标识设置失败:', error);
console.warn('Clarity 加载或标识设置失败:', error)
}
}
// 移除首屏 CSS 加载覆盖层(在 Vue 挂载完成后)
try {
const removeLoader = () => {
document.body.classList.add('app-loaded');
const el = document.getElementById('app-loader');
if (!el) return;
// 与 CSS 过渡对齐,稍等再移除节点,避免闪烁
setTimeout(() => el.remove(), 220);
};
if (document.readyState === 'complete' || document.readyState === 'interactive') {
removeLoader();
} else {
window.addEventListener('DOMContentLoaded', removeLoader, {once: true});
}
} catch {
// 安全失败:即便移除失败也不影响应用
if (document.readyState === 'complete') {
loadClarity()
} else {
window.addEventListener('load', loadClarity, { once: true })
}
+71 -18
View File
@@ -63,7 +63,10 @@
@token-info-updated="updateTokenDisplayInfo"
/>
<div v-if="!shouldShowInit" class="d-flex">
<!-- 首屏骨架屏(数据加载中显示) -->
<HomeSkeleton v-if="!shouldShowInit && !dataReady" />
<div v-if="!shouldShowInit && dataReady" class="d-flex">
<!-- 主要内容区域 -->
<v-container class="main-window flex-grow-1 no-select bloom-container" fluid>
<!-- 常驻通知区域 -->
@@ -430,23 +433,73 @@
</template>
<script>
import MessageLog from "@/components/MessageLog.vue";
import RandomPicker from "@/components/RandomPicker.vue";
import FloatingToolbar from "@/components/FloatingToolbar.vue";
import FloatingICP from "@/components/FloatingICP.vue";
import ChatWidget from "@/components/ChatWidget.vue";
import HomeworkEditDialog from "@/components/HomeworkEditDialog.vue";
import InitServiceChooser from "@/components/InitServiceChooser.vue";
import StudentNameManager from "@/components/StudentNameManager.vue";
import UrgentTestDialog from "@/components/UrgentTestDialog.vue";
import AttendanceSidebar from "@/components/attendance/AttendanceSidebar.vue";
import AttendanceManagementDialog from "@/components/attendance/AttendanceManagementDialog.vue";
import { defineAsyncComponent } from "vue";
import AsyncLoadingPlaceholder from "@/components/common/AsyncLoadingPlaceholder.vue";
// ===== 首屏核心组件(同步加载)=====
import HomeworkGrid from "@/components/home/HomeworkGrid.vue";
import HomeActions from "@/components/home/HomeActions.vue";
import PwaInstallCard from "@/components/PwaInstallCard.vue";
import ExamScheduleCard from "@/components/home/ExamScheduleCard.vue";
import ExamConfigEditor from "@/components/ExamConfigEditor.vue";
import FloatingICP from "@/components/FloatingICP.vue";
import HitokotoCard from "@/components/HitokotoCard.vue";
import HomeSkeleton from "@/components/common/HomeSkeleton.vue";
// ===== 非首屏 / 条件渲染组件(异步懒加载)=====
const MessageLog = defineAsyncComponent({
loader: () => import("@/components/MessageLog.vue"),
loadingComponent: AsyncLoadingPlaceholder,
delay: 200,
});
const RandomPicker = defineAsyncComponent({
loader: () => import("@/components/RandomPicker.vue"),
delay: 0,
});
const FloatingToolbar = defineAsyncComponent({
loader: () => import("@/components/FloatingToolbar.vue"),
delay: 200,
});
const ChatWidget = defineAsyncComponent({
loader: () => import("@/components/ChatWidget.vue"),
delay: 0,
});
const HomeworkEditDialog = defineAsyncComponent({
loader: () => import("@/components/HomeworkEditDialog.vue"),
delay: 0,
});
const InitServiceChooser = defineAsyncComponent({
loader: () => import("@/components/InitServiceChooser.vue"),
loadingComponent: AsyncLoadingPlaceholder,
delay: 200,
});
const StudentNameManager = defineAsyncComponent({
loader: () => import("@/components/StudentNameManager.vue"),
delay: 200,
});
const UrgentTestDialog = defineAsyncComponent({
loader: () => import("@/components/UrgentTestDialog.vue"),
delay: 0,
});
const AttendanceSidebar = defineAsyncComponent({
loader: () => import("@/components/attendance/AttendanceSidebar.vue"),
loadingComponent: AsyncLoadingPlaceholder,
delay: 200,
});
const AttendanceManagementDialog = defineAsyncComponent({
loader: () => import("@/components/attendance/AttendanceManagementDialog.vue"),
delay: 0,
});
const PwaInstallCard = defineAsyncComponent({
loader: () => import("@/components/PwaInstallCard.vue"),
delay: 200,
});
const ExamScheduleCard = defineAsyncComponent({
loader: () => import("@/components/home/ExamScheduleCard.vue"),
loadingComponent: AsyncLoadingPlaceholder,
delay: 200,
});
const ExamConfigEditor = defineAsyncComponent({
loader: () => import("@/components/ExamConfigEditor.vue"),
delay: 0,
});
import dataProvider from "@/utils/dataProvider";
import { useExamStore } from "@/stores/examStore";
import {
@@ -457,9 +510,6 @@ import {
} from "@/utils/settings";
import { kvServerProvider } from "@/utils/providers/kvServerProvider";
import { useDisplay } from "vuetify";
import "../styles/index.scss";
import "../styles/transitions.scss";
import "../styles/global.scss";
import { debounce, throttle } from "@/utils/debounce";
import { Base64 } from "js-base64";
import {
@@ -491,6 +541,7 @@ export default {
PwaInstallCard,
ExamScheduleCard,
ExamConfigEditor,
HomeSkeleton,
},
setup() {
const { mobile } = useDisplay();
@@ -563,6 +614,7 @@ export default {
students: false,
copyToToday: false,
},
dataReady: false,
debouncedUpload: null,
debouncedAttendanceSave: null,
throttledReflow: null,
@@ -948,6 +1000,7 @@ export default {
try {
this.updateBackendUrl();
await this.initializeData();
this.dataReady = true;
// 拉取设备/命名空间信息用于标题显示
await this.loadDeviceInfo();
this.setupAutoRefresh();
+128
View File
@@ -0,0 +1,128 @@
/**
* Sentry 异步初始化模块
*
* 从 main.js 中抽离,在 Vue app 挂载后异步加载,
* 避免 @sentry/vue (~60KB gzip) 阻塞首屏渲染。
*/
import * as Sentry from '@sentry/vue'
import { getVisitorId } from './visitorId'
// 保存 feedback integration 实例的引用
let feedbackIntegration = null
/**
* 异步初始化 Sentry(在 app mount 后调用)
* @param {import('vue').App} app - Vue app 实例
* @param {import('vue-router').Router} router - Vue Router 实例
*/
export function initSentry(app, router) {
Sentry.init({
app,
dsn: 'https://dc34ab47426f49c0925445f0d87b7007@report.houlang.cloud/6',
sendDefaultPii: true,
integrations: [
Sentry.browserTracingIntegration({ router }),
Sentry.replayIntegration({
maskAllText: false,
blockAllMedia: false,
}),
feedbackIntegration = Sentry.feedbackIntegration({
autoInject: false,
colorScheme: 'system',
showBranding: false,
showName: true,
showEmail: true,
isNameRequired: false,
isEmailRequired: false,
useSentryUser: {
name: 'username',
email: 'email',
},
themeDark: {
submitBackground: '#6200EA',
submitBackgroundHover: '#7C4DFF',
},
themeLight: {
submitBackground: '#6200EA',
submitBackgroundHover: '#7C4DFF',
},
}),
],
tracesSampleRate: 1.0,
tracePropagationTargets: [
'localhost',
/^https?:\/\/cs\.(houlang\.cloud|houlangs\.com)/,
],
replaysSessionSampleRate: 0,
replaysOnErrorSampleRate: 0,
enableLogs: true,
beforeSend(event) {
return event
},
})
// 异步设置用户 fingerprint
getVisitorId()
.then((visitorId) => {
Sentry.setUser({ id: visitorId, username: visitorId })
Sentry.setTag('fingerprint', visitorId)
console.log('Sentry 用户标识已设置:', visitorId)
})
.catch((error) => {
console.warn('设置 Sentry 用户标识失败:', error)
})
// 注册全局函数:打开反馈表单
window.openSentryFeedback = () => {
try {
if (!feedbackIntegration) {
console.warn('Sentry Feedback integration 未初始化')
return false
}
if (typeof feedbackIntegration.createWidget === 'function') {
const widget = feedbackIntegration.createWidget()
if (widget && typeof widget.open === 'function') {
widget.open()
console.log('Sentry Feedback 对话框已打开')
return true
}
}
if (typeof feedbackIntegration.openDialog === 'function') {
feedbackIntegration.openDialog()
console.log('Sentry Feedback 对话框已打开')
return true
}
console.warn('无法找到打开 Feedback 的方法')
console.log('可用方法:', Object.keys(feedbackIntegration))
return false
} catch (error) {
console.error('打开 Sentry Feedback 时出错:', error)
return false
}
}
// 注册全局函数:手动启动录制
window.startSentryReplay = () => {
try {
const client = Sentry.getClient()
if (!client) {
console.warn('Sentry 客户端未初始化')
return false
}
const integrations = client.getOptions().integrations || []
const replayIntegration = integrations.find(
(integration) => integration && integration.name === 'Replay'
)
if (replayIntegration && typeof replayIntegration.start === 'function') {
replayIntegration.start()
console.log('Sentry Replay 已手动启动')
return true
}
console.warn('无法找到 Sentry Replay integration')
return false
} catch (error) {
console.error('启动 Sentry Replay 时出错:', error)
return false
}
}
}
+2 -2
View File
@@ -15,7 +15,7 @@ export function getServerUrl() {
if (isRotationEnabled()) {
return getEffectiveServerUrl();
}
// Prefer configured server domain; fallback to env; then current origin
const cfg = getSetting('server.domain');
const envUrl = import.meta?.env?.VITE_SERVER_URL;
@@ -34,7 +34,7 @@ export function getSocket() {
socket = null;
}
connectedDomain = serverUrl;
// For classworkscloud, create socket with the first server in rotation
// Note: Socket.IO's built-in reconnection will retry the same server URL.
// Server rotation is handled at the HTTP request level, not Socket.IO level.