Imported from D1074181045/soj-gl-analysis (
AGENTS.md). Install upstream withnpx skills add D1074181045/soj-gl-analysis. Copyright stays with the author.
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
專案概述
逆水寒「幫會聯賽」結算 CSV 的視覺化 Web 應用。repo 根目錄放範例結算 CSV(檔名格式 日期_時間_我方幫會_對方幫會.csv),應用程式本體在 webapp/。功能:帳密註冊登入 → 上傳結算 CSV → 單場戰報視覺化(總覽對比/職業統計/玩家數據與詳情)→ 隨機連結分享(未登入可看)→ 玩家跨場比較。UI 全為繁體中文。
常用指令(皆在 webapp/ 下執行)
npm run dev # 開發伺服器
npm run build # production build(同時做 TypeScript 型別檢查——本專案沒有獨立的 typecheck 指令)
npm start # 跑 production build(port 3000)
npm run lint # ESLint
沒有單元測試框架。驗證靠兩個腳本(需先 npm start 起伺服器):
node scripts/seed-test.mjs # 建 testuser/test123456 帳號、匯入根目錄兩個 CSV、開啟一場分享
# 走 Kysely,依 DB_DIALECT/DATABASE_URL 連線(三方言皆可);需伺服器先跑過一次建好 schema
# 輸出 JSON:{sessionToken, shareToken, firstMatchId}
node scripts/ui-test.mjs <shareToken> # Playwright 驅動系統 Chrome (/usr/bin/google-chrome)
# 跑完整 UI 流程並截圖到 /tmp/shots/
測試完把 testuser 刪掉(DELETE FROM users WHERE username='testuser',FK cascade 會清掉附屬資料;SQLite 可用 node -e "require('better-sqlite3')('data/app.db')...")。使用者可能有真實帳號資料在 data/app.db,不要整檔刪除。
重啟 production 伺服器的正確方式(lsof -sTCP:LISTEN 在此環境抓不到 next-server,殺不乾淨會 EADDRINUSE,舊伺服器配新 build 會出現 chunk 500):
ss -tlnp | grep :3000 | grep -oP 'pid=\K[0-9]+' | xargs -r kill
Docker:docker compose up -d --build。本機 ~/.docker/config.json 的 credsStore 指向不存在的 docker-credential-desktop.exe,build 前先 export DOCKER_CONFIG=/tmp/docker-config(放一個 {} 的 config.json)繞過。
架構
Next.js 16(App Router、Turbopack)+ TypeScript + Tailwind 4 + Kysely(SQLite/MySQL/PostgreSQL 三方言)+ Recharts。Next 16 慣例:await cookies()、await props.params、全域 PageProps<'/route'> 型別;webapp/AGENTS.md(由 next dev 自動產生,勿刪)提醒 API 可能與訓練資料不同,可查 node_modules/next/dist/docs/。
資料流
lib/db.ts:Kysely 連線單例(globalThis 快取),依DB_DIALECT(sqlite 預設/mysql/postgres)+DATABASE_URL建立對應 dialect;驅動 better-sqlite3/mysql2/pg 以require延遲載入,next.config.ts的serverExternalPackages必須列出三者並保持output: "standalone"。所有查詢前先await ready()(第一次呼叫時以 Kysely schema builder 建表,createTable().ifNotExists();失敗會清掉快取讓下次重試)。方言差異只允許出現在這個檔案與actions.ts的 upsert helper:自增主鍵(PGserial/其餘integer autoIncrement)、取回 id(PGreturning/其餘insertId,見insertReturningId)、字串主鍵/唯一鍵在 MySQL 需varchar(191)(keyText())、時間戳型別(timestamptz/datetime/text)、CREATE INDEX IF NOT EXISTS(MySQL 不支援,createIndexIfMissing查 information_schema)、insert-ignore(MySQL.ignore()/其餘onConflict().doNothing())、upsert(MySQLonDuplicateKeyUpdate/其餘onConflict().doUpdateSet)。bigint欄位(傷害/治療/expires_at)在 pg 可能回字串,data.ts一律Number();created_at用toIso()統一成 ISO 字串。資料層全部 async,頁面要await。- 本機驗證三方言:
docker run起postgres:16-alpine/mysql:8(記得--character-set-server=utf8mb4),用DB_DIALECT=… DATABASE_URL=… PORT=3010 npm start另開伺服器,跑同一份 Playwright 腳本即可;三者共用同一次npm run build。 lib/parse.ts:結算 CSV 解析。格式:兩個幫會區塊,區塊開頭是 2 欄列("幫會名","人數"),之後為 12 欄玩家列;逐行掃描辨識,不靠固定行號。編碼 UTF-8,出現替換字元時 fallback GB18030。lib/actions.ts("use server"):所有寫入操作——註冊/登入/登出、上傳、刪除、分享開關。每個動作都做 session 與場次擁有權檢查。表單用useActionState,回傳{error?}。lib/auth.ts:自建 session(sessioncookie ↔ sessions 資料表,bcryptjs 雜湊)。lib/data.ts:唯讀查詢,把 snake_case 資料列轉成lib/types.ts的 camelCase 型別。
路由
/dashboard(上傳+場次列表+分享控制)、/match/[id](限擁有者)、/share/[token](公開唯讀,同一個 MatchView 加 shareBanner)、/players(跨場比較)、/teams(陣容配置:管理主團/副職清單+把我方玩家分到各團)。未登入訪問受保護頁一律 redirect("/login")。
場次 id 分兩層:資料庫 matches.id 是自增整數,只供 players/match_team_assignments 關聯;對外(網址、MatchSummary.id、表單 hidden input、所有 server action 參數)一律用 matches.public_id(newPublicId()=16 bytes base64url,22 字元、唯一索引)。data.ts 的讀取函式以 public id 為參數並在 toSummary 只回 public id;actions.ts 的 requireOwnedMatch(userId, publicId) 驗證擁有權後回傳內部 id 給後續查詢。**任何新程式都不得把內部 id 送到 client。**舊資料庫由 ensureMatchPublicId() 自動加欄位、回填、建唯一索引(三方言皆已驗證)。分享 token 同為 16 bytes。
UI 層
components/MatchView.tsx:單場戰報主元件,四分頁(總覽/職業統計/玩家數據/分團分析)。分團分析只列出本場有成員的分團(依使用者清單順序,未分團附在最後),卡片可點選展開該團成員表(一次一團),含「調整本場分團」編輯模式(僅擁有者,優先級:本場調整 > 統一配置)。兩種 modal 可互相導覽:ClassDetailModal(職業詳情,含成員清單)→ 點成員開PlayerDetailModal,關閉後回到職業詳情(靠selectedCls && !selectedPlayer條件渲染實現)。components/viz.tsx:共用視覺化元件(CompareRow雙向對比條、ContributionMeter佔比量表、StatTile、SeriesLegend、Recharts 自訂 tooltip)。components/sortable.tsx:表格排序共用件(useSortable/useSortedPlayershook、SortTh標頭、playerSortValue含 KDA 虛擬欄位)。所有玩家列表都要可點欄位排序(玩家數據、各團成員表、職業詳情成員表、陣容配置、本場調整表皆已套用);數值欄預設由大到小、文字欄由小到大。
領域規則
- 欄位語意:重傷 = 死亡次數;KDA =(擊敗+助攻)÷ max(重傷, 1)(
lib/format.ts的kdaOf)。 - 職業專屬指標(
lib/types.ts的metricAppliesToClass):化羽/清泉只屬於素問與潮光、焚骨只屬於九靈。顯示位置規則:同職業比較(與同職業平均、與對方同職業平均)、本團貢獻、與各團對比與職業/成員表格要依職業顯示;只有全隊貢獻佔比不顯示這兩個指標(全隊跨職業佔比無意義)。在本團貢獻與各團對比中,這兩個指標的比較對象只算該團同職業成員(焚骨只跟本團九靈比、化羽/清泉只跟本團同為素問或同為潮光的人比),佔比分母與「第 x/y 名」的 y 都用同職業人數。 - 分團(以「使用者+玩家名字」為鍵、跨場次共用的
team_assignments,加上以「場次+玩家名字」為鍵的單場覆寫match_team_assignments,優先級:本場調整 > 統一陣容配置,合併在MatchView的effectiveTeams):主團與副職都是使用者自訂清單(user_teams/user_sub_roles,註冊時種入預設進攻/機動/防守與保鑣/扛拆/空拆;既有帳號由lib/db.ts的runOnce一次性遷移補上,schema_meta記錄已跑過)。主團必選單選、副職可不選;server action 以清單驗證。改名會連動team_assignments/match_team_assignments;刪主團會清掉引用者的分團,刪副職只把引用者設為無副職。UNASSIGNED_LABEL(未分團)是保留名;顯示用teamLabel()在名稱未以團/隊/組結尾時補「團」。統一配置在/teams(TeamConfig內的NameListEditor同時管兩份清單),單場調整在戰報「分團分析」分頁的編輯模式(僅擁有者)。戰報分團分頁與玩家詳情的團隊區塊都是用名字 join;分享頁由場次反查擁有者設定與清單(getTeamAssignmentsByMatch/getUserTeamsByMatch/getUserSubRolesByMatch)再疊上該場覆寫。 - 配色是經過色盲驗證的固定規則:我方=藍
var(--ally)、對方=橘var(--enemy)(檢視對方視角的詳情時兩色互換,見 modal 內的ownColor/oppColor)。設計 token 全在app/globals.css(明暗雙模式,經@theme inline映射成 Tailwind 類別如bg-surface、text-ink、border-bdr),新 UI 用這些 token,不要另外挑色。 - 大數值以
fmtCompact壓縮(萬/億),完整值放title屬性;表格數字加tabular-nums。