feat(admin): Phase 4 质量词 Admin(平台单层) — QualityWord + 生成侧读配置(无配置回落) + 质量词页

后端:apps/ai QualityWord(stage/slot/text/sort/enabled)+ migration;services.py quality_words/quality_suffix,
4 个 builder 接入(person/model_tryon/storyboard/video)无配置回落原写死值零回归;adminpanel CRUD + 审计。
前端:adminApi quality 系列;Admin 质量词页(按 stage 分组卡 + 编辑模式开关 + 行内增改删 chip)。
测试:adminpanel 23 + accounts 22 = 45 单测过(CRUD/权限/生成读配置+回落/停用词不取);
无头 e2e _admin-p4.mjs 4 断言过 + 0 console error + live 库零残留;tsc+build 绿。
注:apps.ai 3 个 image_edit 路由测试失败为进场前既有(已 stash 比对 HEAD 同样失败),非本 Phase 引入。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-19 21:52:41 +08:00
co-authored by Claude Opus 4.8
parent 6560633959
commit 01d4f235ed
14 changed files with 535 additions and 5 deletions
+51
View File
@@ -144,3 +144,54 @@
.admin-detail-meta .v { font-size: 14px; color: var(--accent-black); }
.admin-detail-subhead { font-size: 13px; font-weight: 500; color: var(--accent-black); margin: 8px 0 10px; }
.admin-inline-pill { margin-left: 8px; }
/* ── 质量词:按 stage 分组 + 编辑模式 chip ── */
.qw-stages { display: flex; flex-direction: column; gap: 16px; }
.qw-stage { padding: 18px 20px; }
.qw-stage-h { display: flex; align-items: baseline; gap: 10px; margin-bottom: 12px; }
.qw-stage-title { font-size: 14px; font-weight: 500; color: var(--accent-black); }
.qw-stage-hint { font-size: 11px; color: var(--black-alpha-48); }
.qw-empty { font-size: 12px; color: var(--black-alpha-48); }
.qw-chips { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.qw-chip {
display: inline-flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 12px;
background: var(--heat-12);
border: 1px solid var(--heat-20);
border-radius: var(--r-pill);
font-size: 12.5px;
color: var(--heat);
}
.qw-chip.editing { background: var(--surface); border-color: var(--border-faint); padding: 0 6px 0 10px; }
.qw-chip-input {
border: 0;
background: transparent;
outline: none;
font: inherit;
color: var(--accent-black);
width: 110px;
height: 26px;
font-size: 12.5px;
}
.qw-add .qw-chip-input {
width: 150px;
border: 1px dashed var(--black-alpha-24);
border-radius: var(--r-pill);
padding: 0 12px;
height: 28px;
}
.qw-del {
display: inline-grid;
place-items: center;
width: 18px;
height: 18px;
border: 0;
background: transparent;
color: var(--black-alpha-48);
cursor: pointer;
border-radius: 50%;
}
.qw-del:hover { color: var(--accent-crimson); background: var(--black-alpha-4); }
+13
View File
@@ -1,4 +1,5 @@
import type {
AdminQualityWord,
AdminTeam,
AdminTeamDetail,
AdminUser,
@@ -564,5 +565,17 @@ export const adminApi = {
},
resetUserPassword(id: string, password: string) {
return request<void>(`/api/admin/users/${id}/reset-password/`, { method: "POST", body: JSON.stringify({ password }) });
},
qualityWords() {
return request<AdminQualityWord[]>("/api/admin/quality-words/");
},
createQualityWord(payload: { stage: string; text: string; sort?: number; slot?: string }) {
return request<AdminQualityWord>("/api/admin/quality-words/", { method: "POST", body: JSON.stringify(payload) });
},
updateQualityWord(id: string, payload: { text?: string; sort?: number; enabled?: boolean }) {
return request<AdminQualityWord>(`/api/admin/quality-words/${id}/`, { method: "PATCH", body: JSON.stringify(payload) });
},
deleteQualityWord(id: string) {
return request<void>(`/api/admin/quality-words/${id}/`, { method: "DELETE" });
}
};
@@ -4,6 +4,7 @@ import { CornerMarks, Decorations, ToastLike } from "../../components/app-shell"
import type { Team, User } from "../../types";
import type { NavigateFn } from "../route-config";
import { AdminInvitesPage } from "./admin-invites";
import { AdminQualityPage } from "./admin-quality";
import { AdminTeamsPage, AdminUsersPage } from "./admin-teams-users";
export type AdminNotify = (type: "success" | "error" | "info", text: string) => void;
@@ -174,6 +175,9 @@ function AdminSectionView({ section, navigateAdmin, notify }: { section: AdminSe
if (section.slug === "users") {
return <AdminUsersPage notify={notify} />;
}
if (section.slug === "quality") {
return <AdminQualityPage notify={notify} />;
}
// 其余模块在各自阶段替换此占位为真实页面
return <AdminPlaceholder section={section} />;
}
@@ -0,0 +1,134 @@
import { useCallback, useEffect, useState } from "react";
import { X } from "lucide-react";
import { adminApi } from "../../api";
import { IconKitSvg } from "../../components/IconKitSvg";
import type { AdminQualityWord } from "../../types";
type Notify = (type: "success" | "error" | "info", text: string) => void;
// 与后端 QualityWord.Stage 对齐(生成侧已接入这 4 个阶段)
const STAGES = [
{ key: "person", label: "人物立绘" },
{ key: "model_tryon", label: "模特上身图" },
{ key: "storyboard", label: "故事板" },
{ key: "video", label: "视频" }
];
export function AdminQualityPage({ notify }: { notify: Notify }) {
const [words, setWords] = useState<AdminQualityWord[]>([]);
const [loading, setLoading] = useState(true);
const [editMode, setEditMode] = useState(false);
const [adding, setAdding] = useState<Record<string, string>>({});
const load = useCallback(async () => {
setLoading(true);
try {
setWords(await adminApi.qualityWords());
} catch {
notify("error", "加载质量词失败");
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => { void load(); }, [load]);
async function add(stage: string) {
const text = (adding[stage] || "").trim();
if (!text) return;
try {
await adminApi.createQualityWord({ stage, text });
setAdding((a) => ({ ...a, [stage]: "" }));
notify("success", "已添加质量词");
await load();
} catch {
notify("error", "添加失败");
}
}
async function saveText(w: AdminQualityWord, text: string) {
const t = text.trim();
if (!t || t === w.text) return;
try {
await adminApi.updateQualityWord(w.id, { text: t });
notify("success", "已更新");
await load();
} catch {
notify("error", "更新失败");
}
}
async function del(w: AdminQualityWord) {
try {
await adminApi.deleteQualityWord(w.id);
notify("success", "已删除");
await load();
} catch {
notify("error", "删除失败");
}
}
return (
<>
<div className="page-head">
<div>
<h1></h1>
<div className="sub"><span className="mono">// 平台单层</span> · 各生成阶段的画质 / 风格词,留空则用默认</div>
</div>
<div className="actions">
<button className={`btn${editMode ? " btn-primary" : ""}`} type="button" onClick={() => setEditMode((v) => !v)}>
{editMode ? "完成编辑" : "编辑模式"}
</button>
</div>
</div>
{loading ? (
<div className="empty-state show"><div className="ic-empty"><IconKitSvg name="type" size={24} /></div><h3></h3><p>// fetching quality words</p></div>
) : (
<div className="qw-stages">
{STAGES.map((s) => {
const ws = words.filter((w) => w.stage === s.key);
return (
<div key={s.key} className="card-hard qw-stage">
<div className="qw-stage-h">
<span className="qw-stage-title">{s.label}</span>
<span className="mono qw-stage-hint">// {s.key}</span>
</div>
{ws.length === 0 && !editMode && <div className="qw-empty mono"> · 使</div>}
<div className="qw-chips">
{ws.map((w) =>
editMode ? (
<span key={w.id} className="qw-chip editing">
<input
className="qw-chip-input"
defaultValue={w.text}
onBlur={(e) => saveText(w, e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") e.currentTarget.blur(); }}
/>
<button className="qw-del" type="button" aria-label="删除" onClick={() => del(w)}><X size={12} /></button>
</span>
) : (
<span key={w.id} className="qw-chip">{w.text}</span>
)
)}
{editMode && (
<span className="qw-add">
<input
className="qw-chip-input"
placeholder="+ 添加质量词"
value={adding[s.key] || ""}
onChange={(e) => setAdding((a) => ({ ...a, [s.key]: e.target.value }))}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); void add(s.key); } }}
/>
</span>
)}
</div>
</div>
);
})}
</div>
)}
</>
);
}
+9
View File
@@ -73,6 +73,15 @@ export type AdminUser = {
date_joined: string;
teams: { team_id: string; team_name: string; role: string }[];
};
export type AdminQualityWord = {
id: string;
stage: string;
slot: string;
text: string;
sort: number;
enabled: boolean;
created_at: string;
};
export type Paginated<T> = {
count: number;