1
0
mirror of https://github.com/ZeroCatDev/Classworks.git synced 2026-03-21 09:13:10 +00:00

Compare commits

..

6 Commits

21 changed files with 660 additions and 312 deletions

View File

@ -34,7 +34,9 @@ export default [
confirm: 'readonly', confirm: 'readonly',
prompt: 'readonly', prompt: 'readonly',
setTimeout: 'readonly', setTimeout: 'readonly',
clearTimeout: 'readonly',
setInterval: 'readonly', setInterval: 'readonly',
clearInterval: 'readonly',
fetch: 'readonly', fetch: 'readonly',
XMLHttpRequest: 'readonly', XMLHttpRequest: 'readonly',
URL: 'readonly', URL: 'readonly',

View File

@ -5,77 +5,38 @@
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Classworks 作业板</title> <title>Classworks 作业板</title>
<meta name="description" content="记录,查看并同步作业" />
<!-- SEO -->
<meta name="description" content="Classworks —— 适用于班级大屏的作业板小工具,支持记录、查看并同步作业,开源免费。" />
<meta name="keywords" content="Classworks,作业板,班级大屏,作业管理,课表,作业同步,开源" />
<meta name="author" content="Sunwuyuan" />
<meta name="robots" content="index, follow" />
<link rel="canonical" href="https://cs.houlang.cloud/" />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:title" content="Classworks 作业板" />
<meta property="og:description" content="适用于班级大屏的作业板小工具,支持记录、查看并同步作业,开源免费。" />
<meta property="og:url" content="https://cs.houlang.cloud/" />
<meta property="og:site_name" content="Classworks" />
<meta property="og:image" content="https://cs.houlang.cloud/banner.png" />
<meta property="og:image:width" content="512" />
<meta property="og:image:height" content="512" />
<meta property="og:locale" content="zh_CN" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="Classworks 作业板" />
<meta name="twitter:description" content="适用于班级大屏的作业板小工具,支持记录、查看并同步作业,开源免费。" />
<meta name="twitter:image" content="https://cs.houlang.cloud/banner.png" />
<link rel="apple-touch-icon" href="/image/apple-touch-icon.png" sizes="180x180" /> <link rel="apple-touch-icon" href="/image/apple-touch-icon.png" sizes="180x180" />
<link rel="mask-icon" href="/image/mask-icon.svg" color="#212121" /> <link rel="mask-icon" href="/image/mask-icon.svg" color="#212121" />
<meta name="theme-color" content="#212121" /> <meta name="theme-color" content="#212121" />
<style>
/* Material 3 风格:纯 CSS 加载覆盖层 */
:root {
color-scheme: light dark;
/* 作为主色的近似值,后续由应用接管主题 */
--md3-primary: #6750A4; /* light primary */
--md3-primary-dark: #D0BCFF; /* dark primary */
--loader-bg: #ffffff;
--loader-fg: var(--md3-primary);
}
@media (prefers-color-scheme: dark) {
:root {
--loader-bg: #121212;
--loader-fg: var(--md3-primary-dark);
}
}
#app-loader {
position: fixed;
inset: 0;
z-index: 2147483647; /* 确保在最上层 */
display: grid;
place-items: center;
background: var(--loader-bg);
transition: opacity .2s ease;
}
#app-loader .md3-loader {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 14px;
color: var(--loader-fg);
}
/* 圆形不确定进度条(近似 M3 */
#app-loader .spinner {
width: 48px;
height: 48px;
border-radius: 50%;
/* 通过 conic-gradient 形成 90° 弧,并旋转实现不确定动画 */
background:
conic-gradient(from 0deg, currentColor 0 90deg, transparent 90deg 360deg);
/* 用 mask 形成环形厚度4px */
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 4px), #000 calc(100% - 4px));
mask: radial-gradient(farthest-side, transparent calc(100% - 4px), #000 calc(100% - 4px));
animation: md3-spin 1s linear infinite;
}
@keyframes md3-spin {
to { transform: rotate(360deg); }
}
#app-loader .label {
font: 500 14px/1.2 system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji";
color: var(--loader-fg);
letter-spacing: .2px;
opacity: .85;
-webkit-user-select: none;
user-select: none;
}
/* 当被移除或隐藏时可渐隐(由应用控制) */
body.app-loaded #app-loader { opacity: 0; pointer-events: none; }
</style>
</head> </head>
<body> <body>
<!-- 应用加载前显示的覆盖层:纯 CSS无脚本依赖 -->
<div id="app-loader" aria-live="polite" aria-busy="true">
<div class="md3-loader">
<div class="spinner" role="progressbar" aria-label="正在加载"></div>
</div>
</div>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener" style="display: none;">xICP备x号-4</a> <a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener" style="display: none;">xICP备x号-4</a>

View File

@ -19,9 +19,7 @@
"axios": "^1.13.2", "axios": "^1.13.2",
"idb": "^8.0.3", "idb": "^8.0.3",
"js-base64": "^3.7.8", "js-base64": "^3.7.8",
"js-yaml": "^4.1.1",
"lucide-vue-next": "^0.555.0", "lucide-vue-next": "^0.555.0",
"marked": "^17.0.1",
"pinyin-pro": "^3.27.0", "pinyin-pro": "^3.27.0",
"ratelimit-header-parser": "^0.1.0", "ratelimit-header-parser": "^0.1.0",
"roboto-fontface": "*", "roboto-fontface": "*",

14
pnpm-lock.yaml generated
View File

@ -32,15 +32,9 @@ importers:
js-base64: js-base64:
specifier: ^3.7.8 specifier: ^3.7.8
version: 3.7.8 version: 3.7.8
js-yaml:
specifier: ^4.1.1
version: 4.1.1
lucide-vue-next: lucide-vue-next:
specifier: ^0.555.0 specifier: ^0.555.0
version: 0.555.0(vue@3.5.25(typescript@5.9.3)) version: 0.555.0(vue@3.5.25(typescript@5.9.3))
marked:
specifier: ^17.0.1
version: 17.0.1
pinyin-pro: pinyin-pro:
specifier: ^3.27.0 specifier: ^3.27.0
version: 3.27.0 version: 3.27.0
@ -2186,6 +2180,7 @@ packages:
glob@11.1.0: glob@11.1.0:
resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true hasBin: true
globals@14.0.0: globals@14.0.0:
@ -2552,11 +2547,6 @@ packages:
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
marked@17.0.1:
resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==}
engines: {node: '>= 20'}
hasBin: true
math-intrinsics@1.1.0: math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -6261,8 +6251,6 @@ snapshots:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
marked@17.0.1: {}
math-intrinsics@1.1.0: {} math-intrinsics@1.1.0: {}
merge2@1.4.1: {} merge2@1.4.1: {}

BIN
public/banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 650 KiB

View File

@ -32,6 +32,11 @@ onMounted(() => {
}); });
</script> </script>
<style> <style>
/* 全局样式(从 index.vue 迁移,确保全局可用且仅加载一次) */
@import "@/styles/index.scss";
@import "@/styles/transitions.scss";
@import "@/styles/global.scss";
.md3-enter-active, .md3-enter-active,
.md3-leave-active { .md3-leave-active {
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1), transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1),

164
src/components/TimeCard.vue Normal file
View File

@ -0,0 +1,164 @@
<template>
<v-card
class="time-card"
elevation="2"
border
rounded="xl"
height="100%"
>
<v-card-text
class="pa-6 d-flex flex-column "
style="height: 100%"
>
<!-- 时间显示 -->
<div
class="time-display"
:style="timeStyle"
>
{{ timeString }}<span
class="seconds-text"
:style="secondsStyle"
>{{ secondsString }}</span>
</div>
<!-- 日期 + 星期 + 时段 -->
<div
class="date-line mt-3"
:style="dateStyle"
>
{{ dateString }} {{ weekdayString }} {{ periodOfDay }}
</div>
</v-card-text>
</v-card>
</template>
<script>
import { SettingsManager, watchSettings } from '@/utils/settings'
//
const TIME_FONT_RATIO = 2.0
const SECONDS_FONT_RATIO = 0.9
const DATE_FONT_RATIO = 0.6
export default {
name: 'TimeCard',
data() {
return {
now: new Date(),
timer: null,
unwatch: null,
fontSize: 28,
}
},
computed: {
timeString() {
const h = String(this.now.getHours()).padStart(2, '0')
const m = String(this.now.getMinutes()).padStart(2, '0')
return `${h}:${m}`
},
secondsString() {
return `:${String(this.now.getSeconds()).padStart(2, '0')}`
},
dateString() {
const y = this.now.getFullYear()
const m = this.now.getMonth() + 1
const d = this.now.getDate()
return `${y}${m}${d}`
},
weekdayString() {
const days = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
return days[this.now.getDay()]
},
periodOfDay() {
const h = this.now.getHours()
if (h < 6) return '凌晨'
if (h < 8) return '早晨'
if (h < 11) return '上午'
if (h < 13) return '中午'
if (h < 17) return '下午'
if (h < 19) return '傍晚'
if (h < 22) return '晚上'
return '深夜'
},
timeStyle() {
return {
'font-size': `${this.fontSize * TIME_FONT_RATIO}px`,
'font-weight': '700',
'line-height': '1',
'letter-spacing': '4px',
'font-variant-numeric': 'tabular-nums',
}
},
secondsStyle() {
return {
'font-size': `${this.fontSize * SECONDS_FONT_RATIO}px`,
'font-variant-numeric': 'tabular-nums',
'vertical-align': 'baseline',
'margin-left': '4px',
'opacity': '0.6',
}
},
dateStyle() {
return {
'font-size': `${this.fontSize * DATE_FONT_RATIO}px`,
'letter-spacing': '1px',
}
},
},
mounted() {
this.loadSettings()
this.startTimer()
this.unwatch = watchSettings(() => {
this.loadSettings()
})
},
beforeUnmount() {
this.stopTimer()
if (this.unwatch) {
this.unwatch()
}
},
methods: {
loadSettings() {
this.fontSize = SettingsManager.getSetting('font.size')
},
startTimer() {
//
this.timer = setInterval(() => {
this.now = new Date()
}, 1000)
},
stopTimer() {
if (this.timer) {
clearInterval(this.timer)
this.timer = null
}
},
},
}
</script>
<style scoped>
.time-card {
transition: all 0.3s ease;
overflow: hidden;
}
.time-card:hover {
transform: translateY(-2px);
}
.time-display {
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
white-space: nowrap;
}
.seconds-text {
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
}
.date-line {
opacity: 0.75;
letter-spacing: 1px;
}
</style>

View File

@ -242,7 +242,6 @@
</template> </template>
<script> <script>
import { pinyin } from "pinyin-pro";
import { useDisplay } from "vuetify"; import { useDisplay } from "vuetify";
import { getSetting } from "@/utils/settings"; import { getSetting } from "@/utils/settings";
@ -328,11 +327,7 @@ export default {
return Array.from(surnameMap.entries()) return Array.from(surnameMap.entries())
.map(([name, count]) => ({ name, count })) .map(([name, count]) => ({ name, count }))
.sort((a, b) => { .sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
const pinyinA = pinyin(a.name, { toneType: "none" });
const pinyinB = pinyin(b.name, { toneType: "none" });
return pinyinA.localeCompare(pinyinB);
});
}, },
}, },
methods: { methods: {

View File

@ -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>

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>

View File

@ -11,8 +11,13 @@
}" }"
class="grid-item" class="grid-item"
> >
<!-- 时间卡片 -->
<div v-if="item.type === 'time'" style="height: 100%">
<time-card />
</div>
<!-- 一言卡片 --> <!-- 一言卡片 -->
<div v-if="item.type === 'hitokoto'" style="height: 100%"> <div v-else-if="item.type === 'hitokoto'" style="height: 100%">
<hitokoto-card /> <hitokoto-card />
</div> </div>
@ -204,6 +209,7 @@
<script> <script>
import HitokotoCard from "@/components/HitokotoCard.vue"; import HitokotoCard from "@/components/HitokotoCard.vue";
import TimeCard from "@/components/TimeCard.vue";
import ConciseExamCard from "@/components/home/ConciseExamCard.vue"; import ConciseExamCard from "@/components/home/ConciseExamCard.vue";
import {getSetting} from "@/utils/settings.js"; import {getSetting} from "@/utils/settings.js";
@ -216,6 +222,7 @@ export default {
}, },
components: { components: {
HitokotoCard, HitokotoCard,
TimeCard,
ConciseExamCard, ConciseExamCard,
}, },
props: { props: {

View File

@ -218,8 +218,16 @@
<script> <script>
import UnsavedWarning from "../common/UnsavedWarning.vue"; import UnsavedWarning from "../common/UnsavedWarning.vue";
import "@/styles/warnings.scss"; import "@/styles/warnings.scss";
import {pinyin} from "pinyin-pro";
import dataProvider from "@/utils/dataProvider"; 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"; import {getSetting} from "@/utils/settings";
export default { export default {
@ -440,10 +448,11 @@ export default {
} }
}, },
sortStudentsByPinyin() { async sortStudentsByPinyin() {
const pinyinFn = await loadPinyin();
const sorted = [...this.modelValue.list].sort((a, b) => { const sorted = [...this.modelValue.list].sort((a, b) => {
const pinyinA = pinyin(a.name, {toneType: "none"}); const pinyinA = pinyinFn(a.name, {toneType: "none"});
const pinyinB = pinyin(b.name, {toneType: "none"}); const pinyinB = pinyinFn(b.name, {toneType: "none"});
return pinyinA.localeCompare(pinyinB); return pinyinA.localeCompare(pinyinB);
}); });
sorted.forEach((s, i) => s.id = i + 1); sorted.forEach((s, i) => s.id = i + 1);

View File

@ -21,8 +21,16 @@
</template> </template>
<script> <script>
import Typewriter from "typewriter-effect/dist/core";
import quotes from "@/data/echoChamber.json"; 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"; import SettingsCard from "@/components/SettingsCard.vue";
const INITIAL_STATE = { const INITIAL_STATE = {
@ -50,7 +58,8 @@ export default {
}, },
methods: { methods: {
initTypewriters() { async initTypewriters() {
const Typewriter = await getTypewriter();
this.typewriter = new Typewriter(this.$refs.typewriter, TYPEWRITER_CONFIG.main); this.typewriter = new Typewriter(this.$refs.typewriter, TYPEWRITER_CONFIG.main);
this.sourceWriter = new Typewriter(this.$refs.sourceWriter, TYPEWRITER_CONFIG.source); this.sourceWriter = new Typewriter(this.$refs.sourceWriter, TYPEWRITER_CONFIG.source);
this.typeQuote(INITIAL_STATE); this.typeQuote(INITIAL_STATE);

View File

@ -1,9 +1,7 @@
<template> <template>
<v-app>
<v-main> <v-main>
<router-view/> <router-view />
</v-main> </v-main>
</v-app>
</template> </template>
<script setup> <script setup>

View File

@ -1,207 +1,61 @@
/** /**
* main.js * main.js
* *
* Bootstraps Vuetify and other plugins then mounts the App` * 精简启动流水线快速挂载 Vue app重型依赖Sentry/Clarity异步加载
*/ */
// Plugins // 核心插件Vuetify / Router / Pinia
import {registerPlugins} from '@/plugins' import { registerPlugins } from '@/plugins'
import {createPinia} from 'pinia'
import router from './router'
const pinia = createPinia()
// Components // Components
import App from './App.vue' import App from './App.vue'
import GlobalMessage from '@/components/GlobalMessage.vue' import GlobalMessage from '@/components/GlobalMessage.vue'
// Composables // Composables
import {createApp} from 'vue' 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 messageService from './utils/message'; import messageService from './utils/message'
import { getVisitorId } from './utils/visitorId';
import * as Sentry from "@sentry/vue";
const app = createApp(App) 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) registerPlugins(app)
//app.use(TDesign) app.use(messageService)
app.use(messageService);
app.use(pinia)
app.component('GlobalMessage', GlobalMessage) app.component('GlobalMessage', GlobalMessage)
// 挂载 Vue app首要目标尽快渲染首屏
app.mount('#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 { try {
const Clarity = (await import('@microsoft/clarity')).default; const { getVisitorId } = await import('./utils/visitorId')
const projectId = "rhp8uqoc3l"; const Clarity = (await import('@microsoft/clarity')).default
Clarity.init(projectId); Clarity.init('rhp8uqoc3l')
// 获取并设置访客标识 const visitorId = await getVisitorId()
const visitorId = await getVisitorId(); console.log('Visitor ID:', visitorId)
console.log('Visitor ID:', visitorId); Clarity.identify(visitorId)
Clarity.identify(visitorId); Clarity.setTag('fingerprintjs', visitorId)
Clarity.setTag('fingerprintjs', visitorId);
} catch (error) { } catch (error) {
console.warn('Clarity 加载或标识设置失败:', error); console.warn('Clarity 加载或标识设置失败:', error)
} }
} }
// 移除首屏 CSS 加载覆盖层(在 Vue 挂载完成后) if (document.readyState === 'complete') {
try { loadClarity()
const removeLoader = () => { } else {
document.body.classList.add('app-loaded'); window.addEventListener('load', loadClarity, { once: true })
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 {
// 安全失败:即便移除失败也不影响应用
} }

View File

@ -63,7 +63,10 @@
@token-info-updated="updateTokenDisplayInfo" @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> <v-container class="main-window flex-grow-1 no-select bloom-container" fluid>
<!-- 常驻通知区域 --> <!-- 常驻通知区域 -->
@ -430,23 +433,73 @@
</template> </template>
<script> <script>
import MessageLog from "@/components/MessageLog.vue"; import { defineAsyncComponent } from "vue";
import RandomPicker from "@/components/RandomPicker.vue"; import AsyncLoadingPlaceholder from "@/components/common/AsyncLoadingPlaceholder.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 HomeworkGrid from "@/components/home/HomeworkGrid.vue"; import HomeworkGrid from "@/components/home/HomeworkGrid.vue";
import HomeActions from "@/components/home/HomeActions.vue"; import HomeActions from "@/components/home/HomeActions.vue";
import PwaInstallCard from "@/components/PwaInstallCard.vue"; import FloatingICP from "@/components/FloatingICP.vue";
import ExamScheduleCard from "@/components/home/ExamScheduleCard.vue";
import ExamConfigEditor from "@/components/ExamConfigEditor.vue";
import HitokotoCard from "@/components/HitokotoCard.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 dataProvider from "@/utils/dataProvider";
import { useExamStore } from "@/stores/examStore"; import { useExamStore } from "@/stores/examStore";
import { import {
@ -457,9 +510,6 @@ import {
} from "@/utils/settings"; } from "@/utils/settings";
import { kvServerProvider } from "@/utils/providers/kvServerProvider"; import { kvServerProvider } from "@/utils/providers/kvServerProvider";
import { useDisplay } from "vuetify"; import { useDisplay } from "vuetify";
import "../styles/index.scss";
import "../styles/transitions.scss";
import "../styles/global.scss";
import { debounce, throttle } from "@/utils/debounce"; import { debounce, throttle } from "@/utils/debounce";
import { Base64 } from "js-base64"; import { Base64 } from "js-base64";
import { import {
@ -491,6 +541,7 @@ export default {
PwaInstallCard, PwaInstallCard,
ExamScheduleCard, ExamScheduleCard,
ExamConfigEditor, ExamConfigEditor,
HomeSkeleton,
}, },
setup() { setup() {
const { mobile } = useDisplay(); const { mobile } = useDisplay();
@ -563,6 +614,7 @@ export default {
students: false, students: false,
copyToToday: false, copyToToday: false,
}, },
dataReady: false,
debouncedUpload: null, debouncedUpload: null,
debouncedAttendanceSave: null, debouncedAttendanceSave: null,
throttledReflow: null, throttledReflow: null,
@ -635,11 +687,21 @@ export default {
return this.mobile; return this.mobile;
}, },
titleText() { titleText() {
// const provider = getSetting("server.provider");
const deviceName = const isOnline = provider === "kv-server" || provider === "classworkscloud";
let displayName;
if (isOnline && this.state.namespaceInfo) {
// 线使
displayName =
this.state.namespaceInfo?.name ||
this.state.namespaceInfo?.device?.name || this.state.namespaceInfo?.device?.name ||
this.state.classNumber || this.state.classNumber ||
"高三八班"; "高三八班";
} else {
// 线使
displayName = this.state.classNumber || "高三八班";
}
const today = this.getToday(); const today = this.getToday();
const yesterday = new Date(today); const yesterday = new Date(today);
@ -650,11 +712,11 @@ export default {
const yesterdayStr = this.formatDate(yesterday); const yesterdayStr = this.formatDate(yesterday);
if (currentDateStr === todayStr) { if (currentDateStr === todayStr) {
return deviceName + " - 今天的作业"; return displayName + " - 今天的作业";
} else if (currentDateStr === yesterdayStr) { } else if (currentDateStr === yesterdayStr) {
return deviceName + " - 昨天的作业"; return displayName + " - 昨天的作业";
} else { } else {
return `${deviceName} - ${currentDateStr}的作业`; return `${displayName} - ${currentDateStr}的作业`;
} }
}, },
sortedItems() { sortedItems() {
@ -713,6 +775,17 @@ export default {
} }
} }
//
if (getSetting("timeCard.enabled")) {
items.push({
key: "time-card",
name: "时间",
type: "time",
order: 9997,
rowSpan: 150,
});
}
// //
if (getSetting("hitokoto.enabled")) { if (getSetting("hitokoto.enabled")) {
items.push({ items.push({
@ -937,6 +1010,7 @@ export default {
try { try {
this.updateBackendUrl(); this.updateBackendUrl();
await this.initializeData(); await this.initializeData();
this.dataReady = true;
// / // /
await this.loadDeviceInfo(); await this.loadDeviceInfo();
this.setupAutoRefresh(); this.setupAutoRefresh();

128
src/utils/sentry.js Normal file
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
}
}
}

View File

@ -100,6 +100,14 @@ const settingsDefinitions = {
icon: "mdi-card-outline", icon: "mdi-card-outline",
}, },
// 时间卡片设置
"timeCard.enabled": {
type: "boolean",
default: true,
description: "启用时间卡片",
icon: "mdi-clock-outline",
},
// 一言设置 // 一言设置
"hitokoto.enabled": { "hitokoto.enabled": {
type: "boolean", type: "boolean",

View File

@ -7,11 +7,47 @@
], ],
"headers": [ "headers": [
{ {
"source": "/:path*", "source": "/assets/:path*",
"headers": [ "headers": [
{ {
"key": "Cache-Control", "key": "Cache-Control",
"value": "no-cache, no-store, must-revalidate" "value": "public, max-age=31536000, immutable"
}
]
},
{
"source": "/pwa/:path*",
"headers": [
{
"key": "Cache-Control",
"value": "public, max-age=604800"
}
]
},
{
"source": "/index.html",
"headers": [
{
"key": "Cache-Control",
"value": "no-cache"
}
]
},
{
"source": "/sw.js",
"headers": [
{
"key": "Cache-Control",
"value": "no-cache"
}
]
},
{
"source": "/sw-cache-manager.js",
"headers": [
{
"key": "Cache-Control",
"value": "no-cache"
} }
] ]
} }

View File

@ -158,11 +158,11 @@ export default defineConfig({
}, },
}), }),
Components({ Components({
//resolvers: [ // 排除已在 index.vue 中通过 defineAsyncComponent 手动懒加载的组件
// TDesignResolver({ // 避免 unplugin-vue-components 生成冲突的静态 import
// library: 'vue-next' directoryAsNamespace: false,
// }) globs: ['src/components/**/[A-Z]*.vue'],
//] exclude: [/pages\/index\.vue$/],
}), }),
Fonts({ Fonts({
google: { google: {
@ -198,6 +198,26 @@ export default defineConfig({
'.vue', '.vue',
], ],
}, },
build: {
// ===== Chunk 分割优化 =====
chunkSizeWarningLimit: 500,
rollupOptions: {
output: {
manualChunks: {
// 核心框架(极少变动,长缓存)
'vendor-vue': ['vue', 'vue-router', 'pinia'],
// UI 框架
'vendor-vuetify': ['vuetify'],
// 监控(异步加载,独立 chunk
'vendor-sentry': ['@sentry/vue'],
// 实时通信
'vendor-socket': ['socket.io-client'],
// 通用工具库
'vendor-utils': ['axios', 'uuid', 'js-base64'],
},
},
},
},
server: { server: {
port: 3031, port: 3031,
}, },