diff --git a/.gitignore b/.gitignore index 1b28a7f7..a171b02a 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,16 @@ public/chunk-manifest.json public/service.worker.js #db -db/generated/ \ No newline at end of file +db/generated/ + +# Environment variables +.env.local + +# IDE +.cursor/ + +# OS +.DS_Store + +# Logs +*.log diff --git a/db/Untitled-1 b/db/Untitled-1 deleted file mode 100644 index bbb61c36..00000000 --- a/db/Untitled-1 +++ /dev/null @@ -1,30 +0,0 @@ -{ - 名称: string, - 是否可捕捉: string, - 等级: number[], - 经验值: number, - 元素属性: string, - 最大生命值: number, - 物理防御: number, - 物理抗性: number, - 魔法防御: number, - 魔法抗性: number, - 暴击抵抗: number, - 回避: number, - 闪躲率: number, - 格挡率: number, - 一般惯性变动率: number, - 物理惯性变动率: number, - 魔法惯性变动率: number, - 额外说明: string, - 所属活动: string, - 所属地图: string[], - 掉落物: { - 道具名称: string, - 道具类型: string, - 破位情况?: { - 部位: string, - 奖励:"可掉落" | "掉落提升" - } - }[] -}[] diff --git a/db/schema/enums.ts b/db/schema/enums.ts index bf5eec62..d30e3d98 100644 --- a/db/schema/enums.ts +++ b/db/schema/enums.ts @@ -426,4 +426,12 @@ export const REGISLET_TYPE = [ "SmashEnhance", "SonicWaveEnhance", ] as const; -export type RegisletType = (typeof REGISLET_TYPE)[number]; \ No newline at end of file +export type RegisletType = (typeof REGISLET_TYPE)[number]; + +// 料理审核状态 +export const DISH_REVIEW_STATUS = ["Pending", "Approved", "Rejected"] as const; +export type DishReviewStatus = (typeof DISH_REVIEW_STATUS)[number]; + +// 料理来源 +export const DISH_SOURCE = ["Web", "QQBot"] as const; +export type DishSource = (typeof DISH_SOURCE)[number]; \ No newline at end of file diff --git a/db/schema/models/data.prisma b/db/schema/models/data.prisma index 5e874f7f..bb3eb806 100644 --- a/db/schema/models/data.prisma +++ b/db/schema/models/data.prisma @@ -837,3 +837,45 @@ model member { belongToTeam team @relation(fields: [belongToTeamId], references: [id], onDelete: Cascade) belongToTeamId String } + +// 料理表 +model dish { + id String @id + + name String // 料理名字 + level Int // 料理等级 (1-10) + playerId String // 门牌号(玩家ID) + source String // Enum DISH_SOURCE 来源:Web或QQBot + status String // Enum DISH_REVIEW_STATUS 审核状态:Pending/Approved/Rejected + qqNumber String? // QQ号(如果是QQ机器人提交的) + remark String? // 备注/审核意见 + + createdAt DateTime + updatedAt DateTime @updatedAt + + reviewedAt DateTime? // 审核时间 + reviewedById String? // 审核人ID + reviewedBy account? @relation("dish_reviewed_by", fields: [reviewedById], references: [id], onDelete: SetNull) + + submittedById String // 提交人ID + submittedBy account @relation("dish_submitted_by", fields: [submittedById], references: [id], onDelete: Cascade) + + @@index([status]) + @@index([level]) + @@index([playerId]) +} + +// 料理配置表 +model dish_config { + id String @id + + key String @unique // 配置键名 + value String // 配置值 + remark String? // 备注 + + createdAt DateTime + updatedAt DateTime @updatedAt + + updatedById String? // 更新人ID + updatedBy account? @relation("dish_config_updated_by", fields: [updatedById], references: [id], onDelete: SetNull) +} diff --git a/db/schema/models/user.prisma b/db/schema/models/user.prisma index 83188bf0..17c82ee0 100644 --- a/db/schema/models/user.prisma +++ b/db/schema/models/user.prisma @@ -29,6 +29,11 @@ model account { create account_create_data? update account_update_data? + // 料理相关 + submittedDishes dish[] @relation("dish_submitted_by") + reviewedDishes dish[] @relation("dish_reviewed_by") + dishConfigs dish_config[] @relation("dish_config_updated_by") + @@unique([provider, providerAccountId]) } diff --git a/db/scripts/migrate-dish.ts b/db/scripts/migrate-dish.ts new file mode 100644 index 00000000..a7a092b2 --- /dev/null +++ b/db/scripts/migrate-dish.ts @@ -0,0 +1,104 @@ +/** + * @file migrate-dish.ts + * @description 手动迁移脚本:创建 dish 和 dish_config 表 + */ + +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import dotenv from "dotenv"; +import dotenvExpand from "dotenv-expand"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// 加载环境变量 +dotenvExpand.expand(dotenv.config({ path: path.join(__dirname, "../.env") })); + +const PG_HOST = process.env.PG_HOST?.replace("${VITE_SERVER_HOST}", "localhost") || "localhost"; +const PG_PORT = parseInt(process.env.PG_PORT || "5432"); +const PG_USERNAME = process.env.PG_USERNAME || "postgres"; +const PG_PASSWORD = process.env.PG_PASSWORD || "123456"; +const PG_DBNAME = process.env.PG_DBNAME || "postgres"; + +const createTablesSQL = ` +-- 创建 dish 表 +CREATE TABLE IF NOT EXISTS "dish" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "level" INTEGER NOT NULL, + "playerId" TEXT NOT NULL, + "source" TEXT NOT NULL, + "status" TEXT NOT NULL, + "qqNumber" TEXT, + "remark" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + "reviewedAt" TIMESTAMP(3), + "reviewedById" TEXT, + "submittedById" TEXT NOT NULL, + CONSTRAINT "dish_pkey" PRIMARY KEY ("id") +); + +-- 创建 dish_config 表 +CREATE TABLE IF NOT EXISTS "dish_config" ( + "id" TEXT NOT NULL, + "key" TEXT NOT NULL, + "value" TEXT NOT NULL, + "remark" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + "updatedById" TEXT, + CONSTRAINT "dish_config_pkey" PRIMARY KEY ("id") +); + +-- 创建唯一索引 +CREATE UNIQUE INDEX IF NOT EXISTS "dish_config_key_key" ON "dish_config"("key"); + +-- 创建索引 +CREATE INDEX IF NOT EXISTS "dish_status_idx" ON "dish"("status"); +CREATE INDEX IF NOT EXISTS "dish_level_idx" ON "dish"("level"); +CREATE INDEX IF NOT EXISTS "dish_playerId_idx" ON "dish"("playerId"); + +-- 添加外键约束 +ALTER TABLE "dish" ADD CONSTRAINT "dish_reviewedById_fkey" + FOREIGN KEY ("reviewedById") REFERENCES "account"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "dish" ADD CONSTRAINT "dish_submittedById_fkey" + FOREIGN KEY ("submittedById") REFERENCES "account"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "dish_config" ADD CONSTRAINT "dish_config_updatedById_fkey" + FOREIGN KEY ("updatedById") REFERENCES "account"("id") ON DELETE SET NULL ON UPDATE CASCADE; +`; + +// 主函数 +async function main() { + console.log("开始创建 dish 和 dish_config 表..."); + console.log(`连接到: ${PG_HOST}:${PG_PORT}/${PG_DBNAME}`); + + // 动态导入 pg + const pg = await import("pg"); + const client = new pg.Client({ + host: PG_HOST, + port: PG_PORT, + user: PG_USERNAME, + password: PG_PASSWORD, + database: PG_DBNAME, + }); + + try { + await client.connect(); + console.log("✅ 数据库连接成功"); + + await client.query(createTablesSQL); + console.log("✅ 表创建成功!"); + + } catch (error) { + console.error("❌ 执行失败:", (error as Error).message); + throw error; + } finally { + await client.end(); + } +} + +main().catch((error) => { + console.error("迁移失败:", error); + process.exit(1); +}); \ No newline at end of file diff --git a/package.json b/package.json index 2cb3f019..35c664ff 100644 --- a/package.json +++ b/package.json @@ -3,31 +3,26 @@ "type": "module", "scripts": { "setup": "pnpm generate && pnpm infra:reset", - "dev": "vite dev", "start": "PORT=3001 node .output/server/index.mjs", - "generate": "pnpm generate:inject && pnpm generate:schema && pnpm generate:colorSystem", "generate:inject": "tsx db/generator/injectEnums.ts", "generate:schema": "prisma generate --schema=db/generated/schema.prisma", "generate:colorSystem": "tsx src/styles/colorSystem/generator/generator.ts", - "infra:up": "docker compose --env-file .env -f ./backend/docker-compose.yaml up -d", "infra:stop": "docker compose --env-file .env -f ./backend/docker-compose.yaml stop", "infra:down": "docker compose --env-file .env -f ./backend/docker-compose.yaml down --volumes", "infra:reset": "pnpm infra:down && pnpm infra:up && pnpm db:restore", - "db:studio": "prisma studio --config=db/studio.config.ts", "db:backup": "tsx db/scripts/backup.ts", "db:restore": "tsx db/scripts/restore.ts", - "build": "node src/worker/sw/build.mjs && NODE_OPTIONS=--max-old-space-size=4096 vite build", - "clean": "pnpm clean:build && pnpm clean:generated", "clean:generated": "rm -rf db/generated", "clean:build": "rm -rf dist .output .nitro bundle.tar.gz public/service.worker.js public/chunk-manifest.json", - "package": "tar -czf bundle.tar.gz .output/" + "package": "tar -czf bundle.tar.gz .output/", + "prepare": "husky" }, "devDependencies": { "@babylonjs/inspector": "8.53.0", @@ -37,6 +32,7 @@ "@types/js-cookie": "^3.0.6", "@types/node": "^22.7.9", "@types/pg": "^8.11.11", + "@types/ws": "^8.18.1", "esbuild": "^0.25.6", "prisma": "7.2.0", "tsx": "^4.21.0", @@ -93,6 +89,7 @@ "solid-motionone": "^1.0.4", "tailwindcss": "latest", "vite": "^7.0.0", + "ws": "^8.20.0", "xstate": "^5.19.2", "zod": "^4.1.12" }, diff --git a/src/components/controls/input.tsx b/src/components/controls/input.tsx index c4e23b2f..6dc6ea1b 100644 --- a/src/components/controls/input.tsx +++ b/src/components/controls/input.tsx @@ -15,11 +15,14 @@ interface InputProps extends JSX.InputHTMLAttributes { state?: InputStateType; validationMessage?: string; inputWidth?: number; + setValue?: (value: string) => void; } export const Input = (props: InputProps) => { const hasChildren = "children" in props; const id = `input-${createId()}`; + // 从 props 中排除 setValue、value、type,避免传递给 DOM 元素时冲突 + const { setValue, value, type, class: classProp, ...restProps } = props; const getSizeClass = () => { const sizeMap = { @@ -78,32 +81,35 @@ export const Input = (props: InputProps) => { 未知类型的输入框}> setValue?.(e.currentTarget.value)} /> setValue?.(e.currentTarget.value)} /> setValue?.(e.currentTarget.value)} /> diff --git a/src/components/features/BtEditor/components/ExamplesMenu/DishMenu.tsx b/src/components/features/BtEditor/components/ExamplesMenu/DishMenu.tsx new file mode 100644 index 00000000..6262735a --- /dev/null +++ b/src/components/features/BtEditor/components/ExamplesMenu/DishMenu.tsx @@ -0,0 +1,95 @@ +import { type Component, createSignal, For, Show, createResource } from "solid-js"; +import { A } from "@solidjs/router"; +import { Button } from "~/components/controls/button"; +import { Icons } from "~/components/icons"; +import { Divider, Menu, MenuItem, MenuList } from "../"; + +export type DishMenuProps = { + // 可选:点击料理项的回调 + onDishSelect?: (dish: { id: string; name: string; level: number; playerId: string }) => void; +}; + +// 获取已审核的料理列表 +async function fetchApprovedDishes() { + try { + const response = await fetch("/api/dish?status=Approved&pageSize=50"); + if (!response.ok) { + console.error("获取料理列表失败:", response.status); + return []; + } + const data = await response.json(); + return data.data as Array<{ + id: string; + name: string; + level: number; + playerId: string; + }> ?? []; + } catch (error) { + console.error("获取料理列表失败:", error); + return []; + } +} + +export const DishMenu: Component = (props) => { + const [anchorEl, setAnchorEl] = createSignal(null); + const open = () => Boolean(anchorEl()); + + const [dishes] = createResource(fetchApprovedDishes); + + const handleClick = (e: MouseEvent) => { + setAnchorEl(e.currentTarget as HTMLElement); + }; + + const handleClose = () => { + setAnchorEl(null); + }; + + const handleDishClick = (dish: { id: string; name: string; level: number; playerId: string }) => { + setAnchorEl(null); + props.onDishSelect?.(dish); + }; + + // 格式化显示:料理名字(等级)门牌号 + const formatDish = (dish: { name: string; level: number; playerId: string }) => { + return `${dish.name}(${dish.level})${dish.playerId}`; + }; + + return ( + <> + + +
+ 料理名单 +
+ +
加载中...
+
+ +
暂无已审核的料理
+
+ + + + {(dish) => ( + handleDishClick(dish)}> + {formatDish(dish)} + + )} + + + + + + +
+ + 料理管理 +
+
+
+
+ + ); +}; diff --git a/src/components/features/BtEditor/components/index.ts b/src/components/features/BtEditor/components/index.ts index 0dc80016..2714796b 100644 --- a/src/components/features/BtEditor/components/index.ts +++ b/src/components/features/BtEditor/components/index.ts @@ -8,6 +8,7 @@ export type { DividerProps } from "./Divider/Divider"; export { Divider } from "./Divider/Divider"; export { ExamplesMenu } from "./ExamplesMenu/ExamplesMenu"; export { SkillLogicExmaplesMenu } from "./ExamplesMenu/SkillLogicExmaplesMenu"; +export { DishMenu } from "./ExamplesMenu/DishMenu"; export type { MenuProps } from "./Menu/Menu"; export { Menu } from "./Menu/Menu"; export type { MenuItemProps } from "./Menu/MenuItem"; diff --git a/src/components/icons/outlineIcons.tsx b/src/components/icons/outlineIcons.tsx index 5b657ede..2f23fdb7 100644 --- a/src/components/icons/outlineIcons.tsx +++ b/src/components/icons/outlineIcons.tsx @@ -1002,4 +1002,20 @@ export const OutlineIcons = { ); }, + + Pizza: (props: JSX.IntrinsicElements["svg"]) => { + return ( + + Pizza + + + + + + + + + + ); + }, }; diff --git a/src/lib/dish-websocket.ts b/src/lib/dish-websocket.ts new file mode 100644 index 00000000..738d4cc4 --- /dev/null +++ b/src/lib/dish-websocket.ts @@ -0,0 +1,553 @@ +import { getDB } from "@db/repositories/database"; +import { createId } from "@paralleldrive/cuid2"; +import WebSocket from "ws"; + +// ===================== 配置常量 ===================== +const CONFIG = { + MAX_MESSAGE_LENGTH: 2000, // 最大消息长度 + MAX_COMMAND_RATE: 10, // 每分钟最大命令数 + COMMAND_COOLDOWN: 1000, // 命令冷却时间 + WS_RECONNECT_DELAY: 3000, // WebSocket重连延迟 + WS_MAX_RECONNECT_ATTEMPTS: 10, // 最大重连尝试次数 + HEARTBEAT_INTERVAL: 30000, // 心跳间隔 + DEFAULT_TRIGGER: ".", // 默认触发头 +}; + +// 转义 LIKE 通配符,防止通配符注入 +function escapeLikePattern(str: string): string { + return str.replace(/[%_\\]/g, "\\$&"); +} + +// 过滤日志中的 ANSI 转义序列和控制字符,防止日志注入 +function sanitizeLogMessage(msg: string): string { + return msg + .replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "") // 移除 ANSI 转义序列 + .replace(/[\r\n]/g, " ") // 替换换行符为空格 + .replace(/[\x00-\x1f]/g, ""); // 移除其他控制字符 +} + +// ===================== 日志函数 ===================== +function log(msg: string, type: 'info' | 'error' | 'warn' | 'success' = 'info') { + const stamp = new Date().toISOString().replace("T", " ").split(".")[0]; + const prefix = { + info: '\x1b[36m', + success: '\x1b[32m', + warn: '\x1b[33m', + error: '\x1b[31m', + }; + const reset = '\x1b[0m'; + const safeMsg = sanitizeLogMessage(msg); + console.log(`${prefix[type]}[${stamp}] [DishWS] ${safeMsg}${reset}`); +} + +// ===================== 命令冷却系统 ===================== +interface CooldownInfo { + count: number; + lastTime: number; + blocked: boolean; +} +const commandCooldown = new Map(); + +function checkCommandCooldown(userId: number): { allowed: boolean; reason?: string } { + const now = Date.now(); + const userCooldown = commandCooldown.get(userId) || { count: 0, lastTime: 0, blocked: false }; + + if (now - userCooldown.lastTime > 60000) { + userCooldown.count = 0; + userCooldown.blocked = false; + } + + userCooldown.count++; + userCooldown.lastTime = now; + + if (userCooldown.count > CONFIG.MAX_COMMAND_RATE) { + userCooldown.blocked = true; + commandCooldown.set(userId, userCooldown); + return { allowed: false, reason: 'rate_limit' }; + } + + if (userCooldown.blocked) { + commandCooldown.set(userId, userCooldown); + return { allowed: false, reason: 'cooldown' }; + } + + commandCooldown.set(userId, userCooldown); + return { allowed: true }; +} + +// ===================== 配置缓存 ===================== +interface DishBotConfig { + triggers: string[]; // 触发头数组,如 [".", "#"] + aliases: Map; // 别名映射: 别名 -> 料理名 +} +let configCache: DishBotConfig | null = null; +let configCacheTime = 0; +const CONFIG_CACHE_TTL = 60000; // 配置缓存1分钟 + +async function getBotConfig(): Promise { + const now = Date.now(); + if (configCache && now - configCacheTime < CONFIG_CACHE_TTL) { + return configCache; + } + + try { + const db = await getDB(); + const configs = await db + .selectFrom("dish_config") + .where("key", "in", ["ws_trigger", "dish_aliases"]) + .selectAll() + .execute(); + + const triggerConfig = configs.find(c => c.key === "ws_trigger"); + const aliasesConfig = configs.find(c => c.key === "dish_aliases"); + + // 解析多个触发头(逗号分隔) + const triggerStr = triggerConfig?.value || CONFIG.DEFAULT_TRIGGER; + const triggers = triggerStr.split(",").map(t => t.trim()).filter(Boolean); + + const aliases = new Map(); + + if (aliasesConfig?.value) { + try { + const aliasObj = JSON.parse(aliasesConfig.value); + for (const [alias, name] of Object.entries(aliasObj)) { + aliases.set(alias, name as string); + } + } catch {} + } + + configCache = { triggers, aliases }; + configCacheTime = now; + return configCache; + } catch { + return { triggers: [CONFIG.DEFAULT_TRIGGER], aliases: new Map() }; + } +} + +// ===================== WebSocket客户端类 ===================== +class DishWebSocketClient { + private ws: WebSocket | null = null; + private reconnectTimer: NodeJS.Timeout | null = null; + private heartbeatTimer: NodeJS.Timeout | null = null; + private isConnected: boolean = false; + private url: string = ""; + private token: string = ""; + private reconnectAttempts: number = 0; + private messageCounter: number = 0; + private activeGroups: Set = new Set(); + + private static instance: DishWebSocketClient | null = null; + + public static getInstance(): DishWebSocketClient { + if (!DishWebSocketClient.instance) { + DishWebSocketClient.instance = new DishWebSocketClient(); + } + return DishWebSocketClient.instance; + } + + private constructor() {} + + async connect(url: string, token?: string): Promise { + if (this.isConnected && this.ws) { + log("已经连接", 'info'); + return true; + } + + this.url = url; + this.token = token || ""; + + return new Promise((resolve) => { + try { + if (this.ws) { + this.ws.removeAllListeners?.(); + if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) { + this.ws.close(); + } + } + + const options = this.token + ? { headers: { Authorization: `Bearer ${this.token}` } } + : {}; + + this.ws = new WebSocket(url, options as any); + + this.ws.onopen = () => { + log(`已连接: ${url}`, 'success'); + this.isConnected = true; + this.reconnectAttempts = 0; + this.startHeartbeat(); + + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + + resolve(true); + }; + + this.ws.onclose = (event) => { + log(`连接关闭 (code: ${event.code})`, 'warn'); + this.isConnected = false; + this.stopHeartbeat(); + this.handleReconnect(); + }; + + this.ws.onerror = () => { + log(`连接错误`, 'error'); + this.isConnected = false; + resolve(false); + }; + + this.ws.onmessage = (event) => { + this.handleMessage(event.data); + }; + + } catch (error) { + log(`创建连接失败: ${error}`, 'error'); + resolve(false); + } + }); + } + + private startHeartbeat(): void { + this.stopHeartbeat(); + this.heartbeatTimer = setInterval(() => { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.ping?.(); + } + }, CONFIG.HEARTBEAT_INTERVAL); + } + + private stopHeartbeat(): void { + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + } + } + + disconnect(): void { + this.stopHeartbeat(); + + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + + if (this.ws) { + this.ws.close(); + this.ws = null; + } + + this.isConnected = false; + log("已断开连接", 'info'); + } + + private handleReconnect(): void { + if (this.reconnectAttempts >= CONFIG.WS_MAX_RECONNECT_ATTEMPTS) { + log(`达到最大重连次数,停止重连`, 'error'); + return; + } + + this.reconnectAttempts++; + const delay = CONFIG.WS_RECONNECT_DELAY * Math.min(this.reconnectAttempts, 5); + log(`${delay/1000}秒后尝试第${this.reconnectAttempts}次重连...`, 'info'); + + this.reconnectTimer = setTimeout(() => { + this.connect(this.url, this.token); + }, delay); + } + + private async handleMessage(data: string): Promise { + let e: any; + try { + e = JSON.parse(data); + } catch { + return; + } + + if (e.post_type !== "message" || e.message_type !== "group") return; + + const text = this.extractText(e.message || []).trim(); + if (!text) return; + + if (text.length > CONFIG.MAX_MESSAGE_LENGTH) { + return; + } + + this.messageCounter++; + this.activeGroups.add(e.group_id); + + // 获取配置(触发头和别名) + const botConfig = await getBotConfig(); + const { triggers, aliases } = botConfig; + + // 检查是否以任一触发头开头 + let matchedTrigger: string | null = null; + for (const trigger of triggers) { + if (text.startsWith(trigger)) { + matchedTrigger = trigger; + break; + } + } + if (!matchedTrigger) return; + + const cooldown = checkCommandCooldown(e.user_id); + if (!cooldown.allowed) { + if (cooldown.reason === 'rate_limit') { + return this.sendGroupMessage(e.group_id, "命令频率过高,请稍后再试"); + } + return; + } + + // 去掉触发头,获取命令内容 + const cmdContent = text.slice(matchedTrigger.length).trim(); + + const result = await this.parseCommand(cmdContent, e.user_id, matchedTrigger, aliases); + if (result) { + this.sendGroupMessage(e.group_id, result); + } + } + + private extractText(message: any[]): string { + if (!Array.isArray(message)) return ''; + return message + .filter((m: any) => m.type === 'text') + .map((m: any) => m.data?.text || '') + .join(''); + } + + private async parseCommand( + cmd: string, + userId: number, + trigger: string, + aliases: Map + ): Promise { + const cmdLower = cmd.toLowerCase(); + + // help - 帮助 + if (cmdLower === 'help' || cmdLower === '帮助') { + return this.getHelp(trigger); + } + + // 加餐 - 提交料理 + if (cmdLower.startsWith('加餐')) { + return await this.handleAddDish(cmd, userId); + } + + // 别名映射检查 + const resolvedName = aliases.get(cmd) || cmd; + + // 检查是否带等级参数: 料理名 10 或 料理名10 + const levelMatch = resolvedName.match(/^(.+?)\s*(\d+)$/); + if (levelMatch) { + const name = levelMatch[1].trim(); + const level = parseInt(levelMatch[2]); + return await this.queryDishByName(name, level, aliases); + } + + // 查询该料理的所有等级 + return await this.queryDishByName(resolvedName, null, aliases); + } + + private getHelp(trigger: string): string { + return `料理查询帮助: +${trigger}help - 显示此帮助 +${trigger}<料理名> - 查询该料理所有等级 +${trigger}<料理名> <等级> - 查询指定等级 +${trigger}加餐<等级><料理名><门牌号> - 提交料理 + +别名示例: ${trigger}暴击 = 查询暴击料理 +别名: ${trigger}攻回 = 查询攻击回复料理 + +已处理 ${this.messageCounter} 条消息`; + } + + private async handleAddDish(cmd: string, userId: number): Promise { + // 紧凑格式: 加餐10暴击158800 + const match1 = cmd.match(/^加餐(\d+)(.+?)(\d{5,})$/); + if (match1) { + const level = parseInt(match1[1]); + const name = match1[2].trim(); + const playerId = match1[3]; + return await this.submitDish(name, level, playerId, userId); + } + + // 空格格式: 加餐 暴击 10 158800 + const match2 = cmd.match(/^加餐\s+(.+?)\s+(\d+)\s+(\d{5,})$/); + if (match2) { + const name = match2[1].trim(); + const level = parseInt(match2[2]); + const playerId = match2[3]; + return await this.submitDish(name, level, playerId, userId); + } + + return `提交格式: +加餐<等级><料理名><门牌号> +例: 加餐10暴击158800 + +或: 加餐 料理名 等级 门牌号 +例: 加餐 暴击 10 158800`; + } + + private async queryDishByName( + name: string, + level: number | null, + aliases: Map + ): Promise { + try { + const db = await getDB(); + + // 解析别名并转义通配符 + const resolvedName = aliases.get(name) || name; + const escapedName = escapeLikePattern(resolvedName); + + let query = db + .selectFrom("dish") + .where("status", "=", "Approved") + .where("name", "like", `%${escapedName}%`) + .selectAll(); + + if (level !== null) { + query = query.where("level", "=", level); + } + + const dishes = await query.orderBy("level", "desc").execute(); + + if (dishes.length === 0) { + const levelText = level ? `${level}级` : ''; + return `暂无${levelText}${resolvedName}料理`; + } + + const formatted = dishes.map(d => `(${d.level})${d.name}+${d.playerId}`).join(" "); + const header = level + ? `${resolvedName}${level}级(${dishes.length}个):\n` + : `${resolvedName}(${dishes.length}个):\n`; + + const fullMsg = header + formatted; + if (fullMsg.length > CONFIG.MAX_MESSAGE_LENGTH) { + const truncated = formatted.substring(0, CONFIG.MAX_MESSAGE_LENGTH - header.length - 10) + "..."; + return header + truncated + `\n(共${dishes.length}个)`; + } + + return fullMsg; + } catch (error) { + log(`查询料理失败: ${error}`, 'error'); + return "查询失败"; + } + } + + private async submitDish(name: string, level: number, playerId: string, userId: number): Promise { + try { + const db = await getDB(); + const id = createId(); + const now = new Date(); + + let systemAccount = await db + .selectFrom("account") + .where("provider", "=", "system") + .where("providerAccountId", "=", "qq-bot") + .selectAll() + .executeTakeFirst(); + + if (!systemAccount) { + const accountId = createId(); + await db.insertInto("account").values({ + id: accountId, + type: "User", + provider: "system", + providerAccountId: "qq-bot", + }).execute(); + systemAccount = { id: accountId } as any; + } + + await db.insertInto("dish").values({ + id, + name, + level, + playerId, + source: "QQBot", + status: "Pending", + qqNumber: String(userId), + remark: null, + createdAt: now, + updatedAt: now, + reviewedAt: null, + reviewedById: null, + submittedById: systemAccount.id, + }).execute(); + + log(`提交: ${name}(${level})${playerId} by ${userId}`, 'success'); + return `提交成功,等待审核\n${name}(${level}) 门牌号:${playerId}`; + } catch (error) { + log(`提交失败: ${error}`, 'error'); + return "提交失败"; + } + } + + private sendGroupMessage(groupId: number, text: string): void { + if (!this.ws || !this.isConnected) { + log("未连接,无法发送消息", 'error'); + return; + } + + if (text.length > CONFIG.MAX_MESSAGE_LENGTH) { + text = text.substring(0, CONFIG.MAX_MESSAGE_LENGTH) + '...'; + } + + try { + this.ws.send(JSON.stringify({ + action: "send_group_msg", + params: { group_id: groupId, message: text }, + })); + } catch (error) { + log(`发送失败: ${error}`, 'error'); + } + } + + getStatus(): { isConnected: boolean; url: string; messageCount: number; activeGroups: number } { + return { + isConnected: this.isConnected, + url: this.url, + messageCount: this.messageCounter, + activeGroups: this.activeGroups.size, + }; + } +} + +export const dishWebSocketClient = DishWebSocketClient.getInstance(); + +export async function initDishWebSocket(): Promise { + try { + const db = await getDB(); + + const configs = await db + .selectFrom("dish_config") + .where("key", "in", ["ws_url", "ws_enabled", "ws_token"]) + .selectAll() + .execute(); + + const wsUrlConfig = configs.find((c) => c.key === "ws_url"); + const wsEnabledConfig = configs.find((c) => c.key === "ws_enabled"); + const wsTokenConfig = configs.find((c) => c.key === "ws_token"); + + if (!wsUrlConfig || !wsEnabledConfig) { + log("未配置WebSocket", 'info'); + return false; + } + + if (wsEnabledConfig.value !== "true") { + log("WebSocket未启用", 'info'); + return false; + } + + return await dishWebSocketClient.connect(wsUrlConfig.value, wsTokenConfig?.value); + } catch (error) { + log(`初始化失败: ${error}`, 'error'); + return false; + } +} + +// 清除配置缓存(配置更新后调用) +export function clearConfigCache() { + configCache = null; +} \ No newline at end of file diff --git a/src/locales/dictionaries/zh_CN.ts b/src/locales/dictionaries/zh_CN.ts index 4850c960..ec984357 100644 --- a/src/locales/dictionaries/zh_CN.ts +++ b/src/locales/dictionaries/zh_CN.ts @@ -3084,6 +3084,118 @@ const dictionary: Dictionary = { formFieldDescription: "" } }, + }, + dish: { + selfName: "料理", + description: "玩家提交的料理信息,支持审核功能", + fields: { + id: { + key: "ID", + tableFieldDescription: "料理的唯一标识符", + formFieldDescription: "料理的唯一标识符" + }, + name: { + key: "料理名称", + tableFieldDescription: "料理的名称", + formFieldDescription: "请输入料理名称" + }, + level: { + key: "料理等级", + tableFieldDescription: "料理的等级(1-10)", + formFieldDescription: "请选择料理等级" + }, + playerId: { + key: "门牌号", + tableFieldDescription: "玩家的门牌号/玩家ID", + formFieldDescription: "请输入门牌号" + }, + source: { + key: "来源", + tableFieldDescription: "料理的提交来源", + formFieldDescription: "选择来源" + }, + status: { + key: "审核状态", + tableFieldDescription: "料理的审核状态", + formFieldDescription: "选择审核状态" + }, + qqNumber: { + key: "QQ号", + tableFieldDescription: "提交者的QQ号", + formFieldDescription: "请输入QQ号" + }, + remark: { + key: "备注", + tableFieldDescription: "审核意见或备注", + formFieldDescription: "请输入备注" + }, + createdAt: { + key: "创建时间", + tableFieldDescription: "料理创建的时间", + formFieldDescription: "" + }, + updatedAt: { + key: "更新时间", + tableFieldDescription: "料理更新的时间", + formFieldDescription: "" + }, + reviewedAt: { + key: "审核时间", + tableFieldDescription: "料理审核的时间", + formFieldDescription: "" + }, + reviewedById: { + key: "审核人ID", + tableFieldDescription: "审核人的账户ID", + formFieldDescription: "" + }, + submittedById: { + key: "提交人ID", + tableFieldDescription: "提交人的账户ID", + formFieldDescription: "" + } + } + }, + dish_config: { + selfName: "料理配置", + description: "料理功能的配置项,如WebSocket地址等", + fields: { + id: { + key: "ID", + tableFieldDescription: "配置的唯一标识符", + formFieldDescription: "配置的唯一标识符" + }, + key: { + key: "配置键", + tableFieldDescription: "配置的键名", + formFieldDescription: "请输入配置键名" + }, + value: { + key: "配置值", + tableFieldDescription: "配置的值", + formFieldDescription: "请输入配置值" + }, + remark: { + key: "备注", + tableFieldDescription: "配置的备注说明", + formFieldDescription: "请输入备注" + }, + createdAt: { + key: "创建时间", + tableFieldDescription: "配置创建的时间", + formFieldDescription: "" + }, + updatedAt: { + key: "更新时间", + tableFieldDescription: "配置更新的时间", + formFieldDescription: "" + }, + updatedById: { + key: "更新人ID", + tableFieldDescription: "更新人的账户ID", + formFieldDescription: "" + } + } } }, }; diff --git a/src/routes/(app)/(index)/index.tsx b/src/routes/(app)/(index)/index.tsx index 4cac3119..4ef725a9 100644 --- a/src/routes/(app)/(index)/index.tsx +++ b/src/routes/(app)/(index)/index.tsx @@ -89,6 +89,13 @@ export default function IndexPage() { icon: , name: "查询构建器", }, + { + onClick: () => { + navigate("dish"); + }, + icon: , + name: "料理名单", + }, ]); // 页面附加功能(右上角按钮组)配置 diff --git a/src/routes/(app)/(toolPages)/dish.tsx b/src/routes/(app)/(toolPages)/dish.tsx new file mode 100644 index 00000000..66c8f8bd --- /dev/null +++ b/src/routes/(app)/(toolPages)/dish.tsx @@ -0,0 +1,776 @@ +import { type Component, createSignal, createResource, For, Show, createEffect, onMount, onCleanup } from "solid-js"; +import { Button } from "~/components/controls/button"; +import { Input } from "~/components/controls/input"; +import { Select } from "~/components/controls/select"; +import { Icons } from "~/components/icons"; + +// 料理数据类型 +type Dish = { + id: string; + name: string; + level: number; + playerId: string; + source: string; + status: string; + qqNumber: string | null; + remark: string | null; + createdAt: string; + updatedAt: string; + reviewedAt: string | null; + reviewedById: string | null; + submittedById: string; +}; + +// 配置数据类型 +type DishConfig = { + id: string; + key: string; + value: string; + remark: string | null; +}; + +// 获取料理列表 +async function fetchDishes(params: { + status?: string; + level?: number; + page?: number; + pageSize?: number; + search?: string; +}) { + try { + const searchParams = new URLSearchParams(); + if (params.status) searchParams.set("status", params.status); + if (params.level) searchParams.set("level", String(params.level)); + if (params.page) searchParams.set("page", String(params.page)); + if (params.pageSize) searchParams.set("pageSize", String(params.pageSize)); + // 限制搜索词长度,防止过长查询 + if (params.search && params.search.length <= 50) { + searchParams.set("search", params.search); + } + + const response = await fetch(`/api/dish?${searchParams.toString()}`); + if (!response.ok) { + console.error("获取料理列表失败:", response.status); + return { data: [], pagination: { page: 1, pageSize: 20, total: 0, totalPages: 0 } }; + } + return response.json(); + } catch (error) { + console.error("获取料理列表失败:", error); + return { data: [], pagination: { page: 1, pageSize: 20, total: 0, totalPages: 0 } }; + } +} + +// 获取配置 +async function fetchConfigs() { + try { + const response = await fetch("/api/dish/config"); + if (!response.ok) { + console.error("获取配置失败:", response.status); + return { data: [] }; + } + return response.json(); + } catch (error) { + console.error("获取配置失败:", error); + return { data: [] }; + } +} + +// 检查是否为管理员(从配置API响应中获取) +async function checkIsAdmin(): Promise { + try { + const response = await fetch("/api/dish/config"); + if (response.ok) { + const data = await response.json(); + return data.isAdmin === true; + } + return false; + } catch { + return false; + } +} + +// 审核状态选项 +const statusOptions = [ + { label: "全部", value: "" }, + { label: "待审核", value: "Pending" }, + { label: "已通过", value: "Approved" }, + { label: "已拒绝", value: "Rejected" }, +]; + +// 等级选项(倒序:10级在最上面) +const levelOptions = [ + { label: "全部", value: 0 }, + ...Array.from({ length: 10 }, (_, i) => ({ label: `等级 ${10 - i}`, value: 10 - i })), +]; + +// 审核料理 +async function reviewDish(id: string, status: "Approved" | "Rejected", remark?: string) { + const response = await fetch("/api/dish/review", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id, status, remark }), + }); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "审核失败"); + } + return response.json(); +} + +// 提交料理 +async function submitDish(data: { name: string; level: number; playerId: string; qqNumber?: string }) { + const response = await fetch("/api/dish", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "提交失败"); + } + return response.json(); +} + +// 更新配置 +async function updateConfig(key: string, value: string, remark?: string) { + const response = await fetch("/api/dish/config", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key, value, remark }), + }); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "更新配置失败"); + } + return response.json(); +} + +const DishPage: Component = () => { + // 管理员状态 + const [isAdmin, setIsAdmin] = createSignal(false); + + // 搜索和筛选 + const [searchText, setSearchText] = createSignal(""); + const [filterStatus, setFilterStatus] = createSignal(""); + const [filterLevel, setFilterLevel] = createSignal(0); + const [page, setPage] = createSignal(1); + + // 料理列表 + const [dishes, { refetch: refetchDishes }] = createResource( + () => ({ + status: filterStatus() || undefined, + level: filterLevel() || undefined, + page: page(), + pageSize: 20, + search: searchText() || undefined, + }), + fetchDishes + ); + + // 配置列表 + const [configs, { refetch: refetchConfigs }] = createResource(fetchConfigs); + + // 提交表单(支持两个料理) + const [submitForm, setSubmitForm] = createSignal({ + playerId: "", + qqNumber: "", + dish1Name: "", + dish1Level: 1, + dish2Name: "", + dish2Level: 1, + }); + const [submitting, setSubmitting] = createSignal(false); + + // 审核表单 + const [reviewRemark, setReviewRemark] = createSignal(""); + const [reviewing, setReviewing] = createSignal(null); + + // WebSocket配置表单 + const [wsUrl, setWsUrl] = createSignal(""); + const [wsToken, setWsToken] = createSignal(""); + const [wsEnabled, setWsEnabled] = createSignal(false); + const [wsTrigger, setWsTrigger] = createSignal("."); // 触发头 + const [dishAliases, setDishAliases] = createSignal>({}); // 别名映射 + const [savingConfig, setSavingConfig] = createSignal(false); + + // WebSocket状态和日志 + const [wsStatus, setWsStatus] = createSignal({ isConnected: false, url: "", messageCount: 0, activeGroups: 0 }); + const [wsLogs, setWsLogs] = createSignal([]); + const MAX_LOGS = 50; + let wsStatusTimer: number | null = null; + + // 添加日志 + const addLog = (msg: string) => { + const time = new Date().toLocaleTimeString("zh-CN"); + setWsLogs(logs => [`[${time}] ${msg}`, ...logs.slice(0, MAX_LOGS - 1)]); + }; + + // 获取WebSocket状态 + const fetchWSStatus = async () => { + try { + const response = await fetch("/api/dish/ws/status"); + if (response.ok) { + const status = await response.json(); + setWsStatus(status); + } + } catch {} + }; + + // 初始化:检查管理员权限 + onMount(async () => { + const admin = await checkIsAdmin(); + setIsAdmin(admin); + + if (admin) { + fetchWSStatus(); + wsStatusTimer = setInterval(fetchWSStatus, 5000) as unknown as number; + } + }); + + onCleanup(() => { + if (wsStatusTimer) clearInterval(wsStatusTimer); + }); + + // 初始化配置(使用createEffect监听configs数据变化) + createEffect(() => { + const configsData = configs(); + if (configsData?.data) { + const wsUrlConfig = configsData.data.find((c: DishConfig) => c.key === "ws_url"); + const wsTokenConfig = configsData.data.find((c: DishConfig) => c.key === "ws_token"); + const wsEnabledConfig = configsData.data.find((c: DishConfig) => c.key === "ws_enabled"); + const wsTriggerConfig = configsData.data.find((c: DishConfig) => c.key === "ws_trigger"); + const aliasesConfig = configsData.data.find((c: DishConfig) => c.key === "dish_aliases"); + + if (wsUrlConfig) setWsUrl(wsUrlConfig.value); + if (wsTokenConfig) setWsToken(wsTokenConfig.value); + if (wsEnabledConfig) setWsEnabled(wsEnabledConfig.value === "true"); + if (wsTriggerConfig) setWsTrigger(wsTriggerConfig.value); + if (aliasesConfig?.value) { + try { + setDishAliases(JSON.parse(aliasesConfig.value)); + } catch {} + } + } + }); + + // 处理提交 + const handleSubmit = async () => { + const form = submitForm(); + + // 输入验证 + if (!form.playerId) { + alert("请填写门牌号"); + return; + } + if (form.playerId.length > 20) { + alert("门牌号过长"); + return; + } + if (!form.dish1Name) { + alert("请填写至少一个料理名称"); + return; + } + if (form.dish1Name.length > 50) { + alert("料理名称过长"); + return; + } + if (form.dish2Name && form.dish2Name.length > 50) { + alert("料理2名称过长"); + return; + } + if (form.qqNumber && !/^\d{5,15}$/.test(form.qqNumber)) { + alert("QQ号格式不正确"); + return; + } + + setSubmitting(true); + try { + await submitDish({ + name: form.dish1Name, + level: form.dish1Level, + playerId: form.playerId, + qqNumber: form.qqNumber || undefined, + }); + + if (form.dish2Name) { + await submitDish({ + name: form.dish2Name, + level: form.dish2Level, + playerId: form.playerId, + qqNumber: form.qqNumber || undefined, + }); + } + + setSubmitForm({ + playerId: "", + qqNumber: "", + dish1Name: "", + dish1Level: 1, + dish2Name: "", + dish2Level: 1, + }); + refetchDishes(); + alert(form.dish2Name ? "两个料理提交成功,等待审核" : "料理提交成功,等待审核"); + } catch (error) { + alert(error instanceof Error ? error.message : "提交失败"); + } finally { + setSubmitting(false); + } + }; + + // 处理审核 + const handleReview = async (id: string, status: "Approved" | "Rejected") => { + setReviewing(id); + try { + await reviewDish(id, status, reviewRemark() || undefined); + setReviewRemark(""); + refetchDishes(); + } catch (error) { + alert(error instanceof Error ? error.message : "审核失败"); + } finally { + setReviewing(null); + } + }; + + // 保存WebSocket配置 + const handleSaveConfig = async () => { + setSavingConfig(true); + addLog("保存配置中..."); + try { + await updateConfig("ws_url", wsUrl(), "WebSocket连接地址"); + await updateConfig("ws_token", wsToken(), "WebSocket认证Token"); + await updateConfig("ws_enabled", wsEnabled() ? "true" : "false", "是否启用WebSocket"); + await updateConfig("ws_trigger", wsTrigger(), "命令触发头"); + await updateConfig("dish_aliases", JSON.stringify(dishAliases()), "料理别名映射"); + refetchConfigs(); + addLog("配置保存成功"); + + // 如果启用了WebSocket,尝试连接 + if (wsEnabled()) { + addLog("正在连接..."); + const response = await fetch("/api/dish/ws/connect", { method: "POST" }); + const result = await response.json(); + if (result.success) { + addLog(`连接成功: ${wsUrl()}`); + fetchWSStatus(); + } else { + addLog(`连接失败: ${result.message || '未知错误'}`); + } + } else { + addLog("WebSocket未启用"); + } + } catch (error) { + addLog(`保存失败: ${error instanceof Error ? error.message : "未知错误"}`); + } finally { + setSavingConfig(false); + } + }; + + // 添加别名 + const addAlias = () => { + const alias = prompt("输入别名(如:攻回)"); + if (!alias) return; + const name = prompt("输入对应料理名(如:攻击回复)"); + if (!name) return; + setDishAliases(prev => ({ ...prev, [alias]: name })); + }; + + // 删除别名 + const removeAlias = (alias: string) => { + setDishAliases(prev => { + const next = { ...prev }; + delete next[alias]; + return next; + }); + }; + + // 格式化日期 + const formatDate = (dateStr: string) => { + return new Date(dateStr).toLocaleString("zh-CN"); + }; + + // 获取状态样式 + const getStatusStyle = (status: string) => { + switch (status) { + case "Approved": + return "bg-green-500/20 text-green-500"; + case "Rejected": + return "bg-red-500/20 text-red-500"; + default: + return "bg-yellow-500/20 text-yellow-500"; + } + }; + + // 获取状态文本 + const getStatusText = (status: string) => { + switch (status) { + case "Approved": + return "已通过"; + case "Rejected": + return "已拒绝"; + default: + return "待审核"; + } + }; + + // 按门牌号分组的料理数据 + const getGroupedDishes = () => { + const data = dishes()?.data || []; + const groups: Record = {}; + + for (const dish of data) { + if (!groups[dish.playerId]) { + groups[dish.playerId] = []; + } + groups[dish.playerId].push(dish); + } + + return Object.entries(groups).map(([playerId, dishes]) => ({ + playerId, + dishes, + })); + }; + + // 复制到剪贴板 + const copyToClipboard = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + // 简单的复制成功提示 + const toast = document.createElement("div"); + toast.textContent = `已复制: ${text}`; + toast.className = "fixed top-4 right-4 bg-green-500 text-white px-4 py-2 rounded-lg shadow-lg z-50 animate-pulse"; + document.body.appendChild(toast); + setTimeout(() => toast.remove(), 2000); + } catch (err) { + console.error("复制失败:", err); + } + }; + + return ( +
+
+ {/* 左侧:料理列表 */} +
+
+ {/* 筛选区域 */} +
+ { setSearchText(e.currentTarget.value); setPage(1); }} + placeholder="搜索门牌号或料理名称..." + class="w-full bg-primary-color text-accent-color rounded-lg px-4 py-2 border border-dividing-color focus:outline-none focus:border-brand-color-1st" + /> +
+ + { setFilterLevel(v as number); setPage(1); }} + options={levelOptions} + class="w-28" + /> +
+
+ +

料理列表

+ +
加载中...
+
+ +
暂无数据
+
+ 0}> +
+ + {(group: { playerId: string; dishes: Dish[] }) => ( +
copyToClipboard(group.playerId)} + title="点击复制门牌号" + > + {/* 料理名称横向排列 */} +
+ + {(dish: Dish) => ( + + {dish.name}({dish.level}) + + )} + +
+ + {/* 管理员状态标签 */} + +
+ + {(dish: Dish) => ( + + {dish.name}: {getStatusText(dish.status)} + + )} + +
+
+ + {/* 门牌号(次要信息) */} +
+ 门牌号: {group.playerId} + 点击复制 +
+ + {/* 审核操作(仅管理员可见,且有待审核的料理) */} + d.status === "Pending")}> +
e.stopPropagation()}> + +
+ d.status === 'Pending')}> + + + {(dish: Dish) => ( +
+ {dish.name}: + + +
+ )} +
+
+
+
+ + d.remark)}> +
+ 审核意见: {group.dishes.find(d => d.remark)?.remark} +
+
+
+ )} +
+
+ {/* 分页 */} + +
+ + + 第 {page()} / {dishes()?.pagination?.totalPages || 1} 页 + + +
+
+
+
+
+ + {/* 右侧:提交表单和配置 */} +
+ {/* 提交料理表单 */} +
+

提交料理

+ +
+ + setSubmitForm(f => ({ ...f, playerId: v }))} + placeholder="输入门牌号" + /> +
+ +
+ +
+ setSubmitForm(f => ({ ...f, dish1Name: v }))} + placeholder="输入料理名称" + /> + setSubmitForm(f => ({ ...f, dish2Name: v }))} + placeholder="输入料理名称(可选)" + /> + setSubmitForm(f => ({ ...f, qqNumber: v }))} + placeholder="输入QQ号" + /> +
+ + +
+ + {/* WebSocket配置(仅管理员可见) */} + +
+
+

机器人配置

+
+ + {wsStatus().isConnected ? '已连接' : '未连接'} +
+
+
+ + +
+
+ + +
+
+ + +
+
+ setWsEnabled(e.currentTarget.checked)} + class="w-4 h-4" + /> + +
+ +
+ {/* 别名配置 */} +
+
+ 料理别名 + +
+
+ +
暂无别名,例:攻回→攻击回复
+
+ + {([alias, name]) => ( +
+ {alias} → {name} + +
+ )} +
+
+
+ {/* 日志框 */} +
+
+ 运行日志 + 消息: {wsStatus().messageCount} | 群: {wsStatus().activeGroups} +
+
+ +
暂无日志...
+
+ + {(log) =>
{log}
} +
+
+
+
+
+
+
+ ); +}; + +export default DishPage; \ No newline at end of file diff --git a/src/routes/api/dish/config.ts b/src/routes/api/dish/config.ts new file mode 100644 index 00000000..94b73d43 --- /dev/null +++ b/src/routes/api/dish/config.ts @@ -0,0 +1,224 @@ +import { getDB } from "@db/repositories/database"; +import { getCookie } from "@solidjs/start/http"; +import type { APIEvent } from "@solidjs/start/server"; +import { jwtVerify } from "jose"; +import { z } from "zod/v4"; +import { createId } from "@paralleldrive/cuid2"; + +// 配置项 schema +const ConfigSchema = z.object({ + key: z.string().min(1), + value: z.string(), + remark: z.string().optional(), +}); + +// WebSocket配置 keys +const WS_CONFIG_KEYS = ["ws_url", "ws_enabled"] as const; + +// 验证JWT并获取用户信息 +async function verifyAuth(event: APIEvent) { + const token = getCookie("jwt"); + if (!token) { + return null; + } + + try { + const secret = new TextEncoder().encode(process.env.AUTH_SECRET); + const { payload } = await jwtVerify(token, secret, { + algorithms: ["HS256"], + }); + return payload; + } catch { + return null; + } +} + +// 检查用户是否是管理员 +async function isAdmin(userId: string): Promise { + const db = await getDB(); + const account = await db + .selectFrom("account") + .where("userId", "=", userId) + .where("type", "=", "Admin") + .selectAll() + .executeTakeFirst(); + return !!account; +} + +// 获取用户的account +async function getUserAccount(userId: string) { + const db = await getDB(); + return await db + .selectFrom("account") + .where("userId", "=", userId) + .selectAll() + .executeTakeFirst(); +} + +// GET: 获取配置 +export async function GET(event: APIEvent) { + const jwtUser = await verifyAuth(event); + if (!jwtUser) { + return new Response(JSON.stringify({ error: "未认证" }), { status: 401 }); + } + + const isAdminUser = await isAdmin(jwtUser.sub); + + try { + const db = await getDB(); + const url = new URL(event.request.url); + const key = url.searchParams.get("key"); + + if (key) { + // 获取单个配置 + const config = await db + .selectFrom("dish_config") + .where("key", "=", key) + .selectAll() + .executeTakeFirst(); + + if (!config) { + return new Response(JSON.stringify({ error: "配置不存在" }), { status: 404 }); + } + + return new Response(JSON.stringify({ data: config, isAdmin: isAdminUser }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } else { + // 获取所有配置 + let query = db.selectFrom("dish_config").selectAll(); + + if (!isAdminUser) { + // 非管理员只能看着! + query = query.where("key", "in", ["ws_enabled"]); + } + + const configs = await query.execute(); + + return new Response(JSON.stringify({ data: configs, isAdmin: isAdminUser }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + } catch (err) { + console.error("获取配置失败:", err); + return new Response(JSON.stringify({ error: "服务器内部错误" }), { status: 500 }); + } +} + +// POST: 创建或更新配置 +export async function POST(event: APIEvent) { + const jwtUser = await verifyAuth(event); + if (!jwtUser) { + return new Response(JSON.stringify({ error: "未认证" }), { status: 401 }); + } + + // 只有管理员可以修改配置 + if (!await isAdmin(jwtUser.sub)) { + return new Response(JSON.stringify({ error: "无权限,只有管理员可以修改配置" }), { status: 403 }); + } + + const account = await getUserAccount(jwtUser.sub); + if (!account) { + return new Response(JSON.stringify({ error: "用户账户不存在" }), { status: 401 }); + } + + try { + const body = await event.request.json(); + const data = ConfigSchema.parse(body); + + const db = await getDB(); + const now = new Date(); + + // 检查配置是否已存在 + const existing = await db + .selectFrom("dish_config") + .where("key", "=", data.key) + .selectAll() + .executeTakeFirst(); + + if (existing) { + // 更新 + await db + .updateTable("dish_config") + .set({ + value: data.value, + remark: data.remark ?? null, + updatedAt: now, + updatedById: account.id, + }) + .where("key", "=", data.key) + .execute(); + + return new Response(JSON.stringify({ success: true, action: "updated" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } else { + // 创建 + const id = createId(); + await db.insertInto("dish_config").values({ + id, + key: data.key, + value: data.value, + remark: data.remark ?? null, + createdAt: now, + updatedAt: now, + updatedById: account.id, + }).execute(); + + return new Response(JSON.stringify({ success: true, action: "created", data: { id } }), { + status: 201, + headers: { "Content-Type": "application/json" }, + }); + } + } catch (err) { + console.error("保存配置失败:", err); + if (err instanceof z.ZodError) { + return new Response(JSON.stringify({ error: err.errors }), { status: 400 }); + } + return new Response(JSON.stringify({ error: "服务器内部错误" }), { status: 500 }); + } +} + +// DELETE: 删除配置 +export async function DELETE(event: APIEvent) { + const jwtUser = await verifyAuth(event); + if (!jwtUser) { + return new Response(JSON.stringify({ error: "未认证" }), { status: 401 }); + } + + // 只有管理员可以删除配置 + if (!await isAdmin(jwtUser.sub)) { + return new Response(JSON.stringify({ error: "无权限,只有管理员可以删除配置" }), { status: 403 }); + } + + try { + const url = new URL(event.request.url); + const key = url.searchParams.get("key"); + + if (!key) { + return new Response(JSON.stringify({ error: "缺少配置key" }), { status: 400 }); + } + + const db = await getDB(); + const result = await db + .deleteFrom("dish_config") + .where("key", "=", key) + .returningAll() + .executeTakeFirst(); + + if (!result) { + return new Response(JSON.stringify({ error: "配置不存在" }), { status: 404 }); + } + + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (err) { + console.error("删除配置失败:", err); + return new Response(JSON.stringify({ error: "服务器内部错误" }), { status: 500 }); + } +} diff --git a/src/routes/api/dish/index.ts b/src/routes/api/dish/index.ts new file mode 100644 index 00000000..3a29584c --- /dev/null +++ b/src/routes/api/dish/index.ts @@ -0,0 +1,220 @@ +import { getDB } from "@db/repositories/database"; +import { findUserById } from "@db/repositories/user"; +import { getCookie } from "@solidjs/start/http"; +import type { APIEvent } from "@solidjs/start/server"; +import { jwtVerify } from "jose"; +import { z } from "zod/v4"; +import { createId } from "@paralleldrive/cuid2"; + +// 转义 LIKE 通配符,防止通配符注入 +function escapeLikePattern(str: string): string { + return str.replace(/[%_\\]/g, "\\$&"); +} + +// 料理提交请求 schema +const SubmitDishSchema = z.object({ + name: z.string().min(1, "料理名称不能为空").max(50, "料理名称过长"), + level: z.number().int().min(1).max(10), + playerId: z.string().min(1, "门牌号不能为空"), + qqNumber: z.string().optional(), +}); + +// 料理查询参数 schema +const QueryDishSchema = z.object({ + status: z.enum(["Pending", "Approved", "Rejected"]).optional(), + level: z.number().int().min(1).max(10).optional(), + playerId: z.string().optional(), + page: z.number().int().min(1).optional(), + pageSize: z.number().int().min(1).max(100).optional(), + search: z.string().optional(), +}); + +// 验证JWT并获取用户信息 +async function verifyAuth(event: APIEvent) { + const token = getCookie("jwt"); + if (!token) { + return null; + } + + try { + const secret = new TextEncoder().encode(process.env.AUTH_SECRET); + const { payload } = await jwtVerify(token, secret, { + algorithms: ["HS256"], + }); + return payload; + } catch { + return null; + } +} + +// 检查用户是否是管理员 +async function isAdmin(userId: string): Promise { + const db = await getDB(); + const account = await db + .selectFrom("account") + .where("userId", "=", userId) + .where("type", "=", "Admin") + .selectAll() + .executeTakeFirst(); + return !!account; +} + +// GET: 获取料理列表 +export async function GET(event: APIEvent) { + const url = new URL(event.request.url); + const params = Object.fromEntries(url.searchParams); + + // 检查是否为管理员 + const jwtUser = await verifyAuth(event); + const isAdminUser = jwtUser ? await isAdmin(jwtUser.sub) : false; + + // 解析查询参数 + const query = QueryDishSchema.parse({ + status: params.status, + level: params.level ? parseInt(params.level) : undefined, + playerId: params.playerId, + page: params.page ? parseInt(params.page) : 1, + pageSize: params.pageSize ? parseInt(params.pageSize) : 20, + search: params.search || undefined, + }); + + try { + const db = await getDB(); + + // 构建查询 + let queryBuilder = db.selectFrom("dish").selectAll(); + + if (!isAdminUser) { + queryBuilder = queryBuilder.where("status", "=", "Approved"); + } else if (query.status) { + // 管理员可以按状态筛选 + queryBuilder = queryBuilder.where("status", "=", query.status); + } + + if (query.level) { + queryBuilder = queryBuilder.where("level", "=", query.level); + } + if (query.playerId) { + queryBuilder = queryBuilder.where("playerId", "=", query.playerId); + } + // 搜索功能:匹配门牌号或料理名称(转义通配符) + if (query.search) { + const escapedSearch = escapeLikePattern(query.search); + queryBuilder = queryBuilder.where((eb) => + eb.or([ + eb("playerId", "like", `%${escapedSearch}%`), + eb("name", "like", `%${escapedSearch}%`), + ]) + ); + } + + // 计算总数(需要同样的过滤条件) + let countQuery = db + .selectFrom("dish") + .select(db.fn.count("id").as("count")); + + // 非管理员只能看到已通过的料理 + if (!isAdminUser) { + countQuery = countQuery.where("status", "=", "Approved"); + } else if (query.status) { + countQuery = countQuery.where("status", "=", query.status!); + } + + countQuery = countQuery + .$if(!!query.level, (qb) => qb.where("level", "=", query.level!)) + .$if(!!query.playerId, (qb) => qb.where("playerId", "=", query.playerId!)); + + if (query.search) { + const escapedSearch = escapeLikePattern(query.search); + countQuery = countQuery.where((eb) => + eb.or([ + eb("playerId", "like", `%${escapedSearch}%`), + eb("name", "like", `%${escapedSearch}%`), + ]) + ); + } + + const countResult = await countQuery.executeTakeFirst(); + + const total = Number(countResult?.count ?? 0); + + // 分页查询 + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 20; + const offset = (page - 1) * pageSize; + + const dishes = await queryBuilder + .orderBy("createdAt", "desc") + .limit(pageSize) + .offset(offset) + .execute(); + + return new Response(JSON.stringify({ + data: dishes, + pagination: { + page, + pageSize, + total, + totalPages: Math.ceil(total / pageSize), + }, + }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (err) { + console.error("查询料理列表失败:", err); + return new Response(JSON.stringify({ error: "服务器内部错误" }), { status: 500 }); + } +} + +// POST: 提交新料理 +export async function POST(event: APIEvent) { + const jwtUser = await verifyAuth(event); + if (!jwtUser) { + return new Response(JSON.stringify({ error: "未认证" }), { status: 401 }); + } + + const user = await findUserById(jwtUser.sub); + if (!user) { + return new Response(JSON.stringify({ error: "用户不存在" }), { status: 401 }); + } + + try { + const body = await event.request.json(); + const data = SubmitDishSchema.parse(body); + + const db = await getDB(); + const id = createId(); + const now = new Date(); + + await db.insertInto("dish").values({ + id, + name: data.name, + level: data.level, + playerId: data.playerId, + source: "Web", + status: "Pending", + qqNumber: data.qqNumber ?? null, + remark: null, + createdAt: now, + updatedAt: now, + reviewedAt: null, + reviewedById: null, + submittedById: user.id, + }).execute(); + + return new Response(JSON.stringify({ + success: true, + data: { id } + }), { + status: 201, + headers: { "Content-Type": "application/json" }, + }); + } catch (err) { + console.error("提交料理失败:", err); + if (err instanceof z.ZodError) { + return new Response(JSON.stringify({ error: err.errors }), { status: 400 }); + } + return new Response(JSON.stringify({ error: "服务器内部错误" }), { status: 500 }); + } +} diff --git a/src/routes/api/dish/review.ts b/src/routes/api/dish/review.ts new file mode 100644 index 00000000..c68acc90 --- /dev/null +++ b/src/routes/api/dish/review.ts @@ -0,0 +1,117 @@ +import { getDB } from "@db/repositories/database"; +import { getCookie } from "@solidjs/start/http"; +import type { APIEvent } from "@solidjs/start/server"; +import { jwtVerify } from "jose"; +import { z } from "zod/v4"; + +// 审核请求 schema +const ReviewDishSchema = z.object({ + id: z.string().min(1, "料理ID不能为空"), + status: z.enum(["Approved", "Rejected"]), + remark: z.string().max(200).optional(), +}); + +// 验证JWT并获取用户信息 +async function verifyAuth(event: APIEvent) { + const token = getCookie("jwt"); + if (!token) { + return null; + } + + try { + const secret = new TextEncoder().encode(process.env.AUTH_SECRET); + const { payload } = await jwtVerify(token, secret, { + algorithms: ["HS256"], + }); + return payload; + } catch { + return null; + } +} + +// 检查用户是否是管理员(通过userId查找关联的account) +async function isAdmin(userId: string): Promise { + const db = await getDB(); + const account = await db + .selectFrom("account") + .where("userId", "=", userId) + .where("type", "=", "Admin") + .selectAll() + .executeTakeFirst(); + return !!account; +} + +// 获取用户的任意一个account +async function getUserAccount(userId: string) { + const db = await getDB(); + return await db + .selectFrom("account") + .where("userId", "=", userId) + .selectAll() + .executeTakeFirst(); +} + +// POST: 审核料理 +export async function POST(event: APIEvent) { + const jwtUser = await verifyAuth(event); + if (!jwtUser) { + return new Response(JSON.stringify({ error: "未认证" }), { status: 401 }); + } + + // 检查管理员权限 + if (!await isAdmin(jwtUser.sub)) { + return new Response(JSON.stringify({ error: "无权限,只有管理员可以审核" }), { status: 403 }); + } + + const account = await getUserAccount(jwtUser.sub); + if (!account) { + return new Response(JSON.stringify({ error: "用户账户不存在" }), { status: 401 }); + } + + try { + const body = await event.request.json(); + const data = ReviewDishSchema.parse(body); + + const db = await getDB(); + const now = new Date(); + + // 检查料理是否存在 + const dish = await db + .selectFrom("dish") + .where("id", "=", data.id) + .selectAll() + .executeTakeFirst(); + + if (!dish) { + return new Response(JSON.stringify({ error: "料理不存在" }), { status: 404 }); + } + + if (dish.status !== "Pending") { + return new Response(JSON.stringify({ error: "该料理已经审核过了" }), { status: 400 }); + } + + // 更新审核状态 + await db + .updateTable("dish") + .set({ + status: data.status, + remark: data.remark ?? null, + reviewedAt: now, + reviewedById: account.id, + updatedAt: now, + }) + .where("id", "=", data.id) + .execute(); + + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } catch (err) { + console.error("审核料理失败:", err); + if (err instanceof z.ZodError) { + return new Response(JSON.stringify({ error: err.errors }), { status: 400 }); + } + return new Response(JSON.stringify({ error: "服务器内部错误" }), { status: 500 }); + } +} \ No newline at end of file diff --git a/src/routes/api/dish/ws/connect.ts b/src/routes/api/dish/ws/connect.ts new file mode 100644 index 00000000..fd3b8401 --- /dev/null +++ b/src/routes/api/dish/ws/connect.ts @@ -0,0 +1,73 @@ +import { getCookie } from "@solidjs/start/http"; +import type { APIEvent } from "@solidjs/start/server"; +import { jwtVerify } from "jose"; +import { dishWebSocketClient, initDishWebSocket } from "~/lib/dish-websocket"; + +// 验证JWT并获取用户信息 +async function verifyAuth(event: APIEvent) { + const token = getCookie("jwt"); + if (!token) return null; + try { + const secret = new TextEncoder().encode(process.env.AUTH_SECRET); + const { payload } = await jwtVerify(token, secret, { algorithms: ["HS256"] }); + return payload; + } catch { + return null; + } +} + +// 检查用户是否是管理员 +async function isAdmin(userId: string): Promise { + const { getDB } = await import("@db/repositories/database"); + const db = await getDB(); + const account = await db + .selectFrom("account") + .where("userId", "=", userId) + .where("type", "=", "Admin") + .selectAll() + .executeTakeFirst(); + return !!account; +} + +// POST: 连接WebSocket +export async function POST(event: APIEvent) { + const jwtUser = await verifyAuth(event); + if (!jwtUser) { + return new Response(JSON.stringify({ error: "未认证" }), { status: 401 }); + } + + if (!await isAdmin(jwtUser.sub)) { + return new Response(JSON.stringify({ error: "无权限" }), { status: 403 }); + } + + const success = await initDishWebSocket(); + const status = dishWebSocketClient.getStatus(); + + return new Response(JSON.stringify({ + success, + status, + message: success ? "连接成功" : "连接失败" + }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +// DELETE: 断开WebSocket +export async function DELETE(event: APIEvent) { + const jwtUser = await verifyAuth(event); + if (!jwtUser) { + return new Response(JSON.stringify({ error: "未认证" }), { status: 401 }); + } + + if (!await isAdmin(jwtUser.sub)) { + return new Response(JSON.stringify({ error: "无权限" }), { status: 403 }); + } + + dishWebSocketClient.disconnect(); + + return new Response(JSON.stringify({ success: true, message: "已断开" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} diff --git a/src/routes/api/dish/ws/status.ts b/src/routes/api/dish/ws/status.ts new file mode 100644 index 00000000..8bf1df5f --- /dev/null +++ b/src/routes/api/dish/ws/status.ts @@ -0,0 +1,55 @@ +import { getCookie } from "@solidjs/start/http"; +import type { APIEvent } from "@solidjs/start/server"; +import { jwtVerify } from "jose"; +import { dishWebSocketClient } from "~/lib/dish-websocket"; + +// 验证JWT并获取用户信息 +async function verifyAuth(event: APIEvent) { + const token = getCookie("jwt"); + if (!token) { + return null; + } + + try { + const secret = new TextEncoder().encode(process.env.AUTH_SECRET); + const { payload } = await jwtVerify(token, secret, { + algorithms: ["HS256"], + }); + return payload; + } catch { + return null; + } +} + +// 检查用户是否是管理员 +async function isAdmin(userId: string): Promise { + const { getDB } = await import("@db/repositories/database"); + const db = await getDB(); + const account = await db + .selectFrom("account") + .where("userId", "=", userId) + .where("type", "=", "Admin") + .selectAll() + .executeTakeFirst(); + return !!account; +} + +// GET: 获取WebSocket状态 +export async function GET(event: APIEvent) { + const jwtUser = await verifyAuth(event); + if (!jwtUser) { + return new Response(JSON.stringify({ error: "未认证" }), { status: 401 }); + } + + // 只有管理员可以查看WebSocket状态 + if (!await isAdmin(jwtUser.sub)) { + return new Response(JSON.stringify({ error: "无权限" }), { status: 403 }); + } + + const status = dishWebSocketClient.getStatus(); + + return new Response(JSON.stringify(status), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +}