diff --git a/docs/superpowers/plans/2026-08-05-tag-problems-modal.md b/docs/superpowers/plans/2026-08-05-tag-problems-modal.md new file mode 100644 index 0000000..5cfe84b --- /dev/null +++ b/docs/superpowers/plans/2026-08-05-tag-problems-modal.md @@ -0,0 +1,460 @@ +# 标签管理点击查看对应题目 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 后台标签管理页点击标签名或题目数,弹窗列出该标签下的题目,支持跳转编辑、按标题搜索、移除该题的这个标签。 + +**Architecture:** 给后台题目列表 API `ProblemAdminAPI.get` 加一个 `tag_id` 查询参数,弹窗直接复用它,从而白拿分页、标题搜索和创建者权限过滤。移除标签复用已有的 `BatchProblemTagAPI`(`action="remove"`)。没有新建后端接口。 + +**Tech Stack:** 后端 Django 6 + DRF(`OnlineJudge/`);前端 Vue 3 + TypeScript + Naive UI(`ojnext/`)。 + +**Spec:** `docs/superpowers/specs/2026-08-05-tag-problems-modal-design.md` + +## Global Constraints + +- **不写测试。** 两个子项目的 CLAUDE.md 都规定 "Do not write tests"。本计划用 lint / 构建 / Django shell 验证代替测试步骤。 +- **两个 git 仓库。** `OnlineJudge/` 和 `ojnext/` 各自是独立 git 仓库,根目录 `/home/xuyue/Projects/OJ` 不是仓库。每个任务的 commit 必须在对应子目录里执行。 +- 前端不需要手写 import:`ref` / `computed` / `watch` / `h` / `useRouter` / `useMessage` / `useToggle` / `watchDebounced` / `DataTableColumn` / 所有 `N*` 组件都由 `unplugin-auto-import` 和 `unplugin-vue-components` 自动导入。在 `render` 函数里以变量形式使用的 Naive UI 组件(如 `h(NButton, ...)`)**必须**手动 `import { NButton } from "naive-ui"`。 +- 后端代码风格:ruff,双引号,行宽 180。 +- 前端代码风格:Prettier,无分号,双引号。 + +--- + +### Task 1: 后端按标签筛选题目 + +**Files:** +- Modify: `OnlineJudge/problem/views/admin.py:270-281`(`ProblemAdminAPI.get`) + +**Interfaces:** +- Consumes: 无 +- Produces: `GET /api/admin/problem?paging=true&offset=0&limit=10&tag_id=` 返回该标签下的题目分页数据(结构与现有列表一致:`{results: [...], total: n}`) + +- [ ] **Step 1: 加 tag_id 过滤** + +在 `OnlineJudge/problem/views/admin.py` 的 `ProblemAdminAPI.get` 里,把现有的 keyword 过滤块: + +```python + keyword = request.GET.get("keyword", "").strip() + if keyword: + problems = problems.filter(Q(title__icontains=keyword) | Q(_id__icontains=keyword)) + if not user.can_mgmt_all_problem(): +``` + +改成: + +```python + keyword = request.GET.get("keyword", "").strip() + if keyword: + problems = problems.filter(Q(title__icontains=keyword) | Q(_id__icontains=keyword)) + + tag_id = request.GET.get("tag_id") + if tag_id: + problems = problems.filter(tags__id=tag_id) + + if not user.can_mgmt_all_problem(): +``` + +- [ ] **Step 2: 跑 lint** + +```bash +cd /home/xuyue/Projects/OJ/OnlineJudge && ruff check problem/views/admin.py && ruff format --check problem/views/admin.py +``` + +Expected: `All checks passed!` 且格式检查无输出报错。 + +- [ ] **Step 3: 用 Django shell 验证过滤能跑通** + +```bash +cd /home/xuyue/Projects/OJ/OnlineJudge && python manage.py shell -c " +from problem.models import Problem, ProblemTag +t = ProblemTag.objects.first() +if t is None: + print('库里没有标签,跳过') +else: + n = Problem.objects.filter(contest_id__isnull=True, tags__id=t.id).count() + print(f'标签 {t.name} (id={t.id}) 下有 {n} 道非比赛题') +" +``` + +Expected: 打印出标签名和题目数,没有异常。数字应与标签管理页显示的 `problem_count` 一致(该标签下若有比赛题则会偏小,属正常)。 + +- [ ] **Step 4: 提交** + +```bash +cd /home/xuyue/Projects/OJ/OnlineJudge && git add problem/views/admin.py && git commit -m "feat: 后台题目列表支持按标签筛选" +``` + +--- + +### Task 2: 标签题目弹窗组件 + +**Files:** +- Modify: `ojnext/src/admin/api.ts:29-54`(`getProblemList`) +- Create: `ojnext/src/admin/problem/components/TagProblemsModal.vue` + +**Interfaces:** +- Consumes: Task 1 的 `tag_id` 查询参数;已有的 `batchTagProblems(problemIds: number[], tagNames: string[], action: "add" | "remove")` +- Produces: + - `getProblemList(offset?: number, limit?: number, keyword: string, author?: string, contestID?: string, tagId?: number)` — 新增第 6 个可选参数 + - `TagProblemsModal.vue` 组件,props `{ show: boolean; tagId: number; tagName: string }`,emits `{ "update:show": [value: boolean]; changed: [] }` + +- [ ] **Step 1: 给 getProblemList 加 tagId 参数** + +在 `ojnext/src/admin/api.ts` 把 `getProblemList` 整个函数替换为: + +```ts +export async function getProblemList( + offset = 0, + limit = 10, + keyword: string, + author?: string, + contestID?: string, + tagId?: number, +) { + const endpoint = !!contestID ? "admin/contest/problem" : "admin/problem" + const res = await http.get<{ results: AdminProblem[]; total: number }>( + endpoint, + { + params: { + paging: true, + offset, + limit, + keyword, + author, + contest_id: contestID, + tag_id: tagId, + }, + }, + ) + return { + results: res.data.results.map(toProblemListItem), + total: res.data.total, + } +} +``` + +`tagId` 为 `undefined` 时 axios 会自动丢掉这个 query 参数,所以现有调用方(`list.vue`)不用改。 + +- [ ] **Step 2: 创建弹窗组件** + +新建 `ojnext/src/admin/problem/components/TagProblemsModal.vue`,内容如下: + +```vue + + + +``` + +- [ ] **Step 3: 格式化并确认能构建** + +```bash +cd /home/xuyue/Projects/OJ/ojnext && npm run fmt && npm run build +``` + +Expected: Prettier 无报错;`vite build` 成功结束(`built in ...`),没有 "Could not resolve" 之类的模块解析错误。 + +- [ ] **Step 4: 提交** + +```bash +cd /home/xuyue/Projects/OJ/ojnext && git add src/admin/api.ts src/admin/problem/components/TagProblemsModal.vue && git commit -m "feat: 新增标签题目弹窗组件" +``` + +--- + +### Task 3: 标签管理页接入弹窗 + +**Files:** +- Modify: `ojnext/src/admin/problem/tags.vue` + +**Interfaces:** +- Consumes: Task 2 的 `TagProblemsModal.vue`(props `show` / `tagId` / `tagName`,emits `update:show` / `changed`) +- Produces: 无(终端任务) + +- [ ] **Step 1: 引入组件和弹窗状态** + +在 `ojnext/src/admin/problem/tags.vue` 的 `