- 迁移 0022:problem_tag 加 category(knowledge / theme)和 category_confirmed; 存量 25 个题材、题集类标签归主题,其余归知识点,全部记为已确认 - 前台题目列表按两组显示标签,组内按题目数从多到少 - 后台标签管理:每行切换分类;出题时新建的标签默认知识点、标「待归类」并排在最前, 页头提示待归类数量,可点「确认」保留默认;加分类筛选 - 新接口 PUT /admin/problem-tags/:id/category,设置分类即视为确认 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e1b6a9e9fa
commit
d07459928d
12 files changed
+4226
-30
No files matched your search
@@ -0,0 +1,16 @@
|
|||||||
|
-- 标签分「知识点 / 主题」两类,前台题目列表按两组显示,含义见契约 tagCategorySchema。
|
||||||
|
-- 带常量默认值的加列只改目录不重写表;新标签默认算知识点、且分类「未确认」,
|
||||||
|
-- 后台标签管理把未确认的排在最前面,等老师点一下归类。
|
||||||
|
ALTER TABLE "problem_tag" ADD COLUMN "category" text DEFAULT 'knowledge' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "problem_tag" ADD COLUMN "category_confirmed" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||||
|
-- 存量标签的归类:按名字挑出主题类标签,其余留在知识点。按 lower(name) 匹配
|
||||||
|
-- (和唯一索引 problem_tag_name_ci_unique 同一口径),库里没有的名字空转即可。
|
||||||
|
-- 必会题、技能队、力扣是题集 / 来源而不是知识点,也归主题。
|
||||||
|
UPDATE "problem_tag" SET "category" = 'theme' WHERE lower("name") IN (
|
||||||
|
'弱智吧', '二次元', '狐妖小红娘', '第五人格', '农药', '哈利波特', '九大学科', '原',
|
||||||
|
'职一第一深情', '异兽', '反诈', '三角洲', '火影', '神秘复苏', 'blue archive',
|
||||||
|
'空洞骑士', '周杰伦', 'lol', '奥特曼', '无畏契约', '图书馆', '物联网',
|
||||||
|
'必会题', '技能队', '力扣'
|
||||||
|
);--> statement-breakpoint
|
||||||
|
-- 上面这版归类已经人工过目,存量标签一律算确认过;之后新建的才进「待归类」
|
||||||
|
UPDATE "problem_tag" SET "category_confirmed" = true;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -155,6 +155,13 @@
|
|||||||
"when": 1789990062378,
|
"when": 1789990062378,
|
||||||
"tag": "0021_ai_hint_level",
|
"tag": "0021_ai_hint_level",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 22,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1790512513639,
|
||||||
|
"tag": "0022_add_tag_category",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -52,6 +52,7 @@ import type {
|
|||||||
ReactionKey,
|
ReactionKey,
|
||||||
SqlConfig,
|
SqlConfig,
|
||||||
SqlDisplay,
|
SqlDisplay,
|
||||||
|
TagCategory,
|
||||||
TutorialType,
|
TutorialType,
|
||||||
} from "@oj2/contract"
|
} from "@oj2/contract"
|
||||||
import {
|
import {
|
||||||
@@ -738,6 +739,10 @@ export const problemTag = pgTable(
|
|||||||
{
|
{
|
||||||
id: serial().primaryKey().notNull(),
|
id: serial().primaryKey().notNull(),
|
||||||
name: text().notNull(),
|
name: text().notNull(),
|
||||||
|
category: text().notNull().default("knowledge").$type<TagCategory>(),
|
||||||
|
// 分类有没有人确认过。出题时顺手新建的标签一律先落在「知识点」,这一列为 false,
|
||||||
|
// 后台标签管理把它们排在最前面等老师归类;存量标签由 0022 统一标成 true
|
||||||
|
categoryConfirmed: boolean("category_confirmed").notNull().default(false),
|
||||||
},
|
},
|
||||||
() => [uniqueIndex("problem_tag_name_ci_unique").using("btree", sql`lower(name)`)],
|
() => [uniqueIndex("problem_tag_name_ci_unique").using("btree", sql`lower(name)`)],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
batchProblemTagRequestSchema,
|
batchProblemTagRequestSchema,
|
||||||
generateFlowchartRequestSchema,
|
generateFlowchartRequestSchema,
|
||||||
renameTagRequestSchema,
|
renameTagRequestSchema,
|
||||||
|
setTagCategoryRequestSchema,
|
||||||
type AcTrend,
|
type AcTrend,
|
||||||
type AdminTag,
|
type AdminTag,
|
||||||
type BatchProblemTagResponse,
|
type BatchProblemTagResponse,
|
||||||
@@ -54,15 +55,22 @@ adminTagRoutes.get("/problem-tags", requireProblemPermission, async (c) => {
|
|||||||
.select({
|
.select({
|
||||||
id: schema.problemTag.id,
|
id: schema.problemTag.id,
|
||||||
name: schema.problemTag.name,
|
name: schema.problemTag.name,
|
||||||
|
category: schema.problemTag.category,
|
||||||
|
categoryConfirmed: schema.problemTag.categoryConfirmed,
|
||||||
problemCount: countDistinct(schema.problemTags.problemId),
|
problemCount: countDistinct(schema.problemTags.problemId),
|
||||||
})
|
})
|
||||||
.from(schema.problemTag)
|
.from(schema.problemTag)
|
||||||
.leftJoin(schema.problemTags, eq(schema.problemTags.problemtagId, schema.problemTag.id))
|
.leftJoin(schema.problemTags, eq(schema.problemTags.problemtagId, schema.problemTag.id))
|
||||||
.where(keyword ? ilike(schema.problemTag.name, `%${keyword}%`) : undefined)
|
.where(keyword ? ilike(schema.problemTag.name, `%${keyword}%`) : undefined)
|
||||||
.groupBy(schema.problemTag.id, schema.problemTag.name)
|
.groupBy(schema.problemTag.id)
|
||||||
// 后台标签管理要看到 problemCount=0 的标签(正是要清理的那些),
|
// 后台标签管理要看到 problemCount=0 的标签(正是要清理的那些),
|
||||||
// 所以这里用 leftJoin 且不加 having —— oj 侧的 /problem-tags 才过滤 >0
|
// 所以这里用 leftJoin 且不加 having —— oj 侧的 /problem-tags 才过滤 >0。
|
||||||
.orderBy(desc(countDistinct(schema.problemTags.problemId)), asc(schema.problemTag.name))
|
// 待归类的新标签排最前面(false 在 true 之前),不然会沉在几十个老标签底下
|
||||||
|
.orderBy(
|
||||||
|
asc(schema.problemTag.categoryConfirmed),
|
||||||
|
desc(countDistinct(schema.problemTags.problemId)),
|
||||||
|
asc(schema.problemTag.name),
|
||||||
|
)
|
||||||
return success(c, rows satisfies AdminTag[])
|
return success(c, rows satisfies AdminTag[])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -134,6 +142,20 @@ adminTagRoutes.put("/problem-tags/:id", requireProblemPermission, async (c) => {
|
|||||||
} satisfies RenameTagResponse)
|
} satisfies RenameTagResponse)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
adminTagRoutes.put("/problem-tags/:id/category", requireProblemPermission, async (c) => {
|
||||||
|
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||||
|
const parsed = await parseBody(c, setTagCategoryRequestSchema, "分类只能是知识点或主题")
|
||||||
|
if (!parsed.success) return parsed.response
|
||||||
|
const [row] = await db
|
||||||
|
.update(schema.problemTag)
|
||||||
|
// 选了分类就算确认过,哪怕选的还是默认的「知识点」
|
||||||
|
.set({ category: parsed.data.category, categoryConfirmed: true })
|
||||||
|
.where(eq(schema.problemTag.id, id))
|
||||||
|
.returning({ id: schema.problemTag.id })
|
||||||
|
if (!row) return failure(c, 404, "tag-not-found", "标签不存在,请刷新后重试")
|
||||||
|
return success(c, null)
|
||||||
|
})
|
||||||
|
|
||||||
adminTagRoutes.delete("/problem-tags/:id", requireProblemPermission, async (c) => {
|
adminTagRoutes.delete("/problem-tags/:id", requireProblemPermission, async (c) => {
|
||||||
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
const id = queryInteger(c.req.param("id"), 0, { min: 1 })
|
||||||
// 中间表 problem_tags 随标签一起清:problemtag_id 是 CASCADE(0010)
|
// 中间表 problem_tags 随标签一起清:problemtag_id 是 CASCADE(0010)
|
||||||
|
|||||||
@@ -193,6 +193,7 @@ problemRoutes.get("/problem-tags", async (c) => {
|
|||||||
.select({
|
.select({
|
||||||
id: schema.problemTag.id,
|
id: schema.problemTag.id,
|
||||||
name: schema.problemTag.name,
|
name: schema.problemTag.name,
|
||||||
|
category: schema.problemTag.category,
|
||||||
problemCount: countDistinct(schema.problemTags.problemId),
|
problemCount: countDistinct(schema.problemTags.problemId),
|
||||||
})
|
})
|
||||||
.from(schema.problemTag)
|
.from(schema.problemTag)
|
||||||
@@ -206,7 +207,7 @@ problemRoutes.get("/problem-tags", async (c) => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.where(keyword ? ilike(schema.problemTag.name, `%${keyword}%`) : undefined)
|
.where(keyword ? ilike(schema.problemTag.name, `%${keyword}%`) : undefined)
|
||||||
.groupBy(schema.problemTag.id, schema.problemTag.name)
|
.groupBy(schema.problemTag.id, schema.problemTag.name, schema.problemTag.category)
|
||||||
.having(sql`count(${schema.problemTags.problemId}) > 0`)
|
.having(sql`count(${schema.problemTags.problemId}) > 0`)
|
||||||
.orderBy(asc(schema.problemTag.name))
|
.orderBy(asc(schema.problemTag.name))
|
||||||
return success(c, rows satisfies Tag[])
|
return success(c, rows satisfies Tag[])
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import type {
|
|||||||
AdminProblemSet,
|
AdminProblemSet,
|
||||||
AdminProblemSetProblem,
|
AdminProblemSetProblem,
|
||||||
TutorialListItem,
|
TutorialListItem,
|
||||||
|
TagCategory,
|
||||||
} from "utils/types"
|
} from "utils/types"
|
||||||
|
|
||||||
export function getBaseInfo() {
|
export function getBaseInfo() {
|
||||||
@@ -114,6 +115,10 @@ export function renameTag(id: number, name: string) {
|
|||||||
return api.put<RenameTagResponse>(`admin/problem-tags/${id}`, { name })
|
return api.put<RenameTagResponse>(`admin/problem-tags/${id}`, { name })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setTagCategory(id: number, category: TagCategory) {
|
||||||
|
return api.put<null>(`admin/problem-tags/${id}/category`, { category })
|
||||||
|
}
|
||||||
|
|
||||||
export function deleteTag(id: number) {
|
export function deleteTag(id: number) {
|
||||||
return api.delete(`admin/problem-tags/${id}`)
|
return api.delete(`admin/problem-tags/${id}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import PageHeader from "admin/components/PageHeader.vue"
|
import PageHeader from "admin/components/PageHeader.vue"
|
||||||
import { NButton, NFlex, NInput } from "naive-ui"
|
import { NButton, NFlex, NInput, NRadioButton, NRadioGroup, NTag } from "naive-ui"
|
||||||
import type { AdminTag } from "utils/types"
|
import type { AdminTag, TagCategory } from "utils/types"
|
||||||
import { deleteTag, getTagAdminList, renameTag } from "../api"
|
import { deleteTag, getTagAdminList, renameTag, setTagCategory } from "../api"
|
||||||
import TagProblemsModal from "./components/TagProblemsModal.vue"
|
import TagProblemsModal from "./components/TagProblemsModal.vue"
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
@@ -10,6 +10,27 @@ const dialog = useDialog()
|
|||||||
|
|
||||||
const tags = ref<AdminTag[]>([])
|
const tags = ref<AdminTag[]>([])
|
||||||
const keyword = ref("")
|
const keyword = ref("")
|
||||||
|
|
||||||
|
type CategoryFilter = "" | "pending" | TagCategory
|
||||||
|
const categoryFilter = ref<CategoryFilter>("")
|
||||||
|
|
||||||
|
// 出题时新建的标签默认算「知识点」、还没人确认过,数出来挂在页头提醒
|
||||||
|
const pendingCount = computed(() => tags.value.filter((t) => !t.categoryConfirmed).length)
|
||||||
|
|
||||||
|
// 标签总共几十个,分类筛选在前端做,不必为它再加一个 query 参数
|
||||||
|
const visibleTags = computed(() => {
|
||||||
|
const filter = categoryFilter.value
|
||||||
|
if (!filter) return tags.value
|
||||||
|
if (filter === "pending") return tags.value.filter((t) => !t.categoryConfirmed)
|
||||||
|
return tags.value.filter((t) => t.category === filter)
|
||||||
|
})
|
||||||
|
|
||||||
|
const categoryOptions = computed(() => [
|
||||||
|
{ label: "全部分类", value: "" },
|
||||||
|
{ label: `待归类(${pendingCount.value})`, value: "pending" },
|
||||||
|
{ label: "知识点", value: "knowledge" },
|
||||||
|
{ label: "主题", value: "theme" },
|
||||||
|
])
|
||||||
const editingId = ref<number | null>(null)
|
const editingId = ref<number | null>(null)
|
||||||
const editingName = ref("")
|
const editingName = ref("")
|
||||||
|
|
||||||
@@ -40,15 +61,54 @@ const columns: DataTableColumn<AdminTag>[] = [
|
|||||||
if (e.key === "Escape") cancelEdit()
|
if (e.key === "Escape") cancelEdit()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
: h(
|
: h(NFlex, { size: 8, align: "center" }, () => [
|
||||||
NButton,
|
h(
|
||||||
{
|
NButton,
|
||||||
text: true,
|
{
|
||||||
type: "primary",
|
text: true,
|
||||||
onClick: () => openTagProblems(row),
|
type: "primary",
|
||||||
},
|
onClick: () => openTagProblems(row),
|
||||||
() => row.name,
|
},
|
||||||
),
|
() => row.name,
|
||||||
|
),
|
||||||
|
row.categoryConfirmed
|
||||||
|
? null
|
||||||
|
: h(NTag, { size: "small", type: "warning", bordered: false }, () => "待归类"),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 前台题目列表按这一列把标签分成「知识点」「主题」两组
|
||||||
|
title: "分类",
|
||||||
|
key: "category",
|
||||||
|
width: 240,
|
||||||
|
render: (row) =>
|
||||||
|
h(NFlex, { size: 8, align: "center", wrap: false }, () => [
|
||||||
|
h(
|
||||||
|
NRadioGroup,
|
||||||
|
{
|
||||||
|
size: "small",
|
||||||
|
value: row.category,
|
||||||
|
onUpdateValue: (v: TagCategory) => changeCategory(row, v),
|
||||||
|
},
|
||||||
|
() => [
|
||||||
|
h(NRadioButton, { value: "knowledge" }, () => "知识点"),
|
||||||
|
h(NRadioButton, { value: "theme" }, () => "主题"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// 新标签默认已经是「知识点」,点它不会触发 update,所以单给一个确认按钮
|
||||||
|
row.categoryConfirmed
|
||||||
|
? null
|
||||||
|
: h(
|
||||||
|
NButton,
|
||||||
|
{
|
||||||
|
size: "small",
|
||||||
|
type: "primary",
|
||||||
|
secondary: true,
|
||||||
|
onClick: () => changeCategory(row, row.category),
|
||||||
|
},
|
||||||
|
() => "确认",
|
||||||
|
),
|
||||||
|
]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "题目数",
|
title: "题目数",
|
||||||
@@ -125,6 +185,13 @@ async function saveTag(tag: AdminTag) {
|
|||||||
listTags()
|
listTags()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function changeCategory(tag: AdminTag, category: TagCategory) {
|
||||||
|
await setTagCategory(tag.id, category)
|
||||||
|
tag.category = category
|
||||||
|
tag.categoryConfirmed = true
|
||||||
|
message.success(`「${tag.name}」已归到${category === "theme" ? "主题" : "知识点"}`)
|
||||||
|
}
|
||||||
|
|
||||||
function confirmDelete(tag: AdminTag) {
|
function confirmDelete(tag: AdminTag) {
|
||||||
dialog.warning({
|
dialog.warning({
|
||||||
title: "删除标签",
|
title: "删除标签",
|
||||||
@@ -148,9 +215,14 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
|
|||||||
<PageHeader title="标签管理">
|
<PageHeader title="标签管理">
|
||||||
<template #filters>
|
<template #filters>
|
||||||
<n-input v-model:value="keyword" style="width: 220px" placeholder="搜索标签" clearable />
|
<n-input v-model:value="keyword" style="width: 220px" placeholder="搜索标签" clearable />
|
||||||
|
<n-select v-model:value="categoryFilter" :options="categoryOptions" style="width: 160px" />
|
||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<n-data-table striped :columns="columns" :data="tags" />
|
<n-alert v-if="pendingCount > 0" type="warning" :bordered="false" class="pending-alert">
|
||||||
|
有 {{ pendingCount }} 个新标签还没归类(出题时新建的标签默认算「知识点」)。
|
||||||
|
前台题目列表按分类把标签分成「知识点」「主题」两组,选好分类或点「确认」即可。
|
||||||
|
</n-alert>
|
||||||
|
<n-data-table striped :columns="columns" :data="visibleTags" />
|
||||||
<TagProblemsModal
|
<TagProblemsModal
|
||||||
v-model:show="showTagProblems"
|
v-model:show="showTagProblems"
|
||||||
:tag-id="activeTag?.id ?? 0"
|
:tag-id="activeTag?.id ?? 0"
|
||||||
@@ -158,3 +230,9 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
|
|||||||
@changed="listTags"
|
@changed="listTags"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pending-alert {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -104,6 +104,22 @@ async function listTags() {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 知识点在前、主题在后;组内按题目数从多到少,最常用的「输入输出」「选择结构」
|
||||||
|
// 排在最前面,不再和一堆游戏名按拼音混排
|
||||||
|
const TAG_GROUPS = [
|
||||||
|
{ category: "knowledge", label: "知识点" },
|
||||||
|
{ category: "theme", label: "主题" },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const tagGroups = computed(() =>
|
||||||
|
TAG_GROUPS.map((group) => ({
|
||||||
|
...group,
|
||||||
|
tags: tags.value
|
||||||
|
.filter((t) => t.category === group.category)
|
||||||
|
.sort((a, b) => b.problemCount - a.problemCount),
|
||||||
|
})).filter((group) => group.tags.length > 0),
|
||||||
|
)
|
||||||
|
|
||||||
function chooseTag(tag: Tag) {
|
function chooseTag(tag: Tag) {
|
||||||
query.tag = tag.checked ? "" : tag.name
|
query.tag = tag.checked ? "" : tag.name
|
||||||
tags.value = tags.value.map((t) => {
|
tags.value = tags.value.map((t) => {
|
||||||
@@ -293,18 +309,24 @@ function rowProps(row: ProblemRow) {
|
|||||||
<Hitokoto v-if="isDesktop" class="problem-list-hitokoto" />
|
<Hitokoto v-if="isDesktop" class="problem-list-hitokoto" />
|
||||||
</div>
|
</div>
|
||||||
<n-collapse-transition :show="showTag">
|
<n-collapse-transition :show="showTag">
|
||||||
<n-flex>
|
<div class="tag-groups">
|
||||||
<n-tag
|
<div v-for="group in tagGroups" :key="group.category" class="tag-group">
|
||||||
v-for="tag in tags"
|
<n-text depth="3" class="tag-group-label">{{ group.label }}</n-text>
|
||||||
:closable="tag.checked"
|
<n-flex>
|
||||||
@close="chooseTag(tag)"
|
<n-tag
|
||||||
@click="chooseTag(tag)"
|
v-for="tag in group.tags"
|
||||||
:key="tag.id"
|
:closable="tag.checked"
|
||||||
:type="tag.checked ? 'success' : 'default'"
|
@close="chooseTag(tag)"
|
||||||
>
|
@click="chooseTag(tag)"
|
||||||
{{ tag.name }}
|
:key="tag.id"
|
||||||
</n-tag>
|
:type="tag.checked ? 'success' : 'default'"
|
||||||
</n-flex>
|
class="tag-chip"
|
||||||
|
>
|
||||||
|
{{ tag.name }}
|
||||||
|
</n-tag>
|
||||||
|
</n-flex>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</n-collapse-transition>
|
</n-collapse-transition>
|
||||||
<n-data-table :bordered="false" :data="problems" :columns="columns" :row-props="rowProps" />
|
<n-data-table :bordered="false" :data="problems" :columns="columns" :row-props="rowProps" />
|
||||||
</n-flex>
|
</n-flex>
|
||||||
@@ -330,6 +352,28 @@ function rowProps(row: ProblemRow) {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tag-groups {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-group {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 48px minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-group-label {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.problem-list-toolbar {
|
.problem-list-toolbar {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export type ProblemStatus = "passed" | "failed" | "not_test"
|
|||||||
* 题目标签。用契约的 —— 它比手抄那份多一个 `problemCount`,
|
* 题目标签。用契约的 —— 它比手抄那份多一个 `problemCount`,
|
||||||
* shared/api.ts 原来还得用 `Tag & { problemCount: number }` 把它补回来。
|
* shared/api.ts 原来还得用 `Tag & { problemCount: number }` 把它补回来。
|
||||||
*/
|
*/
|
||||||
export type { Tag } from "@oj2/contract"
|
export type { Tag, TagCategory } from "@oj2/contract"
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
AdminTag,
|
AdminTag,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
problemDifficultySchema,
|
problemDifficultySchema,
|
||||||
sqlConfigSchema,
|
sqlConfigSchema,
|
||||||
sqlDisplaySchema,
|
sqlDisplaySchema,
|
||||||
|
tagCategorySchema,
|
||||||
} from "./problem"
|
} from "./problem"
|
||||||
|
|
||||||
// 放这里而不是 roles.ts:roles.ts 要保持不 import zod,理由见那边的文件头
|
// 放这里而不是 roles.ts:roles.ts 要保持不 import zod,理由见那边的文件头
|
||||||
@@ -472,9 +473,16 @@ export const adminProblemSetProgressSchema = z.object({
|
|||||||
export const adminTagSchema = z.object({
|
export const adminTagSchema = z.object({
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
|
category: tagCategorySchema,
|
||||||
|
/** 分类有没有人确认过。出题时新建的标签是 false,默认先算知识点,等老师归类 */
|
||||||
|
categoryConfirmed: z.boolean(),
|
||||||
problemCount: z.number().int(),
|
problemCount: z.number().int(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const setTagCategoryRequestSchema = z.object({
|
||||||
|
category: tagCategorySchema,
|
||||||
|
})
|
||||||
|
|
||||||
export const renameTagRequestSchema = z.object({
|
export const renameTagRequestSchema = z.object({
|
||||||
name: z.string().trim().min(1).max(64),
|
name: z.string().trim().min(1).max(64),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -427,9 +427,17 @@ export const problemListItemSchema = z.object({
|
|||||||
|
|
||||||
export const problemListSchema = paginatedSchema(problemListItemSchema)
|
export const problemListSchema = paginatedSchema(problemListItemSchema)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标签分两类:`knowledge` 是知识点(循环结构、字符串……),`theme` 是题目的
|
||||||
|
* 题材(LOL、周杰伦、哈利波特……)。两类原来混在一起,题目列表上四十多个标签
|
||||||
|
* 挤成一片,学生想按知识点找题得在一堆游戏名里挑。新标签默认算知识点。
|
||||||
|
*/
|
||||||
|
export const tagCategorySchema = z.enum(["knowledge", "theme"])
|
||||||
|
|
||||||
export const tagSchema = z.object({
|
export const tagSchema = z.object({
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
|
category: tagCategorySchema,
|
||||||
problemCount: z.number().int().nonnegative(),
|
problemCount: z.number().int().nonnegative(),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -454,6 +462,7 @@ export type ProblemDifficulty = z.infer<typeof problemDifficultySchema>
|
|||||||
export type ProblemListItem = z.infer<typeof problemListItemSchema>
|
export type ProblemListItem = z.infer<typeof problemListItemSchema>
|
||||||
export type ProblemList = z.infer<typeof problemListSchema>
|
export type ProblemList = z.infer<typeof problemListSchema>
|
||||||
export type Tag = z.infer<typeof tagSchema>
|
export type Tag = z.infer<typeof tagSchema>
|
||||||
|
export type TagCategory = z.infer<typeof tagCategorySchema>
|
||||||
export type ProblemAuthor = z.infer<typeof problemAuthorSchema>
|
export type ProblemAuthor = z.infer<typeof problemAuthorSchema>
|
||||||
export type SqlConfig = z.infer<typeof sqlConfigSchema>
|
export type SqlConfig = z.infer<typeof sqlConfigSchema>
|
||||||
export type SqlDisplay = z.infer<typeof sqlDisplaySchema>
|
export type SqlDisplay = z.infer<typeof sqlDisplaySchema>
|
||||||
|
|||||||
Reference in new issue
Block a user