fix(后台建题): 修好 dev 代理走错服务,以及新建页带出上一题 id、超长标签报英文

三处不相干的小毛病,都在「新建/编辑题目」这条路上。

vite 的 /api 代理写的是 localhost:3000。api 用 Bun.serve 起、只绑 IPv4 的
0.0.0.0:3000,而 Node 解析 localhost 时 ::1 排在前面 —— 别的项目的 dev server
一占 [::1]:3000,两边端口就不冲突(一个 v4 一个 v6,谁都不报错),浏览器发出的
/api/* 整个落到那个站上,表现是后台一进就被弹回首页。改成 127.0.0.1。

新建页和编辑页共用同一个 localStorage 草稿键,而编辑页会把服务器数据连 id 一起
写进去。没保存就离开的话,这个 id 跟着草稿漂到新建页:「下载测试点」下的是那道旧
题的包,SQL 测试点编辑器也会去回显那道旧题的脚本。新建分支进来先把 id 摘掉。

新标签没有长度限制,超过 32 字要到保存时才撞出 zod 的英文原文(Too big:
expected string to have <=32 characters)。把 32 提成契约里的
PROBLEM_TAG_MAX_LENGTH,输入框拿它做 maxlength,validateNewTags 再兜一道粘贴
绕过的情况,提示换成中文。校验的松紧没动,32 字仍然存得进、33 字仍然拦下。

editContestProblem 和 editProblem 函数体逐字相同(都是 PUT admin/problems/:id,
比赛由后端从题目自己推导),合成一个。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XvmqDsZNyUo9P3sFQtoWVB
This commit is contained in:
2026-09-07 07:18:23 -06:00
parent d1d071ff7b
commit 2256597838
4 changed files with 34 additions and 11 deletions

View File

@@ -84,6 +84,8 @@ export function deleteContestProblem(id: number) {
return api.delete(`admin/problems/${id}`) return api.delete(`admin/problems/${id}`)
} }
// 比赛题也走这条:后端是同一条 PUT admin/problems/:id比赛由题目自己推导
// 所以不像新建那样需要分两个函数
export function editProblem(problem: AdminProblem | BlankProblem) { export function editProblem(problem: AdminProblem | BlankProblem) {
return api.put<AdminProblem>( return api.put<AdminProblem>(
`admin/problems/${(problem as AdminProblem).id}`, `admin/problems/${(problem as AdminProblem).id}`,
@@ -103,13 +105,6 @@ export function generateFlowchartFromPythonCode(python: string) {
}) })
} }
export function editContestProblem(problem: AdminProblem | BlankProblem) {
return api.put<AdminProblem>(
`admin/problems/${(problem as AdminProblem).id}`,
toProblemBody(problem),
)
}
export function getProblem(id: string | number) { export function getProblem(id: string | number) {
return api.get<AdminProblem>(`admin/problems/${id}`) return api.get<AdminProblem>(`admin/problems/${id}`)
} }

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { PROBLEM_TAG_MAX_LENGTH } from "@oj2/contract"
import { getProblemTagList } from "shared/api" import { getProblemTagList } from "shared/api"
import TextEditor from "shared/components/TextEditor.vue" import TextEditor from "shared/components/TextEditor.vue"
import TestcaseGenerator from "./components/TestcaseGenerator.vue" import TestcaseGenerator from "./components/TestcaseGenerator.vue"
@@ -15,7 +16,6 @@ import type { BlankProblem, LANGUAGE, Tag, Testcase } from "utils/types"
import { import {
createContestProblem, createContestProblem,
createProblem, createProblem,
editContestProblem,
editProblem, editProblem,
generateFlowchartFromPythonCode, generateFlowchartFromPythonCode,
getProblem, getProblem,
@@ -136,6 +136,12 @@ function validateNewTags(v: string[]) {
const existing = new Set(tagList.value.map((t) => t.name)) const existing = new Set(tagList.value.map((t) => t.name))
const blanks: string[] = [] const blanks: string[] = []
for (const tag of unique(v)) { for (const tag of unique(v)) {
// 输入框已经挂了 maxlength这里兜住粘贴等绕过的情况。只丢这一个标签、
// 不像重复那支那样 break —— 后端撞上来只会回一句 zod 的英文
if (tag.length > PROBLEM_TAG_MAX_LENGTH) {
message.error(`标签最多 ${PROBLEM_TAG_MAX_LENGTH} 个字:` + tag)
continue
}
if (existing.has(tag)) { if (existing.has(tag)) {
message.error("已经存在标签:" + tag) message.error("已经存在标签:" + tag)
break break
@@ -213,6 +219,11 @@ watch(
async function getProblemDetail() { async function getProblemDetail() {
if (!props.problemID) { if (!props.problemID) {
// 草稿缓存和编辑页共用同一个 localStorage key编辑页会把服务器数据连 id
// 一起写进去,没保存就离开的话,这个 id 会跟着草稿漂到新建页 ——「下载测试点」
// 下的是那道旧题的包SQL 测试点编辑器也会去回显那道旧题的脚本。
// 新建页不存在 id进来先摘掉。
delete problem.value.id
syncTagInputsFromProblemTags() syncTagInputsFromProblemTags()
toggleReady(true) toggleReady(true)
return return
@@ -445,7 +456,7 @@ async function submit() {
"admin problem create": createProblem, "admin problem create": createProblem,
"admin problem edit": editProblem, "admin problem edit": editProblem,
"admin contest problem create": createContestProblem, "admin contest problem create": createContestProblem,
"admin contest problem edit": editContestProblem, "admin contest problem edit": editProblem,
}[route.name as string] }[route.name as string]
if ( if (
route.name === "admin contest problem create" || route.name === "admin contest problem create" ||
@@ -588,6 +599,7 @@ watch(
</n-flex> </n-flex>
<n-dynamic-tags <n-dynamic-tags
v-model:value="newTags" v-model:value="newTags"
:input-props="{ maxlength: PROBLEM_TAG_MAX_LENGTH }"
@update:value="validateNewTags" @update:value="validateNewTags"
/> />
</n-flex> </n-flex>

View File

@@ -83,8 +83,12 @@ export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "PUBLIC_") const env = loadEnv(mode, process.cwd(), "PUBLIC_")
// 开发时一律指向本机后端apps/api3000 // 开发时一律指向本机后端apps/api3000
// **必须写 IP不能写 localhost**api 用 Bun.serve 起,只绑 IPv4 的
// 0.0.0.0:3000而 Node 解析 localhost 时 ::1 排在前面。别的项目的 dev server
// 一旦占着 [::1]:3000两边端口不冲突一个 v4 一个 v6谁都不报错
// 浏览器发出的 /api/* 就整个落到那个站上 —— 表现是后台一进就被弹回首页。
const backend = { const backend = {
target: "http://localhost:3000", target: "http://127.0.0.1:3000",
changeOrigin: true, changeOrigin: true,
} }

View File

@@ -694,6 +694,18 @@ export const adminProblemSchema = z.object({
sqlDisplay: sqlDisplaySchema.nullable(), sqlDisplay: sqlDisplaySchema.nullable(),
}) })
/**
* 出题页新建标签时的名字上限,沿用旧 CreateProblemSerializer 的 32。
* 出题页的输入框拿它做 maxlength —— 不共享一个常量的话,超长标签只会在保存时
* 撞出 zod 的英文报错("Too big: expected string to have <=32 characters"
* 老师看不懂也不知道该改哪里。
*
* 列本身是 text 没有长度限制标签管理页createTagRequestSchema /
* renameTagRequestSchema放到 64 —— 两处口径不同是旧后端就有的,这里只是把
* 出题页这一侧的数字挪到一处,没有改任何校验的松紧。
*/
export const PROBLEM_TAG_MAX_LENGTH = 32
export const createProblemRequestSchema = z.object({ export const createProblemRequestSchema = z.object({
_id: z.string().trim().min(1).max(32), _id: z.string().trim().min(1).max(32),
title: z.string().trim().min(1).max(1024), title: z.string().trim().min(1).max(1024),
@@ -709,7 +721,7 @@ export const createProblemRequestSchema = z.object({
template: z.record(z.string(), z.string()), template: z.record(z.string(), z.string()),
visible: z.boolean(), visible: z.boolean(),
difficulty: z.enum(["Low", "Mid", "High"]), difficulty: z.enum(["Low", "Mid", "High"]),
tags: z.array(z.string().max(32)).min(1), tags: z.array(z.string().max(PROBLEM_TAG_MAX_LENGTH)).min(1),
hint: z.string().nullable().default(null), hint: z.string().nullable().default(null),
source: z.string().max(256).nullable().default(null), source: z.string().max(256).nullable().default(null),
prompt: z.string().nullable().default(null), prompt: z.string().nullable().default(null),