style(admin): 管理后台 - 统一工作区与模型编辑器布局
This commit is contained in:
@@ -97,26 +97,26 @@ export function AdminShell() {
|
||||
<ConfigProvider theme={getAdminAntThemeConfig(dark)}>
|
||||
<main className="admin-shell app-user-workspace flex h-full min-h-0 overflow-hidden text-foreground">
|
||||
<aside className={cn("app-workspace-sidebar admin-sidebar hidden shrink-0 flex-col overflow-hidden lg:flex", collapsed && "is-collapsed")}>
|
||||
<div className={cn("flex h-13 shrink-0 items-center", collapsed ? "justify-center" : "gap-2 px-3")}>
|
||||
<div className={cn("admin-sidebar-header flex shrink-0 items-center", collapsed ? "justify-center" : "gap-2 px-3")}>
|
||||
{!collapsed ? (
|
||||
<Link to="/" className="app-workspace-brand-link flex min-w-0 flex-1 items-center gap-2" title="影策">
|
||||
<span className="grid size-7 shrink-0 place-items-center rounded-md bg-foreground text-background">
|
||||
<span className="admin-sidebar-brand-mark grid shrink-0 place-items-center bg-foreground text-background">
|
||||
<InfinityIcon className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-[var(--fs-body)] font-semibold">影策</span>
|
||||
<span className="block truncate text-[var(--fs-micro)] text-foreground/42">管理后台</span>
|
||||
<span className="admin-sidebar-brand-copy min-w-0">
|
||||
<span className="block truncate font-semibold">影策</span>
|
||||
<span className="block truncate">管理后台</span>
|
||||
</span>
|
||||
</Link>
|
||||
) : null}
|
||||
<Tooltip mouseEnterDelay={0.1} title={collapsed ? "展开侧栏" : "折叠侧栏"} placement="right">
|
||||
<button type="button" className="app-workspace-icon-button shrink-0" onClick={toggleCollapsed} aria-label={collapsed ? "展开侧栏" : "折叠侧栏"}>
|
||||
<button type="button" className="app-workspace-icon-button admin-sidebar-toggle shrink-0" onClick={toggleCollapsed} aria-label={collapsed ? "展开侧栏" : "折叠侧栏"}>
|
||||
{collapsed ? <PanelLeftOpen className="size-4" /> : <PanelLeftClose className="size-4" />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<AdminNavigation collapsed={collapsed} />
|
||||
<div className="shrink-0 border-t border-border/70 p-2">
|
||||
<div className="admin-sidebar-footer shrink-0">
|
||||
<Tooltip mouseEnterDelay={0.1} title={collapsed ? "更新日志" : undefined} placement="right">
|
||||
<AppChangelogButton
|
||||
className={cn("flex h-8 w-full items-center rounded text-[var(--fs-label)] text-foreground/52 transition-colors hover:bg-surface-hover hover:text-foreground", collapsed ? "justify-center px-0" : "gap-2 px-2")}
|
||||
@@ -140,11 +140,11 @@ export function AdminShell() {
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminPageFrame({ title, actions, back, scroll = false, children }: { title: string; description?: string; actions?: ReactNode; back?: { label: string; onClick: () => void }; scroll?: boolean; children: ReactNode }) {
|
||||
export function AdminPageFrame({ title, description, actions, back, scroll = false, children }: { title: string; description?: string; actions?: ReactNode; back?: { label: string; onClick: () => void }; scroll?: boolean; children: ReactNode }) {
|
||||
return (
|
||||
<WorkspacePage scroll={scroll} fluid className={cn("admin-page-root", scroll && "admin-page-root-scrollable")}>
|
||||
<div className={cn("admin-page-frame", scroll && "admin-page-frame-scrollable")}>
|
||||
<header className="admin-page-header flex min-h-12 shrink-0 flex-col gap-2 pb-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<header className="admin-page-header flex shrink-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
{back ? (
|
||||
<Tooltip title={back.label}>
|
||||
@@ -153,7 +153,10 @@ export function AdminPageFrame({ title, actions, back, scroll = false, children
|
||||
</button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<h1 className="truncate text-base font-semibold leading-6">{title}</h1>
|
||||
<div className="admin-page-title-block min-w-0">
|
||||
<h1 className="admin-page-title truncate font-semibold">{title}</h1>
|
||||
{description ? <p className="admin-page-description">{description}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
|
||||
</header>
|
||||
@@ -191,19 +194,19 @@ function AdminNavigation({ collapsed }: { collapsed: boolean }) {
|
||||
const features = useUserStore((state) => state.features);
|
||||
|
||||
return (
|
||||
<nav className="thin-scrollbar flex-1 overflow-y-auto px-2 py-2" aria-label="管理后台菜单">
|
||||
<nav className="admin-sidebar-nav thin-scrollbar flex-1 overflow-y-auto" aria-label="管理后台菜单">
|
||||
{adminNavigation.map((group) => {
|
||||
const visibleItems = group.items.filter((item) => !item.requireFeature || features[item.requireFeature]);
|
||||
if (visibleItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={group.label} className="mb-3">
|
||||
<div key={group.label} className="admin-nav-group">
|
||||
{!collapsed ? (
|
||||
<div className="admin-nav-group-label mb-1 px-2.5 text-[var(--fs-tiny)] font-medium text-foreground/38">
|
||||
<span>{group.label}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-auto mb-1.5 h-px w-7 bg-border/80" />
|
||||
<div className="admin-nav-collapsed-separator" />
|
||||
)}
|
||||
<div className="space-y-0.5">
|
||||
{visibleItems.map((item) => (
|
||||
|
||||
@@ -13,7 +13,11 @@ export const configuredSecretText = "已配置 · 留空不改";
|
||||
export type AdminStatusTone = "neutral" | "success" | "warning" | "error" | "info";
|
||||
|
||||
export function AdminStatusBadge({ label, tone = "neutral", title }: { label: string; tone?: AdminStatusTone; title?: string }) {
|
||||
return <span className="admin-status-badge" data-tone={tone} title={title}>{label}</span>;
|
||||
return (
|
||||
<span className="admin-status-badge" data-tone={tone} title={title}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminStatTile({ label, value, detail, trend }: { label: string; value: string | number; detail?: string; trend?: { value: string; tone?: AdminStatusTone } }) {
|
||||
@@ -36,6 +40,7 @@ export function AdminDataTable<RecordType extends object>({
|
||||
toolbar,
|
||||
toolbarActive,
|
||||
toolbarFilters,
|
||||
toolbarFiltersAlwaysVisible = true,
|
||||
toolbarActiveFilters,
|
||||
onReset,
|
||||
trailing,
|
||||
@@ -50,6 +55,7 @@ export function AdminDataTable<RecordType extends object>({
|
||||
toolbar?: ReactNode;
|
||||
toolbarActive?: boolean;
|
||||
toolbarFilters?: ReactNode;
|
||||
toolbarFiltersAlwaysVisible?: boolean;
|
||||
toolbarActiveFilters?: ReactNode;
|
||||
onReset?: () => void;
|
||||
trailing?: ReactNode;
|
||||
@@ -66,14 +72,18 @@ export function AdminDataTable<RecordType extends object>({
|
||||
|
||||
return (
|
||||
<div className="admin-data-table">
|
||||
{toolbar ? <ListToolbar active={toolbarActive} filters={toolbarFilters} activeFilters={toolbarActiveFilters} onReset={onReset} trailing={trailing}>{toolbar}</ListToolbar> : null}
|
||||
{toolbar ? (
|
||||
<ListToolbar active={toolbarActive} filters={toolbarFilters} filtersAlwaysVisible={toolbarFiltersAlwaysVisible} activeFilters={toolbarActiveFilters} onReset={onReset} trailing={trailing}>
|
||||
{toolbar}
|
||||
</ListToolbar>
|
||||
) : null}
|
||||
{batchActions}
|
||||
<div className={cn("admin-table-surface", className)}>
|
||||
<div className="admin-table-scroll">
|
||||
{showSkeleton ? <AdminTableSkeleton rows={skeletonRows} columns={skeletonColumns} /> : <Table {...table} locale={{ ...table.locale, emptyText: empty ?? table.locale?.emptyText }} />}
|
||||
<div className={cn("admin-table-frame", footer && "has-pagination")}>
|
||||
<div className={cn("admin-table-surface", className)}>
|
||||
<div className="admin-table-scroll">{showSkeleton ? <AdminTableSkeleton rows={skeletonRows} columns={skeletonColumns} /> : <Table {...table} locale={{ ...table.locale, emptyText: empty ?? table.locale?.emptyText }} />}</div>
|
||||
</div>
|
||||
{footer ? <div className="admin-table-pagination">{footer}</div> : null}
|
||||
</div>
|
||||
{footer ? <div className="admin-table-pagination">{footer}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -114,20 +124,14 @@ export function AdminExportButton({
|
||||
}
|
||||
};
|
||||
|
||||
return <Button {...buttonProps} size={size} icon={<Download className={size === "small" ? "size-3.5" : "size-4"} />} loading={exporting} onClick={() => void runExport()}>{label}</Button>;
|
||||
return (
|
||||
<Button {...buttonProps} size={size} icon={<Download className={size === "small" ? "size-3.5" : "size-4"} />} loading={exporting} onClick={() => void runExport()}>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminTableEmpty({
|
||||
filtered = false,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
filtered?: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
export function AdminTableEmpty({ filtered = false, title, description, action }: { filtered?: boolean; title?: string; description?: string; action?: ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-40 flex-col items-center justify-center px-6 py-8 text-center">
|
||||
<span className="grid size-9 place-items-center rounded-md bg-muted/35 text-foreground/45">
|
||||
@@ -154,7 +158,9 @@ export function AdminTableSkeleton({ rows = 8, columns = 6 }: { rows?: number; c
|
||||
return (
|
||||
<div className="animate-pulse motion-reduce:animate-none" aria-label="正在加载表格" role="status">
|
||||
<div className="grid h-11 items-center gap-4 border-b border-border bg-muted/30 px-4" style={{ gridTemplateColumns: `repeat(${columns}, minmax(72px, 1fr))` }}>
|
||||
{Array.from({ length: columns }).map((_, index) => <span key={index} className="h-3 w-16 max-w-full rounded bg-foreground/10" />)}
|
||||
{Array.from({ length: columns }).map((_, index) => (
|
||||
<span key={index} className="h-3 w-16 max-w-full rounded bg-foreground/10" />
|
||||
))}
|
||||
</div>
|
||||
{Array.from({ length: Math.max(8, rows) }).map((_, rowIndex) => (
|
||||
<div key={rowIndex} className="grid min-h-14 items-center gap-4 border-b border-border/70 px-4 last:border-b-0" style={{ gridTemplateColumns: `repeat(${columns}, minmax(72px, 1fr))` }}>
|
||||
@@ -171,8 +177,16 @@ export function AdminBatchBar({ count, onClear, children }: { count: number; onC
|
||||
if (count <= 0) return null;
|
||||
return (
|
||||
<div className="sticky top-0 z-20 mt-3 flex min-h-11 flex-wrap items-center justify-between gap-3 rounded-md border border-border bg-background/95 px-3 py-2 shadow-sm backdrop-blur">
|
||||
<div className="flex items-center gap-2 text-sm font-medium"><CheckSquare2 className="size-4 text-foreground/60" />已选择 {count} 项</div>
|
||||
<div className="flex flex-wrap items-center gap-2">{children}<Button type="text" size="small" icon={<X className="size-3.5" />} onClick={onClear}>取消选择</Button></div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<CheckSquare2 className="size-4 text-foreground/60" />
|
||||
已选择 {count} 项
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{children}
|
||||
<Button type="text" size="small" icon={<X className="size-3.5" />} onClick={onClear}>
|
||||
取消选择
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -191,18 +205,10 @@ export type AdminRowAction = {
|
||||
};
|
||||
};
|
||||
|
||||
export function AdminRowActions({
|
||||
primary,
|
||||
actions,
|
||||
visibleActionCount,
|
||||
}: {
|
||||
primary?: { label: ReactNode; icon?: ReactNode; onClick: () => void | Promise<void>; disabled?: boolean };
|
||||
actions: AdminRowAction[];
|
||||
visibleActionCount?: number;
|
||||
}) {
|
||||
export function AdminRowActions({ primary, actions, visibleActionCount }: { primary?: { label: ReactNode; icon?: ReactNode; onClick: () => void | Promise<void>; disabled?: boolean }; actions: AdminRowAction[]; visibleActionCount?: number }) {
|
||||
const { modal } = App.useApp();
|
||||
// 少量操作直接露出,只有真正过多时才收进菜单,避免“更多”成为默认操作列。
|
||||
const resolvedVisibleActionCount = visibleActionCount ?? (actions.length <= 3 ? actions.length : 2);
|
||||
// 行内只保留一个最常用的次操作;低频或危险操作收进菜单,避免操作列堆成一排文字链接。
|
||||
const resolvedVisibleActionCount = visibleActionCount ?? (actions.length <= 1 ? actions.length : 1);
|
||||
const visibleActions = actions.slice(0, Math.max(0, resolvedVisibleActionCount));
|
||||
const menuActions = actions.slice(Math.max(0, resolvedVisibleActionCount));
|
||||
const items: MenuProps["items"] = menuActions.map((action) => ({
|
||||
@@ -229,14 +235,7 @@ export function AdminRowActions({
|
||||
};
|
||||
|
||||
const renderActionButton = (action: AdminRowAction) => (
|
||||
<Button
|
||||
key={action.key}
|
||||
type="text"
|
||||
size="small"
|
||||
className={cn("admin-row-action", action.danger && "admin-row-action-danger")}
|
||||
disabled={action.disabled}
|
||||
onClick={() => runAction(action)}
|
||||
>
|
||||
<Button key={action.key} type="text" size="small" className={cn("admin-row-action", action.danger && "admin-row-action-danger")} icon={action.icon} disabled={action.disabled} onClick={() => runAction(action)}>
|
||||
{action.label}
|
||||
</Button>
|
||||
);
|
||||
@@ -244,7 +243,7 @@ export function AdminRowActions({
|
||||
return (
|
||||
<div className="admin-row-actions">
|
||||
{primary ? (
|
||||
<Button type="text" size="small" className="admin-row-action admin-row-action-primary" disabled={primary.disabled} onClick={primary.onClick}>
|
||||
<Button type="text" size="small" className="admin-row-action admin-row-action-primary" icon={primary.icon} disabled={primary.disabled} onClick={primary.onClick}>
|
||||
{primary.label}
|
||||
</Button>
|
||||
) : null}
|
||||
@@ -293,8 +292,8 @@ export function SettingsSectionCard({
|
||||
}) {
|
||||
const isStacked = layout === "stacked";
|
||||
return (
|
||||
<section className={cn("admin-settings-section", isStacked ? "" : "lg:grid lg:grid-cols-4", className)}>
|
||||
<div className={cn("flex flex-wrap items-start justify-between gap-3 px-4 py-4", isStacked ? "" : "lg:col-span-1 lg:block")}>
|
||||
<section className={cn("admin-settings-section", isStacked ? "is-stacked" : "lg:grid lg:grid-cols-4", className)}>
|
||||
<div className={cn("admin-settings-section-summary flex flex-wrap items-start justify-between gap-3 px-4 py-4", isStacked ? "" : "lg:col-span-1 lg:block")}>
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
{icon ? <span className="grid size-8 shrink-0 place-items-center rounded-md bg-muted/40">{icon}</span> : null}
|
||||
<div className="min-w-0">
|
||||
@@ -302,11 +301,19 @@ export function SettingsSectionCard({
|
||||
{description ? <p className="mt-1 text-xs leading-5 text-foreground/55">{description}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
{status ? <div className={cn("shrink-0", isStacked ? "" : "lg:mt-4")}>{isStatusConfig(status) ? <AdminStatusBadge label={status.label} tone={status.color === "success" ? "success" : status.color === "warning" ? "warning" : status.color === "error" ? "error" : status.color === "blue" ? "info" : "neutral"} /> : status}</div> : null}
|
||||
{status ? (
|
||||
<div className={cn("shrink-0", isStacked ? "" : "lg:mt-4")}>
|
||||
{isStatusConfig(status) ? (
|
||||
<AdminStatusBadge label={status.label} tone={status.color === "success" ? "success" : status.color === "warning" ? "warning" : status.color === "error" ? "error" : status.color === "blue" ? "info" : "neutral"} />
|
||||
) : (
|
||||
status
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={cn("min-w-0", isStacked ? "" : "lg:col-span-3", contentClassName)}>
|
||||
<div className={cn("admin-settings-section-content min-w-0", isStacked ? "" : "lg:col-span-3", contentClassName)}>
|
||||
{children}
|
||||
{footer ? <div className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">{footer}</div> : null}
|
||||
{footer ? <div className="admin-settings-section-footer flex flex-wrap items-center justify-between gap-3 px-4 py-3">{footer}</div> : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { App, Button, DatePicker, Form, Input, InputNumber, Modal, Select, Tabs, Tag, Tooltip } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import dayjs, { type Dayjs } from "dayjs";
|
||||
import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { BarChart3, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { Area, CartesianGrid, ComposedChart, Legend, Line, ResponsiveContainer, Tooltip as ChartTooltip, XAxis, YAxis } from "recharts";
|
||||
import { useSearchParams } from "react-router";
|
||||
|
||||
@@ -272,11 +272,26 @@ export default function AnalyticsPanel({ users, channels }: Props) {
|
||||
{
|
||||
title: "操作",
|
||||
width: 170,
|
||||
render: (_, row) => <AdminRowActions primary={{ label: "编辑", icon: <Pencil className="size-3.5" />, onClick: () => openPricing(row) }} actions={[{ key: "delete", label: "删除", icon: <Trash2 className="size-3.5" />, danger: true, confirm: { title: "删除价格配置?", description: "删除后新的调用不再使用这条价格配置,历史费用不受影响。", okText: "确认删除" }, onClick: () => removePricing(row.id) }]} />,
|
||||
render: (_, row) => (
|
||||
<AdminRowActions
|
||||
primary={{ label: "编辑", icon: <Pencil className="size-3.5" />, onClick: () => openPricing(row) }}
|
||||
actions={[
|
||||
{
|
||||
key: "delete",
|
||||
label: "删除",
|
||||
icon: <Trash2 className="size-3.5" />,
|
||||
danger: true,
|
||||
confirm: { title: "删除价格配置?", description: "删除后新的调用不再使用这条价格配置,历史费用不受影响。", okText: "确认删除" },
|
||||
onClick: () => removePricing(row.id),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const trend = data?.trend || [];
|
||||
const hasTrendActivity = trend.some((item) => item.tasks > 0 || item.requests > 0);
|
||||
const currentTrend = trend[trend.length - 1];
|
||||
const previousTrend = trend[trend.length - 2];
|
||||
const modelRows = data?.models || [];
|
||||
@@ -289,17 +304,44 @@ export default function AnalyticsPanel({ users, channels }: Props) {
|
||||
<div className="space-y-5">
|
||||
<ListToolbar
|
||||
active={Boolean(userId || model || channelId || capability)}
|
||||
activeFilters={(
|
||||
activeFilters={
|
||||
<>
|
||||
{userId ? <AdminFilterChip label={`用户:${userOptions.find((user) => user.id === userId)?.displayName || userId}`} onRemove={() => setUserId(undefined)} /> : null}
|
||||
{model ? <AdminFilterChip label={`模型:${model}`} onRemove={() => setModel(undefined)} /> : null}
|
||||
{channelId ? <AdminFilterChip label={`渠道:${channels.find((channel) => channel.id === channelId)?.name || channelId}`} onRemove={() => setChannelId(undefined)} /> : null}
|
||||
{capability ? <AdminFilterChip label={`能力:${capabilityLabel(capability)}`} onRemove={() => setCapability(undefined)} /> : null}
|
||||
</>
|
||||
)}
|
||||
onReset={() => { setUserId(undefined); setModel(undefined); setChannelId(undefined); setCapability(undefined); }}
|
||||
trailing={<><Button icon={<RefreshCw className="size-4" />} loading={loading} onClick={() => void reload()}>刷新</Button><AdminExportButton exportFile={() => exportAdminAnalytics(filters)} fileName={() => `usage-${filters.from}-${filters.to}.csv`} label="导出 CSV" /></>}
|
||||
filters={<><FilterSelect label="用户" value={userId} onChange={setUserId} options={userOptions.map((user) => ({ label: user.displayName || user.username, value: user.id }))} filterOption={false} loading={searchingUsers} onSearch={(value) => void searchUsers(value)} /><FilterSelect label="模型" value={model} onChange={setModel} options={modelOptions} width={210} /><FilterSelect label="渠道" value={channelId} onChange={setChannelId} options={channels.map((channel) => ({ label: channel.name, value: channel.id }))} /><FilterSelect label="能力" value={capability} onChange={setCapability} options={capabilityOptions} /></>}
|
||||
}
|
||||
onReset={() => {
|
||||
setUserId(undefined);
|
||||
setModel(undefined);
|
||||
setChannelId(undefined);
|
||||
setCapability(undefined);
|
||||
}}
|
||||
trailing={
|
||||
<>
|
||||
<Button icon={<RefreshCw className="size-4" />} loading={loading} onClick={() => void reload()}>
|
||||
刷新
|
||||
</Button>
|
||||
<AdminExportButton exportFile={() => exportAdminAnalytics(filters)} fileName={() => `usage-${filters.from}-${filters.to}.csv`} label="导出 CSV" />
|
||||
</>
|
||||
}
|
||||
filters={
|
||||
<>
|
||||
<FilterSelect
|
||||
label="用户"
|
||||
value={userId}
|
||||
onChange={setUserId}
|
||||
options={userOptions.map((user) => ({ label: user.displayName || user.username, value: user.id }))}
|
||||
filterOption={false}
|
||||
loading={searchingUsers}
|
||||
onSearch={(value) => void searchUsers(value)}
|
||||
/>
|
||||
<FilterSelect label="模型" value={model} onChange={setModel} options={modelOptions} width={210} />
|
||||
<FilterSelect label="渠道" value={channelId} onChange={setChannelId} options={channels.map((channel) => ({ label: channel.name, value: channel.id }))} />
|
||||
<FilterSelect label="能力" value={capability} onChange={setCapability} options={capabilityOptions} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-foreground/55">时间范围</div>
|
||||
@@ -308,37 +350,68 @@ export default function AnalyticsPanel({ users, channels }: Props) {
|
||||
</ListToolbar>
|
||||
|
||||
<div className="grid overflow-hidden rounded-md border border-border sm:grid-cols-2 xl:grid-cols-4">
|
||||
<AdminStatTile label="活跃用户" value={data ? formatNumber(data.kpi.activeUsers) : "--"} trend={formatCountDelta(currentTrend?.activeUsers, previousTrend?.activeUsers)} detail={data ? `DAU ${formatNumber(data.kpi.dau)} · WAU ${formatNumber(data.kpi.wau)} · MAU ${formatNumber(data.kpi.mau)}` : undefined} />
|
||||
<AdminStatTile
|
||||
label="活跃用户"
|
||||
value={data ? formatNumber(data.kpi.activeUsers) : "--"}
|
||||
trend={formatCountDelta(currentTrend?.activeUsers, previousTrend?.activeUsers)}
|
||||
detail={data ? `DAU ${formatNumber(data.kpi.dau)} · WAU ${formatNumber(data.kpi.wau)} · MAU ${formatNumber(data.kpi.mau)}` : undefined}
|
||||
/>
|
||||
<AdminStatTile label="上游请求" value={data ? formatNumber(data.kpi.upstreamRequests) : "--"} trend={formatCountDelta(currentTrend?.requests, previousTrend?.requests)} detail="当前统计范围" />
|
||||
<AdminStatTile label="生成任务" value={data ? formatNumber(data.kpi.generationTasks) : "--"} trend={formatCountDelta(currentTrend?.tasks, previousTrend?.tasks)} detail={data ? `队列 ${formatNumber(data.kpi.currentQueuedTasks)}` : undefined} />
|
||||
<AdminStatTile label="请求成功率" value={data ? percent(data.kpi.successRate) : "--"} trend={formatRateDelta(currentTrend?.requestSuccessRate, previousTrend?.requestSuccessRate)} detail={data ? `P95 ${formatDuration(data.kpi.p95DurationMs)}` : undefined} />
|
||||
<AdminStatTile
|
||||
label="生成任务"
|
||||
value={data ? formatNumber(data.kpi.generationTasks) : "--"}
|
||||
trend={formatCountDelta(currentTrend?.tasks, previousTrend?.tasks)}
|
||||
detail={data ? `队列 ${formatNumber(data.kpi.currentQueuedTasks)}` : undefined}
|
||||
/>
|
||||
<AdminStatTile
|
||||
label="请求成功率"
|
||||
value={data ? percent(data.kpi.successRate) : "--"}
|
||||
trend={formatRateDelta(currentTrend?.requestSuccessRate, previousTrend?.requestSuccessRate)}
|
||||
detail={data ? `P95 ${formatDuration(data.kpi.p95DurationMs)}` : undefined}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-x-4 gap-y-2 border-b border-border pb-4 text-xs text-foreground/55 sm:grid-cols-3">
|
||||
<div>当前队列 <span className="ml-1 font-medium text-foreground">{data ? formatNumber(data.kpi.currentQueuedTasks) : "--"}</span></div>
|
||||
<div>P95 耗时 <span className="ml-1 font-medium text-foreground">{data ? formatDuration(data.kpi.p95DurationMs) : "--"}</span></div>
|
||||
<div>估算费用 <span className="ml-1 font-medium text-foreground">{data ? formatCost(data.kpi.estimatedCostMicros, data.kpi.currency, data.kpi.costAvailable) : "--"}</span></div>
|
||||
<div>
|
||||
当前队列 <span className="ml-1 font-medium text-foreground">{data ? formatNumber(data.kpi.currentQueuedTasks) : "--"}</span>
|
||||
</div>
|
||||
<div>
|
||||
P95 耗时 <span className="ml-1 font-medium text-foreground">{data ? formatDuration(data.kpi.p95DurationMs) : "--"}</span>
|
||||
</div>
|
||||
<div>
|
||||
估算费用 <span className="ml-1 font-medium text-foreground">{data ? formatCost(data.kpi.estimatedCostMicros, data.kpi.currency, data.kpi.costAvailable) : "--"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="border-y border-border py-4">
|
||||
<section className="admin-analytics-trend-section">
|
||||
<div className="mb-3">
|
||||
<h3 className="text-sm font-medium">使用趋势</h3>
|
||||
<p className="text-xs text-foreground/50">生成任务与真实上游请求分开统计,成功率按上游请求计算。</p>
|
||||
</div>
|
||||
<div className="h-[300px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={data?.trend || []} margin={{ top: 8, right: 12, bottom: 0, left: -16 }}>
|
||||
<CartesianGrid stroke="currentColor" className="text-foreground/10" vertical={false} />
|
||||
<XAxis dataKey="day" tickFormatter={(value) => value.slice(5)} tick={{ fontSize: 11 }} />
|
||||
<YAxis yAxisId="count" allowDecimals={false} tick={{ fontSize: 11 }} />
|
||||
<YAxis yAxisId="rate" orientation="right" domain={[0, 100]} tickFormatter={(value) => `${value}%`} tick={{ fontSize: 11 }} />
|
||||
<ChartTooltip labelFormatter={(value) => `日期 ${value}`} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Area yAxisId="count" type="monotone" dataKey="tasks" name="生成任务" stroke="var(--admin-chart-primary)" fill="var(--admin-chart-primary)" fillOpacity={0.1} />
|
||||
<Area yAxisId="count" type="monotone" dataKey="requests" name="上游请求" stroke="var(--admin-chart-secondary)" fill="var(--admin-chart-secondary)" fillOpacity={0.08} />
|
||||
<Line yAxisId="rate" type="monotone" dataKey="requestSuccessRate" name="成功率" stroke="var(--admin-chart-warning)" dot={false} strokeWidth={2} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
{hasTrendActivity ? (
|
||||
<div className="h-[300px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={data?.trend || []} margin={{ top: 8, right: 12, bottom: 0, left: -16 }}>
|
||||
<CartesianGrid stroke="currentColor" className="text-foreground/10" vertical={false} />
|
||||
<XAxis dataKey="day" tickFormatter={(value) => value.slice(5)} tick={{ fontSize: 11 }} />
|
||||
<YAxis yAxisId="count" allowDecimals={false} tick={{ fontSize: 11 }} />
|
||||
<YAxis yAxisId="rate" orientation="right" domain={[0, 100]} tickFormatter={(value) => `${value}%`} tick={{ fontSize: 11 }} />
|
||||
<ChartTooltip labelFormatter={(value) => `日期 ${value}`} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Area yAxisId="count" type="monotone" dataKey="tasks" name="生成任务" stroke="var(--admin-chart-primary)" fill="var(--admin-chart-primary)" fillOpacity={0.1} />
|
||||
<Area yAxisId="count" type="monotone" dataKey="requests" name="上游请求" stroke="var(--admin-chart-secondary)" fill="var(--admin-chart-secondary)" fillOpacity={0.08} />
|
||||
<Line yAxisId="rate" type="monotone" dataKey="requestSuccessRate" name="成功率" stroke="var(--admin-chart-warning)" dot={false} strokeWidth={2} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-analytics-empty-chart">
|
||||
<span>
|
||||
<BarChart3 className="size-5" />
|
||||
</span>
|
||||
<div className="font-medium">当前范围暂无使用数据</div>
|
||||
<p>可以调整时间范围或筛选条件后重新查看。</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Tabs
|
||||
@@ -346,13 +419,38 @@ export default function AnalyticsPanel({ users, channels }: Props) {
|
||||
{
|
||||
key: "models",
|
||||
label: "模型分析",
|
||||
children: <AdminDataTable table={{ rowKey: (row) => `${row.model}:${row.capability}`, size: "small", loading, columns: modelColumns, dataSource: pageRows(modelRows, modelPage), pagination: false, scroll: { x: 1250 } }} empty={<AdminTableEmpty />} skeletonColumns={9} footer={<PaginationBar alwaysShow current={modelPage} pageSize={analyticsPageSize} total={modelRows.length} onChange={(page) => setModelPage(page)} pageSizeOptions={[analyticsPageSize]} />} />,
|
||||
children: (
|
||||
<AdminDataTable
|
||||
table={{ rowKey: (row) => `${row.model}:${row.capability}`, size: "small", loading, columns: modelColumns, dataSource: pageRows(modelRows, modelPage), pagination: false, scroll: { x: 1250 } }}
|
||||
empty={<AdminTableEmpty />}
|
||||
skeletonColumns={9}
|
||||
footer={<PaginationBar alwaysShow current={modelPage} pageSize={analyticsPageSize} total={modelRows.length} onChange={(page) => setModelPage(page)} pageSizeOptions={[analyticsPageSize]} />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "users",
|
||||
label: "用户活动",
|
||||
children: (
|
||||
<AdminDataTable
|
||||
table={{ rowKey: "userId", size: "small", loading, columns: userColumns, dataSource: pageRows(userRows, userPage), pagination: false, scroll: { x: 900 } }}
|
||||
empty={<AdminTableEmpty />}
|
||||
skeletonColumns={7}
|
||||
footer={<PaginationBar alwaysShow current={userPage} pageSize={analyticsPageSize} total={userRows.length} onChange={(page) => setUserPage(page)} pageSizeOptions={[analyticsPageSize]} />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ key: "users", label: "用户活动", children: <AdminDataTable table={{ rowKey: "userId", size: "small", loading, columns: userColumns, dataSource: pageRows(userRows, userPage), pagination: false, scroll: { x: 900 } }} empty={<AdminTableEmpty />} skeletonColumns={7} footer={<PaginationBar alwaysShow current={userPage} pageSize={analyticsPageSize} total={userRows.length} onChange={(page) => setUserPage(page)} pageSizeOptions={[analyticsPageSize]} />} /> },
|
||||
{
|
||||
key: "failures",
|
||||
label: `异常定位${data?.failures.length ? ` (${data.failures.reduce((sum, item) => sum + item.count, 0)})` : ""}`,
|
||||
children: <AdminDataTable table={{ rowKey: (row) => `${row.type}:${row.model}`, size: "small", loading, columns: failureColumns, dataSource: pageRows(failureRows, failurePage), pagination: false, scroll: { x: 900 } }} empty={<AdminTableEmpty />} skeletonColumns={5} footer={<PaginationBar alwaysShow current={failurePage} pageSize={analyticsPageSize} total={failureRows.length} onChange={(page) => setFailurePage(page)} pageSizeOptions={[analyticsPageSize]} />} />,
|
||||
children: (
|
||||
<AdminDataTable
|
||||
table={{ rowKey: (row) => `${row.type}:${row.model}`, size: "small", loading, columns: failureColumns, dataSource: pageRows(failureRows, failurePage), pagination: false, scroll: { x: 900 } }}
|
||||
empty={<AdminTableEmpty />}
|
||||
skeletonColumns={5}
|
||||
footer={<PaginationBar alwaysShow current={failurePage} pageSize={analyticsPageSize} total={failureRows.length} onChange={(page) => setFailurePage(page)} pageSizeOptions={[analyticsPageSize]} />}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "pricing",
|
||||
@@ -364,7 +462,12 @@ export default function AnalyticsPanel({ users, channels }: Props) {
|
||||
新增价格
|
||||
</Button>
|
||||
</div>
|
||||
<AdminDataTable table={{ rowKey: "id", size: "small", loading, columns: pricingColumns, dataSource: pageRows(pricingRows, pricingPage), pagination: false, scroll: { x: 980 } }} empty={<AdminTableEmpty />} skeletonColumns={5} footer={<PaginationBar alwaysShow current={pricingPage} pageSize={analyticsPageSize} total={pricingRows.length} onChange={(page) => setPricingPage(page)} pageSizeOptions={[analyticsPageSize]} />} />
|
||||
<AdminDataTable
|
||||
table={{ rowKey: "id", size: "small", loading, columns: pricingColumns, dataSource: pageRows(pricingRows, pricingPage), pagination: false, scroll: { x: 980 } }}
|
||||
empty={<AdminTableEmpty />}
|
||||
skeletonColumns={5}
|
||||
footer={<PaginationBar alwaysShow current={pricingPage} pageSize={analyticsPageSize} total={pricingRows.length} onChange={(page) => setPricingPage(page)} pageSizeOptions={[analyticsPageSize]} />}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -399,7 +502,25 @@ export default function AnalyticsPanel({ users, channels }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function FilterSelect({ label, value, onChange, options, width = 150, filterOption = true, loading, onSearch }: { label: string; value?: string; onChange: (value?: string) => void; options: Array<{ label: string; value: string }>; width?: number; filterOption?: boolean; loading?: boolean; onSearch?: (value: string) => void }) {
|
||||
function FilterSelect({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
width = 150,
|
||||
filterOption = true,
|
||||
loading,
|
||||
onSearch,
|
||||
}: {
|
||||
label: string;
|
||||
value?: string;
|
||||
onChange: (value?: string) => void;
|
||||
options: Array<{ label: string; value: string }>;
|
||||
width?: number;
|
||||
filterOption?: boolean;
|
||||
loading?: boolean;
|
||||
onSearch?: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 text-xs text-foreground/55">{label}</div>
|
||||
@@ -438,14 +559,14 @@ function formatCountDelta(current?: number, previous?: number) {
|
||||
const delta = current - previous;
|
||||
const direction = delta > 0 ? "↑" : delta < 0 ? "↓" : "→";
|
||||
const value = previous === 0 ? formatNumber(Math.abs(delta)) : `${Math.abs((delta / previous) * 100).toFixed(1)}%`;
|
||||
return { value: `${direction} ${value}`, tone: delta >= 0 ? "success" as const : "warning" as const };
|
||||
return { value: `${direction} ${value}`, tone: delta >= 0 ? ("success" as const) : ("warning" as const) };
|
||||
}
|
||||
|
||||
function formatRateDelta(current?: number, previous?: number) {
|
||||
if (current === undefined || previous === undefined) return undefined;
|
||||
const delta = current - previous;
|
||||
const direction = delta > 0 ? "↑" : delta < 0 ? "↓" : "→";
|
||||
return { value: `${direction} ${Math.abs(delta).toFixed(1)}pp`, tone: delta >= 0 ? "success" as const : "warning" as const };
|
||||
return { value: `${direction} ${Math.abs(delta).toFixed(1)}pp`, tone: delta >= 0 ? ("success" as const) : ("warning" as const) };
|
||||
}
|
||||
|
||||
function formatCost(micros: number, currency?: string, available?: boolean) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { App, Button, Drawer, Form, Input, InputNumber, Popconfirm, Segmented, Select, Space, Switch, type FormInstance } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { FlaskConical, Plus, RefreshCw, Search, Trash2, X } from "lucide-react";
|
||||
import { FlaskConical, Plus, RefreshCw, Search, Trash2 } from "lucide-react";
|
||||
|
||||
import { PaginationBar } from "@/components/layout/workspace-page";
|
||||
import { ModelIcon } from "@/components/model-picker";
|
||||
@@ -23,18 +23,18 @@ type FormValues = {
|
||||
displayName?: string;
|
||||
capability: EditableCapability;
|
||||
protocol?: ModelProtocol;
|
||||
priceTiers: PriceTierFormValues[];
|
||||
priceTiers: PriceTierFormValues[];
|
||||
enabled: boolean;
|
||||
capabilityConfig?: ModelCapabilityConfig;
|
||||
};
|
||||
|
||||
type PriceTierFormValues = {
|
||||
operation: string;
|
||||
quality: string;
|
||||
size: string;
|
||||
resolution: string;
|
||||
videoSeconds: number;
|
||||
imageCount: number;
|
||||
operation: string;
|
||||
quality: string;
|
||||
size: string;
|
||||
resolution: string;
|
||||
videoSeconds: number;
|
||||
imageCount: number;
|
||||
providerModelKey?: string;
|
||||
billingMode: ChannelModel["billingMode"];
|
||||
unitPrice: number;
|
||||
@@ -65,7 +65,8 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
const modelProtocol = Form.useWatch("protocol", form);
|
||||
const modelKey = Form.useWatch("modelKey", form) || "";
|
||||
const providerModelKey = Form.useWatch("providerModelKey", form) || "";
|
||||
const capabilityConfig = Form.useWatch("capabilityConfig", form);
|
||||
const capabilityConfig = Form.useWatch("capabilityConfig", form);
|
||||
const modelEnabled = Form.useWatch("enabled", form) !== false;
|
||||
|
||||
const reload = async () => {
|
||||
if (!channel) return;
|
||||
@@ -115,7 +116,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
displayName: "",
|
||||
capability: "text",
|
||||
protocol: availableProtocols.find((item) => item.capability === "text")?.value,
|
||||
priceTiers: [defaultPriceTier()],
|
||||
priceTiers: [defaultPriceTier()],
|
||||
enabled: true,
|
||||
capabilityConfig: defaultModelCapabilityConfig(availableProtocols.find((item) => item.capability === "text")?.value, ""),
|
||||
});
|
||||
@@ -130,11 +131,12 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
displayName: item.displayName,
|
||||
capability: item.capability || undefined,
|
||||
protocol: item.protocol,
|
||||
priceTiers: item.priceTiers?.length ? item.priceTiers.map(priceTierToForm) : [legacyPriceTierToForm(item)],
|
||||
priceTiers: item.priceTiers?.length ? item.priceTiers.map(priceTierToForm) : [legacyPriceTierToForm(item)],
|
||||
enabled: item.enabled,
|
||||
capabilityConfig: item.capability === "text" || item.capability === "image" || item.capability === "video"
|
||||
? normalizeModelCapabilityConfig(item.capabilityConfig || defaultModelCapabilityConfig(item.protocol, item.providerModelKey || item.modelKey))
|
||||
: undefined,
|
||||
capabilityConfig:
|
||||
item.capability === "text" || item.capability === "image" || item.capability === "video"
|
||||
? normalizeModelCapabilityConfig(item.capabilityConfig || defaultModelCapabilityConfig(item.protocol, item.providerModelKey || item.modelKey))
|
||||
: undefined,
|
||||
});
|
||||
setEditorOpen(true);
|
||||
};
|
||||
@@ -142,9 +144,8 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
const save = async () => {
|
||||
const values = await form.validateFields();
|
||||
const upstreamModel = values.providerModelKey?.trim() || values.modelKey.trim();
|
||||
const capabilityConfig = values.capability === "text" || values.capability === "image" || values.capability === "video"
|
||||
? normalizeModelCapabilityConfig(values.capabilityConfig || defaultModelCapabilityConfig(values.protocol, upstreamModel))
|
||||
: undefined;
|
||||
const capabilityConfig =
|
||||
values.capability === "text" || values.capability === "image" || values.capability === "video" ? normalizeModelCapabilityConfig(values.capabilityConfig || defaultModelCapabilityConfig(values.protocol, upstreamModel)) : undefined;
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
@@ -153,19 +154,19 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
displayName: values.displayName?.trim() || values.modelKey.trim(),
|
||||
capability: values.capability,
|
||||
protocol: values.protocol,
|
||||
priceTiers: values.priceTiers.map((tier) => ({
|
||||
selector: skuSelectorFromForm(values.capability, tier),
|
||||
resolution: values.capability === "video" ? (tier.resolution || "*") : "*",
|
||||
videoSeconds: values.capability === "video" ? Number(tier.videoSeconds || 0) : 0,
|
||||
providerModelKey: tier.providerModelKey?.trim() || upstreamModel,
|
||||
billingMode: tier.billingMode,
|
||||
unitPriceMicrocredits: Math.round((tier.unitPrice || 0) * 1_000_000),
|
||||
inputTokenPriceMicrocredits: Math.round((tier.inputTokenPrice || 0) * 1_000_000),
|
||||
outputTokenPriceMicrocredits: Math.round((tier.outputTokenPrice || 0) * 1_000_000),
|
||||
cachedTokenPriceMicrocredits: Math.round((tier.cachedTokenPrice || 0) * 1_000_000),
|
||||
priceConfigured: tier.priceConfigured !== false,
|
||||
enabled: tier.enabled !== false,
|
||||
})),
|
||||
priceTiers: values.priceTiers.map((tier) => ({
|
||||
selector: skuSelectorFromForm(values.capability, tier),
|
||||
resolution: values.capability === "video" ? tier.resolution || "*" : "*",
|
||||
videoSeconds: values.capability === "video" ? Number(tier.videoSeconds || 0) : 0,
|
||||
providerModelKey: tier.providerModelKey?.trim() || upstreamModel,
|
||||
billingMode: tier.billingMode,
|
||||
unitPriceMicrocredits: Math.round((tier.unitPrice || 0) * 1_000_000),
|
||||
inputTokenPriceMicrocredits: Math.round((tier.inputTokenPrice || 0) * 1_000_000),
|
||||
outputTokenPriceMicrocredits: Math.round((tier.outputTokenPrice || 0) * 1_000_000),
|
||||
cachedTokenPriceMicrocredits: Math.round((tier.cachedTokenPrice || 0) * 1_000_000),
|
||||
priceConfigured: tier.priceConfigured !== false,
|
||||
enabled: tier.enabled !== false,
|
||||
})),
|
||||
enabled: values.enabled !== false,
|
||||
capabilityConfig,
|
||||
};
|
||||
@@ -186,9 +187,8 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
const testModel = async () => {
|
||||
const values = await form.validateFields(["modelKey", "providerModelKey", "capability", "protocol", ...(modelCapability === "text" || modelCapability === "image" || modelCapability === "video" ? ["capabilityConfig"] : [])]);
|
||||
const upstreamModel = values.providerModelKey?.trim() || values.modelKey.trim();
|
||||
const capabilityConfig = values.capability === "text" || values.capability === "image" || values.capability === "video"
|
||||
? normalizeModelCapabilityConfig(values.capabilityConfig || defaultModelCapabilityConfig(values.protocol, upstreamModel))
|
||||
: undefined;
|
||||
const capabilityConfig =
|
||||
values.capability === "text" || values.capability === "image" || values.capability === "video" ? normalizeModelCapabilityConfig(values.capabilityConfig || defaultModelCapabilityConfig(values.protocol, upstreamModel)) : undefined;
|
||||
setTesting(true);
|
||||
try {
|
||||
const result = await testAdminChannelModel(channel.id, {
|
||||
@@ -228,17 +228,17 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
form.setFieldValue("protocol", nextProtocol);
|
||||
form.setFieldValue("capabilityConfig", changed.capability === "text" || changed.capability === "image" || changed.capability === "video" ? defaultModelCapabilityConfig(nextProtocol, form.getFieldValue("modelKey")) : undefined);
|
||||
}
|
||||
const nextTiers = (form.getFieldValue("priceTiers") || []).map((tier: PriceTierFormValues) => ({
|
||||
...tier,
|
||||
operation: tier.operation || "*",
|
||||
quality: changed.capability === "image" ? tier.quality || "*" : "*",
|
||||
size: changed.capability === "image" ? tier.size || "*" : "*",
|
||||
resolution: changed.capability === "video" ? tier.resolution || "*" : "*",
|
||||
videoSeconds: changed.capability === "video" ? tier.videoSeconds || 0 : 0,
|
||||
imageCount: changed.capability === "video" ? tier.imageCount || 0 : 0,
|
||||
billingMode: tier.billingMode === "per_second" && changed.capability !== "video" ? "fixed_request" : tier.billingMode,
|
||||
}));
|
||||
form.setFieldValue("priceTiers", nextTiers);
|
||||
const nextTiers = (form.getFieldValue("priceTiers") || []).map((tier: PriceTierFormValues) => ({
|
||||
...tier,
|
||||
operation: tier.operation || "*",
|
||||
quality: changed.capability === "image" ? tier.quality || "*" : "*",
|
||||
size: changed.capability === "image" ? tier.size || "*" : "*",
|
||||
resolution: changed.capability === "video" ? tier.resolution || "*" : "*",
|
||||
videoSeconds: changed.capability === "video" ? tier.videoSeconds || 0 : 0,
|
||||
imageCount: changed.capability === "video" ? tier.imageCount || 0 : 0,
|
||||
billingMode: tier.billingMode === "per_second" && changed.capability !== "video" ? "fixed_request" : tier.billingMode,
|
||||
}));
|
||||
form.setFieldValue("priceTiers", nextTiers);
|
||||
};
|
||||
|
||||
const columns: ColumnsType<ChannelModel> = [
|
||||
@@ -272,7 +272,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
<AdminStatusBadge label="待配置" tone="warning" />
|
||||
),
|
||||
},
|
||||
{ title: "规格价格", width: 280, render: (_, item) => (item.priceConfigured ? billingSummary(item) : <AdminStatusBadge label="未配置价格" tone="warning" />) },
|
||||
{ title: "规格价格", width: 280, render: (_, item) => (item.priceConfigured ? billingSummary(item) : <AdminStatusBadge label="未配置价格" tone="warning" />) },
|
||||
{ title: "版本", dataIndex: "priceVersion", width: 75, render: (value) => `v${value}` },
|
||||
{ title: "状态", dataIndex: "enabled", width: 85, render: (enabled) => <AdminStatusBadge label={enabled ? "启用" : "停用"} tone={enabled ? "success" : "neutral"} /> },
|
||||
{
|
||||
@@ -317,18 +317,50 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
}
|
||||
>
|
||||
<AdminDataTable
|
||||
toolbar={<Input
|
||||
allowClear
|
||||
className="app-list-search"
|
||||
prefix={<Search className="size-4 text-foreground/40" />}
|
||||
value={keyword}
|
||||
placeholder="搜索模型标识或显示名称"
|
||||
onChange={(event) => {
|
||||
setKeyword(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>}
|
||||
toolbarActiveFilters={<>{keyword ? <AdminFilterChip label={`搜索:${keyword}`} onRemove={() => { setKeyword(""); setPage(1); }} /> : null}{capability !== "all" ? <AdminFilterChip label={`能力:${capability}`} onRemove={() => { setCapability("all"); setPage(1); }} /> : null}{status !== "all" ? <AdminFilterChip label={`状态:${status === "enabled" ? "已启用" : "已停用"}`} onRemove={() => { setStatus("all"); setPage(1); }} /> : null}</>}
|
||||
toolbar={
|
||||
<Input
|
||||
allowClear
|
||||
className="app-list-search"
|
||||
prefix={<Search className="size-4 text-foreground/40" />}
|
||||
value={keyword}
|
||||
placeholder="搜索模型标识或显示名称"
|
||||
onChange={(event) => {
|
||||
setKeyword(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
toolbarActiveFilters={
|
||||
<>
|
||||
{keyword ? (
|
||||
<AdminFilterChip
|
||||
label={`搜索:${keyword}`}
|
||||
onRemove={() => {
|
||||
setKeyword("");
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{capability !== "all" ? (
|
||||
<AdminFilterChip
|
||||
label={`能力:${capability}`}
|
||||
onRemove={() => {
|
||||
setCapability("all");
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{status !== "all" ? (
|
||||
<AdminFilterChip
|
||||
label={`状态:${status === "enabled" ? "已启用" : "已停用"}`}
|
||||
onRemove={() => {
|
||||
setStatus("all");
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
toolbarActive={Boolean(keyword || capability !== "all" || status !== "all")}
|
||||
toolbarFilters={
|
||||
<>
|
||||
@@ -339,7 +371,13 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
setCapability(value);
|
||||
setPage(1);
|
||||
}}
|
||||
options={[{ label: "全部能力", value: "all" }, { label: "文本", value: "text" }, { label: "图片", value: "image" }, { label: "视频", value: "video" }, { label: "音频", value: "audio" }]}
|
||||
options={[
|
||||
{ label: "全部能力", value: "all" },
|
||||
{ label: "文本", value: "text" },
|
||||
{ label: "图片", value: "image" },
|
||||
{ label: "视频", value: "video" },
|
||||
{ label: "音频", value: "audio" },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
className="w-32"
|
||||
@@ -348,7 +386,11 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
setStatus(value);
|
||||
setPage(1);
|
||||
}}
|
||||
options={[{ label: "全部状态", value: "all" }, { label: "已启用", value: "enabled" }, { label: "已停用", value: "disabled" }]}
|
||||
options={[
|
||||
{ label: "全部状态", value: "all" },
|
||||
{ label: "已启用", value: "enabled" },
|
||||
{ label: "已停用", value: "disabled" },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
@@ -368,20 +410,45 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
pagination: false,
|
||||
scroll: { x: 990 },
|
||||
}}
|
||||
footer={<PaginationBar alwaysShow current={page} pageSize={pageSize} total={filteredItems.length} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} />}
|
||||
footer={
|
||||
<PaginationBar
|
||||
alwaysShow
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={filteredItems.length}
|
||||
onChange={(nextPage, nextPageSize) => {
|
||||
setPage(nextPageSize !== pageSize ? 1 : nextPage);
|
||||
setPageSize(nextPageSize);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Drawer
|
||||
title={editing ? `编辑模型 / ${editing.displayName || editing.modelKey}` : "新增模型"}
|
||||
open={editorOpen}
|
||||
size="min(1080px, 100vw)"
|
||||
onClose={() => !saving && setEditorOpen(false)}
|
||||
rootClassName="admin-drawer"
|
||||
rootClassName="admin-drawer admin-model-editor-drawer"
|
||||
footer={
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Button icon={<FlaskConical className="size-4" />} loading={testing} disabled={saving} onClick={() => void testModel()}>测试模型</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button disabled={saving || testing} onClick={() => setEditorOpen(false)}>取消</Button>
|
||||
<Button type="primary" loading={saving} disabled={testing} onClick={() => void save()}>{editing ? "保存修改" : "添加模型"}</Button>
|
||||
<div className="admin-model-editor-footer-actions flex items-center justify-between gap-3">
|
||||
<Button icon={<FlaskConical className="size-4" />} loading={testing} disabled={saving} onClick={() => void testModel()}>
|
||||
测试模型
|
||||
</Button>
|
||||
<div className="admin-model-editor-footer-primary flex items-center gap-2">
|
||||
<div className="admin-model-editor-footer-status">
|
||||
<span className={modelEnabled ? "is-enabled" : ""} />
|
||||
<div>
|
||||
<strong>{modelEnabled ? "模型启用" : "模型停用"}</strong>
|
||||
<small>保存后生效</small>
|
||||
</div>
|
||||
<Switch aria-label="启用模型" checked={modelEnabled} disabled={saving || testing} onChange={(checked) => form.setFieldValue("enabled", checked)} />
|
||||
</div>
|
||||
<Button disabled={saving || testing} onClick={() => setEditorOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" loading={saving} disabled={testing} onClick={() => void save()}>
|
||||
{editing ? "保存修改" : "添加模型"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -393,12 +460,16 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<Form form={form} layout="vertical" requiredMark={false} onValuesChange={handleFormValuesChange}>
|
||||
<section className="admin-form-section">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-sm font-semibold">模型身份</h2>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Form className="admin-model-editor-form" form={form} layout="vertical" requiredMark={false} onValuesChange={handleFormValuesChange}>
|
||||
<Form.Item name="capabilityConfig" noStyle>
|
||||
<CapabilityConfigField />
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" noStyle>
|
||||
<EnabledConfigField />
|
||||
</Form.Item>
|
||||
<section className="admin-form-section admin-model-editor-section">
|
||||
<SectionHeading title="模型身份" description="区分产品侧展示标识与上游实际调用 ID。" />
|
||||
<div className="admin-model-editor-section-content admin-model-identity-grid">
|
||||
<Form.Item name="modelKey" label="产品模型标识" rules={[{ required: true, message: "请输入产品模型标识" }]}>
|
||||
<Input
|
||||
prefix={
|
||||
@@ -418,58 +489,93 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-form-section">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-sm font-semibold">能力与协议</h2>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Form.Item className="mb-0" name="capability" label="模型能力" rules={[{ required: true }]}>
|
||||
<section className="admin-form-section admin-model-editor-section">
|
||||
<SectionHeading title="模型能力" description="决定模型在前台可用于哪类生成任务。" />
|
||||
<div className="admin-model-editor-section-content">
|
||||
<Form.Item className="mb-0" name="capability" rules={[{ required: true }]}>
|
||||
<CapabilityCardPicker density="compact" />
|
||||
</Form.Item>
|
||||
{availableProtocols.length ? <Form.Item className="mb-0" name="protocol" label="请求协议" rules={[{ required: true, message: "请选择模型请求协议" }]}>
|
||||
<ProtocolCardPicker capability={modelCapability} density="compact" protocols={availableProtocols} />
|
||||
</Form.Item> : null}
|
||||
</div>
|
||||
{modelCapability === "text" || modelCapability === "image" || modelCapability === "video" ? (
|
||||
<Form.Item name="capabilityConfig" rules={[{ required: true, message: `请配置${capabilityLabel(modelCapability)}能力参数` }]}>
|
||||
<ModelCapabilityEditor capability={modelCapability} model={providerModelKey || modelKey} protocol={form.getFieldValue("protocol")} />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="admin-form-section">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-sm font-semibold">规格价格档</h2>
|
||||
</div>
|
||||
<Form.List name="priceTiers" rules={[{ validator: async (_, value) => { if (!value?.length) throw new Error("请至少配置一个价格档"); } }]}>
|
||||
{(fields, { add, remove }, { errors }) => (
|
||||
<div className="space-y-2">
|
||||
{fields.map((field, index) => (
|
||||
<PriceTierFields
|
||||
key={field.key}
|
||||
index={field.name}
|
||||
ordinal={index + 1}
|
||||
form={form}
|
||||
capability={modelCapability}
|
||||
protocol={modelProtocol}
|
||||
capabilityConfig={capabilityConfig}
|
||||
onRemove={() => remove(field.name)}
|
||||
/>
|
||||
))}
|
||||
<Button icon={<Plus className="size-4" />} onClick={() => add(defaultPriceTier())}>新增价格档</Button>
|
||||
<Form.ErrorList errors={errors} />
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
</section>
|
||||
{availableProtocols.length ? (
|
||||
<section className="admin-form-section admin-model-editor-section">
|
||||
<SectionHeading title="请求协议" description="选择发送到上游的接口格式与响应处理方式。" />
|
||||
<div className="admin-model-editor-section-content">
|
||||
<Form.Item className="mb-0" name="protocol" rules={[{ required: true, message: "请选择模型请求协议" }]}>
|
||||
<ProtocolCardPicker capability={modelCapability} density="compact" protocols={availableProtocols} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="admin-form-section">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-sm font-semibold">启用状态</h2>
|
||||
{modelCapability === "text" || modelCapability === "image" || modelCapability === "video" ? (
|
||||
<section className="admin-form-section admin-model-editor-section admin-model-editor-references">
|
||||
<SectionHeading title="引用与限制" description="按媒体类型纵向配置数量、大小、时长及通用约束。" />
|
||||
<div className="admin-model-editor-section-content">
|
||||
<ModelCapabilityEditor
|
||||
capability={modelCapability}
|
||||
model={providerModelKey || modelKey}
|
||||
protocol={form.getFieldValue("protocol")}
|
||||
section="references"
|
||||
value={capabilityConfig}
|
||||
onChange={(next) => form.setFieldValue("capabilityConfig", next)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{modelCapability === "image" || modelCapability === "video" ? (
|
||||
<section className="admin-form-section admin-model-editor-section admin-model-editor-parameters">
|
||||
<SectionHeading title="协议参数" description="配置可发送参数、支持值与默认值;仅影响当前模型。" />
|
||||
<div className="admin-model-editor-section-content">
|
||||
<ModelCapabilityEditor
|
||||
capability={modelCapability}
|
||||
model={providerModelKey || modelKey}
|
||||
protocol={form.getFieldValue("protocol")}
|
||||
section="protocol"
|
||||
value={capabilityConfig}
|
||||
onChange={(next) => form.setFieldValue("capabilityConfig", next)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="admin-form-section admin-model-editor-section">
|
||||
<SectionHeading title="规格价格档" description="按请求条件匹配上游模型与计费规则。" />
|
||||
<div className="admin-model-editor-section-content">
|
||||
<Form.List
|
||||
name="priceTiers"
|
||||
rules={[
|
||||
{
|
||||
validator: async (_, value) => {
|
||||
if (!value?.length) throw new Error("请至少配置一个价格档");
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
{(fields, { add, remove }, { errors }) => (
|
||||
<div className="space-y-3">
|
||||
{fields.map((field, index) => (
|
||||
<PriceTierFields
|
||||
key={field.key}
|
||||
index={field.name}
|
||||
ordinal={index + 1}
|
||||
form={form}
|
||||
capability={modelCapability}
|
||||
protocol={modelProtocol}
|
||||
capabilityConfig={capabilityConfig}
|
||||
onRemove={() => remove(field.name)}
|
||||
/>
|
||||
))}
|
||||
<Button className="admin-model-editor-add-tier" type="dashed" block icon={<Plus className="size-4" />} onClick={() => add(defaultPriceTier())}>
|
||||
新增价格档
|
||||
</Button>
|
||||
<Form.ErrorList errors={errors} />
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
</div>
|
||||
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</section>
|
||||
</Form>
|
||||
</Drawer>
|
||||
@@ -477,6 +583,23 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeading({ title, description }: { title: string; description: string }) {
|
||||
return (
|
||||
<header className="admin-model-editor-section-heading">
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function CapabilityConfigField(_: { value?: ModelCapabilityConfig; onChange?: (value: ModelCapabilityConfig) => void }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function EnabledConfigField(_: { value?: boolean; onChange?: (value: boolean) => void }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function capabilityLabel(value: ChannelModel["capability"]) {
|
||||
return { text: "文本", image: "图片", video: "视频", audio: "音频", "": "待配置" }[value];
|
||||
}
|
||||
@@ -502,81 +625,125 @@ function PriceTierFields({
|
||||
const video = capabilityConfig?.video;
|
||||
const resolutionOptions = video?.resolutions || [];
|
||||
const durationOptions = video?.duration.selection === "enum" ? video.duration.values || [] : [];
|
||||
const tokenEnabled = Boolean(capability && protocol && modelProtocolSupportsTokenBilling(capability, protocol));
|
||||
const isVideo = capability === "video";
|
||||
const isImage = capability === "image";
|
||||
const selectorColumnClass = isVideo || isImage ? "lg:col-span-3" : "lg:col-span-6";
|
||||
const controlsColumnClass = billingMode === "token" && !isVideo ? "lg:col-span-3" : "lg:col-span-5";
|
||||
const tokenEnabled = Boolean(capability && protocol && modelProtocolSupportsTokenBilling(capability, protocol));
|
||||
const isVideo = capability === "video";
|
||||
const isImage = capability === "image";
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-muted/10 p-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-medium">价格档 {ordinal}</div>
|
||||
<Button type="text" danger aria-label={`删除价格档 ${ordinal}`} title="删除价格档" icon={<X className="size-4" />} onClick={onRemove} />
|
||||
<div className="admin-price-tier-card">
|
||||
<div className="admin-price-tier-card-header">
|
||||
<div>
|
||||
<div className="text-sm font-medium">价格档 {ordinal}</div>
|
||||
<div className="mt-0.5 text-[var(--fs-tiny)] text-foreground/45">请求按以下条件命中后,使用本档模型与价格。</div>
|
||||
</div>
|
||||
<Button type="text" danger aria-label={`删除价格档 ${ordinal}`} icon={<Trash2 className="size-3.5" />} onClick={onRemove}>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-3 lg:grid-cols-12">
|
||||
<Form.Item className={`mb-0 ${selectorColumnClass}`} name={[index, "operation"]} label="生成方式" rules={[{ required: true, message: "请选择生成方式" }]}>
|
||||
<Select options={operationOptions(capability)} />
|
||||
</Form.Item>
|
||||
{isVideo ? (
|
||||
<Form.Item className="mb-0 lg:col-span-3" name={[index, "resolution"]} label="分辨率" rules={[{ required: true, message: "请选择分辨率" }]}>
|
||||
<Select options={[{ label: "任意分辨率", value: "*" }, ...resolutionOptions.map((value) => ({ label: value.toUpperCase(), value }))]} />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{isVideo ? (
|
||||
<Form.Item className="mb-0 lg:col-span-3" name={[index, "videoSeconds"]} label="时长" rules={[{ required: true, message: "请输入时长" }]}>
|
||||
{durationOptions.length ? <Select options={[{ label: "任意时长", value: 0 }, ...durationOptions.map((value) => ({ label: `${value} 秒`, value }))]} /> : <InputNumber className="w-full" min={0} precision={0} />}
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{isVideo ? (
|
||||
<Form.Item className="mb-0 lg:col-span-3" name={[index, "imageCount"]} label="参考图数量" rules={[{ required: true, message: "请输入参考图数量" }]}>
|
||||
<InputNumber className="w-full" min={0} max={9} precision={0} placeholder="0 表示任意数量" />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{isImage ? (
|
||||
<Form.Item className="mb-0 lg:col-span-3" name={[index, "quality"]} label="质量/分辨率" rules={[{ required: true, message: "请选择质量或分辨率" }]}>
|
||||
<Select options={[{ label: "任意质量", value: "*" }, { label: "1K", value: "1k" }, { label: "2K", value: "2k" }, { label: "4K", value: "4k" }]} />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{isImage ? (
|
||||
<Form.Item className="mb-0 lg:col-span-3" name={[index, "size"]} label="画幅/尺寸">
|
||||
<Input placeholder="任意,或 1:1、16:9、1024x1024" />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
<Form.Item className={`mb-0 ${selectorColumnClass}`} name={[index, "providerModelKey"]} label="上游模型 ID">
|
||||
<Input placeholder="留空则使用模型默认上游 ID" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="grid items-end gap-3 lg:grid-cols-12">
|
||||
<Form.Item className="mb-0 lg:col-span-4" name={[index, "billingMode"]} label="计费方式" rules={[{ required: true }]}>
|
||||
<Segmented
|
||||
className="w-full"
|
||||
options={[
|
||||
{ label: "按次", value: "fixed_request" },
|
||||
{ label: "按秒", value: "per_second", disabled: !isVideo },
|
||||
{ label: "Token", value: "token", disabled: !tokenEnabled },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
{billingMode === "token" ? (
|
||||
isVideo ? (
|
||||
<Form.Item className="mb-0 lg:col-span-3" name={[index, "outputTokenPrice"]} label="视频 / 百万 Token" rules={[{ required: true, message: "请输入视频 Token 价格" }]}>
|
||||
<InputNumber className="w-full" min={0.000001} max={1_000_000} precision={6} step={0.1} />
|
||||
<div className="admin-price-tier-card-body">
|
||||
<div className="admin-price-tier-block admin-price-tier-match-block">
|
||||
<div className="admin-price-tier-block-title">
|
||||
<span>01</span> 匹配条件
|
||||
</div>
|
||||
<div className="admin-price-tier-match-grid">
|
||||
<Form.Item className="mb-0" name={[index, "operation"]} label="生成方式" rules={[{ required: true, message: "请选择生成方式" }]}>
|
||||
<Select options={operationOptions(capability)} />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-3 lg:col-span-5">
|
||||
<Form.Item className="mb-0" name={[index, "inputTokenPrice"]} label="输入 / 百万 Token" rules={[{ required: true, message: "请输入输入价格" }]}><InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} /></Form.Item>
|
||||
<Form.Item className="mb-0" name={[index, "outputTokenPrice"]} label="输出 / 百万 Token" rules={[{ required: true, message: "请输入输出价格" }]}><InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} /></Form.Item>
|
||||
<Form.Item className="mb-0" name={[index, "cachedTokenPrice"]} label="缓存 / 百万 Token" rules={[{ required: true, message: "请输入缓存价格" }]}><InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} /></Form.Item>
|
||||
{isVideo ? (
|
||||
<Form.Item className="mb-0" name={[index, "resolution"]} label="分辨率" rules={[{ required: true, message: "请选择分辨率" }]}>
|
||||
<Select options={[{ label: "任意分辨率", value: "*" }, ...resolutionOptions.map((value) => ({ label: value.toUpperCase(), value }))]} />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{isVideo ? (
|
||||
<Form.Item className="mb-0" name={[index, "videoSeconds"]} label="时长" rules={[{ required: true, message: "请输入时长" }]}>
|
||||
{durationOptions.length ? <Select options={[{ label: "任意时长", value: 0 }, ...durationOptions.map((value) => ({ label: `${value} 秒`, value }))]} /> : <InputNumber className="w-full" min={0} precision={0} />}
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{isVideo ? (
|
||||
<Form.Item className="mb-0" name={[index, "imageCount"]} label="参考图数量" rules={[{ required: true, message: "请输入参考图数量" }]}>
|
||||
<InputNumber className="w-full" min={0} max={9} precision={0} placeholder="0 表示任意数量" />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{isImage ? (
|
||||
<Form.Item className="mb-0" name={[index, "quality"]} label="质量/分辨率" rules={[{ required: true, message: "请选择质量或分辨率" }]}>
|
||||
<Select
|
||||
options={[
|
||||
{ label: "任意质量", value: "*" },
|
||||
{ label: "1K", value: "1k" },
|
||||
{ label: "2K", value: "2k" },
|
||||
{ label: "4K", value: "4k" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{isImage ? (
|
||||
<Form.Item className="mb-0" name={[index, "size"]} label="画幅/尺寸">
|
||||
<Input placeholder="任意,或 1:1、16:9、1024x1024" />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
<Form.Item className="admin-price-tier-upstream mb-0" name={[index, "providerModelKey"]} label="命中后使用的上游模型 ID">
|
||||
<Input placeholder="留空则使用模型默认上游 ID" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-price-tier-block admin-price-tier-billing-block">
|
||||
<div className="admin-price-tier-block-title">
|
||||
<span>02</span> 计费与状态
|
||||
</div>
|
||||
<div className="admin-price-tier-billing-grid">
|
||||
<Form.Item className="admin-price-tier-billing-mode mb-0" name={[index, "billingMode"]} label="计费方式" rules={[{ required: true }]}>
|
||||
<Segmented
|
||||
className="w-full"
|
||||
options={[
|
||||
{ label: "按次", value: "fixed_request" },
|
||||
{ label: "按秒", value: "per_second", disabled: !isVideo },
|
||||
{ label: "Token", value: "token", disabled: !tokenEnabled },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
{billingMode === "token" ? (
|
||||
isVideo ? (
|
||||
<Form.Item className="admin-price-tier-unit-price mb-0" name={[index, "outputTokenPrice"]} label="视频 / 百万 Token" rules={[{ required: true, message: "请输入视频 Token 价格" }]}>
|
||||
<InputNumber className="w-full" min={0.000001} max={1_000_000} precision={6} step={0.1} />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<div className="admin-price-tier-token-grid">
|
||||
<Form.Item className="mb-0" name={[index, "inputTokenPrice"]} label="输入 / 百万 Token" rules={[{ required: true, message: "请输入输入价格" }]}>
|
||||
<InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} />
|
||||
</Form.Item>
|
||||
<Form.Item className="mb-0" name={[index, "outputTokenPrice"]} label="输出 / 百万 Token" rules={[{ required: true, message: "请输入输出价格" }]}>
|
||||
<InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} />
|
||||
</Form.Item>
|
||||
<Form.Item className="mb-0" name={[index, "cachedTokenPrice"]} label="缓存 / 百万 Token" rules={[{ required: true, message: "请输入缓存价格" }]}>
|
||||
<InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<Form.Item className="admin-price-tier-unit-price mb-0" name={[index, "unitPrice"]} label={billingMode === "per_second" ? "每秒消耗积分" : "每次消耗积分"} rules={[{ required: true, message: "请输入积分价格" }]}>
|
||||
<InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<div className="admin-price-tier-controls flex items-center gap-6">
|
||||
<div className="admin-price-tier-toggle">
|
||||
<div>
|
||||
<strong>价格已配置</strong>
|
||||
<span>允许按此价格计费</span>
|
||||
</div>
|
||||
<Form.Item name={[index, "priceConfigured"]} noStyle valuePropName="checked">
|
||||
<Switch aria-label="价格已配置" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="admin-price-tier-toggle">
|
||||
<div>
|
||||
<strong>启用价格档</strong>
|
||||
<span>允许参与请求匹配</span>
|
||||
</div>
|
||||
<Form.Item name={[index, "enabled"]} noStyle valuePropName="checked">
|
||||
<Switch aria-label="启用价格档" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<Form.Item className="mb-0 lg:col-span-3" name={[index, "unitPrice"]} label={billingMode === "per_second" ? "每秒消耗积分" : "每次消耗积分"} rules={[{ required: true, message: "请输入积分价格" }]}>
|
||||
<InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<div className={`flex items-center gap-6 ${controlsColumnClass}`}>
|
||||
<Form.Item name={[index, "priceConfigured"]} label="价格已配置" valuePropName="checked" className="mb-0"><Switch /></Form.Item>
|
||||
<Form.Item name={[index, "enabled"]} label="启用此价格档" valuePropName="checked" className="mb-0"><Switch /></Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -584,32 +751,73 @@ function PriceTierFields({
|
||||
}
|
||||
|
||||
function defaultPriceTier(): PriceTierFormValues {
|
||||
return { operation: "*", quality: "*", size: "*", resolution: "*", videoSeconds: 0, imageCount: 0, providerModelKey: "", billingMode: "fixed_request", unitPrice: 0, inputTokenPrice: 0, outputTokenPrice: 0, cachedTokenPrice: 0, priceConfigured: true, enabled: true };
|
||||
return {
|
||||
operation: "*",
|
||||
quality: "*",
|
||||
size: "*",
|
||||
resolution: "*",
|
||||
videoSeconds: 0,
|
||||
imageCount: 0,
|
||||
providerModelKey: "",
|
||||
billingMode: "fixed_request",
|
||||
unitPrice: 0,
|
||||
inputTokenPrice: 0,
|
||||
outputTokenPrice: 0,
|
||||
cachedTokenPrice: 0,
|
||||
priceConfigured: true,
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
function priceTierToForm(tier: ChannelModelPriceTier): PriceTierFormValues {
|
||||
return {
|
||||
operation: tier.selector?.operation || "*", quality: tier.selector?.quality || "*", size: tier.selector?.size || "*",
|
||||
resolution: tier.resolution || "*", videoSeconds: tier.videoSeconds || 0, imageCount: Number(tier.selector?.imageCount || 0), providerModelKey: tier.providerModelKey || "", billingMode: tier.billingMode,
|
||||
unitPrice: tier.unitPriceMicrocredits / 1_000_000, inputTokenPrice: tier.inputTokenPriceMicrocredits / 1_000_000,
|
||||
outputTokenPrice: tier.outputTokenPriceMicrocredits / 1_000_000, cachedTokenPrice: tier.cachedTokenPriceMicrocredits / 1_000_000,
|
||||
priceConfigured: tier.priceConfigured, enabled: tier.enabled,
|
||||
operation: tier.selector?.operation || "*",
|
||||
quality: tier.selector?.quality || "*",
|
||||
size: tier.selector?.size || "*",
|
||||
resolution: tier.resolution || "*",
|
||||
videoSeconds: tier.videoSeconds || 0,
|
||||
imageCount: Number(tier.selector?.imageCount || 0),
|
||||
providerModelKey: tier.providerModelKey || "",
|
||||
billingMode: tier.billingMode,
|
||||
unitPrice: tier.unitPriceMicrocredits / 1_000_000,
|
||||
inputTokenPrice: tier.inputTokenPriceMicrocredits / 1_000_000,
|
||||
outputTokenPrice: tier.outputTokenPriceMicrocredits / 1_000_000,
|
||||
cachedTokenPrice: tier.cachedTokenPriceMicrocredits / 1_000_000,
|
||||
priceConfigured: tier.priceConfigured,
|
||||
enabled: tier.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
function legacyPriceTierToForm(item: ChannelModel): PriceTierFormValues {
|
||||
return {
|
||||
operation: "*", quality: "*", size: "*", resolution: "*", videoSeconds: 0, imageCount: 0, providerModelKey: item.providerModelKey || "", billingMode: item.billingMode,
|
||||
unitPrice: item.unitPriceMicrocredits / 1_000_000, inputTokenPrice: item.inputTokenPriceMicrocredits / 1_000_000,
|
||||
outputTokenPrice: item.outputTokenPriceMicrocredits / 1_000_000, cachedTokenPrice: item.cachedTokenPriceMicrocredits / 1_000_000,
|
||||
priceConfigured: item.priceConfigured, enabled: item.enabled,
|
||||
operation: "*",
|
||||
quality: "*",
|
||||
size: "*",
|
||||
resolution: "*",
|
||||
videoSeconds: 0,
|
||||
imageCount: 0,
|
||||
providerModelKey: item.providerModelKey || "",
|
||||
billingMode: item.billingMode,
|
||||
unitPrice: item.unitPriceMicrocredits / 1_000_000,
|
||||
inputTokenPrice: item.inputTokenPriceMicrocredits / 1_000_000,
|
||||
outputTokenPrice: item.outputTokenPriceMicrocredits / 1_000_000,
|
||||
cachedTokenPrice: item.cachedTokenPriceMicrocredits / 1_000_000,
|
||||
priceConfigured: item.priceConfigured,
|
||||
enabled: item.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
function billingSummary(item: ChannelModel) {
|
||||
const tiers = item.priceTiers?.filter((tier) => tier.enabled && tier.priceConfigured) || [];
|
||||
if (!tiers.length) return <AdminStatusBadge label="未配置价格" tone="warning" />;
|
||||
return <div className="space-y-1 text-xs leading-5">{tiers.slice(0, 3).map((tier) => <div key={tier.id}>{priceTierLabel(tier)}</div>)}{tiers.length > 3 ? <div className="text-foreground/45">另有 {tiers.length - 3} 个规格价格档</div> : null}</div>;
|
||||
const tiers = item.priceTiers?.filter((tier) => tier.enabled && tier.priceConfigured) || [];
|
||||
if (!tiers.length) return <AdminStatusBadge label="未配置价格" tone="warning" />;
|
||||
return (
|
||||
<div className="space-y-1 text-xs leading-5">
|
||||
{tiers.slice(0, 3).map((tier) => (
|
||||
<div key={tier.id}>{priceTierLabel(tier)}</div>
|
||||
))}
|
||||
{tiers.length > 3 ? <div className="text-foreground/45">另有 {tiers.length - 3} 个规格价格档</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function priceTierLabel(tier: ChannelModelPriceTier) {
|
||||
@@ -619,8 +827,8 @@ function priceTierLabel(tier: ChannelModelPriceTier) {
|
||||
selector.quality && selector.quality !== "*" ? selector.quality.toUpperCase() : "",
|
||||
selector.size && selector.size !== "*" ? selector.size : "",
|
||||
tier.resolution === "*" ? "" : tier.resolution.toUpperCase(),
|
||||
tier.videoSeconds ? `${tier.videoSeconds} 秒` : "",
|
||||
selector.imageCount && selector.imageCount !== "*" ? `${selector.imageCount} 张参考图` : "",
|
||||
tier.videoSeconds ? `${tier.videoSeconds} 秒` : "",
|
||||
selector.imageCount && selector.imageCount !== "*" ? `${selector.imageCount} 张参考图` : "",
|
||||
].filter(Boolean);
|
||||
const spec = specParts.length ? specParts.join(" / ") : "默认规格";
|
||||
if (tier.billingMode === "token") return `${spec} · ${formatCredits(tier.outputTokenPriceMicrocredits)} / 百万 Token`;
|
||||
@@ -628,30 +836,30 @@ function priceTierLabel(tier: ChannelModelPriceTier) {
|
||||
}
|
||||
|
||||
function operationOptions(capability: EditableCapability | undefined) {
|
||||
const options = [{ label: "任意生成方式", value: "*" }];
|
||||
if (capability === "image") return [...options, { label: "文生图", value: "text_to_image" }, { label: "图生图", value: "image_to_image" }];
|
||||
if (capability === "video") return [...options, { label: "文生视频", value: "text_to_video" }, { label: "图生视频", value: "image_to_video" }, { label: "视频生视频", value: "video_to_video" }];
|
||||
if (capability === "text") return [...options, { label: "文本生成", value: "text_generation" }];
|
||||
return options;
|
||||
const options = [{ label: "任意生成方式", value: "*" }];
|
||||
if (capability === "image") return [...options, { label: "文生图", value: "text_to_image" }, { label: "图生图", value: "image_to_image" }];
|
||||
if (capability === "video") return [...options, { label: "文生视频", value: "text_to_video" }, { label: "图生视频", value: "image_to_video" }, { label: "视频生视频", value: "video_to_video" }];
|
||||
if (capability === "text") return [...options, { label: "文本生成", value: "text_generation" }];
|
||||
return options;
|
||||
}
|
||||
|
||||
function operationLabel(operation: string) {
|
||||
return ({ text_to_image: "文生图", image_to_image: "图生图", text_to_video: "文生视频", image_to_video: "图生视频", video_to_video: "视频生视频", text_generation: "文本生成" } as Record<string, string>)[operation] || operation;
|
||||
return ({ text_to_image: "文生图", image_to_image: "图生图", text_to_video: "文生视频", image_to_video: "图生视频", video_to_video: "视频生视频", text_generation: "文本生成" } as Record<string, string>)[operation] || operation;
|
||||
}
|
||||
|
||||
function skuSelectorFromForm(capability: EditableCapability, tier: PriceTierFormValues) {
|
||||
const selector: Record<string, string> = {};
|
||||
if (tier.operation && tier.operation !== "*") selector.operation = tier.operation;
|
||||
if (capability === "video") {
|
||||
if (tier.resolution && tier.resolution !== "*") selector.vquality = tier.resolution;
|
||||
if (Number(tier.videoSeconds) > 0) selector.videoSeconds = String(Number(tier.videoSeconds));
|
||||
if (Number(tier.imageCount) > 0) selector.imageCount = String(Number(tier.imageCount));
|
||||
}
|
||||
if (capability === "image") {
|
||||
if (tier.quality && tier.quality !== "*") selector.quality = tier.quality;
|
||||
if (tier.size && tier.size !== "*") selector.size = tier.size;
|
||||
}
|
||||
return selector;
|
||||
const selector: Record<string, string> = {};
|
||||
if (tier.operation && tier.operation !== "*") selector.operation = tier.operation;
|
||||
if (capability === "video") {
|
||||
if (tier.resolution && tier.resolution !== "*") selector.vquality = tier.resolution;
|
||||
if (Number(tier.videoSeconds) > 0) selector.videoSeconds = String(Number(tier.videoSeconds));
|
||||
if (Number(tier.imageCount) > 0) selector.imageCount = String(Number(tier.imageCount));
|
||||
}
|
||||
if (capability === "image") {
|
||||
if (tier.quality && tier.quality !== "*") selector.quality = tier.quality;
|
||||
if (tier.size && tier.size !== "*") selector.size = tier.size;
|
||||
}
|
||||
return selector;
|
||||
}
|
||||
|
||||
function formatCredits(value: number) {
|
||||
|
||||
@@ -15,9 +15,7 @@ type AdjustmentFormValues = { userId: string; amount: number; note: string };
|
||||
type ResolutionFormValues = { note: string };
|
||||
type PolicyFormValues = { signupBonus: number; checkinBonus: number; defaultMultiplier: number; modelMultipliers: string };
|
||||
type BillingResolutionAction = "settle" | "refund";
|
||||
type BillingResolutionTarget =
|
||||
| { kind: "single"; order: BillingOrder; action: BillingResolutionAction }
|
||||
| { kind: "batch"; orderIds: string[]; amountMicrocredits: number; action: BillingResolutionAction };
|
||||
type BillingResolutionTarget = { kind: "single"; order: BillingOrder; action: BillingResolutionAction } | { kind: "batch"; orderIds: string[]; amountMicrocredits: number; action: BillingResolutionAction };
|
||||
|
||||
export default function CreditOperationsPanel({ users }: { users: AdminReferenceData["users"] }) {
|
||||
const { message } = App.useApp();
|
||||
@@ -199,12 +197,17 @@ export default function CreditOperationsPanel({ users }: { users: AdminReference
|
||||
{
|
||||
title: "实际结算 / 用量",
|
||||
width: 190,
|
||||
render: (_, order) => order.billingMode === "token" ? (
|
||||
<div className="text-xs leading-5">
|
||||
<div className="font-medium tabular-nums">{order.status === "settled" ? `${formatCredits(order.actualAmountMicrocredits)} 积分` : "待 usage 结算"}</div>
|
||||
<div className="text-foreground/50">输入 {order.inputTokens} · 输出 {order.outputTokens} · 缓存 {order.cachedTokens}</div>
|
||||
</div>
|
||||
) : <span className="tabular-nums">{order.status === "settled" ? formatCredits(order.actualAmountMicrocredits || order.amountMicrocredits) : "--"}</span>,
|
||||
render: (_, order) =>
|
||||
order.billingMode === "token" ? (
|
||||
<div className="text-xs leading-5">
|
||||
<div className="font-medium tabular-nums">{order.status === "settled" ? `${formatCredits(order.actualAmountMicrocredits)} 积分` : "待 usage 结算"}</div>
|
||||
<div className="text-foreground/50">
|
||||
输入 {order.inputTokens} · 输出 {order.outputTokens} · 缓存 {order.cachedTokens}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="tabular-nums">{order.status === "settled" ? formatCredits(order.actualAmountMicrocredits || order.amountMicrocredits) : "--"}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
@@ -226,10 +229,7 @@ export default function CreditOperationsPanel({ users }: { users: AdminReference
|
||||
!canResolveBillingOrder(order) ? (
|
||||
<span className="text-xs text-foreground/40">处理完成</span>
|
||||
) : (
|
||||
<AdminRowActions
|
||||
primary={{ label: "确认扣费", onClick: () => openSingleResolution(order, "settle") }}
|
||||
actions={[{ key: "refund", label: "退回积分", danger: true, onClick: () => openSingleResolution(order, "refund") }]}
|
||||
/>
|
||||
<AdminRowActions primary={{ label: "确认扣费", onClick: () => openSingleResolution(order, "settle") }} actions={[{ key: "refund", label: "退回积分", danger: true, onClick: () => openSingleResolution(order, "refund") }]} />
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -237,41 +237,77 @@ export default function CreditOperationsPanel({ users }: { users: AdminReference
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
<div className="grid shrink-0 gap-4 xl:grid-cols-2">
|
||||
<section className="rounded-lg bg-background p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<section className="admin-operation-card">
|
||||
<div className="admin-operation-card-heading flex items-start gap-3">
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-md bg-muted/40">
|
||||
<Settings2 className="size-4" />
|
||||
</span>
|
||||
<h2 className="pt-1 text-sm font-semibold">积分策略</h2>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">积分策略</h2>
|
||||
<p>管理注册、签到赠送与模型计费倍率。</p>
|
||||
</div>
|
||||
</div>
|
||||
<Form form={policyForm} layout="vertical" requiredMark={false} className="mt-3">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Form.Item name="signupBonus" label="注册积分" rules={[{ required: true, message: "请填写注册积分" }, { type: "number", min: 0 }]}>
|
||||
<Form.Item
|
||||
name="signupBonus"
|
||||
label="注册积分"
|
||||
rules={[
|
||||
{ required: true, message: "请填写注册积分" },
|
||||
{ type: "number", min: 0 },
|
||||
]}
|
||||
>
|
||||
<InputNumber className="w-full" min={0} precision={6} />
|
||||
</Form.Item>
|
||||
<Form.Item name="checkinBonus" label="签到积分" rules={[{ required: true, message: "请填写签到积分" }, { type: "number", min: 0 }]}>
|
||||
<Form.Item
|
||||
name="checkinBonus"
|
||||
label="签到积分"
|
||||
rules={[
|
||||
{ required: true, message: "请填写签到积分" },
|
||||
{ type: "number", min: 0 },
|
||||
]}
|
||||
>
|
||||
<InputNumber className="w-full" min={0} precision={6} />
|
||||
</Form.Item>
|
||||
<Form.Item name="defaultMultiplier" label="默认倍率" rules={[{ required: true, message: "请填写默认倍率" }, { type: "number", min: 0.0001, max: 100 }]}>
|
||||
<Form.Item
|
||||
name="defaultMultiplier"
|
||||
label="默认倍率"
|
||||
rules={[
|
||||
{ required: true, message: "请填写默认倍率" },
|
||||
{ type: "number", min: 0.0001, max: 100 },
|
||||
]}
|
||||
>
|
||||
<InputNumber className="w-full" min={0.0001} max={100} precision={4} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="modelMultipliers" label="模型独立倍率" extra="每行使用 模型名=倍率">
|
||||
<Input.TextArea rows={2} placeholder={"gpt-image-1=1.5\nseedance-1.0-pro=2"} />
|
||||
</Form.Item>
|
||||
<Button type="primary" loading={savingPolicy} onClick={() => void savePolicy()}>保存积分策略</Button>
|
||||
<Button type="primary" loading={savingPolicy} onClick={() => void savePolicy()}>
|
||||
保存积分策略
|
||||
</Button>
|
||||
</Form>
|
||||
</section>
|
||||
<section className="rounded-lg bg-background p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<section className="admin-operation-card">
|
||||
<div className="admin-operation-card-heading flex items-start gap-3">
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-md bg-muted/40">
|
||||
<UserRoundCog className="size-4" />
|
||||
</span>
|
||||
<h2 className="pt-1 text-sm font-semibold">人工调整积分</h2>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">人工调整积分</h2>
|
||||
<p>针对单个用户增减积分,并保留处理依据。</p>
|
||||
</div>
|
||||
</div>
|
||||
<Form form={adjustmentForm} layout="vertical" requiredMark={false} className="mt-3">
|
||||
<Form.Item name="userId" label="目标用户" rules={[{ required: true, message: "请选择用户" }]}>
|
||||
<Select showSearch filterOption={false} loading={searchingUsers} placeholder="搜索用户名或显示名称" onSearch={(value) => void searchUsers(value)} options={adjustmentUsers.map((user) => ({ label: `${user.displayName || user.username} · @${user.username}`, value: user.id }))} />
|
||||
<Select
|
||||
showSearch
|
||||
filterOption={false}
|
||||
loading={searchingUsers}
|
||||
placeholder="搜索用户名或显示名称"
|
||||
onSearch={(value) => void searchUsers(value)}
|
||||
options={adjustmentUsers.map((user) => ({ label: `${user.displayName || user.username} · @${user.username}`, value: user.id }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Form.Item name="amount" label="积分变化" rules={[{ required: true, message: "请填写积分变化" }]}>
|
||||
@@ -281,43 +317,124 @@ export default function CreditOperationsPanel({ users }: { users: AdminReference
|
||||
<Input maxLength={500} placeholder="工单号或处理依据" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Button type="primary" loading={adjusting} onClick={() => void adjust()}>确认调整</Button>
|
||||
<Button type="primary" loading={adjusting} onClick={() => void adjust()}>
|
||||
确认调整
|
||||
</Button>
|
||||
</Form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="flex min-h-0 flex-1">
|
||||
<AdminDataTable
|
||||
toolbar={<Input
|
||||
allowClear
|
||||
className="app-list-search"
|
||||
prefix={<Search className="size-4 text-foreground/40" />}
|
||||
value={keyword}
|
||||
placeholder="搜索用户、模型、场景或请求号"
|
||||
onChange={(event) => {
|
||||
setKeyword(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>}
|
||||
toolbarActiveFilters={<>{keyword ? <AdminFilterChip label={`搜索:${keyword}`} onRemove={() => { setKeyword(""); setPage(1); }} /> : null}{orderStatus !== "review" ? <AdminFilterChip label={`队列:${orderStatus === "all" ? "全部历史" : orderStatus}`} onRemove={() => { setOrderStatus("review"); setPage(1); }} /> : null}</>}
|
||||
toolbar={
|
||||
<Input
|
||||
allowClear
|
||||
className="app-list-search"
|
||||
prefix={<Search className="size-4 text-foreground/40" />}
|
||||
value={keyword}
|
||||
placeholder="搜索用户、模型、场景或请求号"
|
||||
onChange={(event) => {
|
||||
setKeyword(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
toolbarActiveFilters={
|
||||
<>
|
||||
{keyword ? (
|
||||
<AdminFilterChip
|
||||
label={`搜索:${keyword}`}
|
||||
onRemove={() => {
|
||||
setKeyword("");
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{orderStatus !== "review" ? (
|
||||
<AdminFilterChip
|
||||
label={`队列:${orderStatus === "all" ? "全部历史" : orderStatus}`}
|
||||
onRemove={() => {
|
||||
setOrderStatus("review");
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
toolbarActive={Boolean(keyword || orderStatus !== "review")}
|
||||
toolbarFilters={<Select className="w-36" value={orderStatus} onChange={(value) => { setOrderStatus(value); setPage(1); }} options={[{ label: "待核对队列", value: "review" }, { label: "全部历史", value: "all" }, { label: "费用待核对", value: "uncertain" }, { label: "运行中", value: "running" }, { label: "已冻结", value: "reserved" }, { label: "已结算", value: "settled" }, { label: "已退款", value: "refunded" }]} />}
|
||||
onReset={() => { setKeyword(""); setOrderStatus("review"); setPage(1); }}
|
||||
trailing={<Button type="text" size="small" icon={<RefreshCw className="size-3.5" />} loading={loading} onClick={() => void reload()}>刷新</Button>}
|
||||
batchActions={<AdminBatchBar count={selectedOrderIds.length} onClear={() => setSelectedOrderIds([])}><Button size="small" type="primary" icon={<BadgeCheck className="size-3.5" />} onClick={() => openBatchResolution("settle")}>批量确认扣费</Button><Button size="small" danger icon={<Undo2 className="size-3.5" />} onClick={() => openBatchResolution("refund")}>批量退回积分</Button></AdminBatchBar>}
|
||||
table={{ className: "app-data-table", rowKey: "id", size: "small", loading, pagination: false, columns, dataSource: orders, rowSelection: {
|
||||
toolbarFilters={
|
||||
<Select
|
||||
className="w-36"
|
||||
value={orderStatus}
|
||||
onChange={(value) => {
|
||||
setOrderStatus(value);
|
||||
setPage(1);
|
||||
}}
|
||||
options={[
|
||||
{ label: "待核对队列", value: "review" },
|
||||
{ label: "全部历史", value: "all" },
|
||||
{ label: "费用待核对", value: "uncertain" },
|
||||
{ label: "运行中", value: "running" },
|
||||
{ label: "已冻结", value: "reserved" },
|
||||
{ label: "已结算", value: "settled" },
|
||||
{ label: "已退款", value: "refunded" },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
onReset={() => {
|
||||
setKeyword("");
|
||||
setOrderStatus("review");
|
||||
setPage(1);
|
||||
}}
|
||||
trailing={
|
||||
<Button type="text" size="small" icon={<RefreshCw className="size-3.5" />} loading={loading} onClick={() => void reload()}>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
batchActions={
|
||||
<AdminBatchBar count={selectedOrderIds.length} onClear={() => setSelectedOrderIds([])}>
|
||||
<Button size="small" type="primary" icon={<BadgeCheck className="size-3.5" />} onClick={() => openBatchResolution("settle")}>
|
||||
批量确认扣费
|
||||
</Button>
|
||||
<Button size="small" danger icon={<Undo2 className="size-3.5" />} onClick={() => openBatchResolution("refund")}>
|
||||
批量退回积分
|
||||
</Button>
|
||||
</AdminBatchBar>
|
||||
}
|
||||
table={{
|
||||
className: "app-data-table",
|
||||
rowKey: "id",
|
||||
size: "small",
|
||||
loading,
|
||||
pagination: false,
|
||||
columns,
|
||||
dataSource: orders,
|
||||
rowSelection: {
|
||||
selectedRowKeys: selectedOrderIds,
|
||||
preserveSelectedRowKeys: false,
|
||||
onChange: (keys) => setSelectedOrderIds(keys.map(String)),
|
||||
getCheckboxProps: (order) => ({ disabled: !canResolveBillingOrder(order), name: `${order.model} ${order.scene || order.capability}` }),
|
||||
}, scroll: { x: 1390 } }}
|
||||
},
|
||||
scroll: { x: 1390 },
|
||||
}}
|
||||
empty={<AdminTableEmpty filtered={Boolean(keyword || orderStatus !== "review")} title="暂无计费订单" />}
|
||||
footer={<PaginationBar alwaysShow current={page} pageSize={pageSize} total={total} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} />}
|
||||
footer={
|
||||
<PaginationBar
|
||||
alwaysShow
|
||||
current={page}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
onChange={(nextPage, nextPageSize) => {
|
||||
setPage(nextPageSize !== pageSize ? 1 : nextPage);
|
||||
setPageSize(nextPageSize);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<Modal
|
||||
title={resolutionTarget?.action === "settle" ? (resolutionTarget.kind === "batch" ? "批量确认扣除冻结积分" : "确认扣除冻结积分") : (resolutionTarget?.kind === "batch" ? "批量确认退回冻结积分" : "确认退回冻结积分")}
|
||||
title={resolutionTarget?.action === "settle" ? (resolutionTarget.kind === "batch" ? "批量确认扣除冻结积分" : "确认扣除冻结积分") : resolutionTarget?.kind === "batch" ? "批量确认退回冻结积分" : "确认退回冻结积分"}
|
||||
open={Boolean(resolutionTarget)}
|
||||
okText={resolutionTarget?.action === "settle" ? "确认扣费" : "退回积分"}
|
||||
cancelText="取消"
|
||||
@@ -333,7 +450,8 @@ export default function CreditOperationsPanel({ users }: { users: AdminReference
|
||||
>
|
||||
{resolutionTarget?.kind === "batch" ? (
|
||||
<div className="mb-4 rounded-md border border-border bg-muted/25 px-3 py-2.5 text-sm text-foreground/65">
|
||||
已选择 <span className="font-semibold text-foreground">{resolutionTarget.orderIds.length}</span> 条订单,涉及冻结积分 <span className="font-semibold tabular-nums text-foreground">{formatCredits(resolutionTarget.amountMicrocredits)}</span>。本次核对依据将写入每条订单的审计记录。
|
||||
已选择 <span className="font-semibold text-foreground">{resolutionTarget.orderIds.length}</span> 条订单,涉及冻结积分{" "}
|
||||
<span className="font-semibold tabular-nums text-foreground">{formatCredits(resolutionTarget.amountMicrocredits)}</span>。本次核对依据将写入每条订单的审计记录。
|
||||
</div>
|
||||
) : null}
|
||||
<Form form={resolutionForm} layout="vertical" requiredMark={false}>
|
||||
|
||||
@@ -111,8 +111,15 @@ export default function FeatureAvailabilityPanel() {
|
||||
<h3 className="text-sm font-medium">{item.title}</h3>
|
||||
<span className="rounded border border-border/70 px-1.5 py-0.5 text-[var(--fs-micro)] text-foreground/45">{item.menu}</span>
|
||||
</div>
|
||||
<p className="mt-1 max-w-3xl text-xs leading-5 text-foreground/52">{item.description}</p>
|
||||
</div>
|
||||
<Switch checked={features?.[item.key] === true} loading={!features || saving === item.key} disabled={Boolean(saving && saving !== item.key) || (item.key === "systemPluginsVisibleToUsers" && features?.pluginCenterEnabled !== true)} onChange={(checked) => toggle(item.key, checked)} aria-label={`开放${item.title}`} />
|
||||
<Switch
|
||||
checked={features?.[item.key] === true}
|
||||
loading={!features || saving === item.key}
|
||||
disabled={Boolean(saving && saving !== item.key) || (item.key === "systemPluginsVisibleToUsers" && features?.pluginCenterEnabled !== true)}
|
||||
onChange={(checked) => toggle(item.key, checked)}
|
||||
aria-label={`开放${item.title}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -132,6 +139,7 @@ export default function FeatureAvailabilityPanel() {
|
||||
<h3 className="text-sm font-medium">{item.title}</h3>
|
||||
<span className="rounded border border-border/70 px-1.5 py-0.5 text-[var(--fs-micro)] text-foreground/45">{item.menu}</span>
|
||||
</div>
|
||||
<p className="mt-1 max-w-3xl text-xs leading-5 text-foreground/52">{item.description}</p>
|
||||
</div>
|
||||
<Switch checked={features?.[item.key] === true} loading={!features || saving === item.key} disabled={Boolean(saving && saving !== item.key)} onChange={(checked) => toggle(item.key, checked)} aria-label={`开放${item.title}`} />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user