merge: official v1.5.6 with multimodal storyboard enhancements

This commit is contained in:
Codex
2026-09-20 13:05:42 +08:00
199 changed files with 11465 additions and 2971 deletions

View File

@@ -0,0 +1,22 @@
import type { ColumnsType } from "antd/es/table";
import type { AdminAnalytics, AnalyticsFinance } from "@/services/api/auth";
export function formatCredits(value: number | null | undefined) {
return value == null ? "--" : new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 6 }).format(value / 1_000_000);
}
export function formatFinanceCost(finance: AnalyticsFinance | null | undefined) {
if (!finance?.costedOrders) return "--";
return `${formatCredits(finance.costMicrocredits)}${finance.costedOrders < finance.settledOrders ? "(部分)" : ""}`;
}
export function formatFinanceMargin(value: number | null | undefined) {
return value == null ? "--" : `${value.toFixed(1)}%`;
}
export const analyticsFinanceColumns: ColumnsType<AdminAnalytics["models"][number]> = [
{ title: "收入(积分)", width: 130, render: (_, row) => formatCredits(row.finance?.revenueMicrocredits) },
{ title: "成本(积分)", width: 150, render: (_, row) => formatFinanceCost(row.finance) },
{ title: "利润(积分)", width: 130, render: (_, row) => formatCredits(row.finance?.profitMicrocredits) },
{ title: "利润率", width: 100, render: (_, row) => formatFinanceMargin(row.finance?.profitMargin) },
];

View File

@@ -1,47 +1,23 @@
import { App, Button, DatePicker, Drawer, Form, Input, Modal, Select, Tabs, Tag } from "antd";
import { Alert, App, Button, DatePicker, Select, Tabs, Tag } from "antd";
import { Tooltip } from "@/pages/admin/ui/controls";
import { useCountUp } from "@/hooks/use-count-up";
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import type { ColumnsType } from "antd/es/table";
import dayjs, { type Dayjs } from "dayjs";
import { AlertTriangle, BarChart3, CircleDollarSign, Clock3, Gauge, Pencil, Plus, RefreshCw, Settings2, Trash2, UsersRound, Workflow } from "lucide-react";
import { AlertTriangle, BarChart3, CircleDollarSign, Clock3, Gauge, RefreshCw, UsersRound, Workflow } from "lucide-react";
import { Area, CartesianGrid, ComposedChart, Legend, Line, ResponsiveContainer, Tooltip as ChartTooltip, XAxis, YAxis } from "recharts";
import { useSearchParams } from "react-router";
import { ListToolbar, PaginationBar, AdminDataTable, AdminExportButton, AdminFilterChip, AdminRowActions, AdminStatusBadge, AdminTableEmpty, type AdminStatusTone } from "./admin-ui";
import {
createAdminModelPricing,
deleteAdminModelPricing,
exportAdminAnalytics,
getAdminAnalytics,
listAdminUsers,
listAdminModelPricings,
updateAdminModelPricing,
type AdminReferenceData,
type AdminAnalytics,
type AnalyticsFilters,
type ModelPricing,
} from "@/services/api/auth";
import { ListToolbar, PaginationBar, AdminDataTable, AdminExportButton, AdminFilterChip, AdminStatusBadge, AdminTableEmpty, type AdminStatusTone } from "./admin-ui";
import { exportAdminAnalytics, getAdminAnalytics, listAdminUsers, type AdminReferenceData, type AdminAnalytics, type AnalyticsFilters } from "@/services/api/auth";
import { analyticsFinanceColumns, formatCredits, formatFinanceCost, formatFinanceMargin } from "./analytics-finance";
type Props = {
users: AdminReferenceData["users"];
channels: AdminReferenceData["channels"];
};
type PricingFormValues = {
channelId?: string;
model: string;
capability: ModelPricing["capability"];
currency: string;
inputPerMillion?: string;
outputPerMillion?: string;
cachedPerMillion?: string;
perRequest?: string;
perMedia?: string;
perVideoSecond?: string;
};
type TrendMetric = "volume" | "quality" | "activity";
type AnalysisTab = "models" | "users" | "failures";
type RangePreset = "7d" | "30d" | "60d";
@@ -63,23 +39,14 @@ export default function AnalyticsPanel({ users, channels }: Props) {
const [channelId, setChannelId] = useState(searchParams.get("channelId") || undefined);
const [capability, setCapability] = useState(searchParams.get("capability") || undefined);
const [data, setData] = useState<AdminAnalytics | null>(null);
const [pricings, setPricings] = useState<ModelPricing[]>([]);
const [loading, setLoading] = useState(false);
const [pricingModalOpen, setPricingModalOpen] = useState(false);
const [editingPricing, setEditingPricing] = useState<ModelPricing | null>(null);
const [savingPricing, setSavingPricing] = useState(false);
const [userOptions, setUserOptions] = useState(users);
const [searchingUsers, setSearchingUsers] = useState(false);
const [modelPage, setModelPage] = useState(1);
const [userPage, setUserPage] = useState(1);
const [failurePage, setFailurePage] = useState(1);
const [pricingPage, setPricingPage] = useState(1);
const [trendMetric, setTrendMetric] = useState<TrendMetric>("volume");
const [analysisTab, setAnalysisTab] = useState<AnalysisTab>("models");
const [pricingWorkspaceOpen, setPricingWorkspaceOpen] = useState(false);
const [pendingPricing, setPendingPricing] = useState<ModelPricing | null | undefined>(undefined);
const [form] = Form.useForm<PricingFormValues>();
const pricingChannelId = Form.useWatch("channelId", form);
const analyticsPageSize = 20;
const filters = useMemo<AnalyticsFilters>(
@@ -97,9 +64,8 @@ export default function AnalyticsPanel({ users, channels }: Props) {
const reload = useCallback(async () => {
setLoading(true);
try {
const [analytics, pricingData] = await Promise.all([getAdminAnalytics(filters), listAdminModelPricings()]);
const analytics = await getAdminAnalytics(filters);
setData(analytics);
setPricings(pricingData.pricings);
} catch (error) {
message.error(error instanceof Error ? error.message : "读取统计数据失败");
} finally {
@@ -123,7 +89,6 @@ export default function AnalyticsPanel({ users, channels }: Props) {
setModelPage(1);
setUserPage(1);
setFailurePage(1);
setPricingPage(1);
}, [filters]);
useEffect(() => {
@@ -149,105 +114,6 @@ export default function AnalyticsPanel({ users, channels }: Props) {
return [...names].sort().map((name) => ({ label: name, value: name }));
}, [channels, data?.models]);
const pricingModelOptions = useMemo(() => {
const names = new Set<string>();
const sourceChannels = channels.filter((channel) => channel.enabled !== false && (!pricingChannelId || channel.id === pricingChannelId));
sourceChannels.forEach((channel) => channel.models?.forEach((name) => names.add(name)));
if (editingPricing?.model && (!pricingChannelId || editingPricing.channelId === pricingChannelId)) {
names.add(editingPricing.model);
}
return [...names].sort().map((name) => ({ label: name, value: name }));
}, [channels, editingPricing?.channelId, editingPricing?.model, pricingChannelId]);
const preparePricingForm = (pricing: ModelPricing | null) => {
setEditingPricing(pricing);
form.resetFields();
form.setFieldsValue(
pricing
? {
channelId: pricing.channelId || undefined,
model: pricing.model,
capability: pricing.capability,
currency: pricing.currency,
inputPerMillion: formatPriceInput(pricing.inputPerMillionMicros),
outputPerMillion: formatPriceInput(pricing.outputPerMillionMicros),
cachedPerMillion: formatPriceInput(pricing.cachedPerMillionMicros),
perRequest: formatPriceInput(pricing.perRequestMicros),
perMedia: formatPriceInput(pricing.perMediaMicros),
perVideoSecond: formatPriceInput(pricing.perVideoSecondMicros),
}
: { channelId: undefined, model: "", capability: "text", currency: "USD", inputPerMillion: "", outputPerMillion: "", cachedPerMillion: "", perRequest: "", perMedia: "", perVideoSecond: "" },
);
setPricingModalOpen(true);
};
const openPricing = (pricing?: ModelPricing) => {
const nextPricing = pricing || null;
if (pricingWorkspaceOpen) {
setPendingPricing(nextPricing);
setPricingWorkspaceOpen(false);
} else {
preparePricingForm(nextPricing);
}
};
const handlePricingValuesChange = (changedValues: Partial<PricingFormValues>, values: PricingFormValues) => {
if (Object.prototype.hasOwnProperty.call(changedValues, "channelId")) {
const nextModels = channels.filter((channel) => channel.enabled !== false && (!values.channelId || channel.id === values.channelId)).flatMap((channel) => channel.models || []);
if (values.model && !nextModels.includes(values.model)) form.setFieldValue("model", undefined);
return;
}
if (!Object.prototype.hasOwnProperty.call(changedValues, "model") || !values.model) return;
const matchingChannels = channels.filter((channel) => channel.enabled !== false && channel.models?.includes(values.model));
if (values.channelId && matchingChannels.some((channel) => channel.id === values.channelId)) return;
if (matchingChannels.length) form.setFieldValue("channelId", matchingChannels[0].id);
};
const handlePricingModelChange = (modelName: string) => {
const currentChannelId = form.getFieldValue("channelId") as string | undefined;
const matchingChannels = channels.filter((channel) => channel.enabled !== false && channel.models?.includes(modelName));
const matchingChannel = matchingChannels.find((channel) => channel.id === currentChannelId) || matchingChannels[0];
form.setFieldsValue({ model: modelName, channelId: matchingChannel?.id || currentChannelId });
};
const savePricing = async () => {
const values = await form.validateFields();
const payload = {
channelId: values.channelId || "",
model: values.model.trim(),
capability: values.capability,
currency: values.currency.trim().toUpperCase(),
inputPerMillionMicros: toMicros(values.inputPerMillion),
outputPerMillionMicros: toMicros(values.outputPerMillion),
cachedPerMillionMicros: toMicros(values.cachedPerMillion),
perRequestMicros: toMicros(values.perRequest),
perMediaMicros: toMicros(values.perMedia),
perVideoSecondMicros: toMicros(values.perVideoSecond),
};
setSavingPricing(true);
try {
const result = editingPricing ? await updateAdminModelPricing(editingPricing.id, payload) : await createAdminModelPricing(payload);
setPricings((items) => (editingPricing ? items.map((item) => (item.id === result.pricing.id ? result.pricing : item)) : [...items, result.pricing]));
setPricingModalOpen(false);
message.success("模型价格已保存,后续调用将按新价格记录费用快照");
} catch (error) {
message.error(error instanceof Error ? error.message : "保存价格失败");
} finally {
setSavingPricing(false);
}
};
const removePricing = async (id: string) => {
try {
await deleteAdminModelPricing(id);
setPricings((items) => items.filter((item) => item.id !== id));
message.success("价格配置已删除");
} catch (error) {
message.error(error instanceof Error ? error.message : "删除价格失败");
}
};
const modelColumns: ColumnsType<AdminAnalytics["models"][number]> = [
{
title: "模型",
@@ -270,7 +136,7 @@ export default function AnalyticsPanel({ users, channels }: Props) {
{ title: "P50 / P95", width: 145, render: (_, row) => `${formatDuration(row.p50DurationMs)} / ${formatDuration(row.p95DurationMs)}` },
{ title: "Token(入 / 出 / 缓存)", width: 190, render: (_, row) => (row.usageAvailable ? `${formatNumber(row.inputTokens)} / ${formatNumber(row.outputTokens)} / ${formatNumber(row.cachedTokens)}` : "--") },
{ title: "媒体 / 视频秒", width: 125, render: (_, row) => `${row.mediaCount} / ${row.videoSeconds}` },
{ title: "估算费用", width: 120, render: (_, row) => formatCost(row.estimatedCostMicros, row.currency, row.costAvailable) },
...analyticsFinanceColumns,
];
const userColumns: ColumnsType<AdminAnalytics["users"][number]> = [
@@ -301,60 +167,16 @@ export default function AnalyticsPanel({ users, channels }: Props) {
{ title: "最近发生", dataIndex: "lastSeenAt", width: 170, render: (value) => dayjs(value).format("YYYY-MM-DD HH:mm") },
];
const pricingColumns: ColumnsType<ModelPricing> = [
{
title: "模型",
dataIndex: "model",
width: 210,
render: (value, row) => (
<div>
<div className="font-medium">{value}</div>
<div className="text-xs text-foreground/45">{row.channelId ? channels.find((channel) => channel.id === row.channelId)?.name || row.channelId : "全部渠道"}</div>
</div>
),
},
{ title: "能力", dataIndex: "capability", width: 90, render: capabilityLabel },
{
title: "输入 / 输出 / 缓存(每百万 Token)",
width: 250,
render: (_, row) => `${formatMoney(fromMicros(row.inputPerMillionMicros), row.currency)} / ${formatMoney(fromMicros(row.outputPerMillionMicros), row.currency)} / ${formatMoney(fromMicros(row.cachedPerMillionMicros), row.currency)}`,
},
{
title: "每请求 / 每媒体 / 每视频秒",
width: 220,
render: (_, row) => `${formatMoney(fromMicros(row.perRequestMicros), row.currency)} / ${formatMoney(fromMicros(row.perMediaMicros), row.currency)} / ${formatMoney(fromMicros(row.perVideoSecondMicros), row.currency)}`,
},
{
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),
},
]}
/>
),
},
];
const trend = data?.trend || [];
const currentTrend = trend[trend.length - 1];
const previousTrend = trend[trend.length - 2];
const modelRows = data?.models || [];
const userRows = data?.users || [];
const failureRows = data?.failures || [];
const pricingRows = pricings;
const failureTotal = failureRows.reduce((sum, item) => sum + item.count, 0);
const topFailure = failureRows.reduce<AdminAnalytics["failures"][number] | undefined>((current, item) => (!current || item.count > current.count ? item : current), undefined);
const pricedModelCount = modelRows.filter((item) => item.costAvailable).length;
const finance = data?.kpi.finance;
const financeUnavailable = data !== null && (!finance || modelRows.some((row) => !row.finance));
const trendHasData = trendMetric === "volume" ? trend.some((item) => item.tasks > 0 || item.requests > 0) : trendMetric === "quality" ? trend.some((item) => item.requests > 0) : trend.some((item) => item.activeUsers > 0);
const trendTitle = trendMetric === "volume" ? "任务与请求趋势" : trendMetric === "quality" ? "请求质量趋势" : "用户活跃趋势";
const pageRows = <T,>(rows: T[], page: number) => rows.slice((page - 1) * analyticsPageSize, page * analyticsPageSize);
@@ -443,6 +265,8 @@ export default function AnalyticsPanel({ users, channels }: Props) {
</div>
</ListToolbar>
{financeUnavailable && <Alert type="warning" showIcon title="后端未返回完整财务统计,缺失金额显示为 --。请确认后端已更新并重启后刷新。" />}
<section className="admin-analytics-health-grid" aria-label="运营健康指标">
<AnalyticsHealthCard
icon={<UsersRound className="size-4" />}
@@ -468,10 +292,29 @@ export default function AnalyticsPanel({ users, channels }: Props) {
/>
<AnalyticsHealthCard
icon={<CircleDollarSign className="size-4" />}
label="估算费用"
value={data ? (data.kpi.costAvailable ? formatCost(data.kpi.estimatedCostMicros, data.kpi.currency, true) : "待配置") : "--"}
detail={data ? (data.kpi.costAvailable ? `${pricedModelCount}/${modelRows.length} 个模型可估算` : "价格未完整配置,暂不能汇总") : undefined}
tone={data?.kpi.costAvailable ? "neutral" : "warning"}
label="费用统计(积分)"
value={
finance ? (
<dl className="admin-analytics-finance-values">
<div>
<dt>收入</dt>
<dd>{formatCredits(finance.revenueMicrocredits)}</dd>
</div>
<div>
<dt>成本</dt>
<dd>{formatFinanceCost(finance)}</dd>
</div>
<div>
<dt>利润</dt>
<dd>{formatCredits(finance.profitMicrocredits)}</dd>
</div>
</dl>
) : (
"--"
)
}
detail={finance ? `已结算 ${finance.settledOrders} 笔 · 利润率 ${formatFinanceMargin(finance.profitMargin)}` : undefined}
tone={finance && (finance.costedOrders < finance.settledOrders || (finance.profitMicrocredits ?? 0) < 0) ? "warning" : "neutral"}
/>
</section>
@@ -553,11 +396,19 @@ export default function AnalyticsPanel({ users, channels }: Props) {
/>
<AnalyticsAttentionItem
icon={<CircleDollarSign className="size-4" />}
label="费用可估算模型"
value={data ? `${pricedModelCount}/${modelRows.length}` : "--"}
description={data?.kpi.costAvailable ? "当前范围费用可以汇总" : "仍有调用无法完成费用估算"}
tone={data?.kpi.costAvailable ? "success" : "warning"}
onClick={() => setPricingWorkspaceOpen(true)}
label="成本覆盖订单"
value={finance ? `${finance.costedOrders}/${finance.settledOrders}` : "--"}
description={
!finance
? "财务统计尚未返回"
: finance.settledOrders
? finance.costedOrders === finance.settledOrders
? "已结算订单成本完整"
: `${finance.settledOrders - finance.costedOrders} 笔缺少成本快照或有效用量`
: "当前范围暂无已结算订单"
}
tone={finance && finance.costedOrders < finance.settledOrders ? "warning" : "neutral"}
onClick={() => openAnalysis("models")}
/>
</div>
</aside>
@@ -569,9 +420,6 @@ export default function AnalyticsPanel({ users, channels }: Props) {
<h2>深度分析</h2>
<p>按模型、用户或异常类型继续核对当前统计范围。</p>
</div>
<Button icon={<Settings2 className="size-4" />} onClick={() => setPricingWorkspaceOpen(true)}>
模型价格配置
</Button>
</div>
<Tabs
activeKey={analysisTab}
@@ -583,9 +431,9 @@ export default function AnalyticsPanel({ users, channels }: Props) {
label: "模型分析",
children: (
<AdminDataTable
table={{ rowKey: (row) => `${row.model}:${row.capability}`, size: "small", loading, columns: modelColumns, dataSource: pageRows(modelRows, modelPage), pagination: false, scroll: { x: 1250 } }}
table={{ rowKey: (row) => `${row.model}:${row.capability}`, size: "small", loading, columns: modelColumns, dataSource: pageRows(modelRows, modelPage), pagination: false, scroll: { x: 1600 } }}
empty={<AdminTableEmpty />}
skeletonColumns={9}
skeletonColumns={12}
footer={<PaginationBar alwaysShow current={modelPage} pageSize={analyticsPageSize} total={modelRows.length} onChange={(page) => setModelPage(page)} pageSizeOptions={[analyticsPageSize]} />}
/>
),
@@ -617,77 +465,6 @@ export default function AnalyticsPanel({ users, channels }: Props) {
]}
/>
</section>
<Drawer
rootClassName="admin-secondary-drawer admin-analytics-pricing-drawer"
title="模型价格配置"
open={pricingWorkspaceOpen}
width="min(1120px, calc(100vw - 48px))"
onClose={() => setPricingWorkspaceOpen(false)}
afterOpenChange={(open) => {
if (open || pendingPricing === undefined) return;
const nextPricing = pendingPricing;
setPendingPricing(undefined);
preparePricingForm(nextPricing);
}}
extra={
<Button type="primary" icon={<Plus className="size-4" />} onClick={() => openPricing()}>
新增价格
</Button>
}
>
<p className="admin-analytics-pricing-description">价格只影响后续调用的费用快照,历史统计不会被重新计算。</p>
<AdminDataTable
table={{ rowKey: "id", size: "small", loading, columns: pricingColumns, dataSource: pageRows(pricingRows, pricingPage), pagination: false, scroll: { x: 980 } }}
empty={<AdminTableEmpty title="暂无模型价格" description="新增价格后,后续调用才能形成可汇总的费用快照。" />}
skeletonColumns={5}
footer={<PaginationBar alwaysShow current={pricingPage} pageSize={analyticsPageSize} total={pricingRows.length} onChange={(page) => setPricingPage(page)} pageSizeOptions={[analyticsPageSize]} />}
/>
</Drawer>
<Modal
rootClassName="admin-modal-root admin-analytics-pricing-modal"
title={editingPricing ? "编辑模型价格" : "新增模型价格"}
open={pricingModalOpen}
onCancel={() => setPricingModalOpen(false)}
onOk={() => void savePricing()}
confirmLoading={savingPricing}
okText="保存"
cancelText="取消"
width={760}
zIndex={1200}
destroyOnHidden
>
<Form form={form} layout="vertical" requiredMark={false} onValuesChange={handlePricingValuesChange}>
<div className="grid grid-cols-1 gap-x-4 sm:grid-cols-2">
<Form.Item name="model" label="模型" rules={[{ required: true, message: "请选择模型" }]}>
<Select
showSearch
optionFilterProp="label"
placeholder={pricingModelOptions.length ? "选择已启用模型" : "暂无已启用模型"}
options={pricingModelOptions}
disabled={!pricingModelOptions.length}
onChange={handlePricingModelChange}
/>
</Form.Item>
<Form.Item name="channelId" label="渠道范围">
<Select allowClear placeholder="全部渠道" options={channels.filter((channel) => channel.enabled !== false).map((channel) => ({ label: channel.name, value: channel.id }))} />
</Form.Item>
<Form.Item name="capability" label="能力类型" rules={[{ required: true }]}>
<Select options={capabilityOptions} />
</Form.Item>
<Form.Item name="currency" label="币种" rules={[{ required: true }]}>
<Input maxLength={12} />
</Form.Item>
<PriceInput name="inputPerMillion" label="每百万输入 Token" />
<PriceInput name="outputPerMillion" label="每百万输出 Token" />
<PriceInput name="cachedPerMillion" label="每百万缓存 Token" />
<PriceInput name="perRequest" label="每次请求" />
<PriceInput name="perMedia" label="每个输出媒体" />
<PriceInput name="perVideoSecond" label="每视频秒" />
</div>
</Form>
</Modal>
</div>
);
}
@@ -765,20 +542,6 @@ function FilterSelect({
);
}
function PriceInput({ name, label }: { name: keyof PricingFormValues; label: string }) {
return (
<Form.Item className="admin-analytics-price-field" name={name} label={`${label}(币种单位)`} rules={[{ validator: validatePriceInput }]}>
<Input aria-label={`${label}价格`} disabled={false} readOnly={false} inputMode="decimal" maxLength={30} className="admin-analytics-price-input" style={{ width: "100%" }} />
</Form.Item>
);
}
function validatePriceInput(_: unknown, value?: string) {
const normalized = value?.trim() || "";
if (!normalized || /^(?:\d+(?:\.\d{0,6})?|\.\d{1,6})$/.test(normalized)) return Promise.resolve();
return Promise.reject(new Error("请输入非负价格,最多 6 位小数"));
}
function capabilityLabel(value: string) {
return capabilityOptions.find((item) => item.value === value)?.label || "未分类";
}
@@ -811,32 +574,6 @@ function formatRateDelta(current?: number, previous?: number) {
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) {
return available ? formatMoney(fromMicros(micros), currency || "USD") : "--";
}
function formatMoney(value: number, currency = "USD") {
if (currency === "MIXED") return `${value.toFixed(6)}(混合币种)`;
try {
return new Intl.NumberFormat("zh-CN", { style: "currency", currency, minimumFractionDigits: 2, maximumFractionDigits: 6 }).format(value);
} catch {
return `${currency} ${value.toFixed(6)}`;
}
}
function fromMicros(value: number) {
return value / 1_000_000;
}
function formatPriceInput(micros: number) {
return fromMicros(micros).toFixed(6);
}
function toMicros(value?: string | number) {
const parsed = typeof value === "number" ? value : Number(value?.trim() || 0);
return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed * 1_000_000) : 0;
}
function filterDate(value: string | null, fallback: Dayjs) {
if (!value) return fallback;
const parsed = dayjs(value);

View File

@@ -90,9 +90,7 @@ export function validateChannelModelPrices(values: Pick<ChannelModelFormValues,
if (tier.matchMode === "advanced") {
if (tier.operation && tier.operation !== "*" && !operations[capability]?.includes(tier.operation)) fail("生成方式与模型能力不匹配");
const specific = (value: string | undefined) => Boolean(value && value !== "*");
if (capability === "video" && tier.videoGenerateAudio && !["*", "true", "false"].includes(tier.videoGenerateAudio)) fail("请选择任意音频、有声或无声");
if (!(specific(tier.operation) || (capability === "image" && (specific(tier.quality) || specific(tier.size))) || (capability === "video" && (specific(tier.resolution) || tier.videoSeconds > 0 || tier.imageCount > 0 || specific(tier.videoGenerateAudio)))))
fail("规格价格至少需要一个匹配条件;统一价格请选择默认价格");
if (!(specific(tier.operation) || (capability === "image" && (specific(tier.quality) || specific(tier.size))) || (capability === "video" && specific(tier.resolution)))) fail("规格价格至少需要一个匹配条件;统一价格请选择默认价格");
}
const prices = tier.billingMode !== "token" ? [tier.unitPrice] : capability === "video" ? [tier.outputTokenPrice] : [tier.inputTokenPrice, tier.outputTokenPrice, tier.cachedTokenPrice];
if (prices.some((price) => typeof price !== "number" || !Number.isFinite(price) || price < 0 || price > 1_000_000)) fail("积分价格必须是 0 到 1000000 之间的有效数值");

View File

@@ -1,5 +1,6 @@
import { useRef, useState } from "react";
import { Alert, App, Button, Form, Input, Segmented, Switch, Tabs } from "antd";
import { Alert, App, AutoComplete, Button, Form, Input, Segmented, Switch, Tabs } from "antd";
import { useAdminContext } from "../admin-context";
import { AdminModal } from "@/pages/admin/ui/overlays";
import { FlaskConical, Plus } from "lucide-react";
import { ModelIconPicker } from "@/components/model-logo";
@@ -34,6 +35,10 @@ export function ChannelModelEditor({
onSaved: () => Promise<void>;
}) {
const { message, modal } = App.useApp();
const { references } = useAdminContext();
const displayNameOptions = [...new Set(references.channels.flatMap((item) => item.modelDisplayNames ?? []))]
.sort((a, b) => a.localeCompare(b, "zh-CN"))
.map((value) => ({ value }));
const [form] = Form.useForm<FormValues>();
const [initialValues] = useState(() => initialChannelModelValues(editing, protocols));
const [activeSection, setActiveSection] = useState<EditorSection>("identity");
@@ -248,7 +253,12 @@ export function ChannelModelEditor({
<Input placeholder="留空则使用产品模型标识" />
</Form.Item>
<Form.Item name="displayName" label="模型展示名(一级目录)" tooltip="跨所有系统渠道按此名称分组,例如 MiniMax H3。同名模型归入同一组,不改变调用 ID。">
<Input placeholder="不填则使用模型标识" />
<AutoComplete
options={displayNameOptions}
filterOption={(input, option) => Boolean(option?.value.toLowerCase().includes(input.toLowerCase()))}
placeholder="选择已有分组或输入新名称"
allowClear
/>
</Form.Item>
<Form.Item name="channelLabel" label="渠道展示名(二级目录)" tooltip="此模型下的渠道选项,例如秘塔(满血渠道)。留空使用所属渠道名称。" rules={[{ max: 80, message: "渠道展示名不能超过 80 字" }]}>
<Input maxLength={80} placeholder="例如:正常渠道、优惠渠道-993、特惠渠道-730" />
@@ -372,27 +382,31 @@ export function ChannelModelEditor({
]}
>
{(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}
modelUpstream={modelUpstream}
onDirty={() => {
dirtyRef.current = true;
}}
onRemove={() => remove(field.name)}
/>
))}
<Button className="admin-model-editor-add-tier" type="dashed" block icon={<Plus className="size-4" />} onClick={() => add(defaultPriceTier(hasDefaultPriceTier ? "advanced" : "default"))}>
{hasDefaultPriceTier ? "新增规格价格" : "新增统一默认价格"}
</Button>
<Form.ErrorList errors={errors} />
<div className="admin-price-tier-list-shell">
<div className="admin-price-tier-list" aria-label="积分价格规则列表">
{fields.map((field, index) => (
<PriceTierFields
key={field.key}
index={field.name}
ordinal={index + 1}
form={form}
capability={modelCapability}
protocol={modelProtocol}
capabilityConfig={capabilityConfig}
modelUpstream={modelUpstream}
onDirty={() => {
dirtyRef.current = true;
}}
onRemove={() => remove(field.name)}
/>
))}
</div>
<div className="admin-price-tier-list-footer">
<Button className="admin-model-editor-add-tier" type="dashed" block icon={<Plus className="size-4" />} onClick={() => add(defaultPriceTier(hasDefaultPriceTier ? "advanced" : "default"))}>
{hasDefaultPriceTier ? "新增规格价格" : "新增统一默认价格"}
</Button>
<Form.ErrorList errors={errors} />
</div>
</div>
)}
</Form.List>

View File

@@ -38,23 +38,26 @@ export function PriceTierFields({
const staleTierUpstream = matchMode === "default" && tierUpstream && modelUpstream && tierUpstream !== modelUpstream ? tierUpstream : "";
const video = capabilityConfig?.video;
const resolutionOptions = video?.resolutions || [];
const durationOptions = video?.duration.selection === "enum" ? video.duration.values || [] : [];
const tokenEnabled = modelProtocolSupportsTokenBilling(capability, protocol);
const isVideo = capability === "video";
const isImage = capability === "image";
return (
<div className="admin-price-tier-card">
<div className="admin-price-tier-card-header">
<div>
<div className="text-sm font-medium">{matchMode === "default" ? "默认价格" : `规格价格 ${ordinal}`}</div>
<article className="admin-price-tier-card">
<header className="admin-price-tier-card-header">
<Form.Item name={[index, "priceConfigured"]} hidden valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name={[index, "enabled"]} hidden valuePropName="checked">
<Switch />
</Form.Item>
<div className="admin-price-tier-card-heading">
<span className="admin-price-tier-card-index">{String(ordinal).padStart(2, "0")}</span>
<div className="admin-price-tier-card-heading-copy">
<div className="admin-price-tier-card-title">{matchMode === "default" ? "默认价格" : `规格价格 ${ordinal}`}</div>
<div className="admin-price-tier-card-summary">{matchMode === "default" ? "所有请求使用同一价格" : isVideo ? "按生成方式与分辨率匹配" : "按生成方式、质量或尺寸匹配"}</div>
</div>
</div>
<div className="admin-price-tier-controls">
<Form.Item name={[index, "priceConfigured"]} hidden valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name={[index, "enabled"]} hidden valuePropName="checked">
<Switch />
</Form.Item>
<div className="admin-price-tier-card-actions">
<div className="admin-price-tier-toggle">
<span title="关闭后保留配置,但不参与用户请求及计费匹配">可供用户使用</span>
<Switch
@@ -67,116 +70,125 @@ export function PriceTierFields({
}}
/>
</div>
<Button type="text" danger aria-label={`删除价格档 ${ordinal}`} icon={<Trash2 className="size-3.5" />} onClick={onRemove}>
删除
</Button>
</div>
<Button type="text" danger aria-label={`删除价格档 ${ordinal}`} icon={<Trash2 className="size-3.5" />} onClick={onRemove}>
删除
</Button>
</div>
</header>
<div className="admin-price-tier-card-body">
<div className="admin-price-tier-block admin-price-tier-match-block">
<Form.Item className="mb-0" name={[index, "matchMode"]} label="定价方式" tooltip="默认价格适用于所有请求;按规格配置时,精确规则优先。" rules={[{ required: true }]}>
<section className="admin-price-tier-mode-row">
<div className="admin-price-tier-panel-heading">
<div>
<h3>适用范围</h3>
<p>选择全部请求,或按生成规格精确匹配。</p>
</div>
</div>
<Form.Item className="admin-price-tier-mode-control mb-0" name={[index, "matchMode"]} rules={[{ required: true }]}>
<Segmented
block
aria-label="价格规则适用范围"
options={[
{ label: "统一价格", value: "default" },
{ label: "按规格定价", value: "advanced" },
]}
/>
</Form.Item>
</div>
<div className="admin-price-tier-block admin-price-tier-billing-block">
<div className="mb-2 text-sm font-medium">销售价格</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: isVideo ? "视频 Token" : "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} 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>
</section>
<div className={`admin-price-tier-workspace ${matchMode === "default" ? "is-default" : "is-advanced"}`}>
{matchMode !== "default" && (
<section className="admin-price-tier-panel admin-price-tier-conditions-panel">
<div className="admin-price-tier-panel-heading">
<div>
<h3>匹配条件</h3>
<p>符合以下条件时使用这条价格,精确规则优先。</p>
</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>
{isVideo && billingMode === "token" ? (
<p className="mb-0 mt-2 text-xs text-foreground/60">
所有视频模型均支持 Token 计费,统一按火山引擎模式计算:宽 × 高 × 24 帧/秒 ×(输出时长 + 参考视频时长)÷ 1024。优先按成功任务返回的有效用量结算,未返回用量时按公式结算。平台额外预留 10%,不计入公式结算;最终可能补扣或退回差额。0 表示免费;有声、无声或参考视频可分别配置规格价格。
</p>
) : null}
</div>
<CreditCostFields index={index} form={form} billingMode={billingMode} isVideo={isVideo} />
{matchMode !== "default" && (
<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>
{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, "videoGenerateAudio"]} label="生成音频" rules={[{ required: true, message: "请选择音频条件" }]}>
<Select options={[{ label: "任意音频", value: "*" }, { label: "有声", value: "true" }, { label: "无声", value: "false" }]} />
</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
</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>
{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}
{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>
</section>
)}
<section className="admin-price-tier-panel admin-price-tier-selling-panel">
<div className="admin-price-tier-panel-heading">
<div>
<h3>销售价格</h3>
<p>设置用户完成一次生成实际消耗的积分。</p>
</div>
</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: "*" },
{ label: "1K", value: "1k" },
{ label: "2K", value: "2k" },
{ label: "4K", value: "4k" },
{ label: "按次", value: "fixed_request" },
{ label: "按秒", value: "per_second", disabled: !isVideo },
{ label: isVideo ? "视频 Token" : "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} max={1_000_000} precision={6} step={0.1} />
</Form.Item>
) : (
<div className="admin-price-tier-token-grid">
<Form.Item className="admin-price-tier-unit-price mb-0" name={[index, "inputTokenPrice"]} label="输入 / 百万 Token" rules={[{ required: true, message: "请输入输入 Token 价格" }]}>
<InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} />
</Form.Item>
<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} max={1_000_000} precision={6} step={0.1} />
</Form.Item>
<Form.Item className="admin-price-tier-unit-price mb-0" name={[index, "cachedTokenPrice"]} label="缓存 / 百万 Token" rules={[{ required: true, message: "请输入缓存 Token 价格" }]}>
<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>
{isVideo && billingMode === "token" ? (
<div className="admin-price-tier-helper" role="note">
<strong>视频 Token 计费</strong>
<span>估算:宽 × 高 × 24 帧/秒 ×(输出时长 + 参考视频时长)÷ 1024;优先按上游有效用量结算,无用量时使用公式,授权预留 10% 会在结算后补扣或退回。</span>
</div>
) : 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>
)}
<CreditCostFields index={index} form={form} billingMode={billingMode} isVideo={isVideo} />
</section>
</div>
{staleTierUpstream ? (
<Alert
className="admin-price-tier-alert"
type="warning"
showIcon
title="该统一价格档仍指向独立上游模型 ID"
@@ -195,7 +207,7 @@ export function PriceTierFields({
/>
) : null}
</div>
</div>
</article>
);
}

View File

@@ -5,7 +5,9 @@ export type PriceTierMatchMode = "default" | "advanced";
// 与后端保存口径一致:上游键比较前剥掉一次 models/ 前缀,避免极旧存量行误报差异。
export function normalizeUpstreamModelKey(value: unknown): string {
return String(value || "").trim().replace(/^models\//, "");
return String(value || "")
.trim()
.replace(/^models\//, "");
}
export type PriceTierFormValues = {
@@ -60,7 +62,7 @@ export function defaultPriceTier(matchMode: PriceTierMatchMode = "default"): Pri
export function priceTierToForm(tier: ChannelModelPriceTier): PriceTierFormValues {
const selector = tier.selector || {};
const hasSpecificMatch = Object.values(selector).some((value) => value && value !== "*") || (tier.resolution && tier.resolution !== "*") || tier.videoSeconds > 0;
const hasSpecificMatch = [selector.operation, selector.quality, selector.size].some((value) => value && value !== "*") || (tier.resolution && tier.resolution !== "*");
return {
matchMode: hasSpecificMatch ? "advanced" : "default",
operation: selector.operation || "*",
@@ -107,9 +109,6 @@ export function skuSelectorFromForm(capability: ModelCapabilityChoice, tier: Pri
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 (tier.videoGenerateAudio && tier.videoGenerateAudio !== "*") selector.videoGenerateAudio = tier.videoGenerateAudio;
}
if (capability === "image") {
if (tier.quality && tier.quality !== "*") selector.quality = tier.quality;
@@ -123,7 +122,9 @@ export function priceTierResolutionFromForm(capability: ModelCapabilityChoice, t
}
export function priceTierVideoSecondsFromForm(capability: ModelCapabilityChoice, tier: PriceTierFormValues) {
return capability === "video" && tier.matchMode === "advanced" ? Number(tier.videoSeconds || 0) : 0;
void capability;
void tier;
return 0;
}
export function priceTierPayloadFromForm(capability: ModelCapabilityChoice, tier: PriceTierFormValues, upstreamModel: string) {

View File

@@ -14,12 +14,21 @@ export function CreditCostFields({ index, form, billingMode, isVideo }: { index:
]
: [["costUnitPrice", billingMode === "per_second" ? "积分 / 秒" : "积分 / 次"]];
return (
<div className="admin-price-tier-block">
<Form.Item name={[index, "costConfigured"]} label="积分成本价(仅管理员可见)" valuePropName="checked" extra="按当前价格档的计费方式核算成本,不影响用户售价;开启并填 0 表示零成本。">
<Switch aria-label="配置积分成本价" />
</Form.Item>
<section className="admin-price-tier-cost-panel" aria-label="积分成本设置">
<header className="admin-price-tier-cost-header">
<div className="admin-price-tier-cost-heading">
<div className="admin-price-tier-cost-title">
<span>积分成本</span>
<span className="admin-price-tier-cost-badge">仅管理员可见</span>
</div>
<p>按当前计费方式填写成本,不影响用户售价。</p>
</div>
<Form.Item className="admin-price-tier-cost-switch mb-0" name={[index, "costConfigured"]} valuePropName="checked">
<Switch aria-label="配置积分成本价" />
</Form.Item>
</header>
{configured ? (
<div className={billingMode === "token" && !isVideo ? "admin-price-tier-token-grid" : "admin-price-tier-billing-grid"}>
<div className={billingMode === "token" && !isVideo ? "admin-price-tier-cost-grid admin-price-tier-cost-token-grid" : "admin-price-tier-cost-grid"}>
{fields.map(([field, label]) => (
<Form.Item key={field} className="mb-0" name={[index, field]} label={label} rules={[{ required: true, message: "请输入积分成本价" }]}>
<InputNumber className="w-full" min={0} max={1_000_000} precision={6} step={0.1} />
@@ -27,6 +36,6 @@ export function CreditCostFields({ index, form, billingMode, isVideo }: { index:
))}
</div>
) : null}
</div>
</section>
);
}

View File

@@ -322,7 +322,7 @@ export default function AdminPluginsPage() {
/>
}
/>
<UploadPluginModal open={uploadOpen} onClose={() => setUploadOpen(false)} onUpload={(file) => void upload(file)} />
<UploadPluginModal open={uploadOpen} onClose={() => setUploadOpen(false)} onUpload={upload} />
</AdminPageFrame>
);
}

View File

@@ -974,7 +974,9 @@
@media (prefers-reduced-motion: reduce) {
[data-admin-root] *,
body.admin-overlays * {
transition: none !important;
animation: none !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}

View File

@@ -0,0 +1,19 @@
/** Stored values remain stable; density controls card size, not a fixed column count. */
export const assetGridDensityOptions = [
{ label: "舒适", value: 6 },
{ label: "标准", value: 8 },
{ label: "紧凑", value: 10 },
];
export type AssetGridDensity = 6 | 8 | 10;
export const assetGridCardMinWidth: Record<AssetGridDensity, number> = {
6: 272,
8: 208,
10: 160,
};
export function parseAssetGridDensity(value: unknown): AssetGridDensity {
const number = Number(value);
return number === 6 || number === 10 ? number : 8;
}

View File

@@ -1,4 +1,5 @@
import { CollectionToolbar } from "@/components/layout/collection-toolbar";
import { assetGridCardMinWidth, assetGridDensityOptions, parseAssetGridDensity, type AssetGridDensity } from "./asset-grid-density";
import { DeleteButton } from "@/components/ui/base/buttons/delete-button";
import { AlertTriangle, AudioLines, Box, CheckCheck, Clapperboard, Copy, Download, FileText, FileUp, FolderOpen, FolderPlus, Image as ImageIcon, Images, LayoutGrid, Link2, Maximize2, MoreHorizontal, PencilLine, Play, Plus, RotateCcw, Search, Trash2, Upload, ZoomIn, ZoomOut, type LucideIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -62,7 +63,6 @@ const categoryOptions = [{ label: "全部分类", value: "all" }, ...ASSET_CATEG
const ASSET_LIBRARY_QUERY_KEY = ["asset-library"] as const;
const ASSET_FOLDER_QUERY_KEY = ["asset-folders"] as const;
const ASSET_GRID_DENSITY_KEY = "infinite-canvas:asset-grid-density";
type AssetGridDensity = 6 | 8 | 10;
type AssetFolderFilter = "all" | "uncategorized" | string;
const assetKindIcons: Record<LibraryAsset["kind"], LucideIcon> = {
@@ -604,11 +604,12 @@ export default function AssetsPage() {
}}
/>
<Select
aria-label="素材显示密度"
value={gridDensity}
className="w-full sm:w-32"
suffixIcon={<LayoutGrid className="size-3.5" />}
options={[{ label: "舒适", value: 6 }, { label: "标准", value: 8 }, { label: "紧凑", value: 10 }]}
onChange={(value) => setGridDensity(value as AssetGridDensity)}
prefix={<LayoutGrid aria-hidden className="size-3.5" />}
options={assetGridDensityOptions}
onChange={(value) => setGridDensity(parseAssetGridDensity(value))}
/>
</CollectionToolbar>
</div>
@@ -724,7 +725,7 @@ export default function AssetsPage() {
{visibleAssets.length === 0 ? (
<WorkspaceState icon="assets" compact title="没有匹配的素材" description="调整关键词或左侧分类后再试。" />
) : (
<CollectionGrid className="library-grid assets-library-grid" style={{ "--assets-grid-columns": gridDensity } as React.CSSProperties}>
<CollectionGrid className="library-grid assets-library-grid" style={{ "--collection-grid-min-width": `${assetGridCardMinWidth[gridDensity]}px` } as React.CSSProperties}>
{visibleAssets.map((asset) => (
<AssetCard
key={asset.id}
@@ -1495,8 +1496,7 @@ function assetDownloadLabel(asset: LibraryAsset) {
function readAssetGridDensity(): AssetGridDensity {
if (typeof window === "undefined") return 8;
const value = Number(window.localStorage.getItem(ASSET_GRID_DENSITY_KEY));
return value === 6 || value === 10 ? value : 8;
return parseAssetGridDensity(window.localStorage.getItem(ASSET_GRID_DENSITY_KEY));
}
function assetCountMap<T extends { label: string; value: string }>(options: T[], remote: Record<string, number> | undefined, fallback: LibraryAsset[], valueOf: (asset: LibraryAsset) => string) {

View File

@@ -19,6 +19,7 @@ import { nanoid } from "nanoid";
import { canvasAppearanceBaseTheme, canvasAppearanceForTheme, DEFAULT_CANVAS_BACKGROUND_MODE, normalizeCanvasAppearance, resolveCanvasAppearance, writeCanvasAppearanceDefault, type CanvasAppearance } from "@/lib/canvas/canvas-appearance";
import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme";
import { persistCanvasMediaPerformanceMode, readCanvasMediaPerformanceMode } from "@/lib/canvas/canvas-performance-mode";
import { filterCanvasDisplayConnections, persistCanvasHideNodeConnections, readCanvasHideNodeConnections } from "@/lib/canvas/canvas-connection-visibility";
import { summarizeCanvasContext } from "@/lib/canvas/canvas-context-summary";
import { refreshCanvasCharacterReferenceNodes } from "@/lib/canvas/canvas-character-reference";
import { useAssetStore } from "@/stores/use-asset-store";
@@ -307,6 +308,7 @@ function InfiniteCanvasPage() {
const [showImageInfo, setShowImageInfo] = useState(false);
const [canvasTool, setCanvasTool] = useState<CanvasToolMode>("box-select");
const [mediaPerformanceMode, setMediaPerformanceMode] = useState<CanvasMediaPerformanceMode>(readCanvasMediaPerformanceMode);
const [hideNodeConnections, setHideNodeConnections] = useState(readCanvasHideNodeConnections);
const [projectLoaded, setProjectLoaded] = useState(false);
const workspaceMode: CanvasWorkspaceMode = "professional";
const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
@@ -351,6 +353,10 @@ function InfiniteCanvasPage() {
persistCanvasMediaPerformanceMode(mediaPerformanceMode);
}, [mediaPerformanceMode]);
useEffect(() => {
persistCanvasHideNodeConnections(hideNodeConnections);
}, [hideNodeConnections]);
useEffect(() => {
didInitialCenterRef.current = false;
setNodeStackOrder([]);
@@ -1437,6 +1443,17 @@ function InfiniteCanvasPage() {
scriptEditorNodeId,
dialogNodeId,
});
const visibleDisplayConnections = useMemo(
() => filterCanvasDisplayConnections(displayConnections, {
enabled: hideNodeConnections,
hoveredNodeId,
selectedNodeIds,
activeNodeId,
selectedConnectionId,
}),
[activeNodeId, displayConnections, hideNodeConnections, hoveredNodeId, selectedConnectionId, selectedNodeIds],
);
useEffect(() => {
setNodes((current) => {
let changed = false;
@@ -2609,7 +2626,7 @@ function InfiniteCanvasPage() {
containerRef={containerRef}
viewport={viewport}
theme={theme}
displayConnections={displayConnections}
displayConnections={visibleDisplayConnections}
selectedConnectionId={selectedConnectionId}
relatedConnectionIds={relatedHighlight.connectionIds}
scriptScrollTopById={scriptScrollTopById}
@@ -2643,7 +2660,7 @@ function InfiniteCanvasPage() {
projectId={projectId}
viewportScale={viewport.k}
connectionLayerBounds={connectionLayerBounds}
displayConnections={displayConnections}
displayConnections={visibleDisplayConnections}
selectedConnectionId={selectedConnectionId}
relatedConnectionIds={relatedHighlight.connectionIds}
scriptScrollTopById={scriptScrollTopById}
@@ -2986,6 +3003,8 @@ function InfiniteCanvasPage() {
onScaleChange={setZoomScale}
onFitContent={fitCanvasContent}
onAutoArrange={autoArrangeCanvasNodes}
hideNodeConnections={hideNodeConnections}
onHideNodeConnectionsChange={setHideNodeConnections}
isMiniMapOpen={isMiniMapOpen}
onToggleMiniMap={() => setIsMiniMapOpen((value) => !value)}
onOpenShortcuts={() => setShortcutRequestNonce((value) => value + 1)}

View File

@@ -0,0 +1,23 @@
@layer utilities {
/* Creation surfaces use a compact, theme-aware scrollbar. */
.creation-scrollbar {
scrollbar-color: color-mix(in srgb, var(--user-ink, #fff) 22%, transparent) transparent;
scrollbar-gutter: auto;
scrollbar-width: thin;
}
.creation-scrollbar::-webkit-scrollbar { width: 6px; height: 6px; }
.creation-scrollbar::-webkit-scrollbar-track { background: transparent; }
.creation-scrollbar::-webkit-scrollbar-thumb {
border: 0;
border-radius: 999px;
background: color-mix(in srgb, var(--user-ink, #fff) 18%, transparent);
}
.creation-scrollbar::-webkit-scrollbar-thumb:hover { background: color-mix(in srgb, var(--user-ink, #fff) 32%, transparent); }
html:not(.dark) .creation-scrollbar {
scrollbar-color: color-mix(in srgb, var(--user-ink, #1b1b1f) 22%, transparent) transparent;
}
html:not(.dark) .creation-scrollbar::-webkit-scrollbar-thumb { background: color-mix(in srgb, var(--user-ink, #1b1b1f) 16%, transparent); }
html:not(.dark) .creation-scrollbar::-webkit-scrollbar-thumb:hover { background: color-mix(in srgb, var(--user-ink, #1b1b1f) 28%, transparent); }
}

View File

@@ -46,6 +46,7 @@ import { creationAttachmentKind, creationMediaAspectRatio, removeCreationAttachm
import { conversationTimestamp, isImageAttachment, isVideoAttachment } from "./creation-conversations";
import { conversationTimeFormatter, countOptions, historyDayFormatter, messageTimeFormatter, modeLabels, qualityOptions, ratioOptions, resolutionOptions, shotScriptLabels, type CreationConversation, type CreationMessage, type CreationShotRailEntry, type CreationStatus } from "./creation-types";
import "./creation-product.css";
import "./creation-scrollbars.css";
import { creationFeaturedWorks, inspirationSource } from "./creation-inspirations";
const CanvasPromptOptimizerDrawer = lazy(() => import("@/components/canvas/canvas-prompt-optimizer-drawer").then((module) => ({ default: module.CanvasPromptOptimizerDrawer })));

View File

@@ -1,6 +1,6 @@
import { Modal, Upload } from "antd";
import { App, Modal, Upload } from "antd";
import { CloudUpload, FileText, ShieldCheck } from "lucide-react";
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { RegisteredPlugin } from "@/lib/plugins/plugin-types";
@@ -12,12 +12,30 @@ import "./plugins.css";
type UploadPluginModalProps = {
open: boolean;
onClose: () => void;
onUpload: (file: File) => void;
onUpload: (file: File) => void | Promise<void>;
};
const MANIFEST_EXAMPLE = pluginDevelopmentGuideMarkdown.match(/```json\s*([\s\S]*?)```/)?.[1].trim();
export function UploadPluginModal({ open, onClose, onUpload }: UploadPluginModalProps) {
const { message } = App.useApp();
const [activeTab, setActiveTab] = useState<"install" | "guide">("install");
const [isDraggingPlugin, setIsDraggingPlugin] = useState(false);
const [uploading, setUploading] = useState(false);
const [copied, setCopied] = useState(false);
const dragDepth = useRef(0);
const uploadInFlight = useRef(false);
const copyTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => {
if (!open) {
setActiveTab("install");
setIsDraggingPlugin(false);
setCopied(false);
dragDepth.current = 0;
}
return () => clearTimeout(copyTimer.current);
}, [open]);
const handlePluginDragEnter = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
@@ -40,31 +58,85 @@ export function UploadPluginModal({ open, onClose, onUpload }: UploadPluginModal
setIsDraggingPlugin(false);
};
const handleExecuteUpload = async (file: File) => {
if (uploadInFlight.current) return;
uploadInFlight.current = true;
setUploading(true);
try {
await onUpload(file);
} catch (error) {
message.error(error instanceof Error ? error.message : "安装插件失败");
} finally {
uploadInFlight.current = false;
setUploading(false);
}
};
const copySnippet = async () => {
try {
if (!MANIFEST_EXAMPLE) throw new Error("开发规范中缺少清单示例");
if (!navigator.clipboard) throw new Error("当前环境不支持剪贴板,请从开发规范中手动复制");
await navigator.clipboard.writeText(MANIFEST_EXAMPLE);
setCopied(true);
clearTimeout(copyTimer.current);
copyTimer.current = setTimeout(() => setCopied(false), 2000);
} catch (error) {
message.error(error instanceof Error ? error.message : "复制失败,请手动复制");
}
};
const isCompact = activeTab === "install";
return (
<Modal
className="workspace-modal workspace-modal-wide plugin-upload-modal"
className={`workspace-modal plugin-upload-modal ${isCompact ? "plugin-upload-modal-compact" : "workspace-modal-wide"}`}
title="上传插件"
open={open}
centered
footer={null}
destroyOnHidden
onCancel={onClose}
styles={{ body: { maxHeight: "min(82vh, 900px)", overflowY: "auto", overscrollBehavior: "contain" } }}
closable={!uploading}
mask={{ closable: !uploading }}
onCancel={uploading ? undefined : onClose}
styles={{ body: { maxHeight: "min(84vh, 900px)", overflowY: "auto", overscrollBehavior: "contain", padding: "16px 20px 24px" } }}
>
<div className="plugin-upload-layout">
<section className="plugin-upload-guide">
<PluginMarkdown source={pluginDevelopmentGuideMarkdown} />
</section>
<aside className="plugin-upload-panel">
<div className="plugin-upload-tabs-bar">
<div className="plugin-upload-tabs-nav">
<button type="button" className={`plugin-upload-tab-btn ${activeTab === "install" ? "is-active" : ""}`} aria-pressed={activeTab === "install"} onClick={() => setActiveTab("install")}>
<CloudUpload className="size-4" />
<span>安装插件包</span>
</button>
<button type="button" className={`plugin-upload-tab-btn ${activeTab === "guide" ? "is-active" : ""}`} aria-pressed={activeTab === "guide"} onClick={() => setActiveTab("guide")}>
<FileText className="size-4" />
<span>开发规范与示例</span>
</button>
</div>
{activeTab === "guide" ? (
<button type="button" className="plugin-upload-copy-btn" onClick={() => void copySnippet()} title="复制开发规范中的 manifest.json 配置示例">
<span>{copied ? "已复制清单" : "复制清单示例"}</span>
</button>
) : (
<div className="plugin-upload-badge">
<ShieldCheck className="size-3.5 text-status-success" />
<span>沙箱受控隔离</span>
</div>
)}
</div>
{activeTab === "install" ? (
<div className="plugin-upload-install-view">
<div className="plugin-upload-panel-heading">
<span className="plugin-upload-panel-icon"><CloudUpload className="size-5" /></span>
<span className="plugin-upload-panel-icon">
<CloudUpload className="size-5" />
</span>
<div>
<h2>安装插件包</h2>
<p>选择统一站点插件包,安装后会立即进入插件中心。</p>
</div>
</div>
<div
className={`plugin-upload-dropzone-shell${isDraggingPlugin ? " is-dragging" : ""}`}
className={`plugin-upload-dropzone-shell${isDraggingPlugin ? " is-dragging" : ""}${uploading ? " is-uploading" : ""}`}
onDragEnter={handlePluginDragEnter}
onDragLeave={handlePluginDragLeave}
onDragOver={(event) => event.preventDefault()}
@@ -72,25 +144,54 @@ export function UploadPluginModal({ open, onClose, onUpload }: UploadPluginModal
>
<Upload.Dragger
className="plugin-upload-dropzone"
accept=".yingce-plugin,application/zip"
accept=".yingce-plugin,.zip"
maxCount={1}
disabled={uploading}
showUploadList={false}
beforeUpload={(file) => {
onUpload(file);
void handleExecuteUpload(file);
return false;
}}
>
<CloudUpload className="plugin-upload-dropzone-icon" />
<p className="ant-upload-text">{isDraggingPlugin ? "释放文件以上传插件" : "点击选择插件文件,也可拖拽到此处"}</p>
<p className="ant-upload-hint">支持 .yingce-plugin 包 · 大小不超过 48 MiB</p>
{uploading ? (
<div className="py-6 flex flex-col items-center justify-center gap-3">
<div className="size-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<p className="ant-upload-text">正在安装插件包,请稍候...</p>
<p className="ant-upload-hint">正在解压、校验清单合法性与版本声明</p>
</div>
) : (
<>
<CloudUpload className="plugin-upload-dropzone-icon" />
<p className="ant-upload-text">{isDraggingPlugin ? "释放文件以上传插件" : "点击选择插件文件,也可拖拽到此处"}</p>
<p className="ant-upload-hint">支持 .yingce-plugin 包 · 大小不超过 48 MiB</p>
</>
)}
</Upload.Dragger>
</div>
<div className="plugin-upload-notice">
<ShieldCheck className="size-4" />
<ShieldCheck className="size-4 shrink-0 text-status-success" />
<span>上传前请确认插件来源可信。Web 入口只能进入声明的隔离运行时,不会获得主页面权限;密钥也不会从清单读取。</span>
</div>
</aside>
</div>
<div className="plugin-upload-switch-guide-hint">
<span>初次制作插件?</span>
<button type="button" onClick={() => setActiveTab("guide")} className="plugin-upload-link-btn">
查看《开发与打包规范说明》
</button>
</div>
</div>
) : (
<div className="plugin-upload-guide-view">
<div className="plugin-upload-guide-banner">
<ShieldCheck className="size-4 shrink-0 text-primary" />
<span>以下为开发者技术规范与清单编写参考(只读文档)。安装插件请切换至「安装插件包」。</span>
</div>
<section className="plugin-upload-guide">
<PluginMarkdown source={pluginDevelopmentGuideMarkdown} />
</section>
</div>
)}
</Modal>
);
}
@@ -105,13 +206,15 @@ export function PluginDetailsModal({ plugin, restoreFocus, onClose }: PluginDeta
return (
<Modal
className="workspace-modal workspace-modal-wide plugin-details-modal"
title={plugin ? (
<div className="plugin-details-title">
<FileText className="size-4" />
<span>{plugin.manifest.name}</span>
<span className="plugin-version">v{plugin.manifest.version}</span>
</div>
) : null}
title={
plugin ? (
<div className="plugin-details-title">
<FileText className="size-4" />
<span>{plugin.manifest.name}</span>
<span className="plugin-version">v{plugin.manifest.version}</span>
</div>
) : null
}
open={Boolean(plugin)}
centered
footer={null}

View File

@@ -485,7 +485,135 @@
}
.workspace-modal.plugin-upload-modal {
width: min(1480px, calc(100vw - var(--space-4))) !important;
width: min(1120px, calc(100vw - var(--space-4))) !important;
}
.workspace-modal.plugin-upload-modal.plugin-upload-modal-compact {
width: min(620px, calc(100vw - var(--space-4))) !important;
}
.plugin-upload-tabs-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-4);
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--workspace-border);
}
.plugin-upload-tabs-nav {
display: inline-flex;
align-items: center;
padding: 3px;
border-radius: var(--r-md);
background: color-mix(in srgb, var(--foreground) 6%, transparent);
gap: 2px;
}
.plugin-upload-tab-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
border: none;
border-radius: var(--r-sm);
background: transparent;
color: color-mix(in srgb, var(--foreground) 60%, transparent);
font-size: var(--fs-label);
font-weight: 550;
cursor: pointer;
transition: all 0.18s ease;
}
.plugin-upload-tab-btn:hover {
color: var(--foreground);
}
.plugin-upload-tab-btn.is-active {
background: var(--workspace-surface-strong, #ffffff);
color: var(--workspace-accent, #1677ff);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.plugin-upload-badge {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: var(--fs-caption);
color: color-mix(in srgb, var(--foreground) 50%, transparent);
}
.plugin-upload-copy-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border: 1px solid var(--workspace-border);
border-radius: var(--r-sm);
background: transparent;
color: color-mix(in srgb, var(--foreground) 75%, transparent);
font-size: var(--fs-caption);
cursor: pointer;
transition: all 0.15s ease;
}
.plugin-upload-copy-btn:hover {
border-color: var(--workspace-accent);
color: var(--workspace-accent);
}
.plugin-upload-install-view {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.plugin-upload-dropzone-shell.is-uploading {
pointer-events: none;
opacity: 0.85;
}
.plugin-upload-switch-guide-hint {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
font-size: var(--fs-label);
color: color-mix(in srgb, var(--foreground) 45%, transparent);
margin-top: var(--space-1);
}
.plugin-upload-link-btn {
background: transparent;
border: none;
padding: 0;
color: var(--workspace-accent, #1677ff);
cursor: pointer;
text-decoration: underline;
text-underline-offset: 3px;
font-size: inherit;
}
.plugin-upload-link-btn:hover {
opacity: 0.8;
}
.plugin-upload-guide-view {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.plugin-upload-guide-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: var(--r-md);
background: color-mix(in srgb, var(--workspace-accent) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--workspace-accent) 20%, transparent);
font-size: var(--fs-label);
color: color-mix(in srgb, var(--foreground) 75%, transparent);
}
.plugin-upload-layout {

View File

@@ -148,7 +148,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
capability: nextCapability,
billingMode: "fixed_request",
unitPriceMicrocredits: 0,
capabilityConfig: nextCapability === "image" || nextCapability === "video" ? defaultModelCapabilityConfig(nextProtocol, activeModel) : undefined,
capabilityConfig: nextCapability === "text" || nextCapability === "image" || nextCapability === "video" ? defaultModelCapabilityConfig(nextProtocol, activeModel) : undefined,
});
}}
/>
@@ -165,7 +165,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
protocol: nextProtocol,
billingMode: "fixed_request",
unitPriceMicrocredits: 0,
capabilityConfig: activeCapability === "image" || activeCapability === "video" ? defaultModelCapabilityConfig(nextProtocol, activeModel) : undefined,
capabilityConfig: activeCapability === "text" || activeCapability === "image" || activeCapability === "video" ? defaultModelCapabilityConfig(nextProtocol, activeModel) : undefined,
})}
/>
</section>
@@ -175,7 +175,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
key: "capabilities",
label: "能力与参数",
children: <div inert={Boolean(testingModel)}>
{activeCapability === "image" || activeCapability === "video" ? (
{activeCapability === "text" || activeCapability === "image" || activeCapability === "video" ? (
<ModelCapabilityEditor
capability={activeCapability}
model={activeModel}