feat(admin): 添加模型算力点配置和扣费功能

- 在管理后台设置页面添加模型算力点配置表格
- 实现后端AI接口调用时按模型扣除用户算力点
- 添加算力点余额检查和不足时的错误处理
- 在credit_logs中记录AI调用消费流水
- 更新前端远程调用后刷新用户信息显示
- 文档中补充模型算力点配置说明
This commit is contained in:
HouYunFei
2026-05-25 14:01:04 +08:00
parent 392bd49d8c
commit 2181b3b885
14 changed files with 262 additions and 18 deletions
+82 -5
View File
@@ -7,7 +7,7 @@ import dynamic from "next/dynamic";
import { useEffect, useMemo, useState } from "react";
import { EditorView } from "@uiw/react-codemirror";
import { fetchAdminSettings, fetchChannelModels, saveAdminSettings, testChannelModel, type AdminModelChannel, type AdminSettings } from "@/services/api/admin";
import { fetchAdminSettings, fetchChannelModels, saveAdminSettings, testChannelModel, type AdminModelChannel, type AdminModelCost, type AdminSettings } from "@/services/api/admin";
import { useUserStore } from "@/stores/use-user-store";
const CodeMirror = dynamic(() => import("@uiw/react-codemirror"), { ssr: false });
@@ -28,6 +28,7 @@ const emptySettings: AdminSettings = {
public: {
modelChannel: {
availableModels: [],
modelCosts: [],
defaultModel: "",
defaultImageModel: "",
defaultVideoModel: "",
@@ -62,10 +63,12 @@ export default function AdminSettingsPage() {
const [testResults, setTestResults] = useState<Record<string, { status: "success" | "error"; duration?: string; message: string }>>({});
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [modelCosts, setModelCosts] = useState<AdminModelCost[]>([]);
const [knownModels, setKnownModels] = useState<string[]>([]);
const publicModels = Form.useWatch(["public", "modelChannel", "availableModels"], form) || [];
const channelModels = useMemo(() => collectChannelModels(channels), [channels]);
const channelTableData = useMemo(() => channels.map((channel, index) => ({ ...channel, _index: index, _rowKey: `${index}-${channel.name}-${channel.baseUrl}` })), [channels]);
const modelOptions = useMemo(() => uniqueModels([...publicModels, ...channelModels]), [publicModels, channelModels]);
const modelOptions = useMemo(() => uniqueModels([...knownModels, ...publicModels, ...channelModels, ...modelCosts.map((item) => item.model)]), [knownModels, publicModels, channelModels, modelCosts]);
const activeMode = editorMode[activeTab];
const activeJsonText = jsonText[activeTab];
const jsonError = activeMode === "json" ? getJsonError(activeJsonText) : "";
@@ -77,6 +80,8 @@ export default function AdminSettingsPage() {
const data = normalizeSettings(await fetchAdminSettings(token));
form.setFieldsValue(data);
setChannels(data.private.channels);
setModelCosts(data.public.modelChannel.modelCosts);
setKnownModels(collectKnownModels(data));
setJsonText({
public: JSON.stringify(data.public, null, 2),
private: JSON.stringify(data.private, null, 2),
@@ -108,6 +113,8 @@ export default function AdminSettingsPage() {
const merged = mergeChannelApiKeys(values.private.channels, saved);
form.setFieldsValue(merged);
setChannels(merged.private.channels);
setModelCosts(merged.public.modelChannel.modelCosts);
rememberKnownModels(merged);
setJsonText({
public: JSON.stringify(merged.public, null, 2),
private: JSON.stringify(merged.private, null, 2),
@@ -136,6 +143,8 @@ export default function AdminSettingsPage() {
}
form.setFieldsValue({ [tab]: parsed } as Partial<AdminSettings>);
if (tab === "private") setChannels((parsed as AdminSettings["private"]).channels);
if (tab === "public") setModelCosts((parsed as AdminSettings["public"]).modelChannel.modelCosts);
rememberKnownModels({ ...normalizeSettings(form.getFieldsValue(true) as AdminSettings), [tab]: parsed });
setEditorMode((current) => ({ ...current, [tab]: nextMode }));
};
@@ -145,6 +154,7 @@ export default function AdminSettingsPage() {
message.error("JSON 格式不正确");
return;
}
if (tab === "public") setModelCosts((parsed as AdminSettings["public"]).modelChannel.modelCosts);
setJsonText((current) => ({
...current,
[tab]: JSON.stringify(parsed, null, 2),
@@ -154,7 +164,9 @@ export default function AdminSettingsPage() {
const openChannelDrawer = (index: number | null) => {
setEditingChannelIndex(index);
setIsChannelDrawerOpen(true);
channelForm.setFieldsValue(index === null ? emptyChannel : normalizeChannel(channels[index]));
const channel = index === null ? emptyChannel : normalizeChannel(channels[index]);
channelForm.setFieldsValue(channel);
rememberModels(channel.models);
};
const closeChannelDrawer = () => {
@@ -165,6 +177,7 @@ export default function AdminSettingsPage() {
const saveChannel = async () => {
const channel = normalizeChannel(await channelForm.validateFields());
rememberModels(channel.models);
const nextChannels = [...channels];
if (editingChannelIndex === null) nextChannels.push(channel);
else nextChannels[editingChannelIndex] = channel;
@@ -186,12 +199,21 @@ export default function AdminSettingsPage() {
try {
const channelModels = await fetchChannelModels(token, { index: editingChannelIndex ?? undefined, channel: normalizeChannel(channel) });
channelForm.setFieldValue("models", channelModels);
rememberModels(channelModels);
message.success(`已获取 ${channelModels.length} 个模型`);
} catch (error) {
message.error(error instanceof Error ? error.message : "读取模型失败");
}
};
function rememberModels(models: string[]) {
setKnownModels((current) => uniqueModels([...current, ...models]));
}
function rememberKnownModels(settings: AdminSettings) {
rememberModels(collectKnownModels(settings));
}
const openTestDialog = (index: number) => {
const channel = normalizeChannel(channels[index]);
if (!channel.baseUrl || channel.models.length === 0) {
@@ -248,6 +270,8 @@ export default function AdminSettingsPage() {
const saved = normalizeSettings(await saveAdminSettings(token, nextSettings));
const merged = mergeChannelApiKeys(nextChannels, saved);
setChannels(merged.private.channels);
setModelCosts(merged.public.modelChannel.modelCosts);
rememberKnownModels(merged);
form.setFieldsValue(merged);
setJsonText({
public: JSON.stringify(merged.public, null, 2),
@@ -313,7 +337,7 @@ export default function AdminSettingsPage() {
<Form form={form} layout="vertical" initialValues={emptySettings} requiredMark={false}>
<Row gutter={16}>
<Col span={24}>
<Form.Item name={["public", "modelChannel", "availableModels"]} label="系统可用模型(请先配置渠道)">
<Form.Item name={["public", "modelChannel", "availableModels"]} label="系统可用模型(请先在私有配置里配置渠道)">
<Select mode="tags" tokenSeparators={[",", "\n"]} options={modelOptions.map((item) => ({ label: item, value: item }))} />
</Form.Item>
</Col>
@@ -347,6 +371,34 @@ export default function AdminSettingsPage() {
<Switch />
</Form.Item>
</Col>
<Col span={24}>
<Typography.Title level={5}></Typography.Title>
<Table
rowKey="model"
pagination={false}
size="small"
dataSource={publicModels.map((model) => ({ model, credits: modelCostCredits(modelCosts, model) }))}
columns={[
{ title: "模型", dataIndex: "model" },
{
title: "每次调用扣除",
dataIndex: "credits",
width: 220,
render: (_, item) => (
<InputNumber
min={0}
step={1}
precision={0}
className="!w-full"
value={item.credits}
addonAfter="点"
onChange={(value) => setModelCost(form, setModelCosts, item.model, Number(value) || 0)}
/>
),
},
]}
/>
</Col>
</Row>
</Form>
) : (
@@ -530,7 +582,7 @@ export default function AdminSettingsPage() {
<Form.Item label="渠道可用模型">
<Space.Compact style={{ width: "100%" }}>
<Form.Item name="models" noStyle>
<Select mode="tags" tokenSeparators={[",", "\n"]} />
<Select mode="tags" maxTagCount="responsive" tokenSeparators={[",", "\n"]} options={knownModels.map((model) => ({ label: model, value: model }))} />
</Form.Item>
<Button icon={<ReloadOutlined />} onClick={() => void fetchChannelModelList()}>
@@ -634,6 +686,7 @@ function normalizePublicSetting(setting: Partial<AdminSettings["public"]> = {}):
...emptySettings.public.modelChannel,
...(setting.modelChannel || {}),
availableModels: setting.modelChannel?.availableModels || [],
modelCosts: normalizeModelCosts(setting.modelChannel?.modelCosts || []),
},
auth: {
linuxDo: {
@@ -643,6 +696,10 @@ function normalizePublicSetting(setting: Partial<AdminSettings["public"]> = {}):
};
}
function normalizeModelCosts(items: Partial<AdminSettings["public"]["modelChannel"]["modelCosts"][number]>[]) {
return items.filter((item) => item.model).map((item) => ({ model: item.model || "", credits: Math.max(0, Number(item.credits) || 0) }));
}
function normalizePrivateSetting(setting: Partial<AdminSettings["private"]> = {}): AdminSettings["private"] {
return {
channels: (setting.channels || []).map(normalizeChannel),
@@ -672,6 +729,18 @@ function normalizeChannel(item: Partial<AdminModelChannel> = {}): AdminModelChan
};
}
function modelCostCredits(items: AdminSettings["public"]["modelChannel"]["modelCosts"], model: string) {
return items.find((item) => item.model === model)?.credits || 0;
}
function setModelCost(form: any, setModelCosts: (items: AdminModelCost[]) => void, model: string, credits: number) {
const current = (form.getFieldValue(["public", "modelChannel", "modelCosts"]) || []) as AdminSettings["public"]["modelChannel"]["modelCosts"];
const next = current.filter((item) => item.model !== model);
next.push({ model, credits: Math.max(0, credits) });
form.setFieldValue(["public", "modelChannel", "modelCosts"], next);
setModelCosts(next);
}
function mergeChannelApiKeys(currentChannels: AdminModelChannel[], saved: AdminSettings): AdminSettings {
const channels = saved.private.channels.map((item, index) => ({
...item,
@@ -687,6 +756,14 @@ function collectChannelModels(channels: AdminModelChannel[]) {
return uniqueModels(channels.filter((channel) => channel.enabled).flatMap((channel) => channel.models || []));
}
function collectKnownModels(settings: AdminSettings) {
return uniqueModels([
...(settings.public.modelChannel.availableModels || []),
...(settings.public.modelChannel.modelCosts || []).map((item) => item.model),
...settings.private.channels.flatMap((channel) => channel.models || []),
]);
}
function uniqueModels(models: string[]) {
return Array.from(new Set(models.filter(Boolean)));
}
+6
View File
@@ -169,6 +169,7 @@ export type AdminModelChannel = {
export type AdminPublicModelChannelSettings = {
availableModels: string[];
modelCosts: AdminModelCost[];
defaultModel: string;
defaultImageModel: string;
defaultVideoModel: string;
@@ -177,6 +178,11 @@ export type AdminPublicModelChannelSettings = {
allowCustomChannel: boolean;
};
export type AdminModelCost = {
model: string;
credits: number;
};
export type AdminPublicSettings = {
modelChannel: AdminPublicModelChannelSettings;
auth: {
+11 -2
View File
@@ -121,6 +121,10 @@ function aiHeaders(config: AiConfig, contentType?: string) {
};
}
function refreshRemoteUser(config: AiConfig) {
if (config.channelMode === "remote") void useUserStore.getState().hydrateUser();
}
function withSystemMessage(config: AiConfig, messages: ChatCompletionMessage[]) {
const systemPrompt = config.systemPrompt.trim();
return systemPrompt ? [{ role: "system" as const, content: systemPrompt }, ...messages] : messages;
@@ -143,7 +147,9 @@ export async function requestGeneration(config: AiConfig, prompt: string) {
headers: aiHeaders(config, "application/json"),
},
);
return parseImagePayload(response.data);
const images = parseImagePayload(response.data);
refreshRemoteUser(config);
return images;
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
}
@@ -166,7 +172,9 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
try {
const response = await axios.post<ImageApiResponse>(aiApiUrl(config, "/images/edits"), formData, { headers: aiHeaders(config) });
return parseImagePayload(response.data);
const images = parseImagePayload(response.data);
refreshRemoteUser(config);
return images;
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
}
@@ -230,6 +238,7 @@ export async function requestImageQuestion(config: AiConfig, messages: ChatCompl
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
}
refreshRemoteUser(config);
return answer || "没有返回内容";
}
+5
View File
@@ -17,6 +17,10 @@ function aiHeaders(config: AiConfig) {
return config.channelMode === "remote" ? (token ? { Authorization: `Bearer ${token}` } : undefined) : { Authorization: `Bearer ${config.apiKey}` };
}
function refreshRemoteUser(config: AiConfig) {
if (config.channelMode === "remote") void useUserStore.getState().hydrateUser();
}
export async function requestVideoGeneration(config: AiConfig, prompt: string, references: ReferenceImage[] = []) {
const model = config.model || config.videoModel;
const body = new FormData();
@@ -34,6 +38,7 @@ export async function requestVideoGeneration(config: AiConfig, prompt: string, r
await new Promise((resolve) => setTimeout(resolve, 2500));
}
const content = await axios.get<Blob>(aiApiUrl(config, `/videos/${created.data.id}/content`), { headers: aiHeaders(config), params: config.channelMode === "remote" ? { model } : undefined, responseType: "blob" });
refreshRemoteUser(config);
return content.data;
}