feat(admin): 技能分类 - 重构分类维护与技能归类交互
This commit is contained in:
@@ -48,7 +48,7 @@ const (
|
||||
defaultAppearanceVideoURL = "https://boss-shjd.biliapi.net/updream/aniforge/video/video_bbcb00bd-650d-4249-9346-5cd21fd2484c_m1hc-u0-1pu13x-3v1s.mp4"
|
||||
defaultAppearancePosterURL = "https://i0.hdslb.com/bfs/aitool/aniforge/image/02933f26-5f1b-49ff-a811-b7f95ee5e5b8_m1hc-u0-sau.jpg"
|
||||
defaultAppearanceHeroTitle = "让一个故事,\n从文字走向银幕。"
|
||||
defaultRedeemPurchaseURL = "https://wzyp.cn/shop/JPDK4SRD"
|
||||
defaultRedeemPurchaseURL = "暂未配置"
|
||||
)
|
||||
|
||||
type AppearanceSetting struct {
|
||||
|
||||
@@ -530,6 +530,9 @@ export default function AppearanceSettingsPage() {
|
||||
onChange={(event) => setBrandSlug(event.target.value.toLocaleLowerCase().replace(/[^a-z0-9-]/g, ""))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="兑换码购买链接" extra="填写 HTTPS 购买页面地址;留空则不显示“获取兑换码”入口。">
|
||||
<Input value={redeemPurchaseUrl} maxLength={500} showCount placeholder="例如:https://wzyp.cn/shop/xxx" onChange={(event) => setRedeemPurchaseUrl(event.target.value)} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<div className="admin-appearance-brand-logo admin-appearance-logo-stack">
|
||||
<AssetPicker
|
||||
@@ -748,9 +751,6 @@ export default function AppearanceSettingsPage() {
|
||||
<Form.Item label="备案号" extra="请填写真实备案号,例如“蜀ICP备XXXXXXXX号”;系统不会替你申请或核验备案。">
|
||||
<Input value={icpFilingNumber} maxLength={64} showCount placeholder="例如:蜀ICP备XXXXXXXX号" onChange={(event) => setIcpFilingNumber(event.target.value)} />
|
||||
</Form.Item>
|
||||
<Form.Item label="兑换码购买链接" extra="填写 HTTPS 购买页面地址;留空则不显示“获取兑换码”入口。">
|
||||
<Input value={redeemPurchaseUrl} maxLength={500} showCount placeholder="例如:https://wzyp.cn/shop/JPDK4SRD" onChange={(event) => setRedeemPurchaseUrl(event.target.value)} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<div className="admin-appearance-logo-frame-option">
|
||||
<div className="admin-appearance-logo-frame-copy">
|
||||
|
||||
54
web/src/pages/admin/skill-curation-page.css
Normal file
54
web/src/pages/admin/skill-curation-page.css
Normal file
@@ -0,0 +1,54 @@
|
||||
.admin-skill-curation-section-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid var(--admin-divider);
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.admin-skill-curation-section-heading h3 {
|
||||
margin: 0;
|
||||
color: var(--admin-text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.admin-skill-curation-section-heading p {
|
||||
margin: 2px 0 0;
|
||||
color: var(--admin-text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 17px;
|
||||
}
|
||||
|
||||
.admin-skill-curation-fields-root {
|
||||
grid-template-columns: minmax(180px, 1.4fr) minmax(180px, 1fr) 96px 110px;
|
||||
}
|
||||
|
||||
.admin-skill-curation-icon-option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-skill-curation-subsection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid var(--admin-divider);
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
@media (max-width: 1199px) {
|
||||
.admin-skill-curation-fields-root {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.admin-skill-curation-fields-root {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -1,75 +1,380 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, App, Button, Input, InputNumber, Select, Space } from "antd";
|
||||
import { Alert, App, Button, Input, InputNumber, Select } from "antd";
|
||||
import { Check, Edit3, Plus, RefreshCw, Shapes, SlidersHorizontal } from "lucide-react";
|
||||
import { AdminPageFrame } from "./components/admin-shell";
|
||||
import { AdminDataTable } from "./components/admin-ui";
|
||||
import { AdminDataTable, AdminStatusBadge, AdminTableEmpty, SettingsSectionCard } from "./components/admin-ui";
|
||||
import { AdminSwitch } from "./ui/controls";
|
||||
import { getSkillCuration, updateSkillCuration, type CurationCategory, type CurationChange, type SkillCuration, type CurationRoot } from "@/services/api/skill-curation";
|
||||
import { getSkillCuration, updateSkillCuration, type CurationCategory, type CurationChange, type CurationRoot, type SkillCuration } from "@/services/api/skill-curation";
|
||||
import { curationIcon, curationIconKeys, curationIconLabels } from "@/components/skills/skill-curation-browser";
|
||||
import { listSkills, type Skill } from "@/services/api/skills";
|
||||
import "./skill-curation-page.css";
|
||||
|
||||
const emptyRoot = (): CurationRoot => ({ id: "", name: "", iconKey: "shapes", sortOrder: 0, enabled: true });
|
||||
const emptyCategory = (rootTag = "drama"): CurationCategory => ({ id: "", rootTag, name: "", sortOrder: 0, enabled: true });
|
||||
|
||||
export default function SkillCurationPage() {
|
||||
const { message } = App.useApp();
|
||||
const [data, setData] = useState<SkillCuration | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [category, setCategory] = useState<CurationCategory>({ id: "", rootTag: "drama", name: "", sortOrder: 0, enabled: true });
|
||||
const [root, setRoot] = useState<CurationRoot>(emptyRoot);
|
||||
const [category, setCategory] = useState<CurationCategory>(emptyCategory);
|
||||
const [search, setSearch] = useState("");
|
||||
const [skills, setSkills] = useState<Skill[]>([]);
|
||||
const [skillId, setSkillId] = useState("");
|
||||
const [categoryIds, setCategoryIds] = useState<string[]>([]);
|
||||
const [root, setRoot] = useState<CurationRoot>({ id: "", name: "", iconKey: "shapes", sortOrder: 0, enabled: true });
|
||||
const [assignedRoot, setAssignedRoot] = useState("");
|
||||
const reload = async () => { try { setData(await getSkillCuration(true)); setError(""); } catch (e) { setError(String(e)); } };
|
||||
useEffect(() => { void reload(); }, []);
|
||||
const [categoryIds, setCategoryIds] = useState<string[]>([]);
|
||||
|
||||
const selectedSkill = skills.find((skill) => skill.skillId === skillId);
|
||||
const selectedRoot = data?.roots?.find((item) => item.id === assignedRoot);
|
||||
const availableCategories = data?.categories.filter((item) => item.enabled && item.rootTag === (assignedRoot || selectedSkill?.tag)) ?? [];
|
||||
|
||||
const reload = async () => {
|
||||
try {
|
||||
setData(await getSkillCuration(true));
|
||||
setError("");
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(() => { listSkills({ scope: "public", search, pageSize: 50 }).then((result) => { if (!cancelled) setSkills(result.skills); }).catch((e) => { if (!cancelled) setError(String(e)); }); }, 250);
|
||||
return () => { cancelled = true; clearTimeout(timer); };
|
||||
const timer = setTimeout(() => {
|
||||
listSkills({ scope: "public", search, pageSize: 50 })
|
||||
.then((result) => {
|
||||
if (!cancelled) setSkills(result.skills);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!cancelled) setError(String(e));
|
||||
});
|
||||
}, 250);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [search]);
|
||||
|
||||
const save = async (change: CurationChange) => {
|
||||
if (!data) return;
|
||||
setBusy(true);
|
||||
try { setData(await updateSkillCuration(data.revision, change)); setError(""); message.success("已保存"); window.dispatchEvent(new Event("canvas-skills-changed")); }
|
||||
catch (e) { setError(`${String(e)};请重新加载后核对修改`); }
|
||||
finally { setBusy(false); }
|
||||
try {
|
||||
setData(await updateSkillCuration(data.revision, change));
|
||||
setError("");
|
||||
message.success("已保存");
|
||||
window.dispatchEvent(new Event("canvas-skills-changed"));
|
||||
} catch (e) {
|
||||
setError(`${String(e)};请重新加载后核对修改`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const selectedSkill = skills.find((skill) => skill.skillId === skillId);
|
||||
return <AdminPageFrame title="技能分类" description="平台策展模式默认关闭;开启后使用后台维护的分类。" scroll actions={<Button onClick={() => void reload()}>重新加载</Button>}>
|
||||
<Space orientation="vertical" className="w-full" size="large">
|
||||
{error && <Alert type="error" title={error} />}
|
||||
<Space><AdminSwitch aria-label="平台策展模式" checked={data?.enabled ?? false} disabled={!data || busy} onChange={(enabled) => void save({ enabled })} /><span>平台策展模式</span></Space>
|
||||
<AdminDataTable<CurationRoot> table={{ rowKey: "id", dataSource: data?.roots || [], pagination: false, loading: !data, columns: [
|
||||
{ title: "一级分类", dataIndex: "name" }, { title: "图示", render: (_, item) => { const Icon = curationIcon(item.iconKey); return <Icon size={18} />; } },
|
||||
{ title: "排序", dataIndex: "sortOrder" }, { title: "状态", render: (_, item) => item.enabled ? "启用" : "停用" },
|
||||
{ title: "操作", render: (_, item) => <Button onClick={() => setRoot({ ...item })}>编辑</Button> },
|
||||
] }} />
|
||||
<Space wrap>
|
||||
<Input aria-label="一级分类名称" placeholder="一级分类名称" maxLength={64} value={root.name} onChange={(e) => setRoot({ ...root, name: e.target.value })} />
|
||||
<Select aria-label="一级分类图示" value={root.iconKey || "shapes"} options={curationIconKeys.map((key) => { const Icon = curationIcon(key); return { value: key, label: <Space><Icon size={16} />{curationIconLabels[key]}</Space> }; })} onChange={(iconKey) => setRoot({ ...root, iconKey })} />
|
||||
<InputNumber aria-label="一级分类排序" precision={0} value={root.sortOrder} onChange={(sortOrder) => setRoot({ ...root, sortOrder: sortOrder ?? 0 })} />
|
||||
<AdminSwitch aria-label="一级分类启用" checked={root.enabled} onChange={(enabled) => setRoot({ ...root, enabled })} />
|
||||
<Button type="primary" loading={busy} disabled={!data || !root.name.trim()} onClick={() => void save({ root })}>{root.id ? "保存一级分类" : "新增一级分类"}</Button>
|
||||
<Button onClick={() => setRoot({ id: "", name: "", iconKey: "shapes", sortOrder: 0, enabled: true })}>新建一级分类</Button>
|
||||
</Space>
|
||||
<AdminDataTable<CurationCategory> table={{ rowKey: "id", dataSource: data?.categories || [], pagination: false, loading: !data, columns: [
|
||||
{ title: "一级分类", render: (_, row) => data?.roots?.find((item) => item.id === row.rootTag)?.name || "未归入启用分类" }, { title: "子分类", dataIndex: "name" }, { title: "排序", dataIndex: "sortOrder" },
|
||||
{ title: "状态", render: (_, row) => row.enabled ? "启用" : "停用" }, { title: "操作", render: (_, row) => <Button onClick={() => setCategory({ ...row })}>编辑</Button> },
|
||||
] }} />
|
||||
<Space wrap>
|
||||
<Select aria-label="所属一级分类" value={category.rootTag} disabled={Boolean(category.id)} options={data?.roots?.map((item) => ({ value: item.id, label: item.name, disabled: !item.enabled }))} onChange={(rootTag) => setCategory({ ...category, rootTag })} />
|
||||
<Input aria-label="分类名称" placeholder="分类名称" value={category.name} maxLength={64} onChange={(e) => setCategory({ ...category, name: e.target.value })} />
|
||||
<InputNumber aria-label="排序" value={category.sortOrder} precision={0} onChange={(sortOrder) => setCategory({ ...category, sortOrder: sortOrder ?? 0 })} />
|
||||
<AdminSwitch aria-label="分类启用" checked={category.enabled} onChange={(enabled) => setCategory({ ...category, enabled })} />
|
||||
<Button type="primary" loading={busy} disabled={!data || !category.name.trim()} onClick={() => void save({ category })}>{category.id ? "保存分类" : "新增子分类"}</Button>
|
||||
<Button onClick={() => setCategory({ id: "", rootTag: "drama", name: "", sortOrder: 0, enabled: true })}>新建</Button>
|
||||
</Space>
|
||||
<Space wrap>
|
||||
<Input aria-label="搜索公开技能" placeholder="搜索公开技能(最多50条)" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
<Select aria-label="选择技能" value={skillId || undefined} placeholder="选择技能" options={skills.map((skill) => ({ value: skill.skillId, label: skill.skillName }))} onChange={(id) => { setSkillId(id); setAssignedRoot(data?.rootAssignments?.find((item) => item.skillId === id)?.rootId || ""); setCategoryIds(data?.assignments.filter((item) => item.skillId === id).map((item) => item.categoryId) || []); }} />
|
||||
<Select aria-label="技能一级分类" value={assignedRoot} options={[{ value: "", label: "按原始分类" }, ...(data?.roots || []).map((item) => ({ value: item.id, label: item.name, disabled: !item.enabled }))]} onChange={(id) => { setAssignedRoot(id); setCategoryIds([]); }} />
|
||||
<Select mode="multiple" aria-label="技能子分类" placeholder="选择子分类,留空清除" value={categoryIds} onChange={setCategoryIds} options={data?.categories.filter((item) => item.enabled && item.rootTag === (assignedRoot || selectedSkill?.tag)).map((item) => ({ value: item.id, label: item.name }))} />
|
||||
<Button loading={busy} disabled={!skillId || !data} onClick={() => void save({ assignment: { skillId, categoryIds, rootId: assignedRoot } })}>保存技能归类</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</AdminPageFrame>;
|
||||
|
||||
const selectSkill = (id: string) => {
|
||||
const nextRoot = data?.rootAssignments?.find((item) => item.skillId === id)?.rootId || "";
|
||||
setSkillId(id);
|
||||
setAssignedRoot(nextRoot);
|
||||
setCategoryIds(data?.assignments.filter((item) => item.skillId === id).map((item) => item.categoryId) || []);
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminPageFrame
|
||||
title="技能分类"
|
||||
description="管理平台技能的一级分类、子分类和技能归属。"
|
||||
scroll
|
||||
actions={
|
||||
<Button icon={<RefreshCw className="size-3.5" />} onClick={() => void reload()} disabled={busy}>
|
||||
重新加载
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="admin-skill-curation">
|
||||
{error ? <Alert type="error" showIcon title={error} /> : null}
|
||||
|
||||
<section className="admin-skill-curation-policy" aria-label="平台策展模式">
|
||||
<div className="admin-skill-curation-policy-icon">
|
||||
<SlidersHorizontal className="size-4" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="admin-skill-curation-policy-copy">
|
||||
<div className="admin-skill-curation-policy-title">平台策展模式</div>
|
||||
<p>开启后,技能库会优先使用这里维护的分类和归属;关闭时不影响现有技能展示。</p>
|
||||
</div>
|
||||
<div className="admin-skill-curation-policy-control">
|
||||
<AdminStatusBadge label={data?.enabled ? "已开启" : "未开启"} tone={data?.enabled ? "success" : "neutral"} />
|
||||
<AdminSwitch aria-label="平台策展模式" checked={data?.enabled ?? false} disabled={!data || busy} onChange={(enabled) => void save({ enabled })} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="admin-skill-curation-grid">
|
||||
<SettingsSectionCard
|
||||
layout="stacked"
|
||||
className="admin-skill-curation-panel"
|
||||
icon={<Shapes className="size-4" aria-hidden="true" />}
|
||||
title="分类维护"
|
||||
description="先维护一级分类和子分类,再把公开技能归入对应分类。"
|
||||
status={<AdminStatusBadge label={`${(data?.roots?.length ?? 0) + (data?.categories.length ?? 0)} 个分类`} tone="info" />}
|
||||
>
|
||||
<div className="admin-skill-curation-form" aria-label="一级分类编辑表单">
|
||||
<div className="admin-skill-curation-section-heading">
|
||||
<div>
|
||||
<h3>一级分类</h3>
|
||||
<p>一级分类用于组织技能库的主导航和图示。</p>
|
||||
</div>
|
||||
{root.id ? <AdminStatusBadge label={`正在编辑「${root.name}」`} tone="warning" /> : null}
|
||||
</div>
|
||||
<div className="admin-skill-curation-fields admin-skill-curation-fields-root">
|
||||
<label className="admin-skill-curation-field">
|
||||
<span>分类名称</span>
|
||||
<Input aria-label="一级分类名称" placeholder="例如:视频创作" maxLength={64} value={root.name} onChange={(e) => setRoot({ ...root, name: e.target.value })} />
|
||||
</label>
|
||||
<label className="admin-skill-curation-field">
|
||||
<span>分类图示</span>
|
||||
<Select
|
||||
aria-label="一级分类图示"
|
||||
className="admin-skill-curation-control"
|
||||
value={root.iconKey || "shapes"}
|
||||
options={curationIconKeys.map((key) => {
|
||||
const Icon = curationIcon(key);
|
||||
return {
|
||||
value: key,
|
||||
label: (
|
||||
<span className="admin-skill-curation-icon-option">
|
||||
<Icon className="size-3.5" />
|
||||
{curationIconLabels[key]}
|
||||
</span>
|
||||
),
|
||||
};
|
||||
})}
|
||||
onChange={(iconKey) => setRoot({ ...root, iconKey })}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-skill-curation-field is-order">
|
||||
<span>排序</span>
|
||||
<InputNumber aria-label="一级分类排序" className="admin-skill-curation-control" precision={0} min={0} value={root.sortOrder} onChange={(sortOrder) => setRoot({ ...root, sortOrder: sortOrder ?? 0 })} />
|
||||
</label>
|
||||
<div className="admin-skill-curation-field is-switch">
|
||||
<span>状态</span>
|
||||
<div className="admin-skill-curation-switch-row">
|
||||
<AdminSwitch aria-label="一级分类启用" checked={root.enabled} onChange={(enabled) => setRoot({ ...root, enabled })} />
|
||||
<span>{root.enabled ? "启用" : "停用"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-skill-curation-form-actions">
|
||||
<span className="admin-skill-curation-hint">停用一级分类后,技能仍保留原有数据,不再作为可选归类。</span>
|
||||
<div className="admin-skill-curation-action-buttons">
|
||||
<Button onClick={() => setRoot(emptyRoot())} disabled={busy || (!root.id && !root.name)}>
|
||||
清空
|
||||
</Button>
|
||||
<Button type="primary" icon={root.id ? <Check className="size-3.5" /> : <Plus className="size-3.5" />} loading={busy} disabled={!data || !root.name.trim()} onClick={() => void save({ root })}>
|
||||
{root.id ? "保存一级分类" : "新增一级分类"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-skill-curation-table-wrap">
|
||||
<AdminDataTable<CurationRoot>
|
||||
className="admin-skill-curation-table"
|
||||
table={{
|
||||
rowKey: "id",
|
||||
dataSource: data?.roots || [],
|
||||
pagination: false,
|
||||
loading: !data,
|
||||
columns: [
|
||||
{ title: "一级分类", dataIndex: "name" },
|
||||
{
|
||||
title: "图示",
|
||||
width: 88,
|
||||
render: (_, item) => {
|
||||
const Icon = curationIcon(item.iconKey);
|
||||
return <Icon className="size-4" aria-hidden="true" />;
|
||||
},
|
||||
},
|
||||
{ title: "排序", dataIndex: "sortOrder", width: 78, align: "center" },
|
||||
{ title: "状态", width: 90, render: (_, item) => <AdminStatusBadge label={item.enabled ? "启用" : "停用"} tone={item.enabled ? "success" : "neutral"} /> },
|
||||
{
|
||||
title: "操作",
|
||||
width: 90,
|
||||
render: (_, item) => (
|
||||
<Button type="text" size="small" className="admin-row-action admin-row-action-primary" icon={<Edit3 className="size-3.5" />} onClick={() => setRoot({ ...item })}>
|
||||
编辑
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
],
|
||||
}}
|
||||
empty={
|
||||
<AdminTableEmpty
|
||||
title="还没有一级分类"
|
||||
description="先创建一个一级分类,再继续添加子分类。"
|
||||
action={
|
||||
<Button type="primary" size="small" icon={<Plus className="size-3.5" />} onClick={() => document.querySelector<HTMLInputElement>("[aria-label=一级分类名称]")?.focus()}>
|
||||
创建一级分类
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="admin-skill-curation-subsection">
|
||||
<div className="admin-skill-curation-section-heading">
|
||||
<div>
|
||||
<h3>子分类</h3>
|
||||
<p>子分类必须归属于一个启用中的一级分类。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-skill-curation-fields admin-skill-curation-fields-category">
|
||||
<label className="admin-skill-curation-field">
|
||||
<span>所属一级分类</span>
|
||||
<Select
|
||||
aria-label="所属一级分类"
|
||||
className="admin-skill-curation-control"
|
||||
value={category.rootTag}
|
||||
disabled={Boolean(category.id)}
|
||||
options={data?.roots?.map((item) => ({ value: item.id, label: item.name, disabled: !item.enabled }))}
|
||||
onChange={(rootTag) => setCategory({ ...category, rootTag })}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-skill-curation-field">
|
||||
<span>子分类名称</span>
|
||||
<Input aria-label="分类名称" placeholder="例如:分镜设计" value={category.name} maxLength={64} onChange={(e) => setCategory({ ...category, name: e.target.value })} />
|
||||
</label>
|
||||
<label className="admin-skill-curation-field is-order">
|
||||
<span>排序</span>
|
||||
<InputNumber aria-label="排序" className="admin-skill-curation-control" value={category.sortOrder} precision={0} min={0} onChange={(sortOrder) => setCategory({ ...category, sortOrder: sortOrder ?? 0 })} />
|
||||
</label>
|
||||
<div className="admin-skill-curation-field is-switch">
|
||||
<span>状态</span>
|
||||
<div className="admin-skill-curation-switch-row">
|
||||
<AdminSwitch aria-label="分类启用" checked={category.enabled} onChange={(enabled) => setCategory({ ...category, enabled })} />
|
||||
<span>{category.enabled ? "启用" : "停用"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-skill-curation-form-actions">
|
||||
<span className="admin-skill-curation-hint">{category.id ? `正在编辑「${category.name}」` : "新增后即可在技能归类中使用"}</span>
|
||||
<div className="admin-skill-curation-action-buttons">
|
||||
<Button onClick={() => setCategory(emptyCategory(data?.roots?.[0]?.id || "drama"))} disabled={busy || (!category.id && !category.name)}>
|
||||
清空
|
||||
</Button>
|
||||
<Button type="primary" icon={category.id ? <Check className="size-3.5" /> : <Plus className="size-3.5" />} loading={busy} disabled={!data || !category.name.trim()} onClick={() => void save({ category })}>
|
||||
{category.id ? "保存分类" : "新增子分类"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-skill-curation-table-wrap">
|
||||
<AdminDataTable<CurationCategory>
|
||||
className="admin-skill-curation-table"
|
||||
table={{
|
||||
rowKey: "id",
|
||||
dataSource: data?.categories || [],
|
||||
pagination: false,
|
||||
loading: !data,
|
||||
columns: [
|
||||
{ title: "一级分类", render: (_, row) => data?.roots?.find((item) => item.id === row.rootTag)?.name || "未归入启用分类" },
|
||||
{ title: "子分类", dataIndex: "name" },
|
||||
{ title: "排序", dataIndex: "sortOrder", width: 78, align: "center" },
|
||||
{ title: "状态", width: 90, render: (_, row) => <AdminStatusBadge label={row.enabled ? "启用" : "停用"} tone={row.enabled ? "success" : "neutral"} /> },
|
||||
{
|
||||
title: "操作",
|
||||
width: 90,
|
||||
render: (_, row) => (
|
||||
<Button type="text" size="small" className="admin-row-action admin-row-action-primary" icon={<Edit3 className="size-3.5" />} onClick={() => setCategory({ ...row })}>
|
||||
编辑
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
],
|
||||
}}
|
||||
empty={
|
||||
<AdminTableEmpty
|
||||
title="还没有子分类"
|
||||
description="先在上方创建一个子分类,技能归类时就能直接选择。"
|
||||
action={
|
||||
<Button type="primary" size="small" icon={<Plus className="size-3.5" />} onClick={() => document.querySelector<HTMLInputElement>("[aria-label=分类名称]")?.focus()}>
|
||||
创建子分类
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</SettingsSectionCard>
|
||||
|
||||
<SettingsSectionCard
|
||||
layout="stacked"
|
||||
className="admin-skill-curation-panel"
|
||||
icon={<Check className="size-4" aria-hidden="true" />}
|
||||
title="技能归类"
|
||||
description="搜索公开技能,选择一级分类和它应该出现的子分类。"
|
||||
status={<AdminStatusBadge label={skillId ? "已选择技能" : "等待选择"} tone={skillId ? "success" : "neutral"} />}
|
||||
>
|
||||
<div className="admin-skill-curation-assignment-form" aria-label="技能归类表单">
|
||||
<label className="admin-skill-curation-field">
|
||||
<span>搜索技能</span>
|
||||
<Input aria-label="搜索公开技能" placeholder="按名称搜索公开技能" value={search} onChange={(e) => setSearch(e.target.value)} allowClear />
|
||||
</label>
|
||||
<label className="admin-skill-curation-field">
|
||||
<span>选择技能</span>
|
||||
<Select
|
||||
aria-label="选择技能"
|
||||
className="admin-skill-curation-control"
|
||||
value={skillId || undefined}
|
||||
placeholder="请选择一个技能"
|
||||
options={skills.map((skill) => ({ value: skill.skillId, label: skill.skillName }))}
|
||||
onChange={selectSkill}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
notFoundContent={search ? "没有找到匹配的公开技能" : "暂无公开技能"}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-skill-curation-field">
|
||||
<span>技能一级分类</span>
|
||||
<Select
|
||||
aria-label="技能一级分类"
|
||||
className="admin-skill-curation-control"
|
||||
value={assignedRoot || undefined}
|
||||
placeholder="按原始分类"
|
||||
options={[{ value: "", label: "按原始分类" }, ...(data?.roots || []).map((item) => ({ value: item.id, label: item.name, disabled: !item.enabled }))]}
|
||||
onChange={(id) => {
|
||||
setAssignedRoot(id);
|
||||
setCategoryIds([]);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-skill-curation-field">
|
||||
<span>技能子分类</span>
|
||||
<Select
|
||||
mode="multiple"
|
||||
aria-label="技能子分类"
|
||||
className="admin-skill-curation-control admin-skill-curation-multi-select"
|
||||
placeholder={selectedSkill ? "请选择一个或多个子分类" : "先选择技能"}
|
||||
value={categoryIds}
|
||||
disabled={!selectedSkill}
|
||||
onChange={setCategoryIds}
|
||||
options={availableCategories.map((item) => ({ value: item.id, label: item.name }))}
|
||||
maxTagCount="responsive"
|
||||
notFoundContent={selectedRoot || selectedSkill ? "该一级分类暂无启用的子分类" : "先选择技能"}
|
||||
/>
|
||||
</label>
|
||||
<div className="admin-skill-curation-assignment-footer">
|
||||
<span className="admin-skill-curation-hint">{selectedSkill ? `当前技能:${selectedSkill.skillName}` : "未选择技能时不会修改任何归类"}</span>
|
||||
<Button type="primary" loading={busy} disabled={!skillId || !data} onClick={() => void save({ assignment: { skillId, categoryIds, rootId: assignedRoot } })}>
|
||||
保存技能归类
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSectionCard>
|
||||
</div>
|
||||
</div>
|
||||
</AdminPageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user