481 lines
24 KiB
TypeScript
481 lines
24 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
||
import { Server, X } from "lucide-react";
|
||
import { adminApi } from "../../api";
|
||
import { Pager } from "../../components/pager";
|
||
import { SystemLoading } from "../../components/loading";
|
||
import { CustomSelect } from "../../components/custom-select";
|
||
import type { AdminModel, AdminProvider } from "../../types";
|
||
import { pts } from "../stage-config";
|
||
|
||
|
||
function sortAdminModels(list: AdminModel[]): AdminModel[] {
|
||
// 默认最前,启用次之,停用靠后;同档按能力、供应商
|
||
return [...list].sort((a, b) => {
|
||
if (Boolean(a.is_default) !== Boolean(b.is_default)) return a.is_default ? -1 : 1;
|
||
if ((a.status === "active") !== (b.status === "active")) return a.status === "active" ? -1 : 1;
|
||
const cap = String(a.capability || "").localeCompare(String(b.capability || ""));
|
||
if (cap !== 0) return cap;
|
||
return String(a.provider_name || a.provider || "").localeCompare(String(b.provider_name || b.provider || ""));
|
||
});
|
||
}
|
||
|
||
type Notify = (type: "success" | "error" | "info", text: string) => void;
|
||
|
||
const PAGE_SIZE = 10;
|
||
|
||
const CAPABILITIES = ["text", "image", "video", "vision", "audio", "export"];
|
||
|
||
function statusPill(active: boolean) {
|
||
return active
|
||
? <span className="pill ok"><span className="dot" />启用</span>
|
||
: <span className="pill neutral"><span className="dot" />停用</span>;
|
||
}
|
||
|
||
const EMPTY_PROVIDER = { id: "", name: "", display_name: "", base_url: "", api_key: "", status: "active" };
|
||
const VIDEO_RES_OPTIONS = ["480p", "720p", "1080p", "4k"];
|
||
|
||
type VideoTier = { resolution: string; points_per_second: string; points_per_second_with_ref: string };
|
||
|
||
type ModelForm = {
|
||
id: string;
|
||
provider: string;
|
||
name: string;
|
||
display_name: string;
|
||
capability: string;
|
||
endpoint: string;
|
||
unit_price: string;
|
||
status: string;
|
||
durationsText: string;
|
||
videoTiers: VideoTier[];
|
||
};
|
||
|
||
const EMPTY_MODEL: ModelForm = {
|
||
id: "", provider: "", name: "", display_name: "", capability: "text", endpoint: "",
|
||
unit_price: "", status: "active", durationsText: "4,5,6,8,10,12,15",
|
||
videoTiers: [{ resolution: "480p", points_per_second: "20", points_per_second_with_ref: "" }],
|
||
};
|
||
|
||
function readModelForm(m: AdminModel): ModelForm {
|
||
const meta = (m.metadata || {}) as Record<string, unknown>;
|
||
const caps = (meta.capabilities || {}) as Record<string, unknown>;
|
||
const pricing = (meta.points_pricing || {}) as Record<string, unknown>;
|
||
const durations = Array.isArray(caps.durations) ? caps.durations.map(String) : [];
|
||
const tiersRaw = Array.isArray(pricing.tiers) ? pricing.tiers : [];
|
||
const videoTiers: VideoTier[] = tiersRaw
|
||
.filter((row): row is Record<string, unknown> => !!row && typeof row === "object")
|
||
.map((row) => ({
|
||
resolution: String(row.resolution || ""),
|
||
points_per_second: String(row.points_per_second ?? ""),
|
||
points_per_second_with_ref: String(row.points_per_second_with_ref ?? ""),
|
||
}));
|
||
let unit = m.unit_price || "";
|
||
if (m.capability === "image" && pricing.points_per_image != null) unit = String(pricing.points_per_image);
|
||
if ((m.capability === "text" || m.capability === "vision") && pricing.points_per_call != null) {
|
||
unit = String(pricing.points_per_call);
|
||
}
|
||
return {
|
||
id: m.id,
|
||
provider: m.provider,
|
||
name: m.name,
|
||
display_name: m.display_name,
|
||
capability: m.capability,
|
||
endpoint: m.endpoint,
|
||
unit_price: unit,
|
||
status: m.status,
|
||
durationsText: durations.length ? durations.join(",") : "4,5,6,8,10,12,15",
|
||
videoTiers: videoTiers.length
|
||
? videoTiers
|
||
: [{ resolution: "480p", points_per_second: "", points_per_second_with_ref: "" }],
|
||
};
|
||
}
|
||
|
||
function buildMetadata(form: ModelForm): Record<string, unknown> {
|
||
if (form.capability === "image") {
|
||
const pts = Number(form.unit_price || 0);
|
||
return { points_pricing: { mode: "per_image", points_per_image: Number.isFinite(pts) ? pts : 0 } };
|
||
}
|
||
if (form.capability === "text" || form.capability === "vision") {
|
||
const pts = Number(form.unit_price || 0);
|
||
return { points_pricing: { mode: "per_call", points_per_call: Number.isFinite(pts) ? pts : 0 } };
|
||
}
|
||
if (form.capability === "video") {
|
||
const durations = form.durationsText
|
||
.split(/[,,\s]+/)
|
||
.map((x) => Number(x))
|
||
.filter((n) => Number.isFinite(n) && n > 0);
|
||
const tiers = form.videoTiers
|
||
.filter((row) => row.resolution && row.points_per_second !== "")
|
||
.map((row) => {
|
||
const item: Record<string, unknown> = {
|
||
resolution: row.resolution,
|
||
points_per_second: Number(row.points_per_second),
|
||
};
|
||
if (row.points_per_second_with_ref !== "") {
|
||
item.points_per_second_with_ref = Number(row.points_per_second_with_ref);
|
||
}
|
||
return item;
|
||
});
|
||
return {
|
||
capabilities: {
|
||
resolutions: tiers.map((t) => String(t.resolution)),
|
||
durations,
|
||
operations: ["video_generate"],
|
||
},
|
||
points_pricing: { mode: "per_second", tiers },
|
||
};
|
||
}
|
||
return {};
|
||
}
|
||
|
||
export function AdminModelsPage({ notify }: { notify: Notify }) {
|
||
const [providers, setProviders] = useState<AdminProvider[]>([]);
|
||
const [models, setModels] = useState<AdminModel[]>([]);
|
||
const [modelPage, setModelPage] = useState(1);
|
||
const [loading, setLoading] = useState(true);
|
||
const [provModal, setProvModal] = useState<typeof EMPTY_PROVIDER | null>(null);
|
||
const [modelModal, setModelModal] = useState<ModelForm | null>(null);
|
||
const [saving, setSaving] = useState(false);
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const [ps, ms] = await Promise.all([adminApi.providers(), adminApi.models()]);
|
||
setProviders(ps);
|
||
setModels(sortAdminModels(ms));
|
||
} catch {
|
||
notify("error", "加载模型供应商失败");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
useEffect(() => { void load(); }, [load]);
|
||
|
||
async function toggleProvider(p: AdminProvider) {
|
||
try {
|
||
await adminApi.updateProvider(p.id, { status: p.status === "active" ? "disabled" : "active" });
|
||
notify("success", "供应商状态已更新");
|
||
await load();
|
||
} catch { notify("error", "操作失败"); }
|
||
}
|
||
|
||
async function toggleModel(m: AdminModel) {
|
||
try {
|
||
await adminApi.updateModel(m.id, { status: m.status === "active" ? "disabled" : "active" });
|
||
notify("success", "模型状态已更新");
|
||
await load();
|
||
} catch { notify("error", "操作失败"); }
|
||
}
|
||
|
||
async function setDefault(m: AdminModel) {
|
||
try {
|
||
await adminApi.setDefaultModel(m.id);
|
||
notify("success", `已设为 ${m.capability} 默认模型`);
|
||
await load();
|
||
} catch { notify("error", "设默认失败"); }
|
||
}
|
||
|
||
async function delModel(m: AdminModel) {
|
||
try {
|
||
await adminApi.deleteModel(m.id);
|
||
notify("success", "模型已删除");
|
||
await load();
|
||
} catch { notify("error", "删除失败"); }
|
||
}
|
||
|
||
async function saveProvider() {
|
||
if (!provModal || saving) return;
|
||
if (!provModal.name.trim() || !provModal.display_name.trim()) { notify("error", "请填写名称"); return; }
|
||
setSaving(true);
|
||
try {
|
||
const payload: Record<string, unknown> = { display_name: provModal.display_name, base_url: provModal.base_url, status: provModal.status };
|
||
if (provModal.api_key) payload.api_key = provModal.api_key;
|
||
if (provModal.id) {
|
||
await adminApi.updateProvider(provModal.id, payload);
|
||
} else {
|
||
await adminApi.createProvider({ name: provModal.name, display_name: provModal.display_name, base_url: provModal.base_url, api_key: provModal.api_key || undefined, status: provModal.status });
|
||
}
|
||
notify("success", "供应商已保存");
|
||
setProvModal(null);
|
||
await load();
|
||
} catch (e) { notify("error", e instanceof Error ? e.message : "保存失败"); }
|
||
finally { setSaving(false); }
|
||
}
|
||
|
||
async function saveModel() {
|
||
if (!modelModal || saving) return;
|
||
if (!modelModal.provider || !modelModal.name.trim()) { notify("error", "请选择供应商并填写模型名"); return; }
|
||
if (modelModal.capability === "video") {
|
||
const ok = modelModal.videoTiers.some((row) => row.resolution && row.points_per_second !== "");
|
||
if (!ok) { notify("error", "视频模型请至少添加一档分辨率和每秒积分"); return; }
|
||
}
|
||
setSaving(true);
|
||
try {
|
||
const metadata = buildMetadata(modelModal);
|
||
if (modelModal.id) {
|
||
await adminApi.updateModel(modelModal.id, {
|
||
display_name: modelModal.display_name,
|
||
endpoint: modelModal.endpoint,
|
||
unit_price: modelModal.unit_price || "0",
|
||
status: modelModal.status,
|
||
metadata,
|
||
});
|
||
} else {
|
||
await adminApi.createModel({
|
||
provider: modelModal.provider,
|
||
name: modelModal.name,
|
||
display_name: modelModal.display_name || modelModal.name,
|
||
capability: modelModal.capability,
|
||
endpoint: modelModal.endpoint,
|
||
unit_price: modelModal.unit_price || "0",
|
||
status: modelModal.status,
|
||
metadata,
|
||
} as Parameters<typeof adminApi.createModel>[0] & { metadata: Record<string, unknown> });
|
||
}
|
||
notify("success", "模型已保存");
|
||
setModelModal(null);
|
||
await load();
|
||
} catch (e) { notify("error", e instanceof Error ? e.message : "保存失败"); }
|
||
finally { setSaving(false); }
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<div className="page-head">
|
||
<div>
|
||
<h1>模型供应商</h1>
|
||
<div className="sub"><span className="mono">{providers.length} 供应商 · {models.length} 模型</span> · 热插拔中转站 · 定价 · 设默认</div>
|
||
</div>
|
||
<div className="actions">
|
||
<button className="btn" type="button" onClick={() => setProvModal({ ...EMPTY_PROVIDER })}>+ 供应商</button>
|
||
<button className="btn btn-primary" type="button" onClick={() => setModelModal({ ...EMPTY_MODEL, provider: providers[0]?.id || "" })}>+ 模型</button>
|
||
</div>
|
||
</div>
|
||
|
||
{loading ? (
|
||
<SystemLoading title="正在加载配置" description="正在同步最新数据,请稍候。" icon="server" />
|
||
) : (
|
||
<>
|
||
<div className="admin-detail-subhead">供应商</div>
|
||
<div className="admin-table-wrap">
|
||
<table className="t admin-table">
|
||
<thead><tr><th>名称</th><th>显示名</th><th>Base URL</th><th>密钥</th><th>模型数</th><th>状态</th><th className="col-actions">操作</th></tr></thead>
|
||
<tbody>
|
||
{providers.map((p) => (
|
||
<tr key={p.id}>
|
||
<td className="mono admin-code">{p.name}</td>
|
||
<td>{p.display_name}</td>
|
||
<td className="mono col-time">{p.base_url || <span className="muted">—</span>}</td>
|
||
<td>{p.has_api_key ? <span className="pill ok"><span className="dot" />已配</span> : <span className="pill neutral"><span className="dot" />无</span>}</td>
|
||
<td className="num">{p.model_count}</td>
|
||
<td>{statusPill(p.status === "active")}</td>
|
||
<td className="col-actions">
|
||
<button className="btn btn-sm btn-ghost" type="button" onClick={() => setProvModal({ id: p.id, name: p.name, display_name: p.display_name, base_url: p.base_url, api_key: "", status: p.status })}>编辑</button>
|
||
<button className={`btn btn-sm btn-ghost${p.status === "active" ? " danger" : ""}`} type="button" onClick={() => toggleProvider(p)}>{p.status === "active" ? "停用" : "启用"}</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div className="admin-detail-subhead" style={{ marginTop: 28 }}>模型</div>
|
||
<div className="admin-table-wrap">
|
||
<table className="t admin-table">
|
||
<thead><tr><th>供应商</th><th>模型</th><th>能力</th><th>单价(积分/次)</th><th>状态</th><th>默认</th><th className="col-actions">操作</th></tr></thead>
|
||
<tbody>
|
||
{models.slice((modelPage - 1) * PAGE_SIZE, modelPage * PAGE_SIZE).map((m) => (
|
||
<tr key={m.id}>
|
||
<td className="mono">{m.provider_name}</td>
|
||
<td>{m.display_name || m.name}</td>
|
||
<td><span className="pill neutral"><span className="dot" />{m.capability}</span></td>
|
||
<td className="num mono">{pts(m.unit_price)} 积分</td>
|
||
<td>{statusPill(m.status === "active")}</td>
|
||
<td>{m.is_default ? <span className="pill info"><span className="dot" />默认</span> : <span className="muted">—</span>}</td>
|
||
<td className="col-actions">
|
||
{!m.is_default && <button className="btn btn-sm btn-ghost" type="button" onClick={() => setDefault(m)}>设默认</button>}
|
||
<button className="btn btn-sm btn-ghost" type="button" onClick={() => setModelModal(readModelForm(m))}>编辑</button>
|
||
<button className={`btn btn-sm btn-ghost${m.status === "active" ? " danger" : ""}`} type="button" onClick={() => toggleModel(m)}>{m.status === "active" ? "停用" : "启用"}</button>
|
||
<button className="btn btn-sm btn-ghost danger" type="button" onClick={() => delModel(m)}>删除</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
<Pager page={modelPage} total={models.length} pageSize={PAGE_SIZE} onChange={setModelPage} alwaysShow />
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{provModal && (
|
||
<div className="modal-bg show">
|
||
<div className="modal" role="dialog" aria-modal="true" aria-label="供应商">
|
||
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
||
<div className="modal-h">
|
||
<div className="ic-m"><Server size={16} /></div>
|
||
<div className="ti">{provModal.id ? "编辑供应商" : "新建供应商"}</div>
|
||
<button className="x modal-x" type="button" aria-label="关闭" onClick={() => setProvModal(null)}><X size={14} /></button>
|
||
</div>
|
||
<div className="modal-b">
|
||
<div className="field">
|
||
<label className="field-label">name <span className="field-hint">唯一标识 · 决定路由</span></label>
|
||
<input className="input" type="text" value={provModal.name} disabled={Boolean(provModal.id)} onChange={(e) => setProvModal((p) => p && ({ ...p, name: e.target.value }))} />
|
||
</div>
|
||
<div className="field">
|
||
<label className="field-label">显示名</label>
|
||
<input className="input" type="text" value={provModal.display_name} onChange={(e) => setProvModal((p) => p && ({ ...p, display_name: e.target.value }))} />
|
||
</div>
|
||
<div className="field">
|
||
<label className="field-label">Base URL</label>
|
||
<input className="input" type="text" placeholder="留空走 .env 回退" value={provModal.base_url} onChange={(e) => setProvModal((p) => p && ({ ...p, base_url: e.target.value }))} />
|
||
</div>
|
||
<div className="field">
|
||
<label className="field-label">API Key <span className="field-hint">{provModal.id ? "留空=不改" : "可留空走 .env"}</span></label>
|
||
<input className="input" type="text" placeholder="••••" value={provModal.api_key} onChange={(e) => setProvModal((p) => p && ({ ...p, api_key: e.target.value }))} />
|
||
</div>
|
||
</div>
|
||
<div className="modal-f">
|
||
<button className="btn" type="button" onClick={() => setProvModal(null)}>取消</button>
|
||
<button className="btn btn-primary" type="button" disabled={saving} onClick={() => void saveProvider()}>{saving ? "保存中…" : "保存"}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{modelModal && (
|
||
<div className="modal-bg show">
|
||
<div className="modal" role="dialog" aria-modal="true" aria-label="模型">
|
||
<span className="corner-tr">+</span><span className="corner-bl">+</span>
|
||
<div className="modal-h">
|
||
<div className="ic-m"><Server size={16} /></div>
|
||
<div className="ti">{modelModal.id ? "编辑模型" : "新建模型"}</div>
|
||
<button className="x modal-x" type="button" aria-label="关闭" onClick={() => setModelModal(null)}><X size={14} /></button>
|
||
</div>
|
||
<div className="modal-b">
|
||
<div className="field">
|
||
<label className="field-label">供应商 <span className="req">*</span></label>
|
||
<CustomSelect
|
||
fill
|
||
value={modelModal.provider}
|
||
disabled={Boolean(modelModal.id)}
|
||
placeholder="选择供应商…"
|
||
onChange={(next) => setModelModal((m) => m && ({ ...m, provider: next }))}
|
||
options={providers.map((p) => ({ value: p.id, label: p.display_name }))}
|
||
/>
|
||
</div>
|
||
<div className="field-row">
|
||
<div className="field">
|
||
<label className="field-label">模型名 <span className="req">*</span></label>
|
||
<input className="input" type="text" value={modelModal.name} disabled={Boolean(modelModal.id)} onChange={(e) => setModelModal((m) => m && ({ ...m, name: e.target.value }))} />
|
||
</div>
|
||
<div className="field">
|
||
<label className="field-label">能力</label>
|
||
<CustomSelect
|
||
fill
|
||
value={modelModal.capability}
|
||
disabled={Boolean(modelModal.id)}
|
||
onChange={(next) => setModelModal((m) => m && ({ ...m, capability: next }))}
|
||
options={CAPABILITIES.map((c) => ({ value: c, label: c }))}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="field">
|
||
<label className="field-label">显示名</label>
|
||
<input className="input" type="text" value={modelModal.display_name} onChange={(e) => setModelModal((m) => m && ({ ...m, display_name: e.target.value }))} />
|
||
</div>
|
||
<div className="field">
|
||
<label className="field-label">endpoint</label>
|
||
<input className="input" type="text" value={modelModal.endpoint} onChange={(e) => setModelModal((m) => m && ({ ...m, endpoint: e.target.value }))} />
|
||
</div>
|
||
{modelModal.capability === "image" ? (
|
||
<div className="field">
|
||
<label className="field-label">积分规则 · 每张</label>
|
||
<input className="input" type="number" min={0} step={1} placeholder="例如 10" value={modelModal.unit_price} onChange={(e) => setModelModal((m) => m && ({ ...m, unit_price: e.target.value }))} />
|
||
<div className="field-hint">老板填写:用该模型出 1 张图扣多少积分</div>
|
||
</div>
|
||
) : null}
|
||
{modelModal.capability === "text" || modelModal.capability === "vision" ? (
|
||
<div className="field">
|
||
<label className="field-label">积分规则 · 每次</label>
|
||
<input className="input" type="number" min={0} step={1} placeholder="例如 4" value={modelModal.unit_price} onChange={(e) => setModelModal((m) => m && ({ ...m, unit_price: e.target.value }))} />
|
||
<div className="field-hint">老板填写:调用 1 次扣多少积分</div>
|
||
</div>
|
||
) : null}
|
||
{modelModal.capability === "video" ? (
|
||
<>
|
||
<div className="field">
|
||
<label className="field-label">可用时长(秒,逗号分隔)</label>
|
||
<input className="input" type="text" value={modelModal.durationsText} onChange={(e) => setModelModal((m) => m && ({ ...m, durationsText: e.target.value }))} />
|
||
</div>
|
||
<div className="field">
|
||
<label className="field-label">分辨率积分(每秒)</label>
|
||
<div className="field-hint">每个分辨率一档;含视频参考可另填更高积分,不填则同普通价</div>
|
||
<div style={{ display: "grid", gap: 8, marginTop: 8 }}>
|
||
{modelModal.videoTiers.map((tier, index) => (
|
||
<div key={index} className="field-row" style={{ alignItems: "end" }}>
|
||
<div className="field">
|
||
<label className="field-label">分辨率</label>
|
||
<CustomSelect
|
||
fill
|
||
value={tier.resolution}
|
||
onChange={(next) => setModelModal((m) => {
|
||
if (!m) return m;
|
||
const videoTiers = m.videoTiers.map((row, i) => i === index ? { ...row, resolution: next } : row);
|
||
return { ...m, videoTiers };
|
||
})}
|
||
options={VIDEO_RES_OPTIONS.map((r) => ({ value: r, label: r }))}
|
||
/>
|
||
</div>
|
||
<div className="field">
|
||
<label className="field-label">积分/秒</label>
|
||
<input className="input" type="number" min={0} step={1} value={tier.points_per_second} onChange={(e) => setModelModal((m) => {
|
||
if (!m) return m;
|
||
const videoTiers = m.videoTiers.map((row, i) => i === index ? { ...row, points_per_second: e.target.value } : row);
|
||
return { ...m, videoTiers };
|
||
})} />
|
||
</div>
|
||
<div className="field">
|
||
<label className="field-label">含视频参考·积分/秒</label>
|
||
<input className="input" type="number" min={0} step={1} placeholder="可选" value={tier.points_per_second_with_ref} onChange={(e) => setModelModal((m) => {
|
||
if (!m) return m;
|
||
const videoTiers = m.videoTiers.map((row, i) => i === index ? { ...row, points_per_second_with_ref: e.target.value } : row);
|
||
return { ...m, videoTiers };
|
||
})} />
|
||
</div>
|
||
<button className="btn btn-sm btn-ghost" type="button" onClick={() => setModelModal((m) => m && ({ ...m, videoTiers: m.videoTiers.filter((_, i) => i !== index) }))}>删除</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<button
|
||
className="btn btn-sm"
|
||
type="button"
|
||
style={{ marginTop: 8 }}
|
||
onClick={() => setModelModal((m) => m && ({
|
||
...m,
|
||
videoTiers: [...m.videoTiers, { resolution: "720p", points_per_second: "", points_per_second_with_ref: "" }],
|
||
}))}
|
||
>
|
||
+ 添加分辨率
|
||
</button>
|
||
</div>
|
||
</>
|
||
) : null}
|
||
{!["image", "text", "vision", "video"].includes(modelModal.capability) ? (
|
||
<div className="field">
|
||
<label className="field-label">单价(积分/次)</label>
|
||
<input className="input" type="text" placeholder="0" value={modelModal.unit_price} onChange={(e) => setModelModal((m) => m && ({ ...m, unit_price: e.target.value }))} />
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<div className="modal-f">
|
||
<button className="btn" type="button" onClick={() => setModelModal(null)}>取消</button>
|
||
<button className="btn btn-primary" type="button" disabled={saving} onClick={() => void saveModel()}>{saving ? "保存中…" : "保存"}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
}
|