// Plugins import AutoImport from 'unplugin-auto-import/vite' import Components from 'unplugin-vue-components/vite' import Fonts from 'unplugin-fonts/vite' import Layouts from 'vite-plugin-vue-layouts' import Vue from '@vitejs/plugin-vue' import VueRouter from 'unplugin-vue-router/vite' import Vuetify, { transformAssetUrls } from 'vite-plugin-vuetify' import { VitePWA } from 'vite-plugin-pwa' //import { TDesignResolver } from 'unplugin-vue-components/resolvers' // Utilities import { defineConfig } from 'vite' import { fileURLToPath, URL } from 'node:url' import vueDevTools from 'vite-plugin-vue-devtools' import fs from 'node:fs' import path from 'node:path' // Local File Storage Plugin const localFileStoragePlugin = () => { return { name: 'local-file-storage', configureServer(server) { const dataDir = path.resolve(process.cwd(), 'data'); if (!fs.existsSync(dataDir)) { fs.mkdirSync(dataDir, { recursive: true }); } server.middlewares.use('/api/ppt', async (req, res, next) => { const url = new URL(req.url, `http://${req.headers.host}`); // Handle CORS res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); if (req.method === 'OPTIONS') { res.end(); return; } if (url.pathname === '/check' || url.pathname.endsWith('/check')) { res.setHeader('Content-Type', 'application/json'); // In dev mode on node, we just mock it. Real logic is in start-server.ps1 res.end(JSON.stringify({ success: true, hasPpt: false })); return; } if (url.pathname === '/control' || url.pathname.endsWith('/control')) { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ success: true })); return; } next(); }); server.middlewares.use('/api/local-file', async (req, res, next) => { const url = new URL(req.url, `http://${req.headers.host}`); const key = url.searchParams.get('key'); // Handle CORS res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); if (req.method === 'OPTIONS') { res.end(); return; } if (url.pathname === '/save' && req.method === 'POST') { let body = ''; req.on('data', chunk => body += chunk); req.on('end', () => { try { const { key, data } = JSON.parse(body); if (!key || !data) throw new Error('Missing key or data'); // Sanitize key to prevent path traversal const safeKey = key.replace(/[^a-zA-Z0-9_-]/g, '_'); const filePath = path.join(dataDir, `${safeKey}.json`); fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ success: true })); } catch (e) { res.statusCode = 500; res.end(JSON.stringify({ success: false, error: e.message })); } }); return; } if (url.pathname === '/load' && req.method === 'GET') { if (!key) { res.statusCode = 400; res.end(JSON.stringify({ success: false, error: 'Missing key' })); return; } const safeKey = key.replace(/[^a-zA-Z0-9_-]/g, '_'); const filePath = path.join(dataDir, `${safeKey}.json`); if (fs.existsSync(filePath)) { try { // 文件可能带 UTF-8 BOM(外部编辑器/同步工具写入),解析前需剥离,否则 JSON.parse 抛错 const raw = fs.readFileSync(filePath, 'utf-8').replace(/^\uFEFF/, ''); res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ success: true, data: JSON.parse(raw) })); } catch (e) { res.statusCode = 500; res.end(JSON.stringify({ success: false, error: e.message })); } } else { res.statusCode = 404; res.end(JSON.stringify({ success: false, error: 'Not found' })); } return; } if (url.pathname === '/keys' && req.method === 'GET') { try { const files = fs.readdirSync(dataDir); const keys = files .filter(f => f.endsWith('.json')) .map(f => f.replace('.json', '')); res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify({ success: true, keys })); } catch (e) { res.statusCode = 500; res.end(JSON.stringify({ success: false, error: e.message })); } return; } next(); }); } } } // https://vitejs.dev/config/ export default defineConfig({ base: './', plugins: [ localFileStoragePlugin(), VueRouter(), vueDevTools(), Layouts(), Vue({ template: { transformAssetUrls } }), VitePWA({ registerType: 'autoUpdate', devOptions: { navigateFallback: 'index.html', enabled: false, suppressWarnings: true, }, lang: 'zh-CN', injectRegister: 'auto', strategies: 'generateSW', workbox: { globPatterns: ['*'], navigateFallback: 'index.html', runtimeCaching: [ { urlPattern: ({ url, sameOrigin }) => { return sameOrigin && url.pathname.endsWith('/assets/'); }, handler: 'CacheFirst', options: { cacheName: 'assets-cache', expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 60 // 60 天 }, cacheableResponse: { statuses: [0, 200] } } }, { urlPattern: ({ url, sameOrigin }) => { return sameOrigin && url.pathname.startsWith('/pwa/'); }, handler: 'StaleWhileRevalidate', options: { cacheName: 'pwa-cache', expiration: { maxEntries: 50, maxAgeSeconds: 60 * 60 * 24 * 7 // 7 天 }, cacheableResponse: { statuses: [0, 200] } } }, { // 匹配当前域名下除了上述规则外的所有请求 urlPattern: ({ url, sameOrigin }) => { if (!sameOrigin) return false; const path = url.pathname; // 排除已经由其他规则处理的路径 return !(path.includes('/assets/') || path.includes('/pwa/')); }, handler: 'NetworkFirst', options: { cacheName: 'other-resources', expiration: { maxEntries: 100, maxAgeSeconds: 60 * 60 * 24 // 1 天 }, networkTimeoutSeconds: 10, cacheableResponse: { statuses: [0, 200] } } }, ], additionalManifestEntries: [], clientsClaim: true, skipWaiting: true, importScripts: ['/sw-cache-manager.js'] }, manifest: { name: 'Classworks作业板', short_name: 'Classworks', description: '记录,查看并同步作业', theme_color: '#212121', background_color: '#212121', display: 'standalone', start_url: './', edge_side_panel: { default_path: './', }, icons: [ { src: './pwa/image/pwa-64x64.png', sizes: '64x64', type: 'image/png' }, { src: './pwa/image/pwa-192x192.png', sizes: '192x192', type: 'image/png' }, { src: './pwa/image/pwa-512x512.png', sizes: '512x512', type: 'image/png' }, { src: './pwa/image/maskable-icon-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' } ], shortcuts: [ { name: '随机点名', short_name: '随机点名', url: './#random-picker', icons: [ { src: './pwa/image/pwa-64x64.png', sizes: '64x64', type: 'image/png' } ] }, ], } }), // https://github.com/vuetifyjs/vuetify-loader/tree/master/packages/vite-plugin#readme Vuetify({ autoImport: true, styles: { configFile: 'src/styles/settings.scss', }, }), Components({ // 排除已在 index.vue 中通过 defineAsyncComponent 手动懒加载的组件 // 避免 unplugin-vue-components 生成冲突的静态 import directoryAsNamespace: false, globs: ['src/components/**/[A-Z]*.vue'], exclude: [/pages\/index\.vue$/], }), Fonts({ google: { families: [{ name: 'Roboto', styles: 'wght@300;400;500;700', }], }, }), AutoImport({ imports: [ 'vue', 'vue-router', ], eslintrc: { enabled: true, }, vueTemplate: true, }), ], define: { 'process.env': {} }, resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }, extensions: [ '.js', '.json', '.jsx', '.mjs', '.ts', '.tsx', '.vue', ], }, build: { // ===== Chunk 分割优化 ===== chunkSizeWarningLimit: 500, rollupOptions: { output: { manualChunks: { // 核心框架(极少变动,长缓存) 'vendor-vue': ['vue', 'vue-router', 'pinia'], // UI 框架 'vendor-vuetify': ['vuetify'], // 实时通信 'vendor-socket': ['socket.io-client'], // 通用工具库 'vendor-utils': ['axios', 'uuid', 'js-base64'], }, }, }, }, server: { port: 3031, watch: { ignored: ['**/data/**', path.resolve(process.cwd(), 'data', '**')] } }, css: { preprocessorOptions: { sass: { api: 'modern-compiler', }, }, }, })