外部编辑器/同步工具写入的 data/*.json 带有 BOM,直接 JSON.parse 会抛错并导致 vite dev server 退出。现在统一剥离 BOM,解析失败时返回 500 而非中断进程。
360 lines
11 KiB
JavaScript
360 lines
11 KiB
JavaScript
// 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',
|
|
},
|
|
},
|
|
},
|
|
})
|