refactor: replace tag two-panel with unified n-select combobox
Some checks failed
Deploy / deploy (build, debian, 22, /root/OJDeploy/data/clientnext) (push) Has been cancelled
Deploy / deploy (build:staging, school, 8822, /root/OJ/data/dist) (push) Has been cancelled

This commit is contained in:
2026-05-17 08:09:13 -06:00
parent d25f126710
commit 3c90bedff6

View File

@@ -91,16 +91,7 @@ const problem = useLocalStorage<BlankProblem>(STORAGE_KEY.ADMIN_PROBLEM, {
// 从服务器来的tag列表 // 从服务器来的tag列表
const tagList = shallowRef<Tag[]>([]) const tagList = shallowRef<Tag[]>([])
interface Tags { const tagValue = ref<string[]>([])
select: string[]
upload: string[]
}
// 从 tagList 中选择的 和 新上传的
const tags = useLocalStorage<Tags>(STORAGE_KEY.ADMIN_PROBLEM_TAGS, {
select: [],
upload: [],
})
const tagKeyword = ref("")
// 这几个用的少,就不缓存本地了 // 这几个用的少,就不缓存本地了
const [needTemplate, toggleNeedTemplate] = useToggle(false) const [needTemplate, toggleNeedTemplate] = useToggle(false)
@@ -126,34 +117,9 @@ const languageOptions = [
{ label: LANGUAGE_SHOW_VALUE["C++"], value: "C++" }, { label: LANGUAGE_SHOW_VALUE["C++"], value: "C++" },
] ]
const tagOptions = computed(() =>
const filteredTagList = computed(() => { tagList.value.map((tag) => ({ label: tag.name, value: tag.name }))
const keyword = tagKeyword.value.trim().toLowerCase() )
if (!keyword) return tagList.value
return tagList.value.filter((tag) => tag.name.toLowerCase().includes(keyword))
})
const selectedTagSet = computed(() => new Set(tags.value.select))
function toggleExistingTag(tagName: string) {
const selected = new Set(tags.value.select)
if (selected.has(tagName)) {
selected.delete(tagName)
} else {
selected.add(tagName)
}
tags.value.select = Array.from(selected)
}
function selectFilteredTags() {
const selected = new Set(tags.value.select)
filteredTagList.value.forEach((tag) => selected.add(tag.name))
tags.value.select = Array.from(selected)
}
function clearExistingTags() {
tags.value.select = []
}
async function getProblemDetail() { async function getProblemDetail() {
if (!props.problemID) { if (!props.problemID) {
@@ -213,7 +179,7 @@ async function getProblemDetail() {
} }
}) })
// 标签 // 标签
tags.value.select = data.tags tagValue.value = data.tags
toggleReady(true) toggleReady(true)
} catch (error) { } catch (error) {
message.error("获取题目失败") message.error("获取题目失败")
@@ -226,22 +192,6 @@ async function getTagList() {
tagList.value = res.data tagList.value = res.data
} }
function updateNewTags(v: string[]) {
const blanks = []
const uniqueTags = unique(v)
const items = tagList.value.map((t) => t.name)
for (let i = 0; i < uniqueTags.length; i++) {
const tag = uniqueTags[i]
if (items.indexOf(tag) < 0) {
blanks.push(tag)
} else {
message.error("已经存在标签:" + tag)
break
}
}
tags.value.upload = blanks
}
function addSample() { function addSample() {
problem.value.samples.push({ input: "", output: "" }) problem.value.samples.push({ input: "", output: "" })
} }
@@ -291,7 +241,7 @@ async function validateProblem() {
hasErrors = true hasErrors = true
} }
// 标签 // 标签
else if (tags.value.upload.length === 0 && tags.value.select.length === 0) { else if (tagValue.value.length === 0) {
message.error("标签没有填写") message.error("标签没有填写")
hasErrors = true hasErrors = true
} }
@@ -394,7 +344,7 @@ async function submit() {
try { try {
await api!(problem.value) await api!(problem.value)
problem.value = null problem.value = null
tags.value = null tagValue.value = []
if ( if (
route.name === "admin problem create" || route.name === "admin problem create" ||
route.name === "admin contest problem create" route.name === "admin contest problem create"
@@ -429,7 +379,7 @@ const showClear = computed(
function clear() { function clear() {
problem.value = null problem.value = null
tags.value = null tagValue.value = []
// 为了给所有状态初始化,刷新页面 // 为了给所有状态初始化,刷新页面
location.reload() location.reload()
} }
@@ -449,13 +399,9 @@ onMounted(() => {
getProblemDetail() getProblemDetail()
}) })
watch( watch(tagValue, (val) => {
() => [tags.value.select, tags.value.upload], problem.value.tags = val
(tags) => { })
const uniqueTags = unique<string>(tags[0].concat(tags[1]))
problem.value.tags = uniqueTags
},
)
watch( watch(
() => problem.value.languages, () => problem.value.languages,
(langs) => { (langs) => {
@@ -493,67 +439,17 @@ watch(
<n-form-item label="可见"> <n-form-item label="可见">
<n-switch v-model:value="problem.visible" /> <n-switch v-model:value="problem.visible" />
</n-form-item> </n-form-item>
</n-form> <n-form-item label="标签">
<n-form label-placement="top" :show-feedback="false"> <n-select
<n-grid :cols="2" :x-gap="20" responsive="screen"> v-model:value="tagValue"
<n-gi> multiple
<n-form-item> filterable
<template #label> tag
<n-flex align="center" justify="space-between" class="tag-label"> clearable
<span>现成的标签</span> :options="tagOptions"
<n-text depth="3">已选 {{ tags.select.length }} </n-text> placeholder="搜索或输入新标签Enter 创建)"
</n-flex> />
</template> </n-form-item>
<div class="tag-picker">
<n-flex align="center" justify="space-between" class="tag-toolbar">
<n-input
v-model:value="tagKeyword"
clearable
placeholder="搜索现成标签"
/>
<n-button size="small" tertiary @click="selectFilteredTags">
全选当前
</n-button>
<n-button size="small" tertiary @click="clearExistingTags">
清空
</n-button>
</n-flex>
<n-scrollbar class="tag-scroll">
<n-empty
v-if="filteredTagList.length === 0"
description="没有匹配的现成标签"
/>
<n-flex v-else size="small" class="tag-cloud">
<n-tag
v-for="tag in filteredTagList"
:key="tag.id"
checkable
:checked="selectedTagSet.has(tag.name)"
:bordered="!selectedTagSet.has(tag.name)"
:type="selectedTagSet.has(tag.name) ? 'success' : 'default'"
@click="toggleExistingTag(tag.name)"
>
{{ tag.name }}
</n-tag>
</n-flex>
</n-scrollbar>
</div>
</n-form-item>
</n-gi>
<n-gi>
<n-form-item label="新增的标签">
<div class="tag-picker new-tag-panel">
<n-dynamic-tags
v-model:value="tags.upload"
@update:value="updateNewTags"
/>
<n-text depth="3" class="tag-help">
只填写标签库里还没有的标签已有标签请在左侧点击选择
</n-text>
</div>
</n-form-item>
</n-gi>
</n-grid>
</n-form> </n-form>
<TextEditor <TextEditor
v-if="ready" v-if="ready"
@@ -812,47 +708,4 @@ watch(
.addSamples { .addSamples {
width: 100%; width: 100%;
} }
.tag-label {
width: 100%;
}
.tag-picker {
width: 100%;
min-width: 0;
border: 1px solid var(--n-border-color);
border-radius: 8px;
padding: 12px;
}
.tag-toolbar {
margin-bottom: 10px;
}
.tag-toolbar :deep(.n-input) {
flex: 1 1 220px;
min-width: 160px;
}
.tag-scroll {
max-height: 156px;
}
.tag-cloud {
padding-right: 8px;
}
.tag-cloud :deep(.n-tag) {
cursor: pointer;
user-select: none;
}
.new-tag-panel {
min-height: 220px;
}
.tag-help {
display: block;
margin-top: 10px;
}
</style> </style>