diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 4079ae4..73fb462 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -55,7 +55,7 @@ Entry: `packages/webview/src/index.tsx` → `WebviewApp.tsx`. Uses Tailwind CSS - 4-space indent, no `export default`, prefer `interface` over `type` - No `any` — use `unknown` instead -- Functions max 120 lines (CI enforces 130 hard limit) +- Functions max 80 lines (CI enforces 80 hard limit) - Import order: external deps → `@template/*` packages → relative paths - Component files: PascalCase. Tool/utility files: camelCase - Error handling: use `CfError`/`ProxyError`/`LoginRequiredError`, never bare `throw new Error()` @@ -64,7 +64,7 @@ Entry: `packages/webview/src/index.tsx` → `WebviewApp.tsx`. Uses Tailwind CSS ## CI -PR to `main` triggers: lint → build → test → function length check (>130 lines fails). Results posted as PR comment. +PR to `main` triggers: lint → build → test → function length check (>80 lines fails). Results posted as PR comment. ## Git Branch Rules (Claude MUST follow) diff --git a/.github/workflows/pr-review.yml b/.github/workflows/pr-review.yml index e9e2802..bb44e0e 100644 --- a/.github/workflows/pr-review.yml +++ b/.github/workflows/pr-review.yml @@ -8,10 +8,11 @@ on: permissions: contents: read pull-requests: write + actions: write jobs: - review: - name: Code Review + check: + name: Code Checks runs-on: ubuntu-latest timeout-minutes: 15 @@ -48,38 +49,57 @@ jobs: continue-on-error: true shell: bash run: | - overages="" - while IFS= read -r file; do - if [ -f "$file" ]; then - results=$(node -e " - var fs=require('fs'); - var lines=fs.readFileSync('$file','utf-8').split('\n'); - var start=-1,name=''; - for(var i=0;i=0){ - var cnt=i-start; - if(cnt>130)console.log(name+' in $file ('+cnt+' lines, line '+(start+1)+')'); - } - start=i;name=m[3]; - } - } - ") - if [ -n "$results" ]; then overages="$overages"$'\n'"$results"; fi - fi - done < <(find apps packages -name "*.ts" -o -name "*.tsx") - echo "$overages" | tee linelength_output.txt - if [ -z "$overages" ]; then echo "passed=true" >> $GITHUB_OUTPUT; else echo "passed=false" >> $GITHUB_OUTPUT; fi - - - env: - GH_TOKEN: ${{ github.token }} - BUILD: ${{ steps.build.outcome }} - LINT: ${{ steps.lint.outcome }} - TEST: ${{ steps.test.outcome }} - LL: ${{ steps.linelength.outputs.passed }} + set -o pipefail + node scripts/check-functions.mjs 2>&1 | tee linelength_output.txt + status=$? + if [ "$status" -eq 0 ]; then echo "passed=true" >> $GITHUB_OUTPUT; else echo "passed=false" >> $GITHUB_OUTPUT; fi + + - name: Collect results & logs + shell: bash + run: | + mkdir -p artifact/logs + { + echo "lint=${{ steps.lint.outcome }}" + echo "build=${{ steps.build.outcome }}" + echo "test=${{ steps.test.outcome }}" + echo "ll=${{ steps.linelength.outputs.passed }}" + } > artifact/results.txt + for f in lint build test; do + if [ -f "${f}_output.txt" ]; then tail -n 40 "${f}_output.txt" > "artifact/logs/${f}_output.txt"; fi + done + if [ -f linelength_output.txt ]; then cp linelength_output.txt artifact/logs/linelength_output.txt; fi + + - uses: actions/upload-artifact@v4 + with: + name: check-results + path: artifact/ + retention-days: 1 + + - name: Fail if any check failed + if: >- + steps.lint.outcome == 'failure' || + steps.build.outcome == 'failure' || + steps.test.outcome == 'failure' || + steps.linelength.outputs.passed != 'true' + run: exit 1 + + report: + name: Post PR Report + needs: check + if: always() + runs-on: ubuntu-latest + + steps: + - uses: actions/download-artifact@v4 + with: + name: check-results + path: ./results + + - name: Build report shell: bash run: | + . ./results/results.txt + echo "## PR 审查报告" > comment.md echo "" >> comment.md echo "### 检查结果" >> comment.md @@ -87,28 +107,56 @@ jobs: echo "| 检查项 | 状态 |" >> comment.md echo "|--------|------|" >> comment.md - if [ "$BUILD" = "success" ]; then echo "| \`pnpm build\` | :white_check_mark: |" >> comment.md; else echo "| \`pnpm build\` | :x: |" >> comment.md; fi - if [ "$LINT" = "success" ]; then echo "| \`pnpm lint\` | :white_check_mark: |" >> comment.md; else echo "| \`pnpm lint\` | :x: |" >> comment.md; fi - if [ "$TEST" = "success" ]; then echo "| \`pnpm test\` | :white_check_mark: |" >> comment.md; else echo "| \`pnpm test\` | :x: |" >> comment.md; fi - if [ "$LL" = "true" ]; then echo "| 函数 <= 130 行 | :white_check_mark: |" >> comment.md; else echo "| 函数 <= 130 行 | :x: |" >> comment.md; fi + if [ "$lint" = "success" ]; then echo "| \`pnpm lint\` | :white_check_mark: |" >> comment.md; else echo "| \`pnpm lint\` | :x: |" >> comment.md; fi + if [ "$build" = "success" ]; then echo "| \`pnpm build\` | :white_check_mark: |" >> comment.md; else echo "| \`pnpm build\` | :x: |" >> comment.md; fi + if [ "$test" = "success" ]; then echo "| \`pnpm test:unit\` | :white_check_mark: |" >> comment.md; else echo "| \`pnpm test:unit\` | :x: |" >> comment.md; fi + if [ "$ll" = "true" ]; then echo "| 函数 ≤ 80 行 / tsx ≤ 600 行 | :white_check_mark: |" >> comment.md; else echo "| 函数 ≤ 80 行 / tsx ≤ 600 行 | :x: |" >> comment.md; fi echo "" >> comment.md echo "### 未通过的检查" >> comment.md echo "" >> comment.md any_fail=false - if [ "$BUILD" != "success" ]; then echo "- [ ] \`pnpm build\`" >> comment.md; any_fail=true; fi - if [ "$LINT" != "success" ]; then echo "- [ ] \`pnpm lint\`" >> comment.md; any_fail=true; fi - if [ "$TEST" != "success" ]; then echo "- [ ] \`pnpm test\`" >> comment.md; any_fail=true; fi - if [ "$LL" != "true" ]; then echo "- [ ] 函数长度不超过 130 行" >> comment.md; any_fail=true; fi - - if [ "$any_fail" = false ]; then echo "全部通过,无需修改。" >> comment.md; fi + if [ "$lint" != "success" ]; then echo "- [ ] \`pnpm lint\`" >> comment.md; any_fail=true; fi + if [ "$build" != "success" ]; then echo "- [ ] \`pnpm build\`" >> comment.md; any_fail=true; fi + if [ "$test" != "success" ]; then echo "- [ ] \`pnpm test:unit\`" >> comment.md; any_fail=true; fi + if [ "$ll" != "true" ]; then echo "- [ ] 函数 > 80 行 或 tsx 文件 > 600 行" >> comment.md; any_fail=true; fi + + if [ "$any_fail" = false ]; then + echo "全部通过,无需修改。" >> comment.md + else + echo "" >> comment.md + echo "### 失败详情" >> comment.md + echo "" >> comment.md + + append_log() { + local label="$1" logfile="$2" full="$3" + if [ -f "$logfile" ] && [ -s "$logfile" ]; then + echo "
$label" >> comment.md + echo "" >> comment.md + echo '```' >> comment.md + if [ "$full" = "true" ]; then cat "$logfile" >> comment.md; else tail -n 40 "$logfile" >> comment.md; fi + echo '```' >> comment.md + echo "
" >> comment.md + echo "" >> comment.md + fi + } + + [ "$lint" != "success" ] && append_log "\`pnpm lint\`" "results/logs/lint_output.txt" "false" + [ "$build" != "success" ] && append_log "\`pnpm build\`" "results/logs/build_output.txt" "false" + [ "$test" != "success" ] && append_log "\`pnpm test:unit\`" "results/logs/test_output.txt" "false" + [ "$ll" != "true" ] && append_log "行数限制(超限清单)" "results/logs/linelength_output.txt" "true" + fi echo "" >> comment.md echo "---" >> comment.md echo "" >> comment.md echo "*自动生成 by PR Review workflow*" >> comment.md + - name: Post comment + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + shell: bash + run: | gh pr comment ${{ github.event.pull_request.number }} --body "$(cat comment.md)" - - if [ "$any_fail" = true ]; then exit 1; fi diff --git a/.gitignore b/.gitignore index f0b5554..a164e56 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .turbo/ temp/ out/ +out-test/ src-tauri/target **/.env **/.pnpm-store diff --git a/README.md b/README.md index d2d69fe..2b07b62 100644 --- a/README.md +++ b/README.md @@ -14,18 +14,23 @@ -[特性](#特性) • [项目结构](#项目结构) • [快速开始](#快速开始) • [开发指南](#开发指南) • [常见问题](#常见问题) • [许可证](#许可证) +[特性](#特性) • [项目结构](#项目结构) • [快速开始](#快速开始) • [开发指南](#开发指南) • [发布](#发布) • [常见问题](#常见问题) • [许可证](#许可证) -VS Code 插件,支持浏览 AtCoder 竞赛题目、LaTeX 数学公式渲染、DeepL 翻译、评级/非评级报名。 +VS Code 插件,支持浏览 AtCoder 竞赛题目、LaTeX 数学公式渲染、DeepL 翻译、提交代码与判题跟踪、评级/非评级报名、榜单与提交详情查看。 ## 特性 -- 📋 **题目浏览** — 输入比赛代号,一键加载所有题目列表 +- 📋 **题目浏览** — 输入比赛代号,一键加载所有题目列表与题面 - 📐 **LaTeX 渲染** — 服务端 KaTeX 预渲染,WebView 直接展示 -- 🌐 **DeepL 翻译** — 选中题面段落,自动翻译为中文 -- 📝 **报名比赛** — 支持评级(Rated)/ 非评级报名 +- 🌐 **DeepL 翻译** — 题面段落自动翻译为中文(免费接口 / 官方 API 双模式) +- 📝 **报名比赛** — 支持评级(Rated)/ 非评级报名,自动处理 CSRF 与多步骤表单 +- 📤 **提交代码** — 内置提交面板,提交后自动轮询判题状态 +- 🧾 **提交历史 & 详情** — 查看个人提交列表、代码、逐测试点判定(Judge Result) +- 🏆 **实时榜单** — 读取 Standings JSON,查看排名与得分 +- 🏠 **首页比赛列表** — 侧边栏展示进行中 / 即将开始 / 最近 / 每日比赛 +- 🔗 **CPH 导出** — 一键导入 Competitive Programming Helper - 🔑 **Cookie 登录** — 安全存储 REVEL_SESSION,自动注入请求 - 🎨 **主题适配** — WebView 自动跟随 VS Code 主题 @@ -34,43 +39,57 @@ VS Code 插件,支持浏览 AtCoder 竞赛题目、LaTeX 数学公式渲染、 ``` . ├── apps/ -│ └── vscode-extension/ # 插件主程序 +│ └── vscode-extension/ # 插件主程序(webpack 打包) │ ├── src/ -│ │ ├── extension.ts # 插件入口,消息路由,handler +│ │ ├── extension.ts # 插件入口,激活 & 面板创建 +│ │ ├── viewProvider.ts # 侧边栏 WebviewView Provider │ │ ├── atcoder.ts # AtCoder 爬虫 & 题面解析 -│ │ ├── tools/ -│ │ │ ├── fetch.ts # HTTP 客户端,CF/Proxy/Login 错误类 -│ │ │ ├── submit.ts # 提交代码(fetchSubmitPage / submitCode) -│ │ │ ├── command.ts # 消息分发路由 -│ │ │ ├── types.ts # IncomingMessage / SubStatus -│ │ │ ├── deepl.ts # DeepL 翻译 -│ │ │ ├── copy.ts # Markdown 复制 -│ │ │ └── SignUpContest.ts # 报名逻辑 -│ │ ├── atcoder.test.ts # 单元测试 -│ ├── webpack.config.js # Webpack 构建 -│ ├── package.json # 扩展清单 -│ ├── img.png # 扩展图标 -│ └── LICENSE.txt # MIT 许可证 +│ │ ├── atcoder.test.ts # 单元测试(mocha) +│ │ └── tools/ +│ │ ├── command.ts # 消息分发路由 +│ │ ├── handle.ts # 各命令 handler +│ │ ├── fetch.ts # HTTP 客户端(代理绕过 / CF 挑战 / 登录检测) +│ │ ├── webview.ts # WebView HTML 模板与全局变量注入 +│ │ ├── submit.ts # 提交代码(fetchSubmitPage / submitCode) +│ │ ├── submission.ts # 提交详情 & Judge Result 解析 +│ │ ├── SignUpContest.ts # 报名逻辑(Rated / 非 Rated) +│ │ ├── standings.ts # 榜单 JSON 拉取 +│ │ ├── homepage.ts # 首页比赛列表解析 +│ │ ├── deepl.ts # DeepL 翻译(免费 / API) +│ │ ├── cph.ts # CPH 问题构建与导出 +│ │ ├── copy.ts # Markdown 复制 +│ │ ├── types.ts # IncomingMessage / 提交记录类型 +│ │ └── i18n/ # zh / en 文案 +│ ├── webpack.config.js # 构建 extension.js 与 webview.js +│ ├── package.json # 扩展清单(atc-helper v1.1.2) +│ └── img.png # 扩展图标 │ ├── packages/ -│ ├── core/ # @template/core — 核心工具 -│ ├── ui/ # @template/ui — React 组件库 -│ └── webview/ # @template/webview — WebView 前端 +│ ├── core/ # @template/core — MessageBus / StateManager +│ ├── ui/ # @template/ui — React 组件库(Button/Card/Input/Spinner) +│ └── webview/ # @template/webview — WebView 前端(React + Tailwind + KaTeX) │ └── src/ -│ ├── WebviewApp.tsx # 主应用(题目/提交/状态/复制) +│ ├── index.tsx # 入口,按 mode 分发四种应用 +│ ├── WebviewApp.tsx # 编辑器主视图 +│ ├── SidebarApp.tsx # 侧边栏(比赛列表 / 提交历史 / 登录) +│ ├── ContestApp.tsx # 单场比赛面板(Info/Task/Submit/Rating) +│ ├── SubmissionDetailApp.tsx # 提交详情页 │ ├── VSCodeProvider.tsx # VS Code API 上下文 -│ ├── types.ts # WebviewMessage / SubmitResult -│ ├── components/ -│ │ └── HtmlContent.tsx # HTML 渲染 & 翻译块 +│ ├── types.ts # WebviewMessage / SubmissionDetail +│ ├── hooks/ # useWebviewMessage / useTranslation +│ ├── components/ # ProblemView / HtmlContent / SubmitPanel 等 +│ ├── utils/ # format / status 工具 +│ ├── i18n/ # zh / en │ └── styles.css # Tailwind + KaTeX │ ├── docs/ -│ ├── images/ -│ │ └── img.png # 扩展图标源文件 │ └── Standard.md # 编码规范 │ +├── scripts/ +│ └── check-functions.mjs # 行数检查(ts 函数 ≤80 行 / tsx 文件 ≤600 行) +│ ├── .github/workflows/ -│ └── pr-review.yml # PR CI:lint + build + test +│ └── pr-review.yml # PR CI:lint + build + test + 行数检查 │ ├── release/ │ └── extension.vsix # 打包输出 @@ -83,8 +102,8 @@ VS Code 插件,支持浏览 AtCoder 竞赛题目、LaTeX 数学公式渲染、 | 目录 | 职责 | |------|------| -| `apps/vscode-extension` | 扩展入口、消息路由、AtCoder 通信、提交、报名 | -| `packages/core` | 核心业务逻辑 | +| `apps/vscode-extension` | 插件入口、消息路由、AtCoder 爬虫、HTTP 工具、提交与报名 | +| `packages/core` | 核心业务逻辑(MessageBus, StateManager) | | `packages/ui` | React 组件库(Button, Card, Input, Spinner) | | `packages/webview` | WebView 前端应用(React + Tailwind + KaTeX) | | `docs/Standard.md` | 编码规范 | @@ -100,15 +119,15 @@ pnpm install ### 开发 ```bash -# 启动监听模式(自动编译) +# 启动监听模式(自动编译扩展与 WebView) pnpm dev ``` ### 调试 1. VS Code 中按 `F5`(已预配置 launch.json) -2. 新窗口中 `Ctrl+Shift+P` → `Show WebView` -3. 输入比赛代号(如 `abc345`)→ 加载题目 → 浏览/翻译/报名 +2. 侧边栏点击 AtCoder Helper 图标打开比赛列表,或 `Ctrl+Shift+P` → `AtCoder Helper (Editor)` +3. 输入比赛代号(如 `abc345`)→ 加载题目 → 浏览 / 翻译 / 报名 / 提交 ### 设置 Cookie(用于报名 & 登录后题目) @@ -116,20 +135,20 @@ pnpm dev 2. F12 → Application → Cookies → 复制 `REVEL_SESSION` 的 Value 3. VS Code 中 `Ctrl+Shift+P` → `Set AtCoder Login Cookie`(或在侧边栏底部登录区域粘贴保存) -### 设置 DeepL API Key(用于翻译) +### 设置 DeepL API Key(用于官方 API 翻译模式) -`Ctrl+Shift+P` → `Set DeepL API Key` +`Ctrl+Shift+P` → `Set DeepL API Key`(免费模式无需配置) ## 开发指南 ### 脚本 ```bash -pnpm build # 编译所有包 -pnpm dev # 开发监听模式 -pnpm lint # ESLint 检查 +pnpm build # 编译所有包(turbo build) +pnpm dev # 开发监听模式(turbo dev) +pnpm lint # ESLint 检查(turbo lint) pnpm format # Prettier 格式化 -pnpm test:unit # 运行单元测试(apps/vscode-extension) +pnpm test # 运行单元测试(turbo test,apps/vscode-extension) pnpm clean # 清理构建产物 ``` @@ -139,7 +158,7 @@ pnpm clean # 清理构建产物 - 文件名:组件 PascalCase,工具 camelCase - TypeScript:优先 `interface`,避免 `any` -- 函数不超过 **120 行**(可适当放宽至 130) +- ts 函数不超过 **80 行**;tsx 文件不超过 **600 行**(CI 用 `scripts/check-functions.mjs` 检查) - 导入顺序:外部 → 内部包 → 相对路径 ## 发布 @@ -154,9 +173,9 @@ pnpm package **WebView 不显示?** - 确保已运行 `pnpm build` -- 检查 `dist/webview.js` 是否存在 +- 检查 `dist/extension.js` 与 `dist/webview.js` 是否存在 -**报名失败?** +**报名 / 提交失败?** - 确认已设置 AtCoder Cookie - Cookie 可能过期,重新登录获取 @@ -164,6 +183,9 @@ pnpm package - KaTeX 在扩展端预渲染,WebView 只需 CSS - 检查控制台是否有字体加载错误 +**Cloudflare 挑战?** +- 扩展会提示在浏览器中打开对应页面完成人机验证后重试 + ## 许可证 MIT diff --git a/apps/vscode-extension/README.md b/apps/vscode-extension/README.md index 1f6c355..062ac41 100644 --- a/apps/vscode-extension/README.md +++ b/apps/vscode-extension/README.md @@ -122,7 +122,7 @@ pnpm clean # 清理构建产物 - 文件名:组件 PascalCase,工具 camelCase - TypeScript:优先 `interface`,避免 `any` -- 函数不超过 **120 行**(可适当放宽至 130) +- ts 函数不超过 **80 行**;tsx 文件不超过 **600 行** - 导入顺序:外部 → 内部包 → 相对路径 ## 发布 diff --git a/apps/vscode-extension/package.json b/apps/vscode-extension/package.json index cb51b0d..b2323b3 100644 --- a/apps/vscode-extension/package.json +++ b/apps/vscode-extension/package.json @@ -60,6 +60,7 @@ "watch": "webpack --mode development --watch --progress", "dev": "webpack --mode development --watch --progress", "test": "node ./out/test/runTest.js", + "test:unit": "tsc -p tsconfig.test.json && node out-test/atcoder.test.js", "lint": "eslint src --ext ts", "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist && rm -rf out", "package": "vsce package --no-dependencies --allow-missing-repository --out ../../release/extension.vsix" diff --git a/apps/vscode-extension/src/tools/fetch.ts b/apps/vscode-extension/src/tools/fetch.ts index db9a9df..7b76310 100644 --- a/apps/vscode-extension/src/tools/fetch.ts +++ b/apps/vscode-extension/src/tools/fetch.ts @@ -209,109 +209,151 @@ function isContestStarted(contest: string): Promise { .catch(() => null); } -function handleResponse( +function handleRedirect( url: string, res: http.IncomingMessage, restoreSaved: Record, resolve: (value: string | PromiseLike) => void, reject: (reason: unknown) => void, redirectFetcher: (url: string) => Promise, - logPrefix: string = "[fetchText]", -) { - console.log(`${logPrefix} 收到响应:`, url, `status=${res.statusCode}`); - - if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { - const redirectUrl = new URL(res.headers.location, url).href; - console.log(`${logPrefix} 重定向:`, res.statusCode, `->`, redirectUrl); - restoreProxyEnv(restoreSaved); - if (redirectUrl.includes("/login")) { - console.log(`${logPrefix} 检测到登录重定向`); - reject(new LoginRequiredError(redirectUrl)); - return; - } + logPrefix: string, +): boolean { + if (!res.statusCode || res.statusCode < 300 || res.statusCode >= 400 || !res.headers.location) { + return false; + } + const redirectUrl = new URL(res.headers.location, url).href; + console.log(`${logPrefix} 重定向:`, res.statusCode, `->`, redirectUrl); + restoreProxyEnv(restoreSaved); + if (redirectUrl.includes("/login")) { + console.log(`${logPrefix} 检测到登录重定向`); + reject(new LoginRequiredError(redirectUrl)); + } else { resolve(redirectFetcher(redirectUrl)); - return; } + return true; +} +function pipeDecompress(res: http.IncomingMessage): stream.Readable { const encoding = res.headers["content-encoding"] || ""; - let readableStream: stream.Readable = res; - if (encoding.includes("br")) { - readableStream = res.pipe(zlib.createBrotliDecompress()); - } else if (encoding.includes("gzip")) { - readableStream = res.pipe(zlib.createGunzip()); - } else if (encoding.includes("deflate")) { - readableStream = res.pipe(zlib.createInflate()); - } + if (encoding.includes("br")) return res.pipe(zlib.createBrotliDecompress()); + if (encoding.includes("gzip")) return res.pipe(zlib.createGunzip()); + if (encoding.includes("deflate")) return res.pipe(zlib.createInflate()); + return res; +} - const chunks: Buffer[] = []; - readableStream.on("data", (chunk: Buffer) => { - chunks.push(chunk); +function readBody(readable: stream.Readable): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + readable.on("data", (chunk: Buffer) => { + chunks.push(chunk); + }); + readable.on("end", () => { + resolve(Buffer.concat(chunks).toString("utf8")); + }); + readable.on("error", reject); }); - readableStream.on("end", () => { - const body = Buffer.concat(chunks).toString("utf8"); - console.log(`${logPrefix} 响应体:`, url, `status=${res.statusCode}`, `body.length=${body.length}`); +} - restoreProxyEnv(restoreSaved); +function handleNon200( + url: string, + statusCode: number | undefined, + reject: (reason: unknown) => void, + logPrefix: string, +): void { + if (statusCode !== 404) { + console.log(`${logPrefix} 非 200 状态码:`, statusCode); + reject(new Error(t("err.httpStatus", { status: statusCode }))); + return; + } + if (!sessionCookie) { + console.log(`${logPrefix} 404 且无 Cookie,需要登录`); + reject(new Error(t("err.http404NoCookie"))); + return; + } + const rejectCookieInvalid = () => { + console.log(`${logPrefix} 404 但有 Cookie,可能 Cookie 无效`); + reject(new Error(t("err.http404BadCookie"))); + }; + const contest = extractContestFromUrl(url); + if (!contest) { + rejectCookieInvalid(); + return; + } + void isContestStarted(contest).then((started) => { + if (started === false) { + console.log(`${logPrefix} 404 且比赛未开始,题目未公开: ${url}`); + reject(new Error(t("err.http404NotStarted", { contest }))); + } else { + rejectCookieInvalid(); + } + }); +} - if (isCfChallenge(body)) { - console.log(`${logPrefix} 检测到 Cloudflare 挑战`); +function handleBody( + url: string, + res: http.IncomingMessage, + body: string, + resolve: (value: string | PromiseLike) => void, + reject: (reason: unknown) => void, + logPrefix: string, +): void { + if (isCfChallenge(body)) { + console.log(`${logPrefix} 检测到 Cloudflare 挑战`); + reject(new CfError(url)); + return; + } + if (res.statusCode === 403) { + const broad = ["Cloudflare", "challenge", "attention required"].some((p) => body.toLowerCase().includes(p)); + if (broad) { + console.log(`${logPrefix} 403 + 泛化 CF 特征,判定为 Cloudflare 挑战`); reject(new CfError(url)); - return; - } - if (res.statusCode === 403) { - const broad = ["Cloudflare", "challenge", "attention required"].some((p) => body.toLowerCase().includes(p)); - if (broad) { - console.log(`${logPrefix} 403 + 泛化 CF 特征,判定为 Cloudflare 挑战`); - reject(new CfError(url)); - } else { - console.log(`${logPrefix} 403 但非 CF,可能 Cookie 无效`); - reject(new Error(t("err.http403"))); - } - return; - } - if (isLoginPage(body)) { - console.log(`${logPrefix} 检测到登录页面`); - reject(new LoginRequiredError(url)); - return; - } - if (res.statusCode !== 200) { - if (res.statusCode === 404) { - if (!sessionCookie) { - console.log(`${logPrefix} 404 且无 Cookie,需要登录`); - reject(new Error(t("err.http404NoCookie"))); - return; - } - const rejectCookieInvalid = () => { - console.log(`${logPrefix} 404 但有 Cookie,可能 Cookie 无效`); - reject(new Error(t("err.http404BadCookie"))); - }; - const contest = extractContestFromUrl(url); - if (!contest) { - rejectCookieInvalid(); - return; - } - void isContestStarted(contest).then((started) => { - if (started === false) { - console.log(`${logPrefix} 404 且比赛未开始,题目未公开: ${url}`); - reject(new Error(t("err.http404NotStarted", { contest }))); - } else { - rejectCookieInvalid(); - } - }); - return; - } - console.log(`${logPrefix} 非 200 状态码:`, res.statusCode); - reject(new Error(t("err.httpStatus", { status: res.statusCode }))); - return; + } else { + console.log(`${logPrefix} 403 但非 CF,可能 Cookie 无效`); + reject(new Error(t("err.http403"))); } + return; + } + if (isLoginPage(body)) { + console.log(`${logPrefix} 检测到登录页面`); + reject(new LoginRequiredError(url)); + return; + } + if (res.statusCode !== 200) { + handleNon200(url, res.statusCode, reject, logPrefix); + return; + } + console.log(`${logPrefix} 请求成功:`, url, `body.length=${body.length}`); + resolve(body); +} - console.log(`${logPrefix} 请求成功:`, url, `body.length=${body.length}`); - resolve(body); - }); - readableStream.on("error", (err: Error) => { - restoreProxyEnv(restoreSaved); - reject(err); - }); +function handleResponse( + url: string, + res: http.IncomingMessage, + restoreSaved: Record, + resolve: (value: string | PromiseLike) => void, + reject: (reason: unknown) => void, + redirectFetcher: (url: string) => Promise, + logPrefix: string = "[fetchText]", +) { + console.log(`${logPrefix} 收到响应:`, url, `status=${res.statusCode}`); + + if (handleRedirect(url, res, restoreSaved, resolve, reject, redirectFetcher, logPrefix)) { + return; + } + + const readable = pipeDecompress(res); + + void readBody(readable).then( + (body) => { + restoreProxyEnv(restoreSaved); + console.log(`${logPrefix} 响应体:`, url, `status=${res.statusCode}`, `body.length=${body.length}`); + handleBody(url, res, body, resolve, reject, logPrefix); + }, + (err) => { + restoreProxyEnv(restoreSaved); + reject(err); + } + ); } export function fetchText(url: string, opts?: { withCookie?: boolean }): Promise { diff --git a/apps/vscode-extension/tsconfig.test.json b/apps/vscode-extension/tsconfig.test.json new file mode 100644 index 0000000..aba0717 --- /dev/null +++ b/apps/vscode-extension/tsconfig.test.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2020", + "outDir": "out-test", + "rootDir": "src", + "lib": ["ES2020"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "types": ["node"] + }, + "include": ["src/**/*.test.ts"] +} diff --git a/docs/Standard.md b/docs/Standard.md index d303294..2f54938 100644 --- a/docs/Standard.md +++ b/docs/Standard.md @@ -35,7 +35,7 @@ vscode-boilerplate/ ### 函数 -- **一个函数最多 120 行**,超过需拆分(可适当超出 10-20 行) +- **一个函数最多 80 行**,超过需拆分 - 每个函数只做一件事 - 超过 40 行时应考虑是否需要拆分 @@ -91,6 +91,6 @@ PR 合并到 `main` 前需满足: - [ ] `pnpm lint` 通过 - [ ] `pnpm test` 通过 - [ ] 无新引入的 `any` 类型 -- [ ] 所有函数 ≤ 120 行 +- [ ] 所有函数 ≤ 80 行 - [ ] 文件名符合命名规范 - [ ] 新增消息类型已同步更新 `types.ts` 和 `extension.ts` diff --git a/packages/webview/src/ContestApp.tsx b/packages/webview/src/ContestApp.tsx index 294891b..418259a 100644 --- a/packages/webview/src/ContestApp.tsx +++ b/packages/webview/src/ContestApp.tsx @@ -2,8 +2,14 @@ import React from "react"; import { Button, Card, Spinner } from "@template/ui"; import { useVSCode } from "./VSCodeProvider"; import { useI18n } from "./i18n"; -import type { ContestProblem, SampleCase, Standing, SubmissionRecord, WebviewMessage } from "./types"; -import { HtmlContent, TranslatedBlock } from "./components/HtmlContent"; +import { ProblemView } from "./components/ProblemView"; +import { SubmitPanel } from "./components/SubmitPanel"; +import { SubmissionList } from "./components/SubmissionHistory"; +import { TaskList } from "./components/TaskList"; +import { HtmlContent } from "./components/HtmlContent"; +import { useWebviewMessage } from "./hooks/useWebviewMessage"; +import { useTranslation } from "./hooks/useTranslation"; +import type { ContestProblem, Standing, SubmissionRecord } from "./types"; type Tab = "info" | "task" | "submit" | "rating"; @@ -35,30 +41,6 @@ const TABS: Array<{ key: Tab; label: string }> = [ { key: "rating", label: "ui.tabRating" }, ]; -const statusColor = (status: string): string => { - switch (status) { - case "AC": - return "text-green-500 bg-green-500/10"; - case "WA": - return "text-red-500 bg-red-500/10"; - case "TLE": - return "text-cyan-500 bg-cyan-500/10"; - case "MLE": - return "text-yellow-500 bg-yellow-500/10"; - case "RE": - return "text-purple-500 bg-purple-500/10"; - case "CE": - return "text-gray-400 bg-gray-400/10"; - default: - return "text-gray-400 bg-gray-400/10"; - } -}; - -const formatSubmitTime = (time: string): string => { - const m = time.match(/(\d{2})-(\d{2}) (\d{2}:\d{2})/); - return m ? `${m[1]}-${m[2]} ${m[3]}` : time; -}; - const ContestApp: React.FC = ({ initContest = "" }) => { const vscode = useVSCode(); const { t } = useI18n(); @@ -90,22 +72,18 @@ const ContestApp: React.FC = ({ initContest = "" }) => { const [loadingStandings, setLoadingStandings] = React.useState(false); const standingsCache = React.useRef>({}); - const [translatedCache, setTranslatedCache] = React.useState>>({}); - const [translated, setTranslated] = React.useState | null>(null); - const [translating, setTranslating] = React.useState(false); - const [translationMode, setTranslationMode] = React.useState<"api" | "free">("free"); - const [isLoading, setIsLoading] = React.useState(false); const [status, setStatus] = React.useState(""); const [submissions, setSubmissions] = React.useState([]); const [loadingHistory, setLoadingHistory] = React.useState(false); const selectedTaskRef = React.useRef(selectedTask); selectedTaskRef.current = selectedTask; + const translation = useTranslation(selectedTaskRef, setStatus); const loadProblem = (task: string) => { setIsLoading(true); setStatus(t("status.fetchingProblem", { contest, task })); - setTranslated(translatedCache[task] ?? null); + translation.setTranslatedForTask(task); vscode.postMessage({ command: "loadProblem", contest, task }); }; @@ -116,15 +94,7 @@ const ContestApp: React.FC = ({ initContest = "" }) => { }; const doTranslate = () => { - if (!problem) return; - setTranslating(true); - setStatus(t("status.translating")); - const texts: Record = {}; - if (problem.statement) texts[t("text.problemStatement")] = problem.statement; - if (problem.constraints) texts[t("text.constraints")] = problem.constraints; - if (problem.inputFormat) texts[t("text.inputFormat")] = problem.inputFormat; - if (problem.outputFormat) texts[t("text.outputFormat")] = problem.outputFormat; - vscode.postMessage({ command: "translate", payload: texts, targetLang: "ZH", translationMode }); + translation.translate(problem); }; const doCopyMarkdown = () => { @@ -177,10 +147,6 @@ const ContestApp: React.FC = ({ initContest = "" }) => { } }; - const copySampleText = (key: string, text: string) => { - navigator.clipboard.writeText(text); - }; - React.useEffect(() => { setIsLoading(true); setStatus(t("status.loadingContest", { contest })); @@ -188,105 +154,93 @@ const ContestApp: React.FC = ({ initContest = "" }) => { vscode.postMessage({ command: "getCookie" }); }, []); - React.useEffect(() => { - const handleMessage = (event: MessageEvent) => { - const message = event.data as WebviewMessage; - if (message.type === "tasks") { - setTasks(message.tasks ?? []); - setSelectedTask(""); - setProblem(null); - setStatus(t("status.tasksLoaded", { count: (message.tasks ?? []).length })); - setIsLoading(false); - } - if (message.type === "contestInfo") { - setRated(message.Rated ?? false); - setContestTitle(message.title ?? ""); - setAnnouncement(message.announcement ?? ""); - } - if (message.type === "cf_challenge") { - setCfUrl(message.url ?? null); - setIsLoading(false); - setStatus(t("status.cfChallenge")); - } - if (message.type === "loginRequired") { - setIsLoading(false); - setStatus(t("status.loginRequiredSidebar")); - } - if (message.type === "problem") { - setProblem(message.problem ?? null); - setStatus(t("status.problemLoaded", { title: message.problem?.title ?? "" })); - setIsLoading(false); - } - if (message.type === "loading" || message.type === "update") { - setStatus(message.text ?? t("status.loading")); - } - if (message.type === "error") { - setStatus(message.text ?? t("err.operationFailed")); - setIsLoading(false); - setTranslating(false); - setLoadingStandings(false); - setLoadingHistory(false); - } - if (message.type === "cookieStatus") { - setHasCookie(message.hasCookie ?? false); - if (message.statusMessage) setStatus(message.statusMessage); - } - if (message.type === "registrationStatus") { - setSigned(message.signed ?? false); - setRegistrationMessage(message.registrationMessage ?? null); - setStatus(message.registrationMessage ?? (message.signed ? t("status.registrationSuccess") : t("status.registrationFail"))); - setIsLoading(false); - } - if (message.type === "translation") { - const key = selectedTaskRef.current; - setTranslatedCache((prev) => ({ ...prev, [key]: message.translated ?? {} })); - setTranslated(message.translated ?? null); - setTranslating(false); - setStatus(t("status.translationDone")); - } - if (message.type === "submitPage") { - setSubmitPageError(null); - setSubmitTasks(message.submitTasks ?? []); - setSubmitLanguages(message.languages ?? []); - if (message.submitTasks && message.submitTasks.length > 0) { - setSelectedSubmitTask(message.submitTasks[0].value); - } - if (message.languages && message.languages.length > 0) { - setSelectedSubmitLanguage(message.languages[0].id); - } - setStatus(t("status.submitPageReady")); - setIsLoading(false); - } - if (message.type === "submitPageError") { - setSubmitPageError({ message: message.message ?? t("err.submitPageUnavailable"), url: message.url }); - setStatus(message.message ?? t("err.submitPageUnavailable")); - setIsLoading(false); - } - if (message.type === "submitResult") { - setSubmitResult(message.submitResult ?? null); - setIsLoading(false); - setStatus(message.submitResult?.success ? t("status.submitSuccess") : (message.submitResult?.message ?? t("status.submitFailed"))); - } - if (message.type === "statusUpdate") { - const statuses = message.statuses ?? {}; - setTasks((prev) => prev.map((task) => ({ ...task, status: statuses[task.value] }))); + useWebviewMessage({ + tasks: (message) => { + setTasks(message.tasks ?? []); + setSelectedTask(""); + setProblem(null); + setStatus(t("status.tasksLoaded", { count: (message.tasks ?? []).length })); + setIsLoading(false); + }, + contestInfo: (message) => { + setRated(message.Rated ?? false); + setContestTitle(message.title ?? ""); + setAnnouncement(message.announcement ?? ""); + }, + cf_challenge: (message) => { + setCfUrl(message.url ?? null); + setIsLoading(false); + setStatus(t("status.cfChallenge")); + }, + loginRequired: () => { + setIsLoading(false); + setStatus(t("status.loginRequiredSidebar")); + }, + problem: (message) => { + setProblem(message.problem ?? null); + setStatus(t("status.problemLoaded", { title: message.problem?.title ?? "" })); + setIsLoading(false); + }, + loading: (message) => setStatus(message.text ?? t("status.loading")), + update: (message) => setStatus(message.text ?? t("status.loading")), + error: (message) => { + setStatus(message.text ?? t("err.operationFailed")); + setIsLoading(false); + translation.setTranslating(false); + setLoadingStandings(false); + setLoadingHistory(false); + }, + cookieStatus: (message) => { + setHasCookie(message.hasCookie ?? false); + if (message.statusMessage) setStatus(message.statusMessage); + }, + registrationStatus: (message) => { + setSigned(message.signed ?? false); + setRegistrationMessage(message.registrationMessage ?? null); + setStatus(message.registrationMessage ?? (message.signed ? t("status.registrationSuccess") : t("status.registrationFail"))); + setIsLoading(false); + }, + translation: (message) => translation.applyTranslation(message), + submitPage: (message) => { + setSubmitPageError(null); + setSubmitTasks(message.submitTasks ?? []); + setSubmitLanguages(message.languages ?? []); + if (message.submitTasks && message.submitTasks.length > 0) { + setSelectedSubmitTask(message.submitTasks[0].value); } - if (message.type === "submissionHistory") { - setSubmissions(message.submissions ?? []); - setLoadingHistory(false); - setStatus(t("status.historyLoaded", { count: (message.submissions ?? []).length })); + if (message.languages && message.languages.length > 0) { + setSelectedSubmitLanguage(message.languages[0].id); } - if (message.type === "standings") { - const list = message.standings ?? []; - standingsCache.current[contest] = list; - setStandings(list); - setLoadingStandings(false); - setStatus(t("status.standingsLoaded", { count: list.length })); - } - }; - window.addEventListener("message", handleMessage); - return () => window.removeEventListener("message", handleMessage); - }, []); + setStatus(t("status.submitPageReady")); + setIsLoading(false); + }, + submitPageError: (message) => { + setSubmitPageError({ message: message.message ?? t("err.submitPageUnavailable"), url: message.url }); + setStatus(message.message ?? t("err.submitPageUnavailable")); + setIsLoading(false); + }, + submitResult: (message) => { + setSubmitResult(message.submitResult ?? null); + setIsLoading(false); + setStatus(message.submitResult?.success ? t("status.submitSuccess") : (message.submitResult?.message ?? t("status.submitFailed"))); + }, + statusUpdate: (message) => { + const statuses = message.statuses ?? {}; + setTasks((prev) => prev.map((task) => ({ ...task, status: statuses[task.value] }))); + }, + submissionHistory: (message) => { + setSubmissions(message.submissions ?? []); + setLoadingHistory(false); + setStatus(t("status.historyLoaded", { count: (message.submissions ?? []).length })); + }, + standings: (message) => { + const list = message.standings ?? []; + standingsCache.current[contest] = list; + setStandings(list); + setLoadingStandings(false); + setStatus(t("status.standingsLoaded", { count: list.length })); + }, + }); const renderInfoTab = () => (
@@ -338,150 +292,27 @@ const ContestApp: React.FC = ({ initContest = "" }) => { const renderTaskTab = () => (
{tasks.length > 0 && ( - -
{t("ui.taskList")}
-
- {tasks.map((task) => ( - - ))} -
-
+ { + setSelectedTask(value); + loadProblem(value); + }} + /> )} {problem && ( - -
-
-
{problem.title}
-
{problem.url}
-
-
- - - - -
-
- - {problem.statement && ( -
-
{t("ui.statement")}
- - {translated?.[t("text.problemStatement")] && ( - - )} -
- )} - - {problem.constraints && ( -
-
{t("text.constraints")}
- - {translated?.[t("text.constraints")] && ( - - )} -
- )} - - {problem.inputFormat && ( -
-
{t("text.inputFormat")}
- - {translated?.[t("text.inputFormat")] && ( - - )} -
- )} - - {problem.outputFormat && ( -
-
{t("text.outputFormat")}
- - {translated?.[t("text.outputFormat")] && ( - - )} -
- )} - - {problem.samples?.length > 0 ? ( - problem.samples.map((sample: SampleCase) => ( -
-
{t("ui.sampleLabel", { index: sample.index })}
-
-
Input
-
{sample.input}
- -
-
-
Output
-
{sample.output}
- -
-
- )) - ) : problem.sampleUrl ? ( -
-
{t("ui.sampleExternal")}
- -
- ) : ( -
{t("ui.noSamples")}
- )} -
+ )} {!isLoading && tasks.length === 0 && ( @@ -520,86 +351,22 @@ const ContestApp: React.FC = ({ initContest = "" }) => {
- {submitPageError ? ( -
-
{submitPageError.message}
- -
- ) : submitTasks.length === 0 && submitLanguages.length === 0 ? ( -
{t("ui.clickToStart")}
- ) : ( -
-
-
{t("ui.task")}
- -
- -
-
{t("ui.language")}
- -
- -
-
{t("ui.sourceCode")}
-