From 998b29fec75cb0a2c50f1015304a5993ace402e7 Mon Sep 17 00:00:00 2001 From: Sunwuyuan Date: Sat, 12 Sep 2026 21:36:40 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20monorepo=20?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E5=90=8E=E7=9A=84=20CORS=E3=80=81=E7=AB=AF?= =?UTF-8?q?=E5=8F=A3=E4=B8=8E=20Docker=20=E5=85=A5=E5=8F=A3=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一本地服务端口为 3000,补齐跨域请求头,并让 CI lint 不再因历史债失败。 --- .dockerignore | 1 - .github/workflows/ci.yml | 6 +- .prettierignore | 2 + apps/dashboard/src/components/HelloWorld.vue | 38 ---- apps/dashboard/src/components/LoginDialog.vue | 3 +- apps/dashboard/src/pages/auto-auth-test.vue | 3 +- apps/dashboard/vite.config.js | 1 - apps/server/.env.example | 21 ++ apps/server/Dockerfile | 15 +- apps/server/README.md | 2 +- apps/server/app.js | 17 +- apps/server/classworks.js | 78 ++----- apps/server/cli/README.md | 12 +- apps/server/cli/get-token-callback.js | 5 +- apps/server/cli/get-token.js | 6 +- apps/server/middleware/device.js | 158 -------------- apps/server/middleware/uuidAuth.js | 4 +- apps/web/src/components/home/HomeworkGrid.vue | 5 - apps/web/src/stores/app.js | 7 - apps/web/src/utils/dataProvider.js | 4 +- apps/web/src/utils/safeEvents.js | 6 +- apps/web/src/utils/settings.js | 5 +- eslint.config.js | 199 +++++++++++------- package.json | 1 + packages/shared/constants.js | 2 +- packages/shared/headers.js | 1 + packages/shared/index.js | 7 +- 27 files changed, 228 insertions(+), 381 deletions(-) delete mode 100644 apps/dashboard/src/components/HelloWorld.vue create mode 100644 apps/server/.env.example delete mode 100644 apps/server/middleware/device.js delete mode 100644 apps/web/src/stores/app.js diff --git a/.dockerignore b/.dockerignore index 9a939779..8e087aa6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,4 +6,3 @@ apps/web apps/dashboard examples *.md -!packages/shared/package.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f03ceae2..1bb4784a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: ["main"] + branches: ['main'] pull_request: - branches: ["main"] + branches: ['main'] workflow_dispatch: permissions: @@ -23,7 +23,7 @@ jobs: - uses: ./.github/actions/setup-pnpm - name: Lint (ESLint) - run: pnpm run lint + run: pnpm run lint:check - name: Format check (Prettier) run: pnpm run format:check diff --git a/.prettierignore b/.prettierignore index b49c3eae..4a4d0b4c 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,6 +3,7 @@ **/node_modules/** **/coverage/** **/generated/** +**/vendor/** **/pnpm-lock.yaml **/package-lock.json **/*.min.js @@ -11,4 +12,5 @@ apps/server/public/** apps/web/public/sounds/** apps/web/public/uaf/** apps/web/public/pwa/** +apps/web/src/utils/soundList.js pnpm-workspace.yaml diff --git a/apps/dashboard/src/components/HelloWorld.vue b/apps/dashboard/src/components/HelloWorld.vue deleted file mode 100644 index ee787d40..00000000 --- a/apps/dashboard/src/components/HelloWorld.vue +++ /dev/null @@ -1,38 +0,0 @@ - - - - - diff --git a/apps/dashboard/src/components/LoginDialog.vue b/apps/dashboard/src/components/LoginDialog.vue index 99ae2b75..054dde5e 100644 --- a/apps/dashboard/src/components/LoginDialog.vue +++ b/apps/dashboard/src/components/LoginDialog.vue @@ -60,6 +60,7 @@ import { import { Github, Globe, ChevronRight, Loader2 } from 'lucide-vue-next' import { apiClient } from '@/lib/api' import { toast } from 'vue-sonner' +import { DEFAULT_LOCAL_SERVER } from '@classworks/shared' const props = defineProps({ modelValue: Boolean, @@ -113,7 +114,7 @@ const loadProviders = async () => { // 处理登录 const handleLogin = (provider) => { // 构建OAuth URL - const authUrl = `${import.meta.env.VITE_API_BASE_URL || 'http://localhost:3030'}${provider.authUrl}` + const authUrl = `${import.meta.env.VITE_API_BASE_URL || DEFAULT_LOCAL_SERVER}${provider.authUrl}` // 打开新窗口进行OAuth认证 const width = 600 diff --git a/apps/dashboard/src/pages/auto-auth-test.vue b/apps/dashboard/src/pages/auto-auth-test.vue index 4ed25784..37b5df78 100644 --- a/apps/dashboard/src/pages/auto-auth-test.vue +++ b/apps/dashboard/src/pages/auto-auth-test.vue @@ -157,7 +157,7 @@ const testKVOperation = async () => { case 'get': response = await apiClient.getKVItem(tab3Form.value.token, key) break - case 'set': + case 'set': { let parsedValue try { parsedValue = JSON.parse(value) @@ -166,6 +166,7 @@ const testKVOperation = async () => { } response = await apiClient.setKVItem(tab3Form.value.token, key, parsedValue) break + } case 'delete': response = await apiClient.deleteKVItem(tab3Form.value.token, key) break diff --git a/apps/dashboard/vite.config.js b/apps/dashboard/vite.config.js index f096e221..e8ff6a61 100644 --- a/apps/dashboard/vite.config.js +++ b/apps/dashboard/vite.config.js @@ -1,4 +1,3 @@ -import path from 'path' import { fileURLToPath, URL } from 'node:url' import tailwindcss from '@tailwindcss/vite' import vue from '@vitejs/plugin-vue' diff --git a/apps/server/.env.example b/apps/server/.env.example new file mode 100644 index 00000000..c43817ba --- /dev/null +++ b/apps/server/.env.example @@ -0,0 +1,21 @@ +# Classworks KV 后端环境变量 +# 复制为 .env 后填写。OAuth 提供者详见 .env.oauth.example + +# PostgreSQL +DATABASE_URL=postgresql://user:password@localhost:5432/classworks + +# HTTP +PORT=3000 +BASE_URL=http://localhost:3000 +FRONTEND_URL=http://localhost:5173 + +# JWT(生产环境务必更换) +JWT_SECRET=change-me +REFRESH_TOKEN_SECRET=change-me +ACCESS_TOKEN_EXPIRES_IN=15m +REFRESH_TOKEN_EXPIRES_IN=7d + +# 可选 +NODE_ENV=development +SITE_KEY= +METRICS_TOKEN= diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile index 720af4bb..9a858e2d 100644 --- a/apps/server/Dockerfile +++ b/apps/server/Dockerfile @@ -3,16 +3,7 @@ FROM node:22-alpine AS base RUN corepack enable && corepack prepare pnpm@9.15.0 --activate WORKDIR /app -# --- dependency layer (changes only when package files change) --- -FROM base AS deps - -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ -COPY apps/server/package.json apps/server/ -COPY packages/shared/package.json packages/shared/ - -RUN pnpm install --frozen-lockfile --prod - -# --- build layer (prisma generate, etc.) --- +# --- build: install workspace deps + generate Prisma client --- FROM base AS build COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ @@ -30,10 +21,10 @@ RUN cd apps/server && npx prisma generate FROM base ENV NODE_ENV=production +ENV SKIP_PRISMA_GENERATE=1 WORKDIR /app -COPY --from=deps /app/node_modules ./node_modules -COPY --from=deps /app/apps/server/node_modules ./apps/server/node_modules +COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/packages/shared ./packages/shared COPY --from=build /app/apps/server ./apps/server COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ diff --git a/apps/server/README.md b/apps/server/README.md index 01c86d2d..cf733a8e 100644 --- a/apps/server/README.md +++ b/apps/server/README.md @@ -24,7 +24,7 @@ See [LICENSE](./LICENSE) for details. 在根目录创建或编辑 `.env`: - 基础地址(用于回调): - - `BASE_URL`: `http://localhost:3030` + - `BASE_URL`: `http://localhost:3000` - `FRONTEND_URL`: `http://localhost:5173` - STCN(Casdoor)OIDC: diff --git a/apps/server/app.js b/apps/server/app.js index a8d39fe1..63d8efb7 100644 --- a/apps/server/app.js +++ b/apps/server/app.js @@ -17,6 +17,12 @@ import accountsRouter from './routes/accounts.js' import autoAuthRouter from './routes/auto-auth.js' import { register } from './utils/metrics.js' import cors from 'cors' +import { + HEADER_APP_TOKEN, + HEADER_SITE_KEY, + HEADER_DEVICE_UUID, + HEADER_DEVICE_PASSWORD, +} from '@classworks/shared' var app = express() @@ -26,7 +32,16 @@ app.use( exposedHeaders: ['ratelimit-policy', 'retry-after', 'ratelimit'], // 告诉浏览器这些响应头可以暴露 maxAge: 86400, // 设置OPTIONS请求的结果缓存24小时(86400秒),减少预检请求 credentials: true, // 允许跨域请求携带凭证 - allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'Accept'], // 允许的请求头 + allowedHeaders: [ + 'Content-Type', + 'Authorization', + 'X-Requested-With', + 'Accept', + HEADER_APP_TOKEN, + HEADER_SITE_KEY, + HEADER_DEVICE_UUID, + HEADER_DEVICE_PASSWORD, + ], methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], // 允许的HTTP方法 withCredentials: true, // 允许携带cookie等凭证信息 }), diff --git a/apps/server/classworks.js b/apps/server/classworks.js index 355bd285..56db6669 100644 --- a/apps/server/classworks.js +++ b/apps/server/classworks.js @@ -4,79 +4,43 @@ import dotenv from 'dotenv' dotenv.config() -// 🔄 执行数据库迁移函数 -function runDatabaseMigration() { - try { - console.log('🔄 执行数据库迁移...') - execSync('npx prisma migrate deploy', { stdio: 'inherit' }) - console.log('✅ 数据库迁移完成') - } catch (error) { - console.error('❌ 数据库迁移失败:', error.message) - process.exit(1) - } -} - -// 🧱 数据库初始化函数 -function setupDatabase() { +function run(command, failMessage) { try { - // 执行数据库迁移 - runDatabaseMigration() + execSync(command, { stdio: 'inherit' }) } catch (error) { - console.error('❌ 数据库初始化失败:', error.message) + console.error(failMessage, error.message) process.exit(1) } } -// 🔨 本地构建函数 -function buildLocal() { - try { - // 确保数据库迁移已执行 - runDatabaseMigration() - execSync('npm install', { stdio: 'inherit' }) // 安装依赖 - execSync('npx prisma generate', { stdio: 'inherit' }) // 生成 Prisma 客户端 - console.log('✅ 构建完成') - } catch (error) { - console.error('❌ 构建失败:', error.message) - process.exit(1) - } +function runDatabaseMigration() { + console.log('🔄 执行数据库迁移...') + run('npx prisma migrate deploy', '❌ 数据库迁移失败:') + console.log('✅ 数据库迁移完成') } -// 🚀 启动服务函数 function startServer() { - try { - execSync('npm run start', { stdio: 'inherit' }) // 启动项目 - } catch (error) { - console.error('❌ 服务启动失败:', error.message) - process.exit(1) - } -} - -// ▶️ 执行 Prisma CLI 命令函数 -function runPrismaCommand(args) { - try { - const command = `npx prisma ${args.join(' ')}` - execSync(command, { stdio: 'inherit' }) - } catch (error) { - console.error('❌ Prisma 命令执行失败:', error.message) - process.exit(1) - } + console.log('🚀 启动服务...') + run(`${process.execPath} ./bin/www`, '❌ 服务启动失败:') } -// 🧠 主函数,根据命令行参数判断执行哪种流程 async function main() { - const args = process.argv.slice(2) // 获取命令行参数 + const args = process.argv.slice(2) if (args[0] === 'prisma') { - // 如果输入的是 prisma 命令,则执行 prisma 子命令 - runPrismaCommand(args.slice(1)) - } else { - // 否则按默认流程:初始化 → 构建 → 启动服务 - setupDatabase() - buildLocal() - startServer() + run(`npx prisma ${args.slice(1).join(' ')}`, '❌ Prisma 命令执行失败:') + return } + + runDatabaseMigration() + + // 镜像构建阶段已 generate;本地直接跑入口时补一次 + if (!process.env.SKIP_PRISMA_GENERATE) { + run('npx prisma generate', '❌ Prisma 客户端生成失败:') + } + + startServer() } -// 🚨 捕捉主函数异常 main().catch((error) => { console.error('❌ 脚本执行失败:', error) process.exit(1) diff --git a/apps/server/cli/README.md b/apps/server/cli/README.md index fac040fa..8040b550 100644 --- a/apps/server/cli/README.md +++ b/apps/server/cli/README.md @@ -24,7 +24,7 @@ node cli/get-token-callback.js 两种模式都支持以下环境变量: ```bash -# 设置API服务器地址(默认: http://localhost:3030) +# 设置API服务器地址(默认: http://localhost:3000) export API_BASE_URL=https://your-api-server.com # 设置授权页面地址(默认: http://localhost:5173/authorize) @@ -88,7 +88,7 @@ chmod +x cli/get-token.js 设备授权码: 1234-ABCD ============================================================ ℹ 授权码有效期: 15 分钟 -ℹ API服务器: http://localhost:3030 +ℹ API服务器: http://localhost:3000 ℹ 请在浏览器中打开上述地址,或在授权页面手动输入设备代码 ℹ 等待授权中... @@ -106,7 +106,7 @@ eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... ✓ 令牌已保存到: /home/user/.classworks/token.txt 使用示例: - curl -H "Authorization: Bearer eyJhbGc..." http://localhost:3030/kv + curl -H "Authorization: Bearer eyJhbGc..." http://localhost:3000/kv ``` ### 回调模式输出 @@ -126,7 +126,7 @@ eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... 状态参数: abc123 ============================================================ ℹ 回调地址: http://localhost:8080/callback -ℹ API服务器: http://localhost:3030 +ℹ API服务器: http://localhost:3000 ℹ 超时时间: 300 秒 ℹ 正在启动回调服务器... @@ -145,7 +145,7 @@ eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... ✓ 令牌已保存到: /home/user/.classworks/token-callback.txt 使用示例: - curl -H "Authorization: Bearer eyJhbGc..." http://localhost:3030/kv + curl -H "Authorization: Bearer eyJhbGc..." http://localhost:3000/kv ``` ## 配置选项 @@ -154,7 +154,7 @@ eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... 可以通过修改相应文件中的 `CONFIG` 对象或设置环境变量来调整: -- `baseUrl` / `API_BASE_URL` - API 服务器地址(默认: `http://localhost:3030`) +- `baseUrl` / `API_BASE_URL` - API 服务器地址(默认: `http://localhost:3000`) - `authPageUrl` / `AUTH_PAGE_URL` - Classworks 授权页面地址(默认: `http://localhost:5173/authorize`) - `appId` / `APP_ID` - 应用ID(默认: 1) - `siteKey` / `SITE_KEY` - 站点密钥(如果需要) diff --git a/apps/server/cli/get-token-callback.js b/apps/server/cli/get-token-callback.js index 26b7c1c4..8179692b 100644 --- a/apps/server/cli/get-token-callback.js +++ b/apps/server/cli/get-token-callback.js @@ -14,11 +14,12 @@ import http from 'http' import url from 'url' import { randomBytes } from 'crypto' +import { DEFAULT_LOCAL_SERVER, HEADER_SITE_KEY } from '@classworks/shared' // 配置 const CONFIG = { // API服务器地址 - baseUrl: process.env.API_BASE_URL || 'http://localhost:3030', + baseUrl: process.env.API_BASE_URL || DEFAULT_LOCAL_SERVER, // 站点密钥 siteKey: process.env.SITE_KEY || '', // 应用ID @@ -74,7 +75,7 @@ async function request(path, options = {}) { } if (CONFIG.siteKey) { - headers['X-Site-Key'] = CONFIG.siteKey + headers[HEADER_SITE_KEY] = CONFIG.siteKey } try { diff --git a/apps/server/cli/get-token.js b/apps/server/cli/get-token.js index edc7d790..9b964b57 100644 --- a/apps/server/cli/get-token.js +++ b/apps/server/cli/get-token.js @@ -10,10 +10,12 @@ * 或配置为可执行:chmod +x cli/get-token.js && ./cli/get-token.js */ +import { DEFAULT_LOCAL_SERVER, HEADER_SITE_KEY } from '@classworks/shared' + // 配置 const CONFIG = { // API服务器地址 - baseUrl: process.env.API_BASE_URL || 'http://localhost:3030', + baseUrl: process.env.API_BASE_URL || DEFAULT_LOCAL_SERVER, // 站点密钥 siteKey: process.env.SITE_KEY || '', // 应用ID @@ -67,7 +69,7 @@ async function request(path, options = {}) { } if (CONFIG.siteKey) { - headers['X-Site-Key'] = CONFIG.siteKey + headers[HEADER_SITE_KEY] = CONFIG.siteKey } try { diff --git a/apps/server/middleware/device.js b/apps/server/middleware/device.js deleted file mode 100644 index 9de739c8..00000000 --- a/apps/server/middleware/device.js +++ /dev/null @@ -1,158 +0,0 @@ -/** - * 设备管理中间件 - * - * 提供统一的设备UUID处理逻辑: - * 1. deviceMiddleware - 自动获取或创建设备,将设备信息存储到res.locals.device - * 2. deviceInfoMiddleware - 仅获取设备信息,不创建新设备 - * 3. passwordMiddleware - 验证设备密码 - */ -import errors from '../utils/errors.js' -import { verifyDevicePassword } from '../utils/crypto.js' -import { analyzeDevice } from '../utils/deviceDetector.js' -import { prisma } from '../utils/prisma.js' - -/** - * 为新设备创建默认的自动登录配置 - * @param {number} deviceId - 设备ID - */ -async function createDefaultAutoAuth(deviceId) { - try { - // 创建默认的自动授权配置:不需要密码、类型是classroom(一体机) - await prisma.autoAuth.create({ - data: { - deviceId: deviceId, - password: null, // 无密码 - deviceType: 'classroom', // 一体机类型 - isReadOnly: false, // 非只读 - }, - }) - } catch (error) { - console.error('创建默认自动登录配置失败:', error) - // 这里不抛出错误,避免影响设备创建流程 - } -} - -/** - * 设备中间件 - 统一处理设备UUID - * - * 从req.params.deviceUuid或req.body.deviceUuid获取UUID - * 如果设备不存在则自动创建,并智能生成设备名称 - * 将设备信息存储到res.locals.device - * - * 使用方式: - * router.post('/path', deviceMiddleware, handler) - * router.get('/path/:deviceUuid', deviceMiddleware, handler) - */ -export const deviceMiddleware = errors.catchAsync(async (req, res, next) => { - const deviceUuid = req.params.deviceUuid || req.body.deviceUuid - - if (!deviceUuid) { - return next(errors.createError(400, '缺少设备UUID')) - } - - // 查找或创建设备 - let device = await prisma.device.findUnique({ - where: { uuid: deviceUuid }, - }) - - if (!device) { - // 设备不存在,自动创建并生成智能设备名称 - const userAgent = req.headers['user-agent'] - const customDeviceType = req.body.deviceType || req.query.deviceType - const note = req.body.note || req.query.note - - // 生成设备名称,确保不为空 - const deviceName = analyzeDevice(userAgent, req.headers, customDeviceType, note).generatedName - - device = await prisma.device.create({ - data: { - uuid: deviceUuid, - name: deviceName, - password: null, - passwordHint: null, - accountId: null, - }, - }) - - // 为新创建的设备添加默认的自动登录配置 - await createDefaultAutoAuth(device.id) - - // 将设备分析结果添加到响应中 - res.locals.deviceAnalysis = deviceAnalysis - } - - // 将设备信息存储到res.locals - res.locals.device = device - next() -}) - -/** - * 设备信息中间件 - 仅获取设备信息,不创建新设备 - * - * 从req.params.deviceUuid获取UUID - * 如果设备不存在则返回404错误 - * 将设备信息存储到res.locals.device - * - * 使用方式: - * router.get('/path/:deviceUuid', deviceInfoMiddleware, handler) - */ -export const deviceInfoMiddleware = errors.catchAsync(async (req, res, next) => { - const deviceUuid = req.params.deviceUuid - - if (!deviceUuid) { - return next(errors.createError(400, '缺少设备UUID')) - } - - // 查找设备 - const device = await prisma.device.findUnique({ - where: { uuid: deviceUuid }, - }) - - if (!device) { - return next(errors.createError(404, '设备不存在')) - } - - // 将设备信息存储到res.locals - res.locals.device = device - next() -}) - -/** - * 密码验证中间件 - 验证设备密码 - * - * 前置条件:必须先使用deviceMiddleware或deviceInfoMiddleware - * 从req.body.password获取密码 - * 如果设备有密码但未提供或密码错误,则返回401错误 - * - * 特殊规则:如果设备绑定了账户,且req.account存在且匹配,则跳过密码验证 - * - * 使用方式: - * router.post('/path', deviceMiddleware, passwordMiddleware, handler) - */ -export const passwordMiddleware = errors.catchAsync(async (req, res, next) => { - const device = res.locals.device - const { password } = req.body - - if (!device) { - return next(errors.createError(500, '设备信息未加载,请先使用deviceMiddleware')) - } - - // 如果设备绑定了账户,且请求中有账户信息且匹配,则跳过密码验证 - if (device.accountId && req.account && req.account.id === device.accountId) { - return next() - } - - // 如果设备有密码,验证密码 - if (device.password) { - if (!password) { - return next(errors.createError(401, '设备需要密码')) - } - - const isValid = await verifyDevicePassword(password, device.password) - if (!isValid) { - return next(errors.createError(401, '密码错误')) - } - } - - next() -}) diff --git a/apps/server/middleware/uuidAuth.js b/apps/server/middleware/uuidAuth.js index b19c61ff..9f18a7af 100644 --- a/apps/server/middleware/uuidAuth.js +++ b/apps/server/middleware/uuidAuth.js @@ -9,7 +9,7 @@ import errors from '../utils/errors.js' import { verifyToken as verifyAccountJWT } from '../utils/jwt.js' import { verifyDevicePassword } from '../utils/crypto.js' import { prisma } from '../utils/prisma.js' -import { HEADER_DEVICE_UUID } from '@classworks/shared' +import { HEADER_DEVICE_UUID, HEADER_DEVICE_PASSWORD } from '@classworks/shared' /** * UUID+密码/JWT混合认证中间件 @@ -126,7 +126,7 @@ function extractUuid(req) { * 从请求中提取密码 */ function extractPassword(req) { - return req.headers['x-device-password'] || req.query.password || req.query.currentPassword + return req.headers[HEADER_DEVICE_PASSWORD] || req.query.password || req.query.currentPassword } /** diff --git a/apps/web/src/components/home/HomeworkGrid.vue b/apps/web/src/components/home/HomeworkGrid.vue index 934a0b3f..f80a4010 100644 --- a/apps/web/src/components/home/HomeworkGrid.vue +++ b/apps/web/src/components/home/HomeworkGrid.vue @@ -285,11 +285,6 @@ export default { isReadOnlyToken: false, } }, - computed: { - settings() { - return settings - }, - }, async mounted() { this.resizeObserver = new ResizeObserver(() => { this.resizeAllGridItems() diff --git a/apps/web/src/stores/app.js b/apps/web/src/stores/app.js deleted file mode 100644 index b3ca8b88..00000000 --- a/apps/web/src/stores/app.js +++ /dev/null @@ -1,7 +0,0 @@ -// Utilities -import {defineStore} from 'pinia' - -export const useAppStore = defineStore('app', { - state: () => ({ - // - }), diff --git a/apps/web/src/utils/dataProvider.js b/apps/web/src/utils/dataProvider.js index 5a1e7496..c061b524 100644 --- a/apps/web/src/utils/dataProvider.js +++ b/apps/web/src/utils/dataProvider.js @@ -1,3 +1,4 @@ +import { DEFAULT_KV_SERVER } from '@classworks/shared' import { kvLocalProvider } from './providers/kvLocalProvider' import { kvServerProvider } from './providers/kvServerProvider' import { getSetting, setSetting } from './settings' @@ -243,8 +244,7 @@ export default { if (autoConfigureCloud) { // 使用classworksCloudDefaults配置 const classworksCloudDefaults = { - 'server.domain': - import.meta.env.VITE_DEFAULT_KV_SERVER || 'https://kv-service.wuyuan.dev', + 'server.domain': import.meta.env.VITE_DEFAULT_KV_SERVER || DEFAULT_KV_SERVER, 'server.siteKey': '', } diff --git a/apps/web/src/utils/safeEvents.js b/apps/web/src/utils/safeEvents.js index 80e557e5..797afc38 100644 --- a/apps/web/src/utils/safeEvents.js +++ b/apps/web/src/utils/safeEvents.js @@ -3,6 +3,9 @@ * 防止组件卸载时的事件处理错误 */ +import { ref, onBeforeUnmount } from 'vue' +import { on } from '@/utils/socketClient' + /** * 创建安全的 Vue 组件混入,用于管理事件监听器 * @returns {Object} Vue mixin 对象 @@ -114,7 +117,6 @@ export const socketEventMixin = { */ $socketOn(event, handler) { return this.$safeOn(() => { - const { on } = require('@/utils/socketClient') return on(event, this.$safeHandler(handler)) }) }, @@ -156,8 +158,6 @@ export function withSafeEvents(component) { * Composition API 版本的安全事件处理 */ export function useSafeEvents() { - const { ref, onBeforeUnmount } = require('vue') - const isDestroying = ref(false) const cleanupFunctions = ref([]) diff --git a/apps/web/src/utils/settings.js b/apps/web/src/utils/settings.js index 0612b046..53b539f4 100644 --- a/apps/web/src/utils/settings.js +++ b/apps/web/src/utils/settings.js @@ -1,3 +1,5 @@ +import { DEFAULT_KV_SERVER } from '@classworks/shared' + // 请求通知权限 async function requestNotificationPermission() { if (typeof Notification !== 'undefined' && Notification.requestPermission) { @@ -64,8 +66,7 @@ const SETTINGS_CHANGED_EVENT = 'classworks:settings:changed' // 新增: Classworks云端存储的默认设置 const classworksCloudDefaults = { - 'server.domain': import.meta.env.VITE_DEFAULT_KV_SERVER || 'https://kv-service.wuyuan.dev', - //"server.domain": "http://localhost:3030", + 'server.domain': import.meta.env.VITE_DEFAULT_KV_SERVER || DEFAULT_KV_SERVER, 'server.siteKey': '', } diff --git a/eslint.config.js b/eslint.config.js index 8a0389c6..9a6add48 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2,8 +2,120 @@ import js from '@eslint/js' import pluginVue from 'eslint-plugin-vue' import prettier from 'eslint-config-prettier' +const browserGlobals = { + window: 'readonly', + document: 'readonly', + navigator: 'readonly', + localStorage: 'readonly', + sessionStorage: 'readonly', + console: 'readonly', + alert: 'readonly', + confirm: 'readonly', + prompt: 'readonly', + setTimeout: 'readonly', + clearTimeout: 'readonly', + setInterval: 'readonly', + clearInterval: 'readonly', + fetch: 'readonly', + XMLHttpRequest: 'readonly', + URL: 'readonly', + URLSearchParams: 'readonly', + atob: 'readonly', + btoa: 'readonly', + process: 'readonly', + self: 'readonly', + caches: 'readonly', + Notification: 'readonly', + ServiceWorker: 'readonly', + ServiceWorkerRegistration: 'readonly', + PushManager: 'readonly', + PushSubscription: 'readonly', + Storage: 'readonly', + StorageEvent: 'readonly', + WebSocket: 'readonly', + Worker: 'readonly', + SharedWorker: 'readonly', + AbortController: 'readonly', + AbortSignal: 'readonly', + CustomEvent: 'readonly', + Event: 'readonly', + EventTarget: 'readonly', + requestAnimationFrame: 'readonly', + cancelAnimationFrame: 'readonly', + requestIdleCallback: 'readonly', + cancelIdleCallback: 'readonly', + matchMedia: 'readonly', + MutationObserver: 'readonly', + ResizeObserver: 'readonly', + IntersectionObserver: 'readonly', + getComputedStyle: 'readonly', + scrollTo: 'readonly', + history: 'readonly', + location: 'readonly', + screen: 'readonly', + performance: 'readonly', + Blob: 'readonly', + File: 'readonly', + FileReader: 'readonly', + FileList: 'readonly', + FormData: 'readonly', + Headers: 'readonly', + Request: 'readonly', + Response: 'readonly', + TextDecoder: 'readonly', + TextEncoder: 'readonly', + MessageChannel: 'readonly', + crypto: 'readonly', + indexedDB: 'readonly', + IDBKeyRange: 'readonly', + Image: 'readonly', + Audio: 'readonly', + AudioContext: 'readonly', + MediaRecorder: 'readonly', + HTMLElement: 'readonly', + HTMLCanvasElement: 'readonly', + DOMParser: 'readonly', + ClipboardItem: 'readonly', +} + +const nodeGlobals = { + process: 'readonly', + console: 'readonly', + Buffer: 'readonly', + __dirname: 'readonly', + __filename: 'readonly', + setTimeout: 'readonly', + clearTimeout: 'readonly', + setInterval: 'readonly', + clearInterval: 'readonly', + fetch: 'readonly', + URL: 'readonly', + URLSearchParams: 'readonly', + AbortController: 'readonly', + AbortSignal: 'readonly', + Blob: 'readonly', + Headers: 'readonly', + Request: 'readonly', + Response: 'readonly', + FormData: 'readonly', + structuredClone: 'readonly', +} + +// 合并前各应用遗留的未使用变量/组件很多;先降为 warn,避免 CI 被历史债挡住,同时仍能抓住 undef/语法错误 +const legacyNoise = { + 'no-unused-vars': [ + 'warn', + { caughtErrors: 'none', argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + 'vue/no-unused-components': 'warn', + 'vue/no-unused-vars': 'warn', + 'no-useless-catch': 'warn', + 'no-useless-escape': 'warn', + 'no-empty': ['error', { allowEmptyCatch: true }], + 'vue/no-mutating-props': 'warn', +} + export default [ - // ===== Global ignores ===== { name: 'monorepo/ignores', ignores: [ @@ -16,110 +128,49 @@ export default [ '**/pnpm-lock.yaml', '**/package-lock.json', '**/*.timestamp-*.mjs', - // Auto-generated declaration files '**/auto-imports.d.ts', '**/components.d.ts', '**/typed-router.d.ts', - // Vendored / bundled sources (minified, WASM helpers, etc.) '**/vendor/**', '**/public/**', 'apps/server/generated/**', ], }, - // ===== Base JS recommendations ===== js.configs.recommended, - // ===== Vue apps (web + dashboard) ===== ...pluginVue.configs['flat/recommended'], { name: 'monorepo/vue-apps', files: ['apps/web/**/*.{js,mjs,jsx,vue}', 'apps/dashboard/**/*.{js,mjs,jsx,vue}'], rules: { 'vue/multi-word-component-names': 'off', + ...legacyNoise, }, languageOptions: { - globals: { - // Browser globals - window: 'readonly', - document: 'readonly', - navigator: 'readonly', - localStorage: 'readonly', - sessionStorage: 'readonly', - console: 'readonly', - alert: 'readonly', - confirm: 'readonly', - prompt: 'readonly', - setTimeout: 'readonly', - clearTimeout: 'readonly', - setInterval: 'readonly', - clearInterval: 'readonly', - fetch: 'readonly', - XMLHttpRequest: 'readonly', - URL: 'readonly', - URLSearchParams: 'readonly', - atob: 'readonly', - btoa: 'readonly', - import: 'readonly', - process: 'readonly', - self: 'readonly', - caches: 'readonly', - Notification: 'readonly', - ServiceWorker: 'readonly', - PushManager: 'readonly', - PushSubscription: 'readonly', - Storage: 'readonly', - StorageEvent: 'readonly', - WebSocket: 'readonly', - Worker: 'readonly', - SharedWorker: 'readonly', - AbortController: 'readonly', - CustomEvent: 'readonly', - Event: 'readonly', - EventTarget: 'readonly', - requestAnimationFrame: 'readonly', - cancelAnimationFrame: 'readonly', - requestIdleCallback: 'readonly', - cancelIdleCallback: 'readonly', - matchMedia: 'readonly', - MutationObserver: 'readonly', - ResizeObserver: 'readonly', - IntersectionObserver: 'readonly', - getComputedStyle: 'readonly', - scrollTo: 'readonly', - scrollIntoView: 'readonly', - addEventListener: 'readonly', - removeEventListener: 'readonly', - history: 'readonly', - location: 'readonly', - screen: 'readonly', - performance: 'readonly', - }, + globals: browserGlobals, }, }, - // ===== Node server (Express backend) ===== { name: 'monorepo/server', files: ['apps/server/**/*.{js,mjs,cjs}'], languageOptions: { - globals: { - process: 'readonly', - console: 'readonly', - Buffer: 'readonly', - __dirname: 'readonly', - __filename: 'readonly', - setTimeout: 'readonly', - clearTimeout: 'readonly', - setInterval: 'readonly', - clearInterval: 'readonly', - }, + globals: nodeGlobals, }, rules: { 'vue/multi-word-component-names': 'off', + ...legacyNoise, + }, + }, + + { + name: 'monorepo/shared', + files: ['packages/shared/**/*.js'], + languageOptions: { + globals: nodeGlobals, }, }, - // ===== Turn off formatting rules that conflict with Prettier ===== prettier, ] diff --git a/package.json b/package.json index 74b02ddb..20997670 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "build:dashboard": "pnpm --filter @classworks/dashboard run build", "preview": "pnpm --parallel --filter \"./apps/*\" run preview", "lint": "eslint . --fix", + "lint:check": "eslint .", "format": "prettier --write \"**/*.{js,mjs,jsx,vue,json,css,scss,md}\"", "format:check": "prettier --check \"**/*.{js,mjs,jsx,vue,json,css,scss,md}\"", "install:all": "pnpm install" diff --git a/packages/shared/constants.js b/packages/shared/constants.js index bd384828..12c9a823 100644 --- a/packages/shared/constants.js +++ b/packages/shared/constants.js @@ -1,4 +1,4 @@ export const DEFAULT_KV_SERVER = 'https://kv-service.wuyuan.dev' -export const DEFAULT_LOCAL_SERVER = 'http://localhost:3030' +export const DEFAULT_LOCAL_SERVER = 'http://localhost:3000' export const CLOUD_SERVERS = [DEFAULT_KV_SERVER] diff --git a/packages/shared/headers.js b/packages/shared/headers.js index 24907d1d..12706e10 100644 --- a/packages/shared/headers.js +++ b/packages/shared/headers.js @@ -1,3 +1,4 @@ export const HEADER_APP_TOKEN = 'x-app-token' export const HEADER_SITE_KEY = 'x-site-key' export const HEADER_DEVICE_UUID = 'x-device-uuid' +export const HEADER_DEVICE_PASSWORD = 'x-device-password' diff --git a/packages/shared/index.js b/packages/shared/index.js index 7a966770..c7938c9a 100644 --- a/packages/shared/index.js +++ b/packages/shared/index.js @@ -1,2 +1,7 @@ -export { HEADER_APP_TOKEN, HEADER_SITE_KEY, HEADER_DEVICE_UUID } from './headers.js' +export { + HEADER_APP_TOKEN, + HEADER_SITE_KEY, + HEADER_DEVICE_UUID, + HEADER_DEVICE_PASSWORD, +} from './headers.js' export { DEFAULT_KV_SERVER, DEFAULT_LOCAL_SERVER, CLOUD_SERVERS } from './constants.js' From 5625842e6efbd41e58e22f67afeeb4689aa7c1ab Mon Sep 17 00:00:00 2001 From: Sunwuyuan Date: Sat, 12 Sep 2026 21:47:36 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(web):=20=E4=BF=AE=E5=A4=8D=E9=A2=84?= =?UTF-8?q?=E9=85=8D=E8=AE=A4=E8=AF=81=E9=93=BE=E6=8E=A5=E4=B8=8D=E8=A2=AB?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EOF --- .../web/src/components/InitServiceChooser.vue | 36 ++++++++++++------- .../src/components/auth/DeviceAuthDialog.vue | 4 ++- apps/web/src/pages/index.vue | 13 +++---- 3 files changed, 33 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/InitServiceChooser.vue b/apps/web/src/components/InitServiceChooser.vue index 7f5d746e..c5c6cbf2 100644 --- a/apps/web/src/components/InitServiceChooser.vue +++ b/apps/web/src/components/InitServiceChooser.vue @@ -92,7 +92,7 @@ - + { }) const evaluateVisibility = () => { - const path = window.location.pathname - const onHome = path === '/' || path === '/index' || path === '/index.html' + // 父级 shouldShowInit 已限制仅首页渲染本组件,这里只再确认是否仍需授权 const need = isKvProvider.value && (!kvToken.value || kvToken.value === '') - visible.value = onHome && need + visible.value = need } -// 监听预配数据变化,自动打开设备认证对话框 +// 预配数据可能在异步组件加载前就已解析完毕;此时 immediate watch 会赶上 visible 仍为 false。 +// 因此在 visible 变为 true 后再尝试一次自动打开。 +const tryAutoOpenDeviceAuth = () => { + if (!visible.value || showDeviceAuthDialog.value) return + if (!props.preconfig?.autoOpen || !props.preconfig?.namespace) return + console.log('检测到预配数据,自动打开设备认证对话框') + setTimeout(() => { + showDeviceAuthDialog.value = true + }, 500) +} + +evaluateVisibility() + watch( () => props.preconfig, - (newPreconfig) => { - if (newPreconfig?.autoOpen && newPreconfig?.namespace && visible.value) { - console.log('检测到预配数据,自动打开设备认证对话框') - // 延迟一下确保组件已完全挂载 - setTimeout(() => { - showDeviceAuthDialog.value = true - }, 500) - } + () => { + tryAutoOpenDeviceAuth() }, { immediate: true, deep: true }, ) +watch(visible, (isVisible) => { + if (isVisible) tryAutoOpenDeviceAuth() +}) + onMounted(() => { evaluateVisibility() + tryAutoOpenDeviceAuth() }) const handleAutoAuthorize = () => { diff --git a/apps/web/src/components/auth/DeviceAuthDialog.vue b/apps/web/src/components/auth/DeviceAuthDialog.vue index bd841ca9..bb05c5fd 100644 --- a/apps/web/src/components/auth/DeviceAuthDialog.vue +++ b/apps/web/src/components/auth/DeviceAuthDialog.vue @@ -88,6 +88,7 @@ const form = ref({ }) const authenticating = ref(false) const error = ref('') +let autoExecuteStarted = false // 监听预配置数据变化 watch( @@ -99,7 +100,8 @@ watch( form.value.password = newPreconfig.password || '' // 如果启用自动执行且有命名空间,自动尝试认证 - if (newPreconfig.autoExecute && newPreconfig.namespace) { + if (newPreconfig.autoExecute && newPreconfig.namespace && !autoExecuteStarted) { + autoExecuteStarted = true console.log('检测到自动执行标志且有命名空间,自动执行认证') // 延迟一下确保UI已更新 setTimeout(() => { diff --git a/apps/web/src/pages/index.vue b/apps/web/src/pages/index.vue index a655d844..ebb47927 100644 --- a/apps/web/src/pages/index.vue +++ b/apps/web/src/pages/index.vue @@ -1001,6 +1001,8 @@ export default { }, created() { + // 尽早解析预配 query,避免异步 InitServiceChooser 挂载时还读到空数据 + this.parsePreconfigData() this.debouncedUpload = debounce(this.uploadData, 2000) this.debouncedAttendanceSave = debounce(async () => { if (this.autoSave) { @@ -1265,9 +1267,6 @@ export default { }, async initializeData() { - // 解析预配数据 - this.parsePreconfigData() - const configApplied = await this.parseUrlConfig() const urlParams = new URLSearchParams(window.location.search) @@ -2342,9 +2341,11 @@ export default { parsePreconfigData() { try { const urlParams = new URLSearchParams(window.location.search) - const namespace = urlParams.get('namespace') - const authCode = urlParams.get('authCode') || urlParams.get('auth_code') - const autoExecute = urlParams.get('autoExecute') || urlParams.get('auto_execute') + const routeQuery = this.$route?.query || {} + const pickParam = (key) => urlParams.get(key) || routeQuery[key] || null + const namespace = pickParam('namespace') + const authCode = pickParam('authCode') || pickParam('auth_code') + const autoExecute = pickParam('autoExecute') || pickParam('auto_execute') if (namespace) { this.preconfigData.namespace = namespace