├── .browserslistrc ├── public ├── 328a0e159cf9b3fb3caacaab9c83bf17.txt ├── favicon.ico ├── pwa │ └── image │ │ ├── favicon.ico │ │ ├── pwa-64x64.png │ │ ├── pwa-192x192.png │ │ ├── pwa-512x512.png │ │ ├── maskable-icon-512x512.png │ │ ├── apple-touch-icon-180x180.png │ │ └── logo.svg └── sw-cache-manager.js ├── images └── banner.png ├── src ├── assets │ ├── logo.png │ ├── cslogo.png │ ├── favicon.ico │ ├── fonts │ │ └── TCloudNumberVF.ttf │ └── logo.svg ├── styles │ ├── README.md │ ├── warnings.scss │ ├── glow.scss │ ├── cards.scss │ ├── settings.scss │ ├── global.scss │ └── transitions.scss ├── stores │ ├── index.js │ ├── app.js │ ├── README.md │ └── examStore.js ├── pages │ ├── 404.vue │ ├── README.md │ ├── debug.vue │ ├── exam-editor │ │ └── [id].vue │ ├── authorize.vue │ ├── CacheManagement.vue │ ├── debug-init.vue │ └── exam-player.vue ├── plugins │ ├── README.md │ ├── index.js │ └── vuetify.js ├── layouts │ ├── default.vue │ └── README.md ├── components │ ├── AppHeader.vue │ ├── settings │ │ ├── cards │ │ │ ├── RefreshSettingsCard.vue │ │ │ ├── RandomPickerCard.vue │ │ │ ├── EditSettingsCard.vue │ │ │ ├── DisplaySettingsCard.vue │ │ │ ├── ThemeSettingsCard.vue │ │ │ ├── EchoChamberCard.vue │ │ │ └── ServerSettingsCard.vue │ │ ├── SettingGroup.vue │ │ └── SettingsExplorer.vue │ ├── common │ │ └── UnsavedWarning.vue │ ├── README.md │ ├── FloatingICP.vue │ ├── SettingsCard.vue │ ├── auth │ │ ├── AlternativeCodeDialog.vue │ │ ├── TokenInputDialog.vue │ │ ├── README.md │ │ └── DeviceAuthDialog.vue │ ├── RelativeTimeDisplay.vue │ ├── GlobalMessage.vue │ ├── MessageLog.vue │ ├── error │ │ └── 404.vue │ ├── ReadOnlyTokenWarning.vue │ ├── home │ │ ├── HomeActions.vue │ │ ├── ExamScheduleCard.vue │ │ └── ConciseExamCard.vue │ ├── attendance │ │ └── AttendanceSidebar.vue │ ├── EventSender.vue │ ├── RateLimitModal.vue │ ├── HelloWorld.vue │ ├── HitokotoCard.vue │ ├── KvInitialize.vue │ └── FloatingToolbar.vue ├── utils │ ├── defaults │ │ └── defaultData.js │ ├── debounce.js │ ├── api.js │ ├── visitorId.js │ ├── socketClient.js │ ├── providers │ │ ├── kvLocalProvider.js │ │ └── kvServerProvider.js │ ├── message.js │ ├── gridLayout.js │ └── safeEvents.js ├── App.vue ├── router │ └── index.js ├── axios │ └── axios.js ├── main.js └── sw.js ├── pnpm-workspace.yaml ├── .hintrc ├── .editorconfig ├── .env.example ├── jsconfig.json ├── vercel.json ├── eslint.config.js ├── README.md ├── .github └── workflows │ ├── deploy.yml │ ├── azure-static-web-apps-icy-river-041d8ab00.yml │ ├── claude.yml │ └── claude-code-review.yml ├── package.json ├── .eslintrc-auto-import.json ├── index.html ├── UNIFIED_LINK_GENERATOR.md ├── .gitignore └── vite.config.mjs /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not dead 4 | not ie 11 5 | -------------------------------------------------------------------------------- /public/328a0e159cf9b3fb3caacaab9c83bf17.txt: -------------------------------------------------------------------------------- 1 | 03febad38fd6b89c0d769a8b66e7bd25a02d7a4b -------------------------------------------------------------------------------- /images/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZeroCatDev/Classworks/HEAD/images/banner.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZeroCatDev/Classworks/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZeroCatDev/Classworks/HEAD/src/assets/logo.png -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | onlyBuiltDependencies: 2 | - '@parcel/watcher' 3 | - esbuild 4 | - sharp 5 | -------------------------------------------------------------------------------- /src/assets/cslogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZeroCatDev/Classworks/HEAD/src/assets/cslogo.png -------------------------------------------------------------------------------- /src/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZeroCatDev/Classworks/HEAD/src/assets/favicon.ico -------------------------------------------------------------------------------- /src/styles/README.md: -------------------------------------------------------------------------------- 1 | # Styles 2 | 3 | This directory is for configuring the styles of the application. 4 | -------------------------------------------------------------------------------- /public/pwa/image/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZeroCatDev/Classworks/HEAD/public/pwa/image/favicon.ico -------------------------------------------------------------------------------- /src/stores/index.js: -------------------------------------------------------------------------------- 1 | // Utilities 2 | import {createPinia} from 'pinia' 3 | 4 | export default createPinia() 5 | -------------------------------------------------------------------------------- /public/pwa/image/pwa-64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZeroCatDev/Classworks/HEAD/public/pwa/image/pwa-64x64.png -------------------------------------------------------------------------------- /.hintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "development" 4 | ], 5 | "hints": { 6 | "no-inline-styles": "off" 7 | } 8 | } -------------------------------------------------------------------------------- /public/pwa/image/pwa-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZeroCatDev/Classworks/HEAD/public/pwa/image/pwa-192x192.png -------------------------------------------------------------------------------- /public/pwa/image/pwa-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZeroCatDev/Classworks/HEAD/public/pwa/image/pwa-512x512.png -------------------------------------------------------------------------------- /src/assets/fonts/TCloudNumberVF.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZeroCatDev/Classworks/HEAD/src/assets/fonts/TCloudNumberVF.ttf -------------------------------------------------------------------------------- /public/pwa/image/maskable-icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZeroCatDev/Classworks/HEAD/public/pwa/image/maskable-icon-512x512.png -------------------------------------------------------------------------------- /public/pwa/image/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ZeroCatDev/Classworks/HEAD/public/pwa/image/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue}] 2 | indent_style = space 3 | indent_size = 2 4 | trim_trailing_whitespace = true 5 | insert_final_newline = true 6 | -------------------------------------------------------------------------------- /src/pages/404.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Classworks KV 默认服务器域名 2 | VITE_DEFAULT_KV_SERVER=https://kv.wuyuan.dev 3 | 4 | # Classworks KV 授权服务器域名 5 | VITE_DEFAULT_AUTH_SERVER=https://kv.houlang.cloud 6 | -------------------------------------------------------------------------------- /src/stores/app.js: -------------------------------------------------------------------------------- 1 | // Utilities 2 | import {defineStore} from 'pinia' 3 | 4 | export const useAppStore = defineStore('app', { 5 | state: () => ({ 6 | // 7 | }), 8 | }) 9 | -------------------------------------------------------------------------------- /src/plugins/README.md: -------------------------------------------------------------------------------- 1 | # Plugins 2 | 3 | Plugins are a way to extend the functionality of your Vue application. Use this folder for registering plugins that you 4 | want to use globally. 5 | -------------------------------------------------------------------------------- /src/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 12 | -------------------------------------------------------------------------------- /src/stores/README.md: -------------------------------------------------------------------------------- 1 | # Store 2 | 3 | Pinia stores are used to store reactive state and expose actions to mutate it. 4 | 5 | Full documentation for this feature can be found in the Official [Pinia](https://pinia.esm.dev/) repository. 6 | -------------------------------------------------------------------------------- /src/pages/README.md: -------------------------------------------------------------------------------- 1 | # Pages 2 | 3 | Vue components created in this folder will automatically be converted to navigatable routes. 4 | 5 | Full documentation for this feature can be found in the 6 | Official [unplugin-vue-router](https://github.com/posva/unplugin-vue-router) repository. 7 | -------------------------------------------------------------------------------- /src/layouts/README.md: -------------------------------------------------------------------------------- 1 | # Layouts 2 | 3 | Layouts are reusable components that wrap around pages. They are used to provide a consistent look and feel across 4 | multiple pages. 5 | 6 | Full documentation for this feature can be found in the 7 | Official [vite-plugin-vue-layouts](https://github.com/JohnCampionJr/vite-plugin-vue-layouts) repository. 8 | -------------------------------------------------------------------------------- /src/components/AppHeader.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 16 | -------------------------------------------------------------------------------- /src/plugins/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * plugins/index.js 3 | * 4 | * Automatically included in `./src/main.js` 5 | */ 6 | 7 | // Plugins 8 | import vuetify from './vuetify' 9 | import pinia from '@/stores' 10 | import router from '@/router' 11 | 12 | export function registerPlugins(app) { 13 | app 14 | .use(vuetify) 15 | .use(router) 16 | .use(pinia) 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/defaults/defaultData.js: -------------------------------------------------------------------------------- 1 | export const defaultConfig = { 2 | studentList: [ 3 | "Classworks可以管理学生列表", 4 | '你可以点击设置,在其中找到"学生列表"', 5 | "在添加学生处输入学生姓名,点击添加", 6 | "或者点击高级编辑,从Excel表格中复制数据并粘贴进来", 7 | ], 8 | }; 9 | 10 | export const defaultHomework = { 11 | homework: {}, 12 | attendance: { 13 | absent: [], 14 | late: [], 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "target": "es5", 5 | "module": "esnext", 6 | "baseUrl": "./", 7 | "moduleResolution": "bundler", 8 | "paths": { 9 | "@/*": [ 10 | "src/*" 11 | ] 12 | }, 13 | "lib": [ 14 | "esnext", 15 | "dom", 16 | "dom.iterable", 17 | "scripthost" 18 | ] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": [ 3 | { 4 | "source": "/:path*", 5 | "destination": "/index.html" 6 | } 7 | ], 8 | "headers": [ 9 | { 10 | "source": "/:path*", 11 | "headers": [ 12 | { 13 | "key": "Cache-Control", 14 | "value": "no-cache, no-store, must-revalidate" 15 | } 16 | ] 17 | } 18 | ], 19 | "github": { 20 | "silent": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/plugins/vuetify.js: -------------------------------------------------------------------------------- 1 | /** 2 | * plugins/vuetify.js 3 | * 4 | * Framework documentation: https://vuetifyjs.com` 5 | */ 6 | 7 | // Styles 8 | import '@mdi/font/css/materialdesignicons.css' 9 | import 'vuetify/styles' 10 | 11 | // Composables 12 | import {createVuetify} from 'vuetify' 13 | 14 | // https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides 15 | export default createVuetify({ 16 | theme: { 17 | defaultTheme: 'dark', 18 | }, 19 | }) 20 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import pluginVue from 'eslint-plugin-vue' 3 | 4 | export default [ 5 | { 6 | name: 'app/files-to-lint', 7 | files: ['**/*.{js,mjs,jsx,vue}'], 8 | }, 9 | 10 | { 11 | name: 'app/files-to-ignore', 12 | ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**'], 13 | }, 14 | 15 | js.configs.recommended, 16 | ...pluginVue.configs['flat/recommended'], 17 | 18 | { 19 | rules: { 20 | 'vue/multi-word-component-names': 'off', 21 | }, 22 | } 23 | ] 24 | -------------------------------------------------------------------------------- /public/pwa/image/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/utils/debounce.js: -------------------------------------------------------------------------------- 1 | export function debounce(fn, delay) { 2 | let timer = null; 3 | return function (...args) { 4 | if (timer) clearTimeout(timer); 5 | timer = setTimeout(() => { 6 | fn.apply(this, args); 7 | }, delay); 8 | }; 9 | } 10 | 11 | export function throttle(fn, delay) { 12 | let timer = null; 13 | let last = 0; 14 | return function (...args) { 15 | const now = Date.now(); 16 | if (now - last < delay) { 17 | if (timer) clearTimeout(timer); 18 | timer = setTimeout(() => { 19 | last = now; 20 | fn.apply(this, args); 21 | }, delay); 22 | } else { 23 | last = now; 24 | fn.apply(this, args); 25 | } 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /src/styles/warnings.scss: -------------------------------------------------------------------------------- 1 | @mixin warning-card { 2 | &.warning { 3 | animation: pulse-warning 2s infinite; 4 | position: relative; 5 | 6 | &::before { 7 | content: ''; 8 | position: absolute; 9 | inset: -2px; 10 | border: 2px solid rgb(var(--v-theme-warning)); 11 | border-radius: inherit; 12 | animation: pulse-border 2s infinite; 13 | pointer-events: none; 14 | } 15 | } 16 | } 17 | 18 | @keyframes pulse-warning { 19 | 0%, 100% { 20 | transform: scale(1); 21 | } 22 | 50% { 23 | transform: scale(1.002); 24 | } 25 | } 26 | 27 | @keyframes pulse-border { 28 | 0%, 100% { 29 | opacity: 1; 30 | } 31 | 50% { 32 | opacity: 0.5; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/styles/glow.scss: -------------------------------------------------------------------------------- 1 | .glow-effect { 2 | transition: box-shadow 0.3s ease-in-out, transform 0.3s ease-in-out; 3 | 4 | &:hover { 5 | box-shadow: 0 0 15px rgba(var(--v-theme-primary), 0.5); 6 | transform: translateY(-2px); 7 | } 8 | } 9 | 10 | .glow-text { 11 | text-shadow: 0 0 5px rgba(var(--v-theme-primary), 0.5); 12 | } 13 | 14 | .bloom-container { 15 | .v-card { 16 | transition: box-shadow 0.3s ease; 17 | &:hover { 18 | box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1), 0 0 15px rgba(var(--v-theme-primary), 0.3) !important; 19 | } 20 | } 21 | 22 | .v-btn { 23 | transition: box-shadow 0.3s ease; 24 | &:hover { 25 | box-shadow: 0 0 10px rgba(var(--v-theme-primary), 0.4); 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/components/settings/cards/RefreshSettingsCard.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 29 | -------------------------------------------------------------------------------- /src/components/common/UnsavedWarning.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 26 | 27 | 48 | -------------------------------------------------------------------------------- /src/components/settings/cards/RandomPickerCard.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 29 | -------------------------------------------------------------------------------- /src/components/settings/cards/EditSettingsCard.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 35 | -------------------------------------------------------------------------------- /src/components/README.md: -------------------------------------------------------------------------------- 1 | # Components 2 | 3 | Vue template files in this folder are automatically imported. 4 | 5 | ## 🚀 Usage 6 | 7 | Importing is handled by [unplugin-vue-components](https://github.com/unplugin/unplugin-vue-components). This plugin 8 | automatically imports `.vue` files created in the `src/components` directory, and registers them as global components. 9 | This means that you can use any component in your application without having to manually import it. 10 | 11 | The following example assumes a component located at `src/components/MyComponent.vue`: 12 | 13 | ```vue 14 | 19 | 20 | 23 | ``` 24 | 25 | When your template is rendered, the component's import will automatically be inlined, which renders to this: 26 | 27 | ```vue 28 | 33 | 34 | 37 | ``` 38 | -------------------------------------------------------------------------------- /src/components/FloatingICP.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 20 | 21 | 54 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 28 | 45 | -------------------------------------------------------------------------------- /src/router/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * router/index.ts 3 | * 4 | * Automatic routes for `./src/pages/*.vue` 5 | */ 6 | 7 | // Composables 8 | import {createRouter, createWebHistory} from 'vue-router/auto' 9 | import {setupLayouts} from 'virtual:generated-layouts' 10 | import {routes} from 'vue-router/auto-routes' 11 | 12 | const router = createRouter({ 13 | history: createWebHistory(import.meta.env.BASE_URL), 14 | routes: setupLayouts(routes), 15 | }) 16 | 17 | // Workaround for https://github.com/vitejs/vite/issues/11804 18 | router.onError((err, to) => { 19 | if (err?.message?.includes?.('Failed to fetch dynamically imported module')) { 20 | if (!localStorage.getItem('vuetify:dynamic-reload')) { 21 | console.log('Reloading page to fix dynamic import error') 22 | localStorage.setItem('vuetify:dynamic-reload', 'true') 23 | location.assign(to.fullPath) 24 | } else { 25 | console.error('Dynamic import error, reloading page did not fix it', err) 26 | } 27 | } else { 28 | console.error(err) 29 | } 30 | }) 31 | 32 | router.isReady().then(() => { 33 | localStorage.removeItem('vuetify:dynamic-reload') 34 | }) 35 | 36 | export default router 37 | -------------------------------------------------------------------------------- /src/components/SettingsCard.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 49 | 50 | 55 | -------------------------------------------------------------------------------- /src/pages/debug.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 48 | -------------------------------------------------------------------------------- /src/styles/cards.scss: -------------------------------------------------------------------------------- 1 | // 触摸友好的卡片样式 2 | 3 | .touch-card { 4 | border-radius: 16px; 5 | overflow: hidden; 6 | transition: transform 0.3s ease, box-shadow 0.3s ease; 7 | 8 | .v-card-title { 9 | font-size: 1.25rem; 10 | padding: 16px 20px; 11 | } 12 | 13 | .v-card-text { 14 | padding: 16px 20px; 15 | } 16 | 17 | .v-card-actions { 18 | padding: 12px 20px; 19 | } 20 | 21 | &:active { 22 | transform: scale(0.98); 23 | } 24 | } 25 | 26 | // 卡片发光效果 27 | .glow-card { 28 | position: relative; 29 | overflow: hidden; 30 | 31 | &::after { 32 | content: ''; 33 | position: absolute; 34 | top: 0; 35 | left: 0; 36 | right: 0; 37 | bottom: 0; 38 | background: radial-gradient(circle at var(--x, 50%) var(--y, 50%), 39 | rgba(255, 255, 255, 0.2) 0%, 40 | rgba(255, 255, 255, 0) 60%); 41 | opacity: 0; 42 | transition: opacity 0.5s; 43 | pointer-events: none; 44 | } 45 | 46 | &:hover::after { 47 | opacity: 1; 48 | } 49 | } 50 | 51 | // 网格布局优化 52 | .grid-masonry { 53 | display: grid; 54 | grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); 55 | grid-auto-rows: 20px; 56 | grid-gap: 16px; 57 | } 58 | 59 | // 空科目网格 60 | .empty-subjects-grid { 61 | display: grid; 62 | grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); 63 | grid-gap: 16px; 64 | margin-top: 16px; 65 | } -------------------------------------------------------------------------------- /src/utils/api.js: -------------------------------------------------------------------------------- 1 | import axios from "@/axios/axios"; 2 | import {getSetting} from "@/utils/settings"; 3 | 4 | // Helper function to check if provider is valid for API calls 5 | const isValidProvider = () => { 6 | const provider = getSetting("server.provider"); 7 | return provider === "kv-server" || provider === "classworkscloud"; 8 | }; 9 | 10 | // Helper function to get request headers with kvtoken 11 | const getHeaders = () => { 12 | const headers = {Accept: "application/json"}; 13 | const kvToken = getSetting("server.kvToken"); 14 | const siteKey = getSetting("server.siteKey"); 15 | 16 | // 优先使用新的kvToken 17 | if (kvToken) { 18 | headers["x-app-token"] = kvToken; 19 | } else if (siteKey) { 20 | // 向后兼容旧的siteKey 21 | headers["x-site-key"] = siteKey; 22 | } 23 | 24 | return headers; 25 | }; 26 | 27 | /** 28 | * Get namespace info from the server 29 | * @returns {Promise} Response data containing namespace info 30 | */ 31 | export const getNamespaceInfo = async () => { 32 | if (!isValidProvider()) { 33 | throw new Error("当前数据提供者不支持此操作"); 34 | } 35 | 36 | const serverUrl = getSetting("server.domain"); 37 | 38 | try { 39 | const response = await axios.get(`${serverUrl}/kv/_info`, { 40 | headers: getHeaders(), 41 | }); 42 | 43 | return response.data; 44 | } catch (error) { 45 | throw new Error(error.response?.data?.message || "获取命名空间信息失败"); 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /src/utils/visitorId.js: -------------------------------------------------------------------------------- 1 | let fpPromise 2 | 3 | const buildFallbackAgent = (error) => ({ 4 | get: async () => ({ 5 | visitorId: 'unknown', 6 | error: error?.message || String(error || ''), 7 | fallback: true, 8 | }), 9 | }) 10 | 11 | const loadFingerprintLib = async () => { 12 | try { 13 | const mod = await import('@fingerprintjs/fingerprintjs') 14 | return mod?.default || mod 15 | } catch (err) { 16 | console.warn('Fingerprint library blocked or failed to load; using fallback agent.', err) 17 | return null 18 | } 19 | } 20 | 21 | export const loadFingerprint = () => { 22 | if (!fpPromise) { 23 | fpPromise = (async () => { 24 | const FingerprintJS = await loadFingerprintLib() 25 | if (!FingerprintJS) return buildFallbackAgent(new Error('fingerprint module unavailable')) 26 | 27 | try { 28 | return await FingerprintJS.load() 29 | } catch (err) { 30 | console.warn('FingerprintJS.load failed, using fallback agent.', err) 31 | return buildFallbackAgent(err) 32 | } 33 | })() 34 | } 35 | return fpPromise 36 | } 37 | 38 | export const getVisitorId = async () => { 39 | const fp = await loadFingerprint() 40 | const result = await fp.get() 41 | return result?.visitorId || 'unknown' 42 | } 43 | 44 | export const getFingerprintData = async () => { 45 | const fp = await loadFingerprint() 46 | const result = await fp.get() 47 | return result 48 | } 49 | -------------------------------------------------------------------------------- /src/components/auth/AlternativeCodeDialog.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 69 | -------------------------------------------------------------------------------- /src/styles/settings.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * src/styles/settings.scss 3 | * 4 | * Configures SASS variables and Vuetify overwrites 5 | */ 6 | 7 | // https://vuetifyjs.com/features/sass-variables/` 8 | // @use 'vuetify/settings' with ( 9 | // $color-pack: false 10 | // ); 11 | 12 | .student-card { 13 | transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); 14 | } 15 | 16 | .bg-primary-subtle { 17 | background-color: rgb(var(--v-theme-primary), 0.05); 18 | } 19 | 20 | .action-buttons { 21 | transition: opacity 0.2s ease; 22 | opacity: 0; 23 | } 24 | 25 | .gap-1 { 26 | gap: 4px; 27 | } 28 | 29 | .gap-2 { 30 | gap: 8px; 31 | } 32 | 33 | .student-card .v-text-field { 34 | margin: 0; 35 | padding: 0; 36 | } 37 | 38 | @media (max-width: 600px) { 39 | .v-container { 40 | padding: 12px; 41 | } 42 | 43 | .v-col { 44 | padding: 8px; 45 | } 46 | } 47 | 48 | .student-card.mobile { 49 | margin-bottom: 8px; 50 | } 51 | 52 | .student-card.mobile .v-btn { 53 | min-width: 40px; 54 | min-height: 40px; 55 | } 56 | 57 | .student-card.mobile .v-text-field { 58 | font-size: 16px; 59 | } 60 | 61 | @media (max-width: 600px) { 62 | .v-col { 63 | padding: 6px !important; 64 | } 65 | 66 | .student-card { 67 | margin-bottom: 4px; 68 | } 69 | 70 | .action-buttons { 71 | opacity: 1; 72 | } 73 | } 74 | 75 | .student-card { 76 | -webkit-tap-highlight-color: transparent; 77 | } 78 | 79 | .student-card:active { 80 | background-color: rgb(var(--v-theme-primary), 0.05); 81 | } -------------------------------------------------------------------------------- /src/components/settings/cards/DisplaySettingsCard.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 53 | -------------------------------------------------------------------------------- /src/components/settings/SettingGroup.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 75 | 76 | 81 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Classworks 2 | 3 | ![GitHub](https://img.shields.io/github/license/ZeroCatDev/Classworks?style=flat-square) 4 | ![GitHub stars](https://img.shields.io/github/stars/ZeroCatDev/Classworks?style=flat-square) 5 | ![GitHub forks](https://img.shields.io/github/forks/ZeroCatDev/Classworks?style=flat-square) 6 | ![GitHub issues](https://img.shields.io/github/issues/ZeroCatDev/Classworks?style=flat-square) 7 | ![Classworks](./images/banner.png) 8 | 9 | 适用于班级大屏的作业板小工具 10 | 11 | 请打开 [https://cs.houlangs.com](https://cs.houlangs.com) 立刻使用 12 | 13 | ## 交流 14 | 15 | QQ:[964979747](https://qm.qq.com/q/4RX45b1Oac) 16 | 17 | ## 📦 快速开始 18 | 19 | ### 环境准备 20 | 21 | - Node.js 16+ 22 | - pnpm 23 | 24 | ### 安装步骤 25 | 26 | ```bash 27 | # 克隆项目 28 | git clone https://github.com:ZeroCatDev/Classworks.git 29 | cd Classworks 30 | 31 | # 安装依赖 32 | pnpm install 33 | 34 | # 启动开发服务器 35 | pnpm run dev 36 | 37 | # 构建生产版本 38 | pnpm run build 39 | ``` 40 | 41 | 42 | ## 🤝 参与贡献 43 | 44 | Classworks 非常欢迎你的加入![提一个 Issue](https://github.com/ZeroCatDev/Classworks/issues/new) 或者提交一个 Pull Request。对于小白问题,最好在 qq 群里问,我们会尽量回答。 45 | 46 | ZeroCat 的项目 遵循 [Contributor Covenant](http://contributor-covenant.org/version/1/3/0/) 行为规范 47 |
孙悟元 希望你遵循 [提问的智慧](https://github.com/ryanhanwu/How-To-Ask-Questions-The-Smart-Way/blob/main/README-zh_CN.md) 48 | 49 | ## 👥 联系我们 50 | 51 | - QQ交流群:964979747 52 | - 开发者:[@SunWuyuan](https://github.com/Sunwuyuan) 53 | - 官网:[ZeroCat](https://zerocat.dev) 54 | 55 | ## 🙏 致谢 56 | 57 | 感谢所有为项目做出贡献的开发者! 58 | 59 | ## 📄 开源协议 60 | 61 | ZeroCat 社区项目遵循 [AGPL-3.0 许可证](LICENSE)。 62 | 63 | 64 | 版权所有 (C) 2020-2024 孙悟元。 65 | Copyright (C) 2020-2024 Sun Wuyuan. 66 | -------------------------------------------------------------------------------- /public/sw-cache-manager.js: -------------------------------------------------------------------------------- 1 | // 添加缓存管理消息处理 2 | self.addEventListener('message', (event) => { 3 | if (event.data && event.data.type === 'CACHE_KEYS') { 4 | // 获取所有缓存键 5 | caches.keys().then((cacheNames) => { 6 | event.ports[0].postMessage({ cacheNames }); 7 | }); 8 | } else if (event.data && event.data.type === 'CACHE_CONTENT') { 9 | // 获取特定缓存的内容 10 | const cacheName = event.data.cacheName; 11 | caches.open(cacheName).then((cache) => { 12 | cache.keys().then((requests) => { 13 | const urls = requests.map(request => request.url); 14 | event.ports[0].postMessage({ cacheName, urls }); 15 | }); 16 | }); 17 | } else if (event.data && event.data.type === 'CLEAR_CACHE') { 18 | // 清除特定缓存 19 | const cacheName = event.data.cacheName; 20 | caches.delete(cacheName).then((success) => { 21 | event.ports[0].postMessage({ success, cacheName }); 22 | }); 23 | } else if (event.data && event.data.type === 'CLEAR_URL') { 24 | // 清除特定URL的缓存 25 | const cacheName = event.data.cacheName; 26 | const url = event.data.url; 27 | caches.open(cacheName).then((cache) => { 28 | cache.delete(url).then((success) => { 29 | event.ports[0].postMessage({ success, cacheName, url }); 30 | }); 31 | }); 32 | } else if (event.data && event.data.type === 'CLEAR_ALL_CACHES') { 33 | // 清除所有缓存 34 | caches.keys().then((cacheNames) => { 35 | Promise.all( 36 | cacheNames.map(name => caches.delete(name)) 37 | ).then(() => { 38 | event.ports[0].postMessage({ success: true }); 39 | }); 40 | }); 41 | } 42 | }); 43 | 44 | console.log('Cache Manager extension loaded'); -------------------------------------------------------------------------------- /src/components/RelativeTimeDisplay.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 57 | -------------------------------------------------------------------------------- /src/components/settings/cards/ThemeSettingsCard.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 65 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | workflow_dispatch: 7 | 8 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 9 | permissions: 10 | contents: write 11 | pages: write 12 | id-token: write 13 | 14 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 15 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 16 | concurrency: 17 | group: "pages" 18 | cancel-in-progress: false 19 | jobs: 20 | build: 21 | runs-on: ubuntu-latest 22 | environment: 23 | name: github-pages 24 | url: ${{ steps.deployment.outputs.page_url }} 25 | steps: 26 | - uses: actions/checkout@v4 27 | 28 | - name: Use Node.js 20 29 | 30 | uses: actions/setup-node@v3 31 | with: 32 | node-version: "20.x" 33 | 34 | - name: Build 35 | env: 36 | VITE_APP_ID: d158067f53627d2b98babe8bffd2fd7d 37 | VITE_DEFAULT_KV_SERVER: https://kv.wuyuan.dev 38 | VITE_DEFAULT_AUTH_SERVER: https://kv.houlang.cloud 39 | run: | 40 | npm install 41 | npm run build 42 | - name: Setup Pages 43 | uses: actions/configure-pages@v5 44 | - name: Upload artifact 45 | uses: actions/upload-pages-artifact@v3 46 | with: 47 | # Upload entire repository 48 | path: "./dist" 49 | - name: Deploy to GitHub Pages 50 | id: deployment 51 | uses: actions/deploy-pages@v4 52 | 53 | - name: Deploy 54 | uses: peaceiris/actions-gh-pages@v4 55 | with: 56 | github_token: ${{ secrets.GITHUB_TOKEN }} 57 | publish_dir: ./dist 58 | publish_branch: build 59 | -------------------------------------------------------------------------------- /src/components/GlobalMessage.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 63 | 64 | 67 | -------------------------------------------------------------------------------- /src/pages/exam-editor/[id].vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "classworks", 3 | "private": true, 4 | "type": "module", 5 | "version": "0.0.0", 6 | "scripts": { 7 | "dev": "vite --host", 8 | "build": "vite build", 9 | "preview": "vite preview", 10 | "lint": "eslint . --fix" 11 | }, 12 | "dependencies": { 13 | "@examaware-cs/core": "^1.0.0", 14 | "@examaware-cs/player": "^1.0.2", 15 | "@fingerprintjs/fingerprintjs": "^5.0.1", 16 | "@mdi/font": "7.4.47", 17 | "@microsoft/clarity": "^1.0.2", 18 | "@vueuse/core": "^14.1.0", 19 | "axios": "^1.13.2", 20 | "idb": "^8.0.3", 21 | "js-base64": "^3.7.8", 22 | "js-yaml": "^4.1.1", 23 | "lucide-vue-next": "^0.555.0", 24 | "marked": "^17.0.1", 25 | "pinyin-pro": "^3.27.0", 26 | "ratelimit-header-parser": "^0.1.0", 27 | "roboto-fontface": "*", 28 | "socket.io-client": "^4.8.1", 29 | "typewriter-effect": "^2.22.0", 30 | "uuid": "^13.0.0", 31 | "vue": "^3.5.25", 32 | "vue-sonner": "^2.0.9", 33 | "vuetify": "^3.11.0" 34 | }, 35 | "devDependencies": { 36 | "@eslint/js": "^9.39.1", 37 | "@vite-pwa/assets-generator": "^1.0.2", 38 | "@vitejs/plugin-vue": "^6.0.2", 39 | "eslint": "^9.39.1", 40 | "eslint-plugin-import": "^2.32.0", 41 | "eslint-plugin-n": "^17.23.1", 42 | "eslint-plugin-node": "^11.1.0", 43 | "eslint-plugin-promise": "^7.2.1", 44 | "eslint-plugin-vue": "^10.6.2", 45 | "pinia": "^3.0.4", 46 | "sass": "1.94.2", 47 | "sass-embedded": "^1.93.3", 48 | "unplugin-auto-import": "^20.3.0", 49 | "unplugin-fonts": "^1.4.0", 50 | "unplugin-vue-components": "^30.0.0", 51 | "unplugin-vue-router": "^0.18.0", 52 | "vite": "^5.4.11", 53 | "vite-plugin-pwa": "^1.2.0", 54 | "vite-plugin-vue-devtools": "^7.6.8", 55 | "vite-plugin-vue-layouts": "^0.11.0", 56 | "vite-plugin-vuetify": "^2.1.2", 57 | "vue-router": "^4.6.3" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/styles/global.scss: -------------------------------------------------------------------------------- 1 | // 全局 UI 美化样式 2 | 3 | // 卡片悬浮效果 4 | .hover-card { 5 | transition: transform 0.2s ease, box-shadow 0.3s ease; 6 | will-change: transform, box-shadow; 7 | 8 | &:hover, &:focus { 9 | transform: translateY(-4px); 10 | box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15) !important; 11 | } 12 | 13 | &:active { 14 | transform: translateY(-2px); 15 | box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1) !important; 16 | } 17 | } 18 | 19 | // 触摸友好的按钮 20 | .touch-button { 21 | min-height: 48px; 22 | min-width: 48px; 23 | border-radius: 12px; 24 | padding: 12px 24px; 25 | 26 | &.v-btn--icon { 27 | min-height: 56px; 28 | min-width: 56px; 29 | } 30 | } 31 | 32 | // 波纹效果增强 33 | .ripple-enhanced { 34 | position: relative; 35 | overflow: hidden; 36 | 37 | &::after { 38 | content: ''; 39 | position: absolute; 40 | top: 0; 41 | left: 0; 42 | right: 0; 43 | bottom: 0; 44 | background: radial-gradient(circle at var(--x, 50%) var(--y, 50%), 45 | rgba(255, 255, 255, 0.2) 0%, 46 | rgba(255, 255, 255, 0) 60%); 47 | opacity: 0; 48 | transition: opacity 0.5s; 49 | pointer-events: none; 50 | } 51 | 52 | &:active::after { 53 | opacity: 1; 54 | transition: opacity 0.2s; 55 | } 56 | } 57 | 58 | // 平滑滚动 59 | html { 60 | scroll-behavior: smooth; 61 | } 62 | 63 | .v-app-bar { 64 | position: fixed !important; 65 | } 66 | 67 | // 触摸友好的列表项 68 | .touch-list-item { 69 | min-height: 56px; 70 | padding: 12px 16px; 71 | } 72 | 73 | // 大型触摸目标 74 | .large-touch-target { 75 | min-height: 56px; 76 | min-width: 56px; 77 | } 78 | 79 | // 全屏模式样式 80 | .fullscreen-mode { 81 | .v-app-bar { 82 | background-color: rgba(var(--v-theme-surface-variant), 0.85) !important; 83 | -webkit-backdrop-filter: blur(10px); 84 | backdrop-filter: blur(10px); 85 | } 86 | 87 | .main-window { 88 | padding-top: 16px; 89 | padding-bottom: 16px; 90 | } 91 | } -------------------------------------------------------------------------------- /src/axios/axios.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import {getSetting} from "@/utils/settings"; 3 | import {parseRateLimit} from "ratelimit-header-parser"; 4 | import RateLimitModal from "@/components/RateLimitModal.vue"; 5 | import {Base64} from "js-base64"; 6 | 7 | // 基本配置 8 | const axiosInstance = axios.create({ 9 | // 可以在这里添加基础配置,例如超时时间等 10 | timeout: 10000, 11 | }); 12 | 13 | // 请求拦截器 14 | axiosInstance.interceptors.request.use( 15 | (requestConfig) => { 16 | const provider = getSetting("server.provider"); 17 | 18 | // 只有在 kv-server 或 classworkscloud 模式下才添加请求头 19 | if (provider === "kv-server" || provider === "classworkscloud") { 20 | // 优先使用新的 kvToken 21 | const kvToken = getSetting("server.kvToken"); 22 | if (kvToken) { 23 | requestConfig.headers["x-app-token"] = kvToken; 24 | } else { 25 | // 向后兼容旧的 siteKey 26 | const siteKey = getSetting("server.siteKey"); 27 | if (siteKey) { 28 | requestConfig.headers["x-site-key"] = Base64.encode(siteKey); 29 | } 30 | } 31 | } 32 | 33 | return requestConfig; 34 | }, 35 | (error) => { 36 | console.log(error); 37 | return Promise.reject(error); 38 | } 39 | ); 40 | 41 | // 响应拦截器 42 | axiosInstance.interceptors.response.use( 43 | (response) => { 44 | return response; 45 | }, 46 | (error) => { 47 | // 处理限速响应 (HTTP 429) 48 | if (error.response && error.response.status === 429) { 49 | try { 50 | // 解析限速头信息 51 | const rateLimitInfo = parseRateLimit(error.response); 52 | 53 | if (rateLimitInfo) { 54 | // 显示限速弹窗,直接传递重置时间 55 | RateLimitModal.show( 56 | rateLimitInfo.reset, 57 | error.config.url, 58 | error.config.method.toUpperCase() 59 | ); 60 | } 61 | } catch (parseError) { 62 | console.error("解析限速头信息失败:", parseError); 63 | } 64 | } 65 | 66 | return Promise.reject(error); 67 | } 68 | ); 69 | 70 | export default axiosInstance; 71 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | /** 2 | * main.js 3 | * 4 | * Bootstraps Vuetify and other plugins then mounts the App` 5 | */ 6 | 7 | // Plugins 8 | import {registerPlugins} from '@/plugins' 9 | import {createPinia} from 'pinia' 10 | 11 | const pinia = createPinia() 12 | 13 | // Components 14 | import App from './App.vue' 15 | import GlobalMessage from '@/components/GlobalMessage.vue' 16 | 17 | // Composables 18 | import {createApp} from 'vue' 19 | //import TDesign from 'tdesign-vue-next' 20 | //import 'tdesign-vue-next/es/style/index.css' 21 | //import '@examaware-cs/player/dist/player.css' 22 | 23 | import messageService from './utils/message'; 24 | import { getVisitorId } from './utils/visitorId'; 25 | 26 | const app = createApp(App) 27 | 28 | registerPlugins(app) 29 | //app.use(TDesign) 30 | app.use(messageService); 31 | app.use(pinia) 32 | 33 | app.component('GlobalMessage', GlobalMessage) 34 | 35 | app.mount('#app') 36 | 37 | // 异步加载 Clarity 以提升初始加载速度 38 | if (document.readyState === 'complete') { 39 | loadClarity(); 40 | } else { 41 | window.addEventListener('load', loadClarity, { once: true }); 42 | } 43 | 44 | async function loadClarity() { 45 | try { 46 | const Clarity = (await import('@microsoft/clarity')).default; 47 | const projectId = "rhp8uqoc3l"; 48 | Clarity.init(projectId); 49 | 50 | // 获取并设置访客标识 51 | const visitorId = await getVisitorId(); 52 | console.log('Visitor ID:', visitorId); 53 | Clarity.identify(visitorId); 54 | Clarity.setTag('fingerprintjs', visitorId); 55 | } catch (error) { 56 | console.warn('Clarity 加载或标识设置失败:', error); 57 | } 58 | } 59 | 60 | // 移除首屏 CSS 加载覆盖层(在 Vue 挂载完成后) 61 | try { 62 | const removeLoader = () => { 63 | document.body.classList.add('app-loaded'); 64 | const el = document.getElementById('app-loader'); 65 | if (!el) return; 66 | // 与 CSS 过渡对齐,稍等再移除节点,避免闪烁 67 | setTimeout(() => el.remove(), 220); 68 | }; 69 | if (document.readyState === 'complete' || document.readyState === 'interactive') { 70 | removeLoader(); 71 | } else { 72 | window.addEventListener('DOMContentLoaded', removeLoader, {once: true}); 73 | } 74 | } catch { 75 | // 安全失败:即便移除失败也不影响应用 76 | } 77 | -------------------------------------------------------------------------------- /src/components/MessageLog.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 77 | -------------------------------------------------------------------------------- /.github/workflows/azure-static-web-apps-icy-river-041d8ab00.yml: -------------------------------------------------------------------------------- 1 | name: Azure Static Web Apps CI/CD 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | types: [opened, synchronize, reopened, closed] 9 | branches: 10 | - main 11 | 12 | jobs: 13 | build_and_deploy_job: 14 | if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed') 15 | runs-on: ubuntu-latest 16 | name: Build and Deploy Job 17 | permissions: 18 | id-token: write 19 | contents: read 20 | steps: 21 | - uses: actions/checkout@v3 22 | with: 23 | submodules: true 24 | lfs: false 25 | 26 | - name: Install OIDC Client from Core Package 27 | run: npm install @actions/core@1.6.0 @actions/http-client 28 | 29 | - name: Get Id Token 30 | uses: actions/github-script@v6 31 | id: idtoken 32 | with: 33 | script: | 34 | const coredemo = require('@actions/core') 35 | return await coredemo.getIDToken() 36 | result-encoding: string 37 | 38 | - name: Build And Deploy 39 | id: builddeploy 40 | uses: Azure/static-web-apps-deploy@v1 41 | env: 42 | NODE_VERSION: 20 # 👈 Force Node.js 20.x instead of Oryx default (18.x) 43 | with: 44 | azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN_ICY_RIVER_041D8AB00 }} 45 | action: "upload" 46 | ###### Repository/Build Configurations ###### 47 | app_location: "/" 48 | api_location: "" 49 | output_location: "dist" 50 | github_id_token: ${{ steps.idtoken.outputs.result }} 51 | ###### End of Repository/Build Configurations ###### 52 | 53 | close_pull_request_job: 54 | if: github.event_name == 'pull_request' && github.event.action == 'closed' 55 | runs-on: ubuntu-latest 56 | name: Close Pull Request Job 57 | steps: 58 | - name: Close Pull Request 59 | id: closepullrequest 60 | uses: Azure/static-web-apps-deploy@v1 61 | with: 62 | action: "close" -------------------------------------------------------------------------------- /.github/workflows/claude.yml: -------------------------------------------------------------------------------- 1 | name: Claude Code 2 | 3 | on: 4 | issue_comment: 5 | types: [created] 6 | pull_request_review_comment: 7 | types: [created] 8 | issues: 9 | types: [opened, assigned] 10 | pull_request_review: 11 | types: [submitted] 12 | 13 | jobs: 14 | claude: 15 | if: | 16 | (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || 17 | (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || 18 | (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || 19 | (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) 20 | runs-on: ubuntu-latest 21 | permissions: 22 | contents: read 23 | pull-requests: read 24 | issues: read 25 | id-token: write 26 | actions: read # Required for Claude to read CI results on PRs 27 | steps: 28 | - name: Checkout repository 29 | uses: actions/checkout@v4 30 | with: 31 | fetch-depth: 1 32 | 33 | - name: Run Claude Code 34 | id: claude 35 | uses: anthropics/claude-code-action@v1 36 | env: 37 | ANTHROPIC_BASE_URL: ${{ vars.ANTHROPIC_BASE_URL }} 38 | with: 39 | anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} 40 | 41 | # This is an optional setting that allows Claude to read CI results on PRs 42 | additional_permissions: | 43 | actions: read 44 | 45 | # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. 46 | # prompt: 'Update the pull request description to include a summary of changes.' 47 | 48 | # Optional: Add claude_args to customize behavior and configuration 49 | # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md 50 | # or https://docs.claude.com/en/docs/claude-code/sdk#command-line for available options 51 | # claude_args: '--model claude-opus-4-1-20250805 --allowed-tools Bash(gh pr:*)' 52 | 53 | -------------------------------------------------------------------------------- /src/pages/authorize.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 75 | -------------------------------------------------------------------------------- /.github/workflows/claude-code-review.yml: -------------------------------------------------------------------------------- 1 | name: Claude Code Review 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize] 6 | # Optional: Only run on specific file changes 7 | # paths: 8 | # - "src/**/*.ts" 9 | # - "src/**/*.tsx" 10 | # - "src/**/*.js" 11 | # - "src/**/*.jsx" 12 | 13 | jobs: 14 | claude-review: 15 | # Optional: Filter by PR author 16 | # if: | 17 | # github.event.pull_request.user.login == 'external-contributor' || 18 | # github.event.pull_request.user.login == 'new-developer' || 19 | # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' 20 | 21 | runs-on: ubuntu-latest 22 | permissions: 23 | contents: read 24 | pull-requests: read 25 | issues: read 26 | id-token: write 27 | 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v4 31 | with: 32 | fetch-depth: 1 33 | 34 | - name: Run Claude Code Review 35 | id: claude-review 36 | uses: anthropics/claude-code-action@v1 37 | env: 38 | ANTHROPIC_BASE_URL: ${{ vars.ANTHROPIC_BASE_URL }} 39 | with: 40 | anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} 41 | prompt: | 42 | Please review this pull request and provide feedback on: 43 | - Code quality and best practices 44 | - Potential bugs or issues 45 | - Performance considerations 46 | - Security concerns 47 | - Test coverage 48 | 49 | Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback. 50 | 51 | Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR. 52 | 53 | When commenting, always use Chinese for description. 54 | # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md 55 | # or https://docs.claude.com/en/docs/claude-code/sdk#command-line for available options 56 | claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' 57 | 58 | -------------------------------------------------------------------------------- /src/pages/CacheManagement.vue: -------------------------------------------------------------------------------- 1 | 56 | 57 | 70 | -------------------------------------------------------------------------------- /.eslintrc-auto-import.json: -------------------------------------------------------------------------------- 1 | { 2 | "globals": { 3 | "Component": true, 4 | "ComponentPublicInstance": true, 5 | "ComputedRef": true, 6 | "DirectiveBinding": true, 7 | "EffectScope": true, 8 | "ExtractDefaultPropTypes": true, 9 | "ExtractPropTypes": true, 10 | "ExtractPublicPropTypes": true, 11 | "InjectionKey": true, 12 | "MaybeRef": true, 13 | "MaybeRefOrGetter": true, 14 | "PropType": true, 15 | "Ref": true, 16 | "ShallowRef": true, 17 | "Slot": true, 18 | "Slots": true, 19 | "VNode": true, 20 | "WritableComputedRef": true, 21 | "computed": true, 22 | "createApp": true, 23 | "customRef": true, 24 | "defineAsyncComponent": true, 25 | "defineComponent": true, 26 | "effectScope": true, 27 | "getCurrentInstance": true, 28 | "getCurrentScope": true, 29 | "getCurrentWatcher": true, 30 | "h": true, 31 | "inject": true, 32 | "isProxy": true, 33 | "isReactive": true, 34 | "isReadonly": true, 35 | "isRef": true, 36 | "isShallow": true, 37 | "markRaw": true, 38 | "nextTick": true, 39 | "onActivated": true, 40 | "onBeforeMount": true, 41 | "onBeforeRouteLeave": true, 42 | "onBeforeRouteUpdate": true, 43 | "onBeforeUnmount": true, 44 | "onBeforeUpdate": true, 45 | "onDeactivated": true, 46 | "onErrorCaptured": true, 47 | "onMounted": true, 48 | "onRenderTracked": true, 49 | "onRenderTriggered": true, 50 | "onScopeDispose": true, 51 | "onServerPrefetch": true, 52 | "onUnmounted": true, 53 | "onUpdated": true, 54 | "onWatcherCleanup": true, 55 | "provide": true, 56 | "reactive": true, 57 | "readonly": true, 58 | "ref": true, 59 | "resolveComponent": true, 60 | "shallowReactive": true, 61 | "shallowReadonly": true, 62 | "shallowRef": true, 63 | "toRaw": true, 64 | "toRef": true, 65 | "toRefs": true, 66 | "toValue": true, 67 | "triggerRef": true, 68 | "unref": true, 69 | "useAttrs": true, 70 | "useCssModule": true, 71 | "useCssVars": true, 72 | "useId": true, 73 | "useLink": true, 74 | "useModel": true, 75 | "useRoute": true, 76 | "useRouter": true, 77 | "useSlots": true, 78 | "useTemplateRef": true, 79 | "watch": true, 80 | "watchEffect": true, 81 | "watchPostEffect": true, 82 | "watchSyncEffect": true 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/components/error/404.vue: -------------------------------------------------------------------------------- 1 | 88 | 89 | 92 | -------------------------------------------------------------------------------- /src/stores/examStore.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import dataProvider from '@/utils/dataProvider' 3 | 4 | export const useExamStore = defineStore('exam', { 5 | state: () => ({ 6 | examList: [], // List of exam IDs 7 | exams: {}, // Map of ID -> Exam Details 8 | loadingList: false, 9 | loadingDetails: {}, // Map of ID -> boolean 10 | }), 11 | 12 | actions: { 13 | async fetchExamList() { 14 | if (this.loadingList) return 15 | this.loadingList = true 16 | try { 17 | const response = await dataProvider.loadData('es_list') 18 | if (Array.isArray(response)) { 19 | this.examList = response 20 | } else { 21 | this.examList = [] 22 | } 23 | } catch (error) { 24 | console.error('Failed to load exam list:', error) 25 | } finally { 26 | this.loadingList = false 27 | } 28 | }, 29 | 30 | async fetchExam(id) { 31 | if (this.exams[id]) return this.exams[id] // Return cached if available 32 | if (this.loadingDetails[id]) return // Prevent duplicate requests 33 | 34 | this.loadingDetails[id] = true 35 | try { 36 | const response = await dataProvider.loadData(`es_${id}`) 37 | if (response) { 38 | this.exams[id] = response 39 | } 40 | return response 41 | } catch (error) { 42 | console.error(`Failed to load exam details for ${id}:`, error) 43 | } finally { 44 | this.loadingDetails[id] = false 45 | } 46 | }, 47 | 48 | async getUpcomingExams(limit = 25) { 49 | await this.fetchExamList() 50 | 51 | const upcoming = [] 52 | const now = new Date() 53 | const twoDaysLater = new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000) 54 | 55 | // Process up to 'limit' exams from the list 56 | const examsToCheck = this.examList.slice(0, limit) 57 | 58 | for (const item of examsToCheck) { 59 | let exam = this.exams[item.id] 60 | if (!exam) { 61 | exam = await this.fetchExam(item.id) 62 | } 63 | 64 | if (exam && exam.examInfos && Array.isArray(exam.examInfos)) { 65 | // Check if any subject in this exam starts within the next 2 days 66 | const hasUpcoming = exam.examInfos.some(info => { 67 | const start = new Date(info.start) 68 | return start >= now && start <= twoDaysLater 69 | }) 70 | 71 | if (hasUpcoming) { 72 | upcoming.push({ id: item.id, ...exam }) 73 | } 74 | } 75 | } 76 | 77 | return upcoming 78 | } 79 | } 80 | }) 81 | -------------------------------------------------------------------------------- /src/components/auth/TokenInputDialog.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 104 | -------------------------------------------------------------------------------- /src/utils/socketClient.js: -------------------------------------------------------------------------------- 1 | // Lightweight reusable Socket.IO client singleton 2 | // - Uses server domain from settings when available 3 | // - Exposes join/leave helpers and event on/off wrappers 4 | 5 | import {io} from 'socket.io-client'; 6 | import {getSetting} from '@/utils/settings'; 7 | 8 | let socket = null; 9 | let connectedDomain = null; 10 | const listeners = new Set(); 11 | 12 | export function getServerUrl() { 13 | // Prefer configured server domain; fallback to env; then current origin 14 | const cfg = getSetting('server.domain'); 15 | const envUrl = import.meta?.env?.VITE_SERVER_URL; 16 | return cfg || envUrl || window.location.origin; 17 | } 18 | 19 | export function getSocket() { 20 | const serverUrl = getServerUrl(); 21 | if (!socket || connectedDomain !== serverUrl) { 22 | if (socket) { 23 | try { 24 | socket.disconnect(); 25 | } catch (e) { 26 | void e; // ignore 27 | } 28 | socket = null; 29 | } 30 | connectedDomain = serverUrl; 31 | socket = io(serverUrl, {transports: ["polling","websocket"]}); 32 | 33 | // Re-attach previously registered event handlers on new socket instance 34 | listeners.forEach(({event, handler}) => { 35 | socket.on(event, handler); 36 | }); 37 | } 38 | return socket; 39 | } 40 | 41 | export function on(event, handler) { 42 | const s = getSocket(); 43 | s.on(event, handler); 44 | listeners.add({event, handler}); 45 | return () => off(event, handler); 46 | } 47 | 48 | export function off(event, handler) { 49 | if (!socket) return; 50 | socket.off(event, handler); 51 | // Remove only matching entry 52 | for (const item of Array.from(listeners)) { 53 | if (item.event === event && item.handler === handler) { 54 | listeners.delete(item); 55 | } 56 | } 57 | } 58 | 59 | export function joinToken(token) { 60 | const s = getSocket(); 61 | if (!token) return; 62 | s.emit('join-token', {token}); 63 | } 64 | 65 | export function leaveToken(token) { 66 | if (!socket) return; 67 | socket.emit('leave-token', {token}); 68 | } 69 | 70 | export function leaveAll() { 71 | if (!socket) return; 72 | socket.emit('leave-all'); 73 | } 74 | 75 | export function onConnect(handler) { 76 | const s = getSocket(); 77 | s.on('connect', handler); 78 | return () => s.off('connect', handler); 79 | } 80 | 81 | export function sendEvent(type, content = null) { 82 | const s = getSocket(); 83 | s.emit('send-event', { 84 | type, 85 | content 86 | }); 87 | } 88 | 89 | export function disconnect() { 90 | if (!socket) return; 91 | try { 92 | socket.disconnect(); 93 | } catch (e) { 94 | void e; // ignore 95 | } 96 | socket = null; 97 | connectedDomain = null; 98 | listeners.clear(); 99 | } 100 | -------------------------------------------------------------------------------- /src/components/auth/README.md: -------------------------------------------------------------------------------- 1 | # 认证组件 2 | 3 | 这个目录包含可复用的认证相关组件,可以在应用的任何地方使用。 4 | 5 | ## 组件列表 6 | 7 | ### DeviceAuthDialog.vue 8 | 9 | 设备认证对话框,用于通过 namespace 和密码进行设备认证。 10 | 11 | **Props:** 12 | 13 | - `showCancel` (Boolean): 是否显示取消按钮,默认为 `false` 14 | 15 | **Events:** 16 | 17 | - `@success`: 认证成功时触发,传递认证数据 18 | - `@cancel`: 点击取消按钮时触发 19 | 20 | **暴露的方法:** 21 | 22 | - `reset()`: 清空表单和错误信息 23 | 24 | **使用示例:** 25 | 26 | ```vue 27 | 36 | 37 | 40 | ``` 41 | 42 | --- 43 | 44 | ### TokenInputDialog.vue 45 | 46 | Token 输入对话框,用于手动输入 KV 授权 Token。 47 | 48 | **Props:** 49 | 50 | - `showCancel` (Boolean): 是否显示取消按钮,默认为 `false` 51 | 52 | **Events:** 53 | 54 | - `@success`: Token 验证成功时触发 55 | - `@cancel`: 点击取消按钮时触发 56 | 57 | **暴露的方法:** 58 | 59 | - `reset()`: 清空表单和错误信息 60 | 61 | **使用示例:** 62 | 63 | ```vue 64 | 73 | 74 | 77 | ``` 78 | 79 | --- 80 | 81 | ### AlternativeCodeDialog.vue 82 | 83 | 替代代码输入对话框(功能暂未实现)。 84 | 85 | **Props:** 86 | 87 | - `showCancel` (Boolean): 是否显示取消按钮,默认为 `false` 88 | 89 | **Events:** 90 | 91 | - `@submit`: 提交代码时触发,传递代码内容 92 | - `@cancel`: 点击取消按钮时触发 93 | 94 | **暴露的方法:** 95 | 96 | - `reset()`: 清空表单 97 | 98 | **使用示例:** 99 | 100 | ```vue 101 | 110 | 111 | 114 | ``` 115 | 116 | --- 117 | 118 | ### FirstTimeGuide.vue 119 | 120 | 初次使用指南,介绍 Classworks KV 的功能和使用方式。 121 | 122 | **Events:** 123 | 124 | - `@close`: 关闭指南时触发 125 | 126 | **使用示例:** 127 | 128 | ```vue 129 | 134 | 135 | 138 | ``` 139 | 140 | ## 设计原则 141 | 142 | 1. **可复用性**: 所有组件都被设计为独立可复用的,可以在应用的任何地方使用 143 | 2. **独立性**: 每个组件都包含自己的逻辑和样式,不依赖外部状态 144 | 3. **统一接口**: 所有对话框组件都遵循相同的 props 和 events 模式 145 | 4. **响应式设计**: 组件适配各种屏幕尺寸 146 | 147 | ## 注意事项 148 | 149 | - 这些组件需要配合 Vuetify 使用 150 | - 组件内部使用了 `@/utils/settings` 和 `@/axios/axios`,确保这些依赖可用 151 | - 建议将这些组件包裹在 `v-dialog` 中使用,以获得最佳的用户体验 152 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Classworks 作业板 8 | 9 | 10 | 11 | 12 | 71 | 72 | 73 | 74 |
75 |
76 |
77 |
78 |
79 |
80 | 81 | xICP备x号-4 82 | 83 | 84 | -------------------------------------------------------------------------------- /src/components/ReadOnlyTokenWarning.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 119 | 120 | 126 | -------------------------------------------------------------------------------- /UNIFIED_LINK_GENERATOR.md: -------------------------------------------------------------------------------- 1 | # 统一链接生成器功能测试 2 | 3 | ## 功能概述 4 | 5 | 新的统一链接生成器将预配置认证信息和设置分享功能合并到一个链接中,用户可以: 6 | 7 | 1. 输入命名空间和认证码(预配置认证) 8 | 2. 选择需要分享的设置项 9 | 3. 生成包含两种信息的统一链接 10 | 11 | ## 核心特性 12 | 13 | ### ✅ **统一链接生成** 14 | - 同时包含预配置认证参数和设置配置 15 | - 一个链接完成设备认证和设置应用 16 | - 自动编码和参数组合 17 | 18 | ### ✅ **智能界面设计** 19 | - 分层式界面:预配置认证 + 设置分享 + 链接生成 20 | - 实时预览和状态显示 21 | - 安全提醒和敏感信息保护 22 | 23 | ### ✅ **安全优化** 24 | - 数据源设置和已变更设置默认排除 `server.kvToken` 25 | - 敏感设置项标记和值遮盖 26 | - 多重安全提醒 27 | 28 | ## 链接格式 29 | 30 | ### 基础预配置链接 31 | ``` 32 | https://domain.com/?namespace=classroom-001&authCode=pass123&autoExecute=true 33 | ``` 34 | 35 | ### 包含设置的统一链接 36 | ``` 37 | https://domain.com/?namespace=classroom-001&authCode=pass123&autoExecute=true&config=eyJ... 38 | ``` 39 | 40 | 其中: 41 | - `namespace`: 设备命名空间 42 | - `authCode`: 认证码(可选) 43 | - `autoExecute`: 是否自动执行认证 44 | - `config`: Base64编码的设置JSON对象 45 | 46 | ## 使用流程 47 | 48 | 1. **输入预配置信息** 49 | - 命名空间(必填) 50 | - 认证码(可选) 51 | - 是否自动执行认证 52 | 53 | 2. **选择设置项** 54 | - 快速选择:数据源设置、已变更设置、全选 55 | - 手动选择:通过详细列表选择特定设置 56 | - 安全保护:敏感设置默认不选中 57 | 58 | 3. **生成统一链接** 59 | - 点击"生成统一链接"按钮 60 | - 链接实时生成和预览 61 | - 一键复制和测试 62 | 63 | ## 技术实现 64 | 65 | ### 参数组合逻辑 66 | ```javascript 67 | // 1. 添加预配置参数 68 | params.append("namespace", namespace); 69 | params.append("authCode", authCode); 70 | params.append("autoExecute", "true"); 71 | 72 | // 2. 添加设置配置 73 | if (selectedSettings.length > 0) { 74 | const configObj = {}; 75 | selectedSettings.forEach(key => { 76 | configObj[key] = allSettings[key]; 77 | }); 78 | 79 | const base64Config = btoa(JSON.stringify(configObj)); 80 | params.append("config", base64Config); 81 | } 82 | ``` 83 | 84 | ### 自动更新机制 85 | - 监听预配置表单变化 86 | - 监听设置选择变化 87 | - 实时生成统一链接 88 | 89 | ## 使用场景 90 | 91 | ### 1. **设备批量部署 + 环境配置** 92 | ``` 93 | https://classworks.example.com/?namespace=classroom-01&authCode=device01&autoExecute=true&config=eyJzZXJ2ZXIuZG9tYWluIjoiaHR0cHM6Ly9hcGkuZXhhbXBsZS5jb20ifQ== 94 | ``` 95 | - 自动认证为指定设备 96 | - 自动配置服务器地址等设置 97 | 98 | ### 2. **演示环境快速部署** 99 | ``` 100 | https://classworks.example.com/?namespace=demo&autoExecute=true&config=eyJkaXNwbGF5LnRoZW1lIjoiZGFyayIsImVkaXQubW9kZSI6InJlYWRvbmx5In0= 101 | ``` 102 | - 自动认证为演示账号 103 | - 自动应用演示环境设置 104 | 105 | ### 3. **培训环境标准化** 106 | ``` 107 | https://classworks.example.com/?namespace=training&authCode=train123&config=eyJkaXNwbGF5LnNob3dIZWxwIjp0cnVlLCJlZGl0LmVuYWJsZUd1aWRlIjp0cnVlfQ== 108 | ``` 109 | - 预配置培训账号 110 | - 启用帮助和引导功能 111 | 112 | ## 安全考虑 113 | 114 | ### ✅ **默认安全** 115 | - Token等敏感信息默认不包含 116 | - 快速选择按钮智能排除敏感设置 117 | - 明确的安全警告和提醒 118 | 119 | ### ✅ **用户控制** 120 | - 用户仍可手动选择包含敏感设置 121 | - 敏感设置有明确标记 122 | - 提供充分的风险提示 123 | 124 | ### ✅ **传输安全** 125 | - 建议HTTPS传输 126 | - URL参数会被自动清理 127 | - 设置信息经过Base64编码 128 | 129 | ## 兼容性 130 | 131 | - ✅ 向后兼容现有的预配置功能 132 | - ✅ 向后兼容现有的设置分享功能 133 | - ✅ 新增统一链接格式 134 | - ✅ 保持所有现有API接口 135 | 136 | ## 测试建议 137 | 138 | 1. **基础功能测试** 139 | - 仅预配置信息的链接生成 140 | - 预配置 + 设置的统一链接生成 141 | - 链接复制和测试功能 142 | 143 | 2. **安全功能测试** 144 | - 验证敏感设置默认不选中 145 | - 验证敏感设置标记显示 146 | - 验证安全提醒展示 147 | 148 | 3. **兼容性测试** 149 | - 测试生成的链接是否正常工作 150 | - 测试预配置认证是否正常 151 | - 测试设置应用是否正常 152 | 153 | ## 优势总结 154 | 155 | 1. **用户体验**: 一个链接完成所有配置,无需多步操作 156 | 2. **部署效率**: 批量设备部署更加便捷 157 | 3. **管理简化**: 减少链接管理复杂度 158 | 4. **安全平衡**: 在便捷性和安全性之间找到平衡 159 | 5. **功能完整**: 涵盖认证和配置的完整解决方案 -------------------------------------------------------------------------------- /src/utils/providers/kvLocalProvider.js: -------------------------------------------------------------------------------- 1 | import {openDB} from "idb"; 2 | import {formatResponse, formatError} from "../dataProvider"; 3 | 4 | // Database initialization for local storage 5 | const DB_NAME = "ClassworksDB"; 6 | const DB_VERSION = 2; 7 | 8 | const initDB = async () => { 9 | return openDB(DB_NAME, DB_VERSION, { 10 | upgrade(db) { 11 | // Create or update stores as needed 12 | if (!db.objectStoreNames.contains("kv")) { 13 | db.createObjectStore("kv"); 14 | } 15 | 16 | // Add a system store for machine ID and other system settings 17 | if (!db.objectStoreNames.contains("system")) { 18 | db.createObjectStore("system"); 19 | } 20 | }, 21 | }); 22 | }; 23 | 24 | export const kvLocalProvider = { 25 | async loadData(key) { 26 | try { 27 | const db = await initDB(); 28 | const data = await db.get("kv", key); 29 | 30 | if (!data) { 31 | return formatError("数据不存在", "NOT_FOUND"); 32 | } 33 | 34 | return formatResponse(JSON.parse(data)); 35 | } catch (error) { 36 | return formatError("读取本地数据失败:" + error); 37 | } 38 | }, 39 | 40 | async saveData(key, data) { 41 | try { 42 | const db = await initDB(); 43 | await db.put("kv", JSON.stringify(data), key); 44 | return formatResponse(true); 45 | } catch (error) { 46 | return formatError("保存本地数据失败:" + error); 47 | } 48 | }, 49 | 50 | /** 51 | * 获取本地存储的键名列表 52 | * @param {Object} options - 查询选项 53 | * @param {string} options.sortBy - 排序字段,默认为 "key" 54 | * @param {string} options.sortDir - 排序方向,"asc" 或 "desc",默认为 "asc" 55 | * @param {number} options.limit - 每页返回的记录数,默认为 100 56 | * @param {number} options.skip - 跳过的记录数,默认为 0 57 | * @returns {Promise} 包含键名列表和分页信息的响应对象 58 | * 59 | * 返回值示例: 60 | * { 61 | * keys: ["key1", "key2", "key3"], 62 | * total_rows: 150, 63 | * current_page: { 64 | * limit: 10, 65 | * skip: 0, 66 | * count: 10 67 | * }, 68 | * load_more: null // 本地存储不需要分页URL 69 | * } 70 | */ 71 | async loadKeys(options = {}) { 72 | try { 73 | const db = await initDB(); 74 | const transaction = db.transaction(["kv"], "readonly"); 75 | const store = transaction.objectStore("kv"); 76 | 77 | // 获取所有键名 78 | const allKeys = await store.getAllKeys(); 79 | 80 | // 设置默认参数 81 | const { 82 | sortDir = "asc", 83 | limit = 100, 84 | skip = 0 85 | } = options; 86 | // 排序键名(本地存储只支持按键名排序) 87 | const sortedKeys = allKeys.sort((a, b) => { 88 | if (sortDir === "desc") { 89 | return b.localeCompare(a); 90 | } 91 | return a.localeCompare(b); 92 | }); 93 | 94 | // 应用分页 95 | const totalRows = sortedKeys.length; 96 | const paginatedKeys = sortedKeys.slice(skip, skip + limit); 97 | 98 | // 构建响应数据 99 | const responseData = { 100 | keys: paginatedKeys, 101 | total_rows: totalRows, 102 | current_page: { 103 | limit, 104 | skip, 105 | count: paginatedKeys.length 106 | }, 107 | load_more: null // 本地存储不需要分页URL 108 | }; 109 | 110 | return formatResponse(responseData); 111 | } catch (error) { 112 | return formatError("获取本地键名列表失败:" + error.message); 113 | } 114 | }, 115 | }; 116 | -------------------------------------------------------------------------------- /src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/components/settings/cards/EchoChamberCard.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 103 | 104 | 118 | -------------------------------------------------------------------------------- /src/components/home/HomeActions.vue: -------------------------------------------------------------------------------- 1 | 105 | 106 | 129 | -------------------------------------------------------------------------------- /src/components/attendance/AttendanceSidebar.vue: -------------------------------------------------------------------------------- 1 | 83 | 84 | 119 | 120 | 123 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | /dev-dist 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | .env 10 | 11 | # Log files 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | pnpm-debug.log* 16 | 17 | # Editor directories and files 18 | .idea 19 | .vscode 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | # Created by https://www.toptal.com/developers/gitignore/api/node 27 | # Edit at https://www.toptal.com/developers/gitignore?templates=node 28 | 29 | ### Node ### 30 | # Logs 31 | logs 32 | *.log 33 | npm-debug.log* 34 | yarn-debug.log* 35 | yarn-error.log* 36 | lerna-debug.log* 37 | .pnpm-debug.log* 38 | 39 | # Diagnostic reports (https://nodejs.org/api/report.html) 40 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 41 | 42 | # Runtime data 43 | pids 44 | *.pid 45 | *.seed 46 | *.pid.lock 47 | 48 | # Directory for instrumented libs generated by jscoverage/JSCover 49 | lib-cov 50 | 51 | # Coverage directory used by tools like istanbul 52 | coverage 53 | *.lcov 54 | 55 | # nyc test coverage 56 | .nyc_output 57 | 58 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 59 | .grunt 60 | 61 | # Bower dependency directory (https://bower.io/) 62 | bower_components 63 | 64 | # node-waf configuration 65 | .lock-wscript 66 | 67 | # Compiled binary addons (https://nodejs.org/api/addons.html) 68 | build/Release 69 | 70 | # Dependency directories 71 | node_modules/ 72 | jspm_packages/ 73 | 74 | # Snowpack dependency directory (https://snowpack.dev/) 75 | web_modules/ 76 | 77 | # TypeScript cache 78 | *.tsbuildinfo 79 | 80 | # Optional npm cache directory 81 | .npm 82 | 83 | # Optional eslint cache 84 | .eslintcache 85 | 86 | # Optional stylelint cache 87 | .stylelintcache 88 | 89 | # Microbundle cache 90 | .rpt2_cache/ 91 | .rts2_cache_cjs/ 92 | .rts2_cache_es/ 93 | .rts2_cache_umd/ 94 | 95 | # Optional REPL history 96 | .node_repl_history 97 | 98 | # Output of 'npm pack' 99 | *.tgz 100 | 101 | # Yarn Integrity file 102 | .yarn-integrity 103 | 104 | # dotenv environment variable files 105 | .env 106 | .env.development.local 107 | .env.test.local 108 | .env.production.local 109 | .env.local 110 | 111 | # parcel-bundler cache (https://parceljs.org/) 112 | .cache 113 | .parcel-cache 114 | 115 | # Next.js build output 116 | .next 117 | out 118 | 119 | # Nuxt.js build / generate output 120 | .nuxt 121 | dist 122 | 123 | # Gatsby files 124 | .cache/ 125 | # Comment in the public line in if your project uses Gatsby and not Next.js 126 | # https://nextjs.org/blog/next-9-1#public-directory-support 127 | # public 128 | 129 | # vuepress build output 130 | .vuepress/dist 131 | 132 | # vuepress v2.x temp and cache directory 133 | .temp 134 | 135 | # Docusaurus cache and generated files 136 | .docusaurus 137 | 138 | # Serverless directories 139 | .serverless/ 140 | 141 | # FuseBox cache 142 | .fusebox/ 143 | 144 | # DynamoDB Local files 145 | .dynamodb/ 146 | 147 | # TernJS port file 148 | .tern-port 149 | 150 | # Stores VSCode versions used for testing VSCode extensions 151 | .vscode-test 152 | 153 | # yarn v2 154 | .yarn/cache 155 | .yarn/unplugged 156 | .yarn/build-state.yml 157 | .yarn/install-state.gz 158 | .pnp.* 159 | 160 | ### Node Patch ### 161 | # Serverless Webpack directories 162 | .webpack/ 163 | 164 | # Optional stylelint cache 165 | 166 | # SvelteKit build / generate output 167 | .svelte-kit 168 | 169 | # End of https://www.toptal.com/developers/gitignore/api/node 170 | 171 | # Vite 临时文件 172 | vite.config.*.timestamp-*.mjs 173 | *.timestamp-* 174 | 175 | -------------------------------------------------------------------------------- /src/utils/message.js: -------------------------------------------------------------------------------- 1 | import {getSetting} from './settings'; 2 | 3 | class LogDB { 4 | constructor() { 5 | this.logs = []; 6 | } 7 | 8 | async addLog(message) { 9 | this.logs.push(message); 10 | // 只保留最近100条消息 11 | if (this.logs.length > 100) { 12 | this.logs.shift(); 13 | } 14 | return true; 15 | } 16 | 17 | async getLogs(limit = 20) { 18 | return this.logs.slice(-limit).reverse(); 19 | } 20 | } 21 | 22 | const logDB = new LogDB(); 23 | 24 | const messages = []; 25 | let snackbarCallback = null; 26 | let logCallback = null; 27 | 28 | const MessageType = { 29 | SUCCESS: 'success', 30 | ERROR: 'error', 31 | INFO: 'info', 32 | WARNING: 'warning' 33 | }; 34 | 35 | const defaultOptions = { 36 | timeout: 3000, 37 | showSnackbar: true, 38 | addToLog: true 39 | }; 40 | 41 | async function createMessage(type, title, content = '', options = {}) { 42 | const msgOptions = {...defaultOptions, ...options}; 43 | const message = { 44 | id: Date.now() + Math.random(), 45 | type, 46 | title, 47 | content: content.substring(0, 500), 48 | timestamp: new Date() 49 | }; 50 | 51 | if (msgOptions.addToLog) { 52 | try { 53 | await logDB.addLog(message); 54 | messages.unshift(message); 55 | while (messages.length > getSetting('message.maxActiveMessages')) { 56 | messages.pop(); 57 | } 58 | logCallback?.(messages); 59 | } catch (error) { 60 | console.error('保存日志失败:', error); 61 | } 62 | } 63 | 64 | if (msgOptions.showSnackbar) { 65 | snackbarCallback?.(message); 66 | } 67 | 68 | return message; 69 | } 70 | 71 | function debounce(fn, delay) { 72 | let timer = null; 73 | return function (...args) { 74 | if (timer) clearTimeout(timer); 75 | timer = setTimeout(() => { 76 | fn.apply(this, args); 77 | }, delay); 78 | }; 79 | } 80 | 81 | export default { 82 | install: (app) => { 83 | app.config.globalProperties.$message = { 84 | success: (title, content, options) => createMessage(MessageType.SUCCESS, title, content, options), 85 | error: (title, content, options) => createMessage(MessageType.ERROR, title, content, options), 86 | info: (title, content, options) => createMessage(MessageType.INFO, title, content, options), 87 | warning: (title, content, options) => createMessage(MessageType.WARNING, title, content, options), 88 | }; 89 | }, 90 | onSnackbar: (callback) => { 91 | snackbarCallback = callback; 92 | }, 93 | onLog: (callback) => { 94 | logCallback = callback; 95 | }, 96 | getMessages: async () => { 97 | try { 98 | return await logDB.getLogs(); 99 | } catch (error) { 100 | console.error('获取日志失败:', error); 101 | return [...messages]; 102 | } 103 | }, 104 | clearMessages: async () => { 105 | try { 106 | await logDB.clearLogs(); 107 | messages.length = 0; 108 | logCallback?.(messages); 109 | } catch (error) { 110 | console.error('清除日志失败:', error); 111 | } 112 | }, 113 | MessageType, 114 | markAsRead: () => { 115 | }, // 移除标记已读功能 116 | deleteMessage: async (messageId) => { 117 | try { 118 | await logDB.deleteLog(messageId); 119 | const index = messages.findIndex(m => m.id === messageId); 120 | if (index !== -1) { 121 | messages.splice(index, 1); 122 | } 123 | logCallback?.(messages); 124 | } catch (error) { 125 | console.error('删除消息失败:', error); 126 | } 127 | }, 128 | getUnreadCount: () => 0, // 移除未读计数 129 | debounce, 130 | }; 131 | -------------------------------------------------------------------------------- /src/components/EventSender.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 134 | -------------------------------------------------------------------------------- /src/sw.js: -------------------------------------------------------------------------------- 1 | import {precacheAndRoute, cleanupOutdatedCaches} from 'workbox-precaching' 2 | import {registerRoute, setCatchHandler} from 'workbox-routing' 3 | import {CacheFirst, NetworkFirst, StaleWhileRevalidate} from 'workbox-strategies' 4 | import {ExpirationPlugin} from 'workbox-expiration' 5 | import {CacheableResponsePlugin} from 'workbox-cacheable-response' 6 | 7 | // 使用 self.__WB_MANIFEST 是 workbox 的一个特殊变量,会被实际的预缓存清单替换 8 | precacheAndRoute(self.__WB_MANIFEST) 9 | cleanupOutdatedCaches() 10 | 11 | // JS 文件缓存 12 | registerRoute( 13 | /\.(?:js)$/i, 14 | new StaleWhileRevalidate({ 15 | cacheName: 'js-cache', 16 | plugins: [ 17 | new ExpirationPlugin({ 18 | maxEntries: 100, 19 | maxAgeSeconds: 60 * 60 * 24 * 7 // 7 天 20 | }) 21 | ] 22 | }) 23 | ) 24 | 25 | // CSS 文件缓存 26 | registerRoute( 27 | /\.(?:css)$/i, 28 | new StaleWhileRevalidate({ 29 | cacheName: 'css-cache', 30 | plugins: [ 31 | new ExpirationPlugin({ 32 | maxEntries: 50, 33 | maxAgeSeconds: 60 * 60 * 24 * 7 // 7 天 34 | }) 35 | ] 36 | }) 37 | ) 38 | 39 | // HTML 文件缓存 40 | registerRoute( 41 | /\.(?:html)$/i, 42 | new NetworkFirst({ 43 | cacheName: 'html-cache', 44 | plugins: [ 45 | new ExpirationPlugin({ 46 | maxEntries: 20, 47 | maxAgeSeconds: 60 * 60 * 24 // 1 天 48 | }) 49 | ] 50 | }) 51 | ) 52 | 53 | // 图片缓存 54 | registerRoute( 55 | /\.(?:png|jpg|jpeg|svg|gif)$/i, 56 | new StaleWhileRevalidate({ 57 | cacheName: 'images-cache', 58 | plugins: [ 59 | new ExpirationPlugin({ 60 | maxEntries: 50, 61 | maxAgeSeconds: 60 * 60 * 24 * 30 // 30 天 62 | }) 63 | ] 64 | }) 65 | ) 66 | 67 | // CDN 缓存 68 | registerRoute( 69 | /\/cdn-cgi\/.*/i, 70 | new NetworkFirst({ 71 | cacheName: 'cdn-cgi-cache', 72 | plugins: [ 73 | new ExpirationPlugin({ 74 | maxEntries: 50, 75 | maxAgeSeconds: 60 * 60 * 24 // 1 天 76 | }) 77 | ], 78 | networkTimeoutSeconds: 10 79 | }) 80 | ) 81 | 82 | // 外部资源缓存 83 | registerRoute( 84 | ({url}) => url.origin !== self.location.origin, 85 | new NetworkFirst({ 86 | cacheName: 'external-resources', 87 | plugins: [ 88 | new ExpirationPlugin({ 89 | maxEntries: 100, 90 | maxAgeSeconds: 60 * 60 * 24 // 1 天 91 | }), 92 | new CacheableResponsePlugin({ 93 | statuses: [0, 200] 94 | }) 95 | ], 96 | networkTimeoutSeconds: 10 97 | }) 98 | ) 99 | 100 | // 添加缓存管理消息处理 101 | self.addEventListener('message', (event) => { 102 | if (event.data && event.data.type === 'CACHE_KEYS') { 103 | // 获取所有缓存键 104 | caches.keys().then((cacheNames) => { 105 | event.ports[0].postMessage({cacheNames}); 106 | }); 107 | } else if (event.data && event.data.type === 'CACHE_CONTENT') { 108 | // 获取特定缓存的内容 109 | const cacheName = event.data.cacheName; 110 | caches.open(cacheName).then((cache) => { 111 | cache.keys().then((requests) => { 112 | const urls = requests.map(request => request.url); 113 | event.ports[0].postMessage({cacheName, urls}); 114 | }); 115 | }); 116 | } else if (event.data && event.data.type === 'CLEAR_CACHE') { 117 | // 清除特定缓存 118 | const cacheName = event.data.cacheName; 119 | caches.delete(cacheName).then((success) => { 120 | event.ports[0].postMessage({success, cacheName}); 121 | }); 122 | } else if (event.data && event.data.type === 'CLEAR_URL') { 123 | // 清除特定URL的缓存 124 | const cacheName = event.data.cacheName; 125 | const url = event.data.url; 126 | caches.open(cacheName).then((cache) => { 127 | cache.delete(url).then((success) => { 128 | event.ports[0].postMessage({success, cacheName, url}); 129 | }); 130 | }); 131 | } else if (event.data && event.data.type === 'CLEAR_ALL_CACHES') { 132 | // 清除所有缓存 133 | caches.keys().then((cacheNames) => { 134 | Promise.all( 135 | cacheNames.map(name => caches.delete(name)) 136 | ).then(() => { 137 | event.ports[0].postMessage({success: true}); 138 | }); 139 | }); 140 | } 141 | }); 142 | -------------------------------------------------------------------------------- /src/components/home/ExamScheduleCard.vue: -------------------------------------------------------------------------------- 1 | 60 | 61 | 108 | 109 | 114 | -------------------------------------------------------------------------------- /src/components/settings/SettingsExplorer.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 129 | 130 | 150 | -------------------------------------------------------------------------------- /src/components/RateLimitModal.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 146 | -------------------------------------------------------------------------------- /src/pages/debug-init.vue: -------------------------------------------------------------------------------- 1 | 84 | 85 | 140 | -------------------------------------------------------------------------------- /src/utils/providers/kvServerProvider.js: -------------------------------------------------------------------------------- 1 | import axios from "@/axios/axios"; 2 | import {formatResponse, formatError} from "../dataProvider"; 3 | import {getSetting} from "../settings"; 4 | 5 | // Helper function to get request headers with kvtoken 6 | const getHeaders = () => { 7 | const headers = {Accept: "application/json"}; 8 | const kvToken = getSetting("server.kvToken"); 9 | const siteKey = getSetting("server.siteKey"); 10 | 11 | // 优先使用新的kvToken 12 | if (kvToken) { 13 | headers["x-app-token"] = kvToken; 14 | } else if (siteKey) { 15 | // 向后兼容旧的siteKey 16 | headers["x-site-key"] = siteKey; 17 | } 18 | 19 | return headers; 20 | }; 21 | 22 | export const kvServerProvider = { 23 | async loadNamespaceInfo() { 24 | try { 25 | // 使用 Classworks Cloud 或者用户配置的服务器域名 26 | const serverUrl = getSetting("server.domain"); 27 | 28 | const res = await axios.get(`${serverUrl}/kv/_info`, { 29 | headers: getHeaders(), 30 | }); 31 | 32 | // 直接返回新格式 API 数据,包含 device 和 account 信息 33 | return formatResponse(res.data); 34 | } catch (error) { 35 | console.error("获取命名空间信息失败:", error); 36 | return formatError( 37 | error.response?.data?.message || "获取命名空间信息失败", 38 | "NAMESPACE_ERROR" 39 | ); 40 | } 41 | }, 42 | 43 | async updateNamespaceInfo(data) { 44 | try { 45 | const serverUrl = getSetting("server.domain"); 46 | 47 | const res = await axios.put(`${serverUrl}/kv/_info`, data, { 48 | headers: getHeaders(), 49 | }); 50 | 51 | return res; 52 | } catch (error) { 53 | return formatError( 54 | error.response?.data?.message || "更新命名空间信息失败", 55 | "NAMESPACE_ERROR" 56 | ); 57 | } 58 | }, 59 | 60 | async loadData(key) { 61 | try { 62 | const serverUrl = getSetting("server.domain"); 63 | 64 | const res = await axios.get(`${serverUrl}/kv/${key}`, { 65 | headers: getHeaders(), 66 | }); 67 | 68 | return formatResponse(res.data); 69 | } catch (error) { 70 | if (error.response?.status === 404) { 71 | return formatError("数据不存在", "NOT_FOUND"); 72 | } 73 | console.log(error); 74 | return formatError( 75 | error.response?.data?.message || "服务器连接失败", 76 | "NETWORK_ERROR" 77 | ); 78 | } 79 | }, 80 | 81 | async saveData(key, data) { 82 | try { 83 | const serverUrl = getSetting("server.domain"); 84 | await axios.post(`${serverUrl}/kv/${key}`, data, { 85 | headers: getHeaders(), 86 | }); 87 | return formatResponse(true); 88 | } catch (error) { 89 | console.log(error); 90 | return formatError( 91 | error.response?.data?.message || "保存失败", 92 | "SAVE_ERROR" 93 | ); 94 | } 95 | }, 96 | 97 | /** 98 | * 获取键名列表 99 | * @param {Object} options - 查询选项 100 | * @param {string} options.sortBy - 排序字段,默认为 "key" 101 | * @param {string} options.sortDir - 排序方向,"asc" 或 "desc",默认为 "asc" 102 | * @param {number} options.limit - 每页返回的记录数,默认为 100 103 | * @param {number} options.skip - 跳过的记录数,默认为 0 104 | * @returns {Promise} 包含键名列表和分页信息的响应对象 105 | * 106 | * 返回值示例: 107 | * { 108 | * keys: ["key1", "key2", "key3"], 109 | * total_rows: 150, 110 | * current_page: { 111 | * limit: 10, 112 | * skip: 0, 113 | * count: 10 114 | * }, 115 | * load_more: "/api/kv/namespace/_keys?sortBy=key&sortDir=asc&limit=10&skip=10" 116 | * } 117 | */ 118 | async loadKeys(options = {}) { 119 | try { 120 | const serverUrl = getSetting("server.domain"); 121 | 122 | // 设置默认参数 123 | const { 124 | sortBy = "key", 125 | sortDir = "asc", 126 | limit = 100, 127 | skip = 0 128 | } = options; 129 | 130 | // 构建查询参数 131 | const params = new URLSearchParams({ 132 | sortBy, 133 | sortDir, 134 | limit: limit.toString(), 135 | skip: skip.toString() 136 | }); 137 | 138 | const res = await axios.get(`${serverUrl}/kv/_keys?${params}`, { 139 | headers: getHeaders(), 140 | }); 141 | 142 | return formatResponse(res.data); 143 | } catch (error) { 144 | if (error.response?.status === 404) { 145 | return formatError("命名空间不存在", "NOT_FOUND"); 146 | } 147 | if (error.response?.status === 403) { 148 | return formatError("无权限访问此命名空间", "PERMISSION_DENIED"); 149 | } 150 | if (error.response?.status === 401) { 151 | return formatError("认证失败", "UNAUTHORIZED"); 152 | } 153 | console.log(error); 154 | return formatError( 155 | error.response?.data?.message || "获取键名列表失败", 156 | "NETWORK_ERROR" 157 | ); 158 | } 159 | }, 160 | }; 161 | -------------------------------------------------------------------------------- /src/components/home/ConciseExamCard.vue: -------------------------------------------------------------------------------- 1 | 61 | 62 | 144 | 145 | 162 | -------------------------------------------------------------------------------- /src/components/HelloWorld.vue: -------------------------------------------------------------------------------- 1 | 161 | 162 | 165 | -------------------------------------------------------------------------------- /src/styles/transitions.scss: -------------------------------------------------------------------------------- 1 | // Material Design 3 动画曲线 2 | $emphasized-decelerate: cubic-bezier(0.05, 0.7, 0.1, 1.0); 3 | $emphasized-accelerate: cubic-bezier(0.3, 0.0, 0.8, 0.15); 4 | $standard-easing: cubic-bezier(0.2, 0.0, 0, 1.0); 5 | $standard-decelerate: cubic-bezier(0.0, 0.0, 0.0, 1.0); 6 | $standard-accelerate: cubic-bezier(0.3, 0.0, 1.0, 1.0); 7 | 8 | // 网格项目的进入和离开动画 9 | .grid-item { 10 | transition: transform 400ms $emphasized-decelerate, 11 | opacity 200ms $standard-easing; 12 | will-change: transform, opacity; 13 | backface-visibility: hidden; 14 | 15 | &.v-enter-active { 16 | transition: transform 400ms $emphasized-decelerate, 17 | opacity 250ms $standard-easing; 18 | } 19 | 20 | &.v-move { 21 | transition: transform 400ms $emphasized-decelerate; 22 | z-index: 1; 23 | } 24 | 25 | &.v-leave-active { 26 | position: absolute !important; 27 | transition: transform 300ms $emphasized-accelerate, 28 | opacity 200ms $standard-accelerate; 29 | } 30 | 31 | &.v-enter-from, 32 | &.v-leave-to { 33 | opacity: 0; 34 | transform: scale(0.95); 35 | } 36 | } 37 | 38 | // 空科目卡片动画 39 | .empty-subject-card { 40 | transition: all 250ms $standard-easing; 41 | 42 | &:hover { 43 | transform: translateY(-4px) scale(1.02); 44 | box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12); 45 | } 46 | } 47 | 48 | // 列表项目动画 49 | .v-list-enter-active { 50 | transition: all 400ms $emphasized-decelerate; 51 | } 52 | 53 | .v-list-leave-active { 54 | transition: all 300ms $emphasized-accelerate; 55 | } 56 | 57 | .v-list-enter-from, 58 | .v-list-leave-to { 59 | opacity: 0; 60 | transform: translateX(-24px); 61 | } 62 | 63 | // 出勤数据变化动画 64 | .attendance-area { 65 | h2, h3 { 66 | transition: all 300ms $standard-easing; 67 | } 68 | } 69 | 70 | // 卡片展开收起动画 71 | .v-card { 72 | transition: all 400ms $emphasized-decelerate; 73 | 74 | &:active { 75 | transform: scale(0.98); 76 | transition-duration: 100ms; 77 | } 78 | } 79 | 80 | // 优化卡片触摸体验 81 | .v-card { 82 | touch-action: manipulation; 83 | 84 | &:active { 85 | transform: scale(0.99); 86 | transition-duration: 80ms; 87 | } 88 | 89 | @media (pointer: coarse) { 90 | &::before { 91 | // 增加触摸反馈区域 92 | margin: -8px; 93 | } 94 | } 95 | } 96 | 97 | // 修改对话框过渡动画 - 移除点击波纹效果 98 | .v-dialog { 99 | &::before { 100 | display: none !important; 101 | } 102 | } 103 | 104 | // 对话框过渡动画 105 | .v-dialog { 106 | // 禁用原生点击波纹效果 107 | &::before, 108 | &::after { 109 | display: none !important; 110 | } 111 | 112 | // 禁用卡片点击效果 113 | .v-card { 114 | transition: none; 115 | 116 | &:active { 117 | transform: none; 118 | } 119 | } 120 | } 121 | 122 | // 保持对话框本身的过渡动画 123 | .v-dialog-transition-enter-active { 124 | transition: transform 400ms $emphasized-decelerate, 125 | opacity 300ms $standard-easing; 126 | } 127 | 128 | .v-dialog-transition-leave-active { 129 | transition: transform 250ms $emphasized-accelerate, 130 | opacity 200ms $standard-accelerate; 131 | } 132 | 133 | // 按钮状态变化动画 134 | .v-btn { 135 | transition: background-color 250ms $standard-easing, 136 | transform 150ms $emphasized-decelerate; 137 | touch-action: manipulation; 138 | min-height: 40px; // 确保触摸目标足够大 139 | min-width: 40px; 140 | 141 | &:active { 142 | transform: scale(0.98); 143 | transition-duration: 80ms; 144 | } 145 | 146 | @media (pointer: coarse) { 147 | // 触摸设备上的特殊处理 148 | padding: 8px 16px; 149 | margin: 4px; 150 | 151 | &::before { 152 | // 增加触摸反馈区域 153 | margin: -8px; 154 | } 155 | } 156 | } 157 | 158 | // 禁用文字选择 159 | .no-select { 160 | -webkit-user-select: none; 161 | -moz-user-select: none; 162 | -ms-user-select: none; 163 | user-select: none; 164 | -webkit-touch-callout: none; 165 | } 166 | 167 | // 动画过渡效果 168 | 169 | // 网格项目过渡 170 | .grid-enter-active, 171 | .grid-leave-active { 172 | transition: all 0.5s ease; 173 | } 174 | 175 | .grid-enter-from { 176 | opacity: 0; 177 | transform: translateY(20px); 178 | } 179 | 180 | .grid-leave-to { 181 | opacity: 0; 182 | transform: translateY(-20px); 183 | } 184 | 185 | // 列表项目过渡 186 | .v-list-enter-active, 187 | .v-list-leave-active { 188 | transition: all 0.3s ease; 189 | } 190 | 191 | .v-list-enter-from { 192 | opacity: 0; 193 | transform: translateX(-20px); 194 | } 195 | 196 | .v-list-leave-to { 197 | opacity: 0; 198 | transform: translateX(20px); 199 | } 200 | 201 | // 页面过渡 202 | .page-enter-active, 203 | .page-leave-active { 204 | transition: opacity 0.3s, transform 0.3s; 205 | } 206 | 207 | .page-enter-from { 208 | opacity: 0; 209 | transform: translateY(20px); 210 | } 211 | 212 | .page-leave-to { 213 | opacity: 0; 214 | transform: translateY(-20px); 215 | } 216 | 217 | // 淡入淡出 218 | .fade-enter-active, 219 | .fade-leave-active { 220 | transition: opacity 0.3s; 221 | } 222 | 223 | .fade-enter-from, 224 | .fade-leave-to { 225 | opacity: 0; 226 | } 227 | 228 | // 缩放过渡 229 | .scale-enter-active, 230 | .scale-leave-active { 231 | transition: all 0.3s; 232 | } 233 | 234 | .scale-enter-from, 235 | .scale-leave-to { 236 | opacity: 0; 237 | transform: scale(0.9); 238 | } 239 | -------------------------------------------------------------------------------- /src/pages/exam-player.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 141 | 217 | -------------------------------------------------------------------------------- /src/utils/gridLayout.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 优化网格布局算法 3 | * 目标:使各列高度尽可能平均且最大高度最小 4 | * 策略:LPT (Longest Processing Time) + 局部搜索 (Local Search) 5 | * 6 | * @param {Array} items - 待排序的卡片项,每项需包含 rowSpan 属性 7 | * @param {number} maxColumns - 最大列数 8 | * @returns {Array} - 排序后的卡片项,包含 order 属性 9 | */ 10 | export function optimizeGridLayout(items, maxColumns) { 11 | if (maxColumns <= 1 || !items || items.length === 0) return items; 12 | 13 | // 1. 初始分配:LPT (Longest Processing Time) 算法 14 | // 按高度降序排序,优先处理大卡片 15 | // 使用浅拷贝避免修改原数组 16 | const sortedByHeight = [...items].sort((a, b) => b.rowSpan - a.rowSpan); 17 | 18 | // 初始化列状态 19 | // 使用 Int32Array 存储高度以提高性能(假设高度不会溢出) 20 | const columnHeights = new Int32Array(maxColumns); 21 | const columnItems = Array.from({ length: maxColumns }, () => []); 22 | 23 | // 贪心分配 24 | for (let i = 0; i < sortedByHeight.length; i++) { 25 | const item = sortedByHeight[i]; 26 | // 寻找当前最矮的列 27 | let shortestColIndex = 0; 28 | let minHeight = columnHeights[0]; 29 | 30 | for (let j = 1; j < maxColumns; j++) { 31 | if (columnHeights[j] < minHeight) { 32 | minHeight = columnHeights[j]; 33 | shortestColIndex = j; 34 | } 35 | } 36 | 37 | columnItems[shortestColIndex].push(item); 38 | columnHeights[shortestColIndex] += item.rowSpan; 39 | } 40 | 41 | // 2. 优化阶段:尝试平衡最高和最低列 42 | // 限制迭代次数,防止耗时过长 43 | const MAX_ITERATIONS = 50; 44 | 45 | for (let iter = 0; iter < MAX_ITERATIONS; iter++) { 46 | // 找到最高和最低的列 47 | let minIdx = 0; 48 | let maxIdx = 0; 49 | let minH = columnHeights[0]; 50 | let maxH = columnHeights[0]; 51 | 52 | for (let i = 1; i < maxColumns; i++) { 53 | const h = columnHeights[i]; 54 | if (h < minH) { 55 | minH = h; 56 | minIdx = i; 57 | } else if (h > maxH) { 58 | maxH = h; 59 | maxIdx = i; 60 | } 61 | } 62 | 63 | const heightDiff = maxH - minH; 64 | // 如果高度差很小,或者只有一列(逻辑上不可能,前面已拦截),则停止 65 | if (heightDiff <= 1) break; 66 | 67 | let bestAction = null; 68 | let bestDiffReduction = 0; 69 | 70 | const maxColItems = columnItems[maxIdx]; 71 | const minColItems = columnItems[minIdx]; 72 | 73 | // 策略 A: 尝试从高列移动一个卡片到低列 74 | // 只需要检查能减少高度差的卡片 75 | // 移动卡片 h,新高度差为 |(maxH - h) - (minH + h)| = |maxH - minH - 2h| 76 | // 我们希望 |maxH - minH - 2h| < maxH - minH 77 | for (let i = 0; i < maxColItems.length; i++) { 78 | const item = maxColItems[i]; 79 | const h = item.rowSpan; 80 | 81 | // 如果卡片高度大于高度差的一半,移动后反而可能导致低列变得比高列还高很多,需要检查绝对值 82 | // 优化目标是最小化新的 max(newMaxH, newMinH) - min(newMaxH, newMinH) 83 | // 但这里简化为只关注这两列的平衡 84 | 85 | const newMaxH = maxH - h; 86 | const newMinH = minH + h; 87 | const newDiff = Math.abs(newMaxH - newMinH); 88 | 89 | if (newDiff < heightDiff) { 90 | const reduction = heightDiff - newDiff; 91 | if (reduction > bestDiffReduction) { 92 | bestDiffReduction = reduction; 93 | bestAction = { type: "move", itemIdx: i, reduction }; 94 | 95 | // 如果已经找到非常好的移动(几乎完美平衡),可以提前结束搜索 96 | if (newDiff <= 1) break; 97 | } 98 | } 99 | } 100 | 101 | // 策略 B: 尝试交换高列的一个大卡片和低列的一个小卡片 102 | // 仅当策略 A 没有找到完美解时尝试 103 | if (!bestAction || bestAction.reduction < heightDiff * 0.5) { 104 | for (let i = 0; i < maxColItems.length; i++) { 105 | const itemA = maxColItems[i]; 106 | for (let j = 0; j < minColItems.length; j++) { 107 | const itemB = minColItems[j]; 108 | 109 | const hA = itemA.rowSpan; 110 | const hB = itemB.rowSpan; 111 | 112 | // 必须是高列拿出更大的卡片 113 | if (hA <= hB) continue; 114 | 115 | const change = hA - hB; 116 | const newMaxH = maxH - change; 117 | const newMinH = minH + change; 118 | const newDiff = Math.abs(newMaxH - newMinH); 119 | 120 | if (newDiff < heightDiff) { 121 | const reduction = heightDiff - newDiff; 122 | if (reduction > bestDiffReduction) { 123 | bestDiffReduction = reduction; 124 | bestAction = { type: "swap", idxA: i, idxB: j }; 125 | } 126 | } 127 | } 128 | } 129 | } 130 | 131 | if (bestAction) { 132 | if (bestAction.type === "move") { 133 | const item = maxColItems[bestAction.itemIdx]; 134 | // 移除 135 | maxColItems.splice(bestAction.itemIdx, 1); 136 | // 添加 137 | minColItems.push(item); 138 | // 更新高度 139 | columnHeights[maxIdx] -= item.rowSpan; 140 | columnHeights[minIdx] += item.rowSpan; 141 | } else { 142 | const itemA = maxColItems[bestAction.idxA]; 143 | const itemB = minColItems[bestAction.idxB]; 144 | // 交换 145 | maxColItems[bestAction.idxA] = itemB; 146 | minColItems[bestAction.idxB] = itemA; 147 | // 更新高度 148 | const diff = itemA.rowSpan - itemB.rowSpan; 149 | columnHeights[maxIdx] -= diff; 150 | columnHeights[minIdx] += diff; 151 | } 152 | } else { 153 | // 无法进一步优化 154 | break; 155 | } 156 | } 157 | 158 | // 3. 保持列内科目顺序并展平 159 | // 预先计算总长度以分配数组 160 | const result = new Array(items.length); 161 | let resultIdx = 0; 162 | 163 | for (let i = 0; i < maxColumns; i++) { 164 | const colItems = columnItems[i]; 165 | // 列内排序 166 | if (colItems.length > 1) { 167 | colItems.sort((a, b) => a.order - b.order); 168 | } 169 | 170 | for (let j = 0; j < colItems.length; j++) { 171 | // 复制对象以避免修改原始引用(如果需要纯函数特性) 172 | // 这里为了性能直接修改或浅拷贝,根据需求调整 173 | // 题目要求返回带 order 的新对象 174 | const item = colItems[j]; 175 | result[resultIdx] = { ...item, order: resultIdx }; 176 | resultIdx++; 177 | } 178 | } 179 | 180 | return result; 181 | } 182 | -------------------------------------------------------------------------------- /src/components/auth/DeviceAuthDialog.vue: -------------------------------------------------------------------------------- 1 | 101 | 102 | 205 | 206 | 227 | -------------------------------------------------------------------------------- /src/utils/safeEvents.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Vue 组件安全事件处理工具 3 | * 防止组件卸载时的事件处理错误 4 | */ 5 | 6 | /** 7 | * 创建安全的 Vue 组件混入,用于管理事件监听器 8 | * @returns {Object} Vue mixin 对象 9 | */ 10 | export function createSafeEventMixin() { 11 | return { 12 | data() { 13 | return { 14 | _isDestroying: false, 15 | _eventCleanupFunctions: [] 16 | } 17 | }, 18 | 19 | methods: { 20 | /** 21 | * 安全地注册事件监听器 22 | * @param {Function} registerFn - 注册事件的函数,返回清理函数 23 | * @returns {Function} 清理函数 24 | */ 25 | $safeOn(registerFn) { 26 | if (this._isDestroying) return () => {} 27 | 28 | try { 29 | const cleanup = registerFn() 30 | if (typeof cleanup === 'function') { 31 | this._eventCleanupFunctions.push(cleanup) 32 | return cleanup 33 | } 34 | } catch (error) { 35 | console.error('事件注册失败:', error) 36 | } 37 | 38 | return () => {} 39 | }, 40 | 41 | /** 42 | * 创建安全的事件处理器 43 | * @param {Function} handler - 原始事件处理器 44 | * @returns {Function} 安全的事件处理器 45 | */ 46 | $safeHandler(handler) { 47 | return (...args) => { 48 | if (this._isDestroying || !this.$el) return 49 | 50 | try { 51 | return handler.apply(this, args) 52 | } catch (error) { 53 | console.error('事件处理失败:', error) 54 | } 55 | } 56 | }, 57 | 58 | /** 59 | * 安全地执行 DOM 操作 60 | * @param {Function} domOperation - DOM 操作函数 61 | */ 62 | $safeDom(domOperation) { 63 | if (this._isDestroying || !this.$el) return 64 | 65 | try { 66 | requestAnimationFrame(() => { 67 | if (!this._isDestroying && this.$el) { 68 | domOperation() 69 | } 70 | }) 71 | } catch (error) { 72 | console.error('DOM 操作失败:', error) 73 | } 74 | }, 75 | 76 | /** 77 | * 清理所有事件监听器 78 | */ 79 | $cleanupEvents() { 80 | this._isDestroying = true 81 | 82 | this._eventCleanupFunctions.forEach(cleanup => { 83 | try { 84 | if (typeof cleanup === 'function') { 85 | cleanup() 86 | } 87 | } catch (error) { 88 | console.warn('事件清理失败:', error) 89 | } 90 | }) 91 | 92 | this._eventCleanupFunctions = [] 93 | } 94 | }, 95 | 96 | beforeUnmount() { 97 | this.$cleanupEvents() 98 | } 99 | } 100 | } 101 | 102 | /** 103 | * Socket 事件安全处理混入 104 | */ 105 | export const socketEventMixin = { 106 | ...createSafeEventMixin(), 107 | 108 | methods: { 109 | /** 110 | * 安全地注册 socket 事件监听器 111 | * @param {string} event - 事件名 112 | * @param {Function} handler - 事件处理器 113 | * @returns {Function} 清理函数 114 | */ 115 | $socketOn(event, handler) { 116 | return this.$safeOn(() => { 117 | const { on } = require('@/utils/socketClient') 118 | return on(event, this.$safeHandler(handler)) 119 | }) 120 | } 121 | } 122 | } 123 | 124 | /** 125 | * 为现有组件添加安全事件处理 126 | * @param {Object} component - Vue 组件选项 127 | * @returns {Object} 增强后的组件选项 128 | */ 129 | export function withSafeEvents(component) { 130 | const safeMixin = createSafeEventMixin() 131 | 132 | return { 133 | ...component, 134 | mixins: [...(component.mixins || []), safeMixin], 135 | 136 | // 增强现有的 beforeUnmount 137 | beforeUnmount() { 138 | // 调用原有的 beforeUnmount 139 | if (component.beforeUnmount) { 140 | try { 141 | component.beforeUnmount.call(this) 142 | } catch (error) { 143 | console.error('原 beforeUnmount 执行失败:', error) 144 | } 145 | } 146 | 147 | // 调用安全清理 148 | if (this.$cleanupEvents) { 149 | this.$cleanupEvents() 150 | } 151 | } 152 | } 153 | } 154 | 155 | /** 156 | * Composition API 版本的安全事件处理 157 | */ 158 | export function useSafeEvents() { 159 | const { ref, onBeforeUnmount } = require('vue') 160 | 161 | const isDestroying = ref(false) 162 | const cleanupFunctions = ref([]) 163 | 164 | const safeOn = (registerFn) => { 165 | if (isDestroying.value) return () => {} 166 | 167 | try { 168 | const cleanup = registerFn() 169 | if (typeof cleanup === 'function') { 170 | cleanupFunctions.value.push(cleanup) 171 | return cleanup 172 | } 173 | } catch (error) { 174 | console.error('事件注册失败:', error) 175 | } 176 | 177 | return () => {} 178 | } 179 | 180 | const safeHandler = (handler) => { 181 | return (...args) => { 182 | if (isDestroying.value) return 183 | 184 | try { 185 | return handler(...args) 186 | } catch (error) { 187 | console.error('事件处理失败:', error) 188 | } 189 | } 190 | } 191 | 192 | const safeDom = (domOperation) => { 193 | if (isDestroying.value) return 194 | 195 | try { 196 | requestAnimationFrame(() => { 197 | if (!isDestroying.value) { 198 | domOperation() 199 | } 200 | }) 201 | } catch (error) { 202 | console.error('DOM 操作失败:', error) 203 | } 204 | } 205 | 206 | const cleanup = () => { 207 | isDestroying.value = true 208 | 209 | cleanupFunctions.value.forEach(fn => { 210 | try { 211 | if (typeof fn === 'function') { 212 | fn() 213 | } 214 | } catch (error) { 215 | console.warn('事件清理失败:', error) 216 | } 217 | }) 218 | 219 | cleanupFunctions.value = [] 220 | } 221 | 222 | onBeforeUnmount(() => { 223 | cleanup() 224 | }) 225 | 226 | return { 227 | isDestroying: isDestroying.value, 228 | safeOn, 229 | safeHandler, 230 | safeDom, 231 | cleanup 232 | } 233 | } 234 | 235 | export default { 236 | createSafeEventMixin, 237 | socketEventMixin, 238 | withSafeEvents, 239 | useSafeEvents 240 | } 241 | -------------------------------------------------------------------------------- /vite.config.mjs: -------------------------------------------------------------------------------- 1 | // Plugins 2 | import AutoImport from 'unplugin-auto-import/vite' 3 | import Components from 'unplugin-vue-components/vite' 4 | import Fonts from 'unplugin-fonts/vite' 5 | import Layouts from 'vite-plugin-vue-layouts' 6 | import Vue from '@vitejs/plugin-vue' 7 | import VueRouter from 'unplugin-vue-router/vite' 8 | import Vuetify, { transformAssetUrls } from 'vite-plugin-vuetify' 9 | import { VitePWA } from 'vite-plugin-pwa' 10 | //import { TDesignResolver } from 'unplugin-vue-components/resolvers' 11 | 12 | // Utilities 13 | import { defineConfig } from 'vite' 14 | import { fileURLToPath, URL } from 'node:url' 15 | import vueDevTools from 'vite-plugin-vue-devtools' 16 | 17 | // https://vitejs.dev/config/ 18 | export default defineConfig({ 19 | base: './', 20 | plugins: [ 21 | VueRouter(), 22 | vueDevTools(), 23 | Layouts(), 24 | Vue({ 25 | template: { transformAssetUrls } 26 | }), 27 | VitePWA({ 28 | registerType: 'autoUpdate', 29 | devOptions: { 30 | navigateFallback: 'index.html', 31 | enabled: false, 32 | suppressWarnings: true, 33 | }, 34 | 35 | lang: 'zh-CN', 36 | injectRegister: 'auto', 37 | strategies: 'generateSW', 38 | 39 | 40 | workbox: { 41 | globPatterns: ['*'], 42 | navigateFallback: 'index.html', 43 | runtimeCaching: [ 44 | { 45 | urlPattern: ({ url, sameOrigin }) => { 46 | return sameOrigin && url.pathname.endsWith('/assets/'); 47 | }, 48 | handler: 'CacheFirst', 49 | options: { 50 | cacheName: 'assets-cache', 51 | expiration: { 52 | maxEntries: 200, 53 | maxAgeSeconds: 60 * 60 * 24 * 60 // 60 天 54 | }, 55 | cacheableResponse: { 56 | statuses: [0, 200] 57 | } 58 | } 59 | }, 60 | { 61 | urlPattern: ({ url, sameOrigin }) => { 62 | return sameOrigin && url.pathname.startsWith('/pwa/'); 63 | }, 64 | handler: 'StaleWhileRevalidate', 65 | options: { 66 | cacheName: 'pwa-cache', 67 | expiration: { 68 | maxEntries: 50, 69 | maxAgeSeconds: 60 * 60 * 24 * 7 // 7 天 70 | }, 71 | cacheableResponse: { 72 | statuses: [0, 200] 73 | } 74 | } 75 | }, 76 | { 77 | // 匹配当前域名下除了上述规则外的所有请求 78 | urlPattern: ({ url, sameOrigin }) => { 79 | if (!sameOrigin) return false; 80 | const path = url.pathname; 81 | // 排除已经由其他规则处理的路径 82 | return !(path.includes('/assets/') || path.includes('/pwa/')); 83 | }, 84 | handler: 'NetworkFirst', 85 | options: { 86 | cacheName: 'other-resources', 87 | expiration: { 88 | maxEntries: 100, 89 | maxAgeSeconds: 60 * 60 * 24 // 1 天 90 | }, 91 | networkTimeoutSeconds: 10, 92 | cacheableResponse: { 93 | statuses: [0, 200] 94 | } 95 | } 96 | }, 97 | ], 98 | additionalManifestEntries: [], 99 | clientsClaim: true, 100 | skipWaiting: true, 101 | importScripts: ['/sw-cache-manager.js'] 102 | }, 103 | manifest: { 104 | name: 'Classworks作业板', 105 | short_name: 'Classworks', 106 | description: '记录,查看并同步作业', 107 | theme_color: '#212121', 108 | background_color: '#212121', 109 | display: 'standalone', 110 | start_url: './', 111 | edge_side_panel: { 112 | default_path: './', 113 | }, 114 | icons: [ 115 | { 116 | src: './pwa/image/pwa-64x64.png', 117 | sizes: '64x64', 118 | type: 'image/png' 119 | }, 120 | { 121 | src: './pwa/image/pwa-192x192.png', 122 | sizes: '192x192', 123 | type: 'image/png' 124 | }, 125 | { 126 | src: './pwa/image/pwa-512x512.png', 127 | sizes: '512x512', 128 | type: 'image/png' 129 | }, 130 | { 131 | src: './pwa/image/maskable-icon-512x512.png', 132 | sizes: '512x512', 133 | type: 'image/png', 134 | purpose: 'maskable' 135 | } 136 | ], 137 | shortcuts: [ 138 | { 139 | name: '随机点名', 140 | short_name: '随机点名', 141 | url: './#random-picker', 142 | icons: [ 143 | { 144 | src: './pwa/image/pwa-64x64.png', 145 | sizes: '64x64', 146 | type: 'image/png' 147 | } 148 | ] 149 | }, 150 | ], 151 | } 152 | }), 153 | // https://github.com/vuetifyjs/vuetify-loader/tree/master/packages/vite-plugin#readme 154 | Vuetify({ 155 | autoImport: true, 156 | styles: { 157 | configFile: 'src/styles/settings.scss', 158 | }, 159 | }), 160 | Components({ 161 | //resolvers: [ 162 | // TDesignResolver({ 163 | // library: 'vue-next' 164 | // }) 165 | //] 166 | }), 167 | Fonts({ 168 | google: { 169 | families: [{ 170 | name: 'Roboto', 171 | styles: 'wght@100;300;400;500;700;900', 172 | }], 173 | }, 174 | }), 175 | AutoImport({ 176 | imports: [ 177 | 'vue', 178 | 'vue-router', 179 | ], 180 | eslintrc: { 181 | enabled: true, 182 | }, 183 | vueTemplate: true, 184 | }), 185 | ], 186 | define: { 'process.env': {} }, 187 | resolve: { 188 | alias: { 189 | '@': fileURLToPath(new URL('./src', import.meta.url)) 190 | }, 191 | extensions: [ 192 | '.js', 193 | '.json', 194 | '.jsx', 195 | '.mjs', 196 | '.ts', 197 | '.tsx', 198 | '.vue', 199 | ], 200 | }, 201 | server: { 202 | port: 3031, 203 | }, 204 | css: { 205 | preprocessorOptions: { 206 | sass: { 207 | api: 'modern-compiler', 208 | }, 209 | }, 210 | }, 211 | }) 212 | -------------------------------------------------------------------------------- /src/components/HitokotoCard.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 183 | 184 | 196 | -------------------------------------------------------------------------------- /src/components/KvInitialize.vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | 196 | -------------------------------------------------------------------------------- /src/components/settings/cards/ServerSettingsCard.vue: -------------------------------------------------------------------------------- 1 | 116 | 117 | 222 | -------------------------------------------------------------------------------- /src/components/FloatingToolbar.vue: -------------------------------------------------------------------------------- 1 | 95 | 96 | 133 | 134 | 266 | --------------------------------------------------------------------------------