feat(compute): 供应商可编辑+模型创建优化设计
- vendors: 新增编辑支持, 表单含「支持的模型」清单(逗号分隔, 创建模型时可选) - models: 创建改「选供应商→从该供应商支持模型选实际使用模型」(datalist 建议+可自定义); 完整配置: 模型名称/模型标识/实际使用模型/所属分组/计费单位/输入/输出/缓存命中价格/视觉支持/标签/描述/状态 - 列表新增实际模型/分组/输入输出价/视觉列; 供应商下拉+模型建议经 computeProxy 拉取
This commit is contained in:
@@ -10,10 +10,24 @@ import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { computeAction, useComputeList, fmtInt, fmtTime } from "./useCompute";
|
||||
import { computeProxy } from "@/api/resources";
|
||||
import { computeAction, useComputeList, fmtTime } from "./useCompute";
|
||||
|
||||
interface Row { [k: string]: any }
|
||||
const blank: Row = { model_name: "", description: "", icon: "", tags: "", endpoints: "", status: 1, sync_official: 1, vendor_id: 0 };
|
||||
const blank: Row = {
|
||||
model_name: "", name: "", actual_model: "", vendor_id: 0, group: "default",
|
||||
billing_unit: "/百万tokens", cache_hit_price: 0, input_price: 0, output_price: 0,
|
||||
vision_support: false, tags: "", endpoints: "", description: "", icon: "", status: 1, sync_official: 1,
|
||||
};
|
||||
|
||||
function vendorModels(vendor: Row | undefined): string[] {
|
||||
const m = vendor?.models;
|
||||
if (Array.isArray(m)) return m.filter(Boolean);
|
||||
if (typeof m === "string") {
|
||||
try { const a = JSON.parse(m); return Array.isArray(a) ? a.filter(Boolean) : []; } catch { return []; }
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export default function ModelsPage() {
|
||||
const [status, setStatus] = React.useState("1");
|
||||
@@ -26,25 +40,28 @@ export default function ModelsPage() {
|
||||
async function toggle(r: Row) {
|
||||
setBusy(true); setMsg(null);
|
||||
const out = await computeAction("PUT", "models", { ...r, id: r.id, status: r.status === 1 ? 0 : 1 });
|
||||
setMsg(out.ok ? `模型 ${r.model_name} 已${r.status === 1 ? "停用" : "启用"}` : `操作失败:${out.message}`);
|
||||
setMsg(out.ok ? `模型 ${r.name || r.model_name} 已${r.status === 1 ? "停用" : "启用"}` : `操作失败:${out.message}`);
|
||||
setBusy(false); res.reload();
|
||||
}
|
||||
async function del(r: Row) {
|
||||
setBusy(true); setMsg(null);
|
||||
const out = await computeAction("DELETE", `models/${r.id}`);
|
||||
setMsg(out.ok ? `已删除 ${r.model_name}` : `删除失败:${out.message}`);
|
||||
setMsg(out.ok ? `已删除 ${r.name || r.model_name}` : `删除失败:${out.message}`);
|
||||
setBusy(false); res.reload();
|
||||
}
|
||||
|
||||
const columns: Column<Row>[] = [
|
||||
{ key: "id", header: "ID", cell: (r) => <span className="text-muted-foreground">{r.id}</span> },
|
||||
{ key: "model_name", header: "模型名", cell: (r) => <span className="font-medium">{r.model_name}</span> },
|
||||
{ key: "tags", header: "标签", cell: (r) => r.tags || "-" },
|
||||
{ key: "vendor_id", header: "供应商", cell: (r) => r.vendor_id ? `#${fmtInt(r.vendor_id)}` : "-" },
|
||||
{ key: "endpoints", header: "接入端点", cell: (r) => <span className="text-xs text-muted-foreground">{r.endpoints || "-"}</span> },
|
||||
{ key: "matched_count", header: "匹配通道", cell: (r) => fmtInt(r.matched_count) },
|
||||
{ key: "model_name", header: "模型", cell: (r) => (
|
||||
<div><div className="font-medium">{r.name || r.model_name}</div><div className="text-xs text-muted-foreground">{r.model_name}</div></div>
|
||||
) },
|
||||
{ key: "actual_model", header: "实际模型", cell: (r) => r.actual_model || r.model_name },
|
||||
{ key: "group", header: "分组", cell: (r) => r.group || "default" },
|
||||
{ key: "input_price", header: "输入(¥)", cell: (r) => r.input_price ?? "-" },
|
||||
{ key: "output_price", header: "输出(¥)", cell: (r) => r.output_price ?? "-" },
|
||||
{ key: "vision_support", header: "视觉", cell: (r) => (r.vision_support ? <Badge variant="default">支持</Badge> : <Badge variant="secondary">否</Badge>) },
|
||||
{ key: "status", header: "状态", cell: (r) => <Badge variant={r.status === 1 ? "default" : "secondary"}>{r.status === 1 ? "启用" : "停用"}</Badge> },
|
||||
{ key: "updated", header: "更新时间", cell: (r) => fmtTime(r.updated_time) },
|
||||
{ key: "updated", header: "更新", cell: (r) => fmtTime(r.updated_time) },
|
||||
{
|
||||
key: "actions", header: "操作", cell: (r) => (
|
||||
<div className="flex gap-1">
|
||||
@@ -60,7 +77,7 @@ export default function ModelsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader title="模型管理" description="模型元数据(映射 / 标签 / 供应商 / 端点)——对齐 new-api models"
|
||||
<PageHeader title="模型管理" description="模型元数据(名称/实际模型/供应商/分组/计费/视觉),可从供应商支持模型中选择"
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("text-sm", msg?.includes("失败") ? "text-destructive" : "text-muted-foreground")}>{msg}</span>
|
||||
@@ -94,18 +111,34 @@ export default function ModelsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function ModelFormDialog({ open, row, onClose, onDone }: {
|
||||
open: boolean; row: Row | null; onClose: () => void; onDone: () => void;
|
||||
}) {
|
||||
function ModelFormDialog({ open, row, onClose, onDone }: { open: boolean; row: Row | null; onClose: () => void; onDone: () => void }) {
|
||||
const [f, setF] = React.useState<Row>(blank);
|
||||
const [vendors, setVendors] = React.useState<Row[]>([]);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [err, setErr] = React.useState<string | null>(null);
|
||||
React.useEffect(() => { if (open) { setF(row?.id ? blank : { ...blank, ...row }); setErr(null); } }, [open, row]);
|
||||
const vendor = vendors.find((v) => v.id === Number(f.vendor_id));
|
||||
const avail = vendorModels(vendor);
|
||||
const set = (k: string, v: any) => setF((p) => ({ ...p, [k]: v }));
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setF(row?.id ? { ...blank, ...row, model_name: row.model_name ?? "", name: row.name ?? "" } : blank);
|
||||
setErr(null);
|
||||
computeProxy<{ items: Row[] }>("GET", "vendors", undefined, { p: 1, page_size: 100 })
|
||||
.then((r: any) => setVendors((r?.body?.data?.items) ?? []))
|
||||
.catch(() => setVendors([]));
|
||||
}
|
||||
}, [open, row]);
|
||||
|
||||
async function save() {
|
||||
setBusy(true); setErr(null);
|
||||
const body = { ...f, id: row?.id, vendor_id: Number(f.vendor_id) || 0, status: Number(f.status ?? 1), sync_official: Number(f.sync_official ?? 1) };
|
||||
const body: any = {
|
||||
...f, id: row?.id, vendor_id: Number(f.vendor_id) || 0,
|
||||
input_price: Number(f.input_price) || 0, output_price: Number(f.output_price) || 0,
|
||||
cache_hit_price: Number(f.cache_hit_price) || 0, status: Number(f.status ?? 1),
|
||||
vision_support: !!f.vision_support, actual_model: f.actual_model || f.model_name,
|
||||
};
|
||||
delete body.created_time; delete body.updated_time; delete body.matched_count;
|
||||
const out = row?.id ? await computeAction("PUT", "models", body) : await computeAction("POST", "models", body);
|
||||
setBusy(false);
|
||||
if (out.ok) onDone(); else setErr(out.message || "保存失败");
|
||||
@@ -113,20 +146,39 @@ function ModelFormDialog({ open, row, onClose, onDone }: {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader><DialogTitle>{row?.id ? "编辑模型" : "新增模型"}</DialogTitle></DialogHeader>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label="模型名" full><Input value={f.model_name} onChange={(e) => set("model_name", e.target.value)} placeholder="gpt-4o" /></Field>
|
||||
<Field label="标签"><Input value={f.tags || ""} onChange={(e) => set("tags", e.target.value)} placeholder="chat,vision" /></Field>
|
||||
<Field label="供应商 ID"><Input type="number" value={f.vendor_id || 0} onChange={(e) => set("vendor_id", e.target.value)} /></Field>
|
||||
<Field label="接入端点" full><Input value={f.endpoints || ""} onChange={(e) => set("endpoints", e.target.value)} placeholder="gpt-4o,chatgpt-4o" /></Field>
|
||||
<Field label="所属供应商" full>
|
||||
<Select value={String(f.vendor_id ?? 0)} onValueChange={(v) => set("vendor_id", Number(v))}>
|
||||
<SelectTrigger><SelectValue placeholder="选择供应商" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">未指定</SelectItem>
|
||||
{vendors.map((v) => <SelectItem key={v.id} value={String(v.id)}>{v.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="模型名称(展示)"><Input value={f.name || ""} onChange={(e) => set("name", e.target.value)} placeholder="DeepSeek Chat" /></Field>
|
||||
<Field label="模型标识(请求用,如 deepseek-chat)"><Input value={f.model_name} onChange={(e) => set("model_name", e.target.value)} placeholder="deepseek-chat" /></Field>
|
||||
<Field label="实际使用的模型" full>
|
||||
<Input list="model-suggest" value={f.actual_model || f.model_name} onChange={(e) => set("actual_model", e.target.value)} placeholder="选择或输入上游模型名" />
|
||||
<datalist id="model-suggest">
|
||||
{avail.map((m) => <option key={m} value={m} />)}
|
||||
</datalist>
|
||||
</Field>
|
||||
<Field label="所属分组"><Input value={f.group || "default"} onChange={(e) => set("group", e.target.value)} /></Field>
|
||||
<Field label="计费单位"><Input value={f.billing_unit || "/百万tokens"} onChange={(e) => set("billing_unit", e.target.value)} /></Field>
|
||||
<Field label="输入价格(¥)"><Input type="number" value={f.input_price} onChange={(e) => set("input_price", e.target.value)} /></Field>
|
||||
<Field label="输出价格(¥)"><Input type="number" value={f.output_price} onChange={(e) => set("output_price", e.target.value)} /></Field>
|
||||
<Field label="缓存命中价格(¥)"><Input type="number" value={f.cache_hit_price} onChange={(e) => set("cache_hit_price", e.target.value)} /></Field>
|
||||
<Field label="标签"><Input value={f.tags || ""} onChange={(e) => set("tags", e.target.value)} /></Field>
|
||||
<Field label="描述" full><Input value={f.description || ""} onChange={(e) => set("description", e.target.value)} /></Field>
|
||||
<label className="flex items-center gap-2 text-sm sm:col-span-2">
|
||||
<input type="checkbox" checked={!!f.vision_support} onChange={(e) => set("vision_support", e.target.checked)} /> 视觉支持(图片/多模态)
|
||||
</label>
|
||||
<Field label="状态"><Select value={String(f.status ?? 1)} onValueChange={(v) => set("status", Number(v))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger><SelectContent>
|
||||
<SelectItem value="1">启用</SelectItem><SelectItem value="0">停用</SelectItem></SelectContent></Select></Field>
|
||||
<Field label="同步官方"><Select value={String(f.sync_official ?? 1)} onValueChange={(v) => set("sync_official", Number(v))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger><SelectContent>
|
||||
<SelectItem value="1">是</SelectItem><SelectItem value="0">否</SelectItem></SelectContent></Select></Field>
|
||||
</div>
|
||||
{err && <p className="text-sm text-destructive">{err}</p>}
|
||||
<DialogFooter><Button variant="outline" onClick={onClose} disabled={busy}>取消</Button>
|
||||
|
||||
@@ -97,11 +97,23 @@ function VendorFormDialog({ open, row, onClose, onDone }: { open: boolean; row:
|
||||
setBusy(true); setErr(null);
|
||||
const body = { ...f, id: row?.id, status: Number(f.status ?? 1) };
|
||||
delete body.created_time; delete body.updated_time;
|
||||
// models: 逗号/换行分隔 → JSON 数组
|
||||
body.models = Array.isArray(f.models)
|
||||
? f.models
|
||||
: String(f.models || "").split(/[,\n]/).map((s) => s.trim()).filter(Boolean);
|
||||
const out = row?.id ? await computeAction("PUT", "vendors", body) : await computeAction("POST", "vendors", body);
|
||||
setBusy(false);
|
||||
if (out.ok) onDone(); else setErr(out.message || "保存失败");
|
||||
}
|
||||
|
||||
function parseModels(v: any): string {
|
||||
if (Array.isArray(v)) return v.join(", ");
|
||||
if (typeof v === "string") {
|
||||
try { const a = JSON.parse(v); return Array.isArray(a) ? a.join(", ") : v; } catch { return v; }
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
||||
@@ -109,6 +121,8 @@ function VendorFormDialog({ open, row, onClose, onDone }: { open: boolean; row:
|
||||
<div className="grid gap-3">
|
||||
<Label>名称</Label><Input value={f.name} onChange={(e) => set("name", e.target.value)} />
|
||||
<Label>图标 URL</Label><Input value={f.icon || ""} onChange={(e) => set("icon", e.target.value)} />
|
||||
<Label>支持的模型(逗号分隔,创建模型时可选)</Label>
|
||||
<Input value={parseModels(f.models)} onChange={(e) => set("models", e.target.value)} placeholder="deepseek-chat, deepseek-reasoner" />
|
||||
<Label>描述</Label><Input value={f.description || ""} onChange={(e) => set("description", e.target.value)} />
|
||||
<Label>状态</Label>
|
||||
<Select value={String(f.status ?? 1)} onValueChange={(v) => set("status", Number(v))}>
|
||||
|
||||
Reference in New Issue
Block a user