docs: 标签管理点击查看对应题目的实现计划
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
460
docs/superpowers/plans/2026-08-05-tag-problems-modal.md
Normal file
460
docs/superpowers/plans/2026-08-05-tag-problems-modal.md
Normal file
@@ -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=<int>` 返回该标签下的题目分页数据(结构与现有列表一致:`{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
|
||||
<script setup lang="ts">
|
||||
import { NButton, NTag } from "naive-ui"
|
||||
import Pagination from "shared/components/Pagination.vue"
|
||||
import type { AdminProblemFiltered } from "utils/types"
|
||||
import { batchTagProblems, getProblemList } from "admin/api"
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
tagId: number
|
||||
tagName: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
"update:show": [value: boolean]
|
||||
changed: []
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const problems = ref<AdminProblemFiltered[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const limit = ref(10)
|
||||
const keyword = ref("")
|
||||
|
||||
const columns: DataTableColumn<AdminProblemFiltered>[] = [
|
||||
{ title: "显示编号", key: "_id", width: 100 },
|
||||
{
|
||||
title: "标题",
|
||||
key: "title",
|
||||
minWidth: 200,
|
||||
render: (row) =>
|
||||
h(
|
||||
NButton,
|
||||
{ text: true, type: "primary", onClick: () => goEdit(row) },
|
||||
() => row.title,
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "可见",
|
||||
key: "visible",
|
||||
width: 80,
|
||||
render: (row) =>
|
||||
h(NTag, { size: "small", type: row.visible ? "success" : "default" }, () =>
|
||||
row.visible ? "公开" : "隐藏",
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "选项",
|
||||
key: "actions",
|
||||
width: 110,
|
||||
render: (row) =>
|
||||
h(
|
||||
NButton,
|
||||
{ size: "small", type: "error", onClick: () => removeTag(row) },
|
||||
() => "移除标签",
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
async function listProblems() {
|
||||
if (page.value < 1) page.value = 1
|
||||
const offset = (page.value - 1) * limit.value
|
||||
const res = await getProblemList(
|
||||
offset,
|
||||
limit.value,
|
||||
keyword.value,
|
||||
"",
|
||||
undefined,
|
||||
props.tagId,
|
||||
)
|
||||
problems.value = res.results
|
||||
total.value = res.total
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit("update:show", false)
|
||||
}
|
||||
|
||||
function goEdit(row: AdminProblemFiltered) {
|
||||
close()
|
||||
router.push({ name: "admin problem edit", params: { problemID: row.id } })
|
||||
}
|
||||
|
||||
async function removeTag(row: AdminProblemFiltered) {
|
||||
await batchTagProblems([row.id], [props.tagName], "remove")
|
||||
message.success(`已移除「${row.title}」的标签`)
|
||||
emit("changed")
|
||||
// 移掉本页最后一条时退回上一页,交给下面的 watcher 重新拉取
|
||||
if (problems.value.length === 1 && page.value > 1) {
|
||||
page.value -= 1
|
||||
} else {
|
||||
listProblems()
|
||||
}
|
||||
}
|
||||
|
||||
// 改搜索词就回到第一页
|
||||
watch(keyword, () => (page.value = 1))
|
||||
|
||||
// 每次打开弹窗重置状态,拉取交给下面的 watcher
|
||||
watch(
|
||||
() => props.show,
|
||||
(show) => {
|
||||
if (!show) return
|
||||
page.value = 1
|
||||
keyword.value = ""
|
||||
},
|
||||
)
|
||||
|
||||
// 打开 / 翻页 / 改每页条数 / 改搜索词都走这里,防抖把同一批变更合并成一次请求
|
||||
watchDebounced(
|
||||
() => [props.show, props.tagId, page.value, limit.value, keyword.value],
|
||||
() => {
|
||||
if (!props.show) return
|
||||
listProblems()
|
||||
},
|
||||
{ debounce: 300, maxWait: 800 },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-modal
|
||||
:show="show"
|
||||
preset="card"
|
||||
:title="`标签「${tagName}」下的题目`"
|
||||
style="width: 720px"
|
||||
@close="close"
|
||||
>
|
||||
<n-flex vertical size="large">
|
||||
<n-flex justify="space-between" align="center">
|
||||
<span>共 {{ total }} 道题</span>
|
||||
<n-input
|
||||
v-model:value="keyword"
|
||||
style="width: 220px"
|
||||
placeholder="输入标题关键字"
|
||||
clearable
|
||||
/>
|
||||
</n-flex>
|
||||
<n-data-table striped :columns="columns" :data="problems" />
|
||||
<Pagination :total="total" v-model:limit="limit" v-model:page="page" />
|
||||
</n-flex>
|
||||
</n-modal>
|
||||
</template>
|
||||
```
|
||||
|
||||
- [ ] **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` 的 `<script setup>` 里,把顶部 import 和状态声明部分:
|
||||
|
||||
```ts
|
||||
import { NButton, NFlex, NInput } from "naive-ui"
|
||||
import type { AdminTag } from "utils/types"
|
||||
import { deleteTag, getTagAdminList, renameTag } from "../api"
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
|
||||
const tags = ref<AdminTag[]>([])
|
||||
const keyword = ref("")
|
||||
const editingId = ref<number | null>(null)
|
||||
const editingName = ref("")
|
||||
```
|
||||
|
||||
改成:
|
||||
|
||||
```ts
|
||||
import { NButton, NFlex, NInput } from "naive-ui"
|
||||
import type { AdminTag } from "utils/types"
|
||||
import { deleteTag, getTagAdminList, renameTag } from "../api"
|
||||
import TagProblemsModal from "./components/TagProblemsModal.vue"
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
|
||||
const tags = ref<AdminTag[]>([])
|
||||
const keyword = ref("")
|
||||
const editingId = ref<number | null>(null)
|
||||
const editingName = ref("")
|
||||
|
||||
const activeTag = ref<AdminTag | null>(null)
|
||||
const [showTagProblems, toggleTagProblems] = useToggle(false)
|
||||
|
||||
function openTagProblems(tag: AdminTag) {
|
||||
activeTag.value = tag
|
||||
toggleTagProblems(true)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 让标签名和题目数可点击**
|
||||
|
||||
把 `columns` 里的「标签名」列 render 的非编辑态分支(原来直接是 `row.name`)和「题目数」列改掉。两列替换后如下:
|
||||
|
||||
```ts
|
||||
{
|
||||
title: "标签名",
|
||||
key: "name",
|
||||
minWidth: 200,
|
||||
render: (row) =>
|
||||
editingId.value === row.id
|
||||
? h(NInput, {
|
||||
value: editingName.value,
|
||||
autofocus: true,
|
||||
size: "small",
|
||||
style: "max-width: 240px",
|
||||
onUpdateValue: (v: string) => (editingName.value = v),
|
||||
onKeyup: (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter") saveTag(row)
|
||||
if (e.key === "Escape") cancelEdit()
|
||||
},
|
||||
})
|
||||
: h(
|
||||
NButton,
|
||||
{
|
||||
text: true,
|
||||
type: "primary",
|
||||
onClick: () => openTagProblems(row),
|
||||
},
|
||||
() => row.name,
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "题目数",
|
||||
key: "problem_count",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
NButton,
|
||||
{ text: true, type: "primary", onClick: () => openTagProblems(row) },
|
||||
() => String(row.problem_count),
|
||||
),
|
||||
},
|
||||
```
|
||||
|
||||
编辑态下标签名仍然是输入框,不可点击。
|
||||
|
||||
- [ ] **Step 3: 在模板里挂上弹窗**
|
||||
|
||||
把 `tags.vue` 模板末尾的 `<n-data-table ... />` 行:
|
||||
|
||||
```vue
|
||||
<n-data-table striped :columns="columns" :data="tags" />
|
||||
```
|
||||
|
||||
改成:
|
||||
|
||||
```vue
|
||||
<n-data-table striped :columns="columns" :data="tags" />
|
||||
<TagProblemsModal
|
||||
v-model:show="showTagProblems"
|
||||
:tag-id="activeTag?.id ?? 0"
|
||||
:tag-name="activeTag?.name ?? ''"
|
||||
@changed="listTags"
|
||||
/>
|
||||
```
|
||||
|
||||
不要用 `v-if` 包这个组件:组件内部靠 `show` 从 `false` 变 `true` 触发拉取,`v-if` 挂载时 `show` 已经是 `true`,watcher 不会触发,弹窗会是空的。
|
||||
|
||||
- [ ] **Step 4: 格式化并确认能构建**
|
||||
|
||||
```bash
|
||||
cd /home/xuyue/Projects/OJ/ojnext && npm run fmt && npm run build
|
||||
```
|
||||
|
||||
Expected: Prettier 无报错;`vite build` 成功结束。
|
||||
|
||||
- [ ] **Step 5: 手动验证**
|
||||
|
||||
前置:后端在跑(`cd /home/xuyue/Projects/OJ/OnlineJudge && python dev.py`),前端在跑(`cd /home/xuyue/Projects/OJ/ojnext && npm start`),用超管账号登录。
|
||||
|
||||
打开 `http://localhost:5173/admin/problem/tags`,逐条确认:
|
||||
|
||||
1. 标签名和题目数都是蓝色可点的,点任一个都弹出「标签「X」下的题目」
|
||||
2. 弹窗顶部「共 N 道题」的 N 与该行题目数一致
|
||||
3. 表格里题目条数正确,超过 10 条时底部分页可翻页
|
||||
4. 在弹窗搜索框输入标题关键字,列表过滤正确,且自动回到第 1 页
|
||||
5. 点某道题标题 → 弹窗关闭并跳到该题编辑页;退回标签管理页
|
||||
6. 点某道题「移除标签」→ 提示「已移除「X」的标签」,该题从弹窗列表消失,关掉弹窗后该行题目数减 1
|
||||
7. 点「重命名」进入编辑态时,标签名变成输入框且点不动
|
||||
|
||||
- [ ] **Step 6: 提交**
|
||||
|
||||
```bash
|
||||
cd /home/xuyue/Projects/OJ/ojnext && git add src/admin/problem/tags.vue && git commit -m "feat: 标签管理页点击标签查看对应题目"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 备注
|
||||
|
||||
- 标签表的 `problem_count` 来自 `TagAdminAPI` 的全量 `Count("problem")`,弹窗走 `ProblemAdminAPI` 会按创建者过滤,非超管可能看到两个数字对不上。这是 spec 里明确记录的已知不一致,本次不处理。
|
||||
- `problem_count` 为 0 的标签点开会是空列表,属预期。
|
||||
Reference in New Issue
Block a user