feat(platform): 开源发布 - 初始化 Open AI Canvas

This commit is contained in:
ddCat
2026-07-22 14:18:14 +08:00
commit aea35896eb
365 changed files with 66410 additions and 0 deletions

View File

@@ -0,0 +1,66 @@
import { Modal, Tag } from "antd";
import { motion, useReducedMotion } from "motion/react";
import { ScrollText } from "lucide-react";
import { useState, type CSSProperties } from "react";
import ReactMarkdown from "react-markdown";
import { aceternityMotion } from "@/lib/aceternity-motion";
export const APP_VERSION = __APP_VERSION__;
type AppChangelogButtonProps = {
className?: string;
style?: CSSProperties;
showVersion?: boolean;
};
export function AppChangelogButton({ className, style, showVersion = false }: AppChangelogButtonProps) {
const [open, setOpen] = useState(false);
return (
<>
<button type="button" className={className} style={style} onClick={() => setOpen(true)} aria-label="查看更新日志" title="更新日志">
<ScrollText className="size-4 shrink-0" />
{showVersion ? <span className="truncate">更新日志 · v{APP_VERSION.replace(/^v/, "")}</span> : null}
</button>
<AppChangelogModal open={open} onClose={() => setOpen(false)} />
</>
);
}
function AppChangelogModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const reducedMotion = useReducedMotion();
return (
<Modal
rootClassName="app-spatial-modal"
title={<div className="flex min-w-0 items-center gap-3 pr-8"><span className="grid size-9 shrink-0 place-items-center rounded-full border border-border bg-muted/45"><ScrollText className="size-4" /></span><div className="min-w-0"><div className="flex items-center gap-2 text-base font-semibold">更新日志<Tag bordered={false}>v{APP_VERSION.replace(/^v/, "")}</Tag></div><div className="mt-0.5 text-xs font-normal text-foreground/45">产品能力、交互与稳定性变化</div></div></div>}
open={open}
width={760}
footer={null}
centered
onCancel={onClose}
styles={{ body: { maxHeight: "min(72vh, 760px)", overflowY: "auto", overscrollBehavior: "contain" } }}
modalRender={(node) => (
<motion.div initial={reducedMotion ? false : { opacity: 0, y: 14, scale: 0.975 }} animate={{ opacity: 1, y: 0, scale: 1 }} transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }}>
{node}
</motion.div>
)}
>
<div className="thin-scrollbar pr-2 text-sm leading-6 text-foreground/75">
<ReactMarkdown
components={{
h1: ({ children }) => <h2 className="mb-4 text-xl font-semibold text-foreground">{children}</h2>,
h2: ({ children }) => <h3 className="mb-2 mt-6 border-b border-border pb-2 text-base font-semibold text-foreground first:mt-0">{children}</h3>,
ul: ({ children }) => <ul className="space-y-2 pl-5">{children}</ul>,
li: ({ children }) => <li className="list-disc pl-1 marker:text-foreground/35">{children}</li>,
p: ({ children }) => <p className="my-2">{children}</p>,
code: ({ children }) => <code className="rounded bg-muted px-1 py-0.5 text-xs text-foreground">{children}</code>,
}}
>
{__APP_CHANGELOG__}
</ReactMarkdown>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,762 @@
import { App, Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Tag, Tooltip } from "antd";
import { Boxes, CircleCheck, Cloud, Info, Plus, RadioTower, RefreshCw, SlidersHorizontal, Trash2 } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { ModelIcon, ModelPicker } from "@/components/model-picker";
import { UserOSSSettingsForm } from "@/components/layout/user-oss-settings-form";
import { refreshSystemChannels } from "@/lib/user-session";
import { fetchChannelModels } from "@/services/api/image";
import { audioFormatOptions, audioVoiceOptions, normalizeAudioSpeedValue } from "@/lib/audio-generation";
import {
createModelChannel,
defaultBaseUrlForApiFormat,
defaultBaseUrlForChannelInterface,
defaultConfig,
modelOptionLabel,
modelOptionName,
modelOptionsFromChannels,
normalizeModelOptionValue,
resolveModelChannel,
useConfigStore,
type AiConfig,
type ChannelInterfaceType,
type ModelCapability,
type ModelChannel,
} from "@/stores/use-config-store";
import { useUserStore } from "@/stores/use-user-store";
type ModelGroup = {
capability: ModelCapability;
modelKey: "imageModel" | "videoModel" | "textModel" | "audioModel";
modelsKey: "imageModels" | "videoModels" | "textModels" | "audioModels";
defaultLabel: string;
optionsLabel: string;
};
type ModelSelectOption = {
label: ReactNode;
title: string;
value: string;
};
type ModelSelectOptionGroup = {
label: ReactNode;
title: string;
options: ModelSelectOption[];
};
type ConfigSectionKey = "channels" | "models" | "preferences" | "storage";
const configSections: Array<{ key: ConfigSectionKey; label: string; description: string; icon: ReactNode }> = [
{ key: "channels", label: "自定义渠道", description: "连接你自己的模型服务", icon: <RadioTower className="size-4" /> },
{ key: "models", label: "模型选择", description: "设置可选项和默认模型", icon: <Boxes className="size-4" /> },
{ key: "preferences", label: "生成偏好", description: "画布、音频与系统提示词", icon: <SlidersHorizontal className="size-4" /> },
{ key: "storage", label: "我的 OSS", description: "管理个人媒体存储", icon: <Cloud className="size-4" /> },
];
const modelGroups: ModelGroup[] = [
{ capability: "image", modelKey: "imageModel", modelsKey: "imageModels", defaultLabel: "默认生图模型", optionsLabel: "生图模型可选项" },
{ capability: "video", modelKey: "videoModel", modelsKey: "videoModels", defaultLabel: "默认视频模型", optionsLabel: "视频模型可选项" },
{ capability: "text", modelKey: "textModel", modelsKey: "textModels", defaultLabel: "默认文本模型", optionsLabel: "文本模型可选项" },
{ capability: "audio", modelKey: "audioModel", modelsKey: "audioModels", defaultLabel: "默认音频模型", optionsLabel: "音频模型可选项" },
];
type UserChannelProtocol = ChannelInterfaceType | "auto" | "gemini";
const channelProtocolOptions = [
{ label: "OpenAI 自动兼容", value: "auto" },
{ label: "Gemini 原生", value: "gemini" },
{
label: "文本",
options: [
{ label: "Chat Completions", value: "chat-completion" },
{ label: "OpenAI Responses", value: "openai-response" },
],
},
{ label: "图片", options: [{ label: "OpenAI Images", value: "openai-image" }] },
{
label: "视频",
options: [
{ label: "NewAPI 视频", value: "newapi" },
{ label: "NewAPI 渠道 1", value: "newapi-channel-1" },
{ label: "NewAPI 渠道 2", value: "newapi-channel-2" },
],
},
];
export function AppConfigModal() {
const { message } = App.useApp();
const [activeTab, setActiveTab] = useState<ConfigSectionKey>("channels");
const [loadingChannelIds, setLoadingChannelIds] = useState<string[]>([]);
const config = useConfigStore((state) => state.config);
const updateConfig = useConfigStore((state) => state.updateConfig);
const replaceConfig = useConfigStore((state) => state.replaceConfig);
const isConfigOpen = useConfigStore((state) => state.isConfigOpen);
const shouldPromptContinue = useConfigStore((state) => state.shouldPromptContinue);
const configDialogSection = useConfigStore((state) => state.configDialogSection);
const setConfigDialogOpen = useConfigStore((state) => state.setConfigDialogOpen);
const clearPromptContinue = useConfigStore((state) => state.clearPromptContinue);
const userId = useUserStore((state) => state.user?.id);
const userChannels = config.channels.filter((channel) => channel.scope !== "system");
const modelOptionGroups = buildModelOptionGroups(config);
const availableModelSet = new Set(config.models);
useEffect(() => {
if (isConfigOpen && configDialogSection) setActiveTab(configDialogSection);
}, [configDialogSection, isConfigOpen]);
useEffect(() => {
if (!isConfigOpen || !userId) return;
let cancelled = false;
void refreshSystemChannels().catch((error) => {
if (!cancelled) message.warning(error instanceof Error ? `系统模型刷新失败:${error.message}` : "系统模型刷新失败,继续使用本地缓存");
});
return () => {
cancelled = true;
};
}, [isConfigOpen, message, userId]);
const finishConfig = () => {
const invalidChannel = userChannels.find((channel) => channelValidationError(channel));
if (invalidChannel) {
setActiveTab("channels");
message.warning(`${invalidChannel.name || "未命名渠道"}:${channelValidationError(invalidChannel)}`);
focusInvalidChannelField(invalidChannel);
return;
}
const ready = config.channels.some(isChannelReady);
if (shouldPromptContinue && !ready) {
setActiveTab("channels");
message.error("请先完成至少一个渠道的 Base URL、API Key 和模型配置");
return;
}
setConfigDialogOpen(false);
if (ready) message.success(shouldPromptContinue ? "配置已保存,请继续刚才的请求" : "配置已保存");
clearPromptContinue();
};
const closeConfig = () => {
setConfigDialogOpen(false);
clearPromptContinue();
};
const updateChannels = (channels: ModelChannel[], baseConfig = config) => {
replaceConfig(withChannels(baseConfig, channels));
};
const updateChannel = (id: string, patch: Partial<ModelChannel>) => {
updateChannels(config.channels.map((channel) => (channel.id === id ? { ...channel, ...patch, models: patch.models ? uniqueModels(patch.models) : channel.models } : channel)));
};
const updateChannelProtocol = (channel: ModelChannel, protocol: UserChannelProtocol) => {
const apiFormat = protocol === "gemini" ? "gemini" : "openai";
const interfaceType = protocol === "auto" || protocol === "gemini" ? undefined : protocol;
const defaultBaseUrl = protocol === "gemini" ? defaultBaseUrlForApiFormat("gemini") : defaultBaseUrlForChannelInterface(interfaceType);
const baseUrl = isKnownDefaultBaseUrl(channel.baseUrl) ? defaultBaseUrl : channel.baseUrl;
updateChannel(channel.id, { apiFormat, interfaceType, baseUrl });
};
const addChannel = () => {
const channel = createModelChannel({ name: `渠道 ${userChannels.length + 1}` });
updateChannels([...config.channels, channel]);
requestAnimationFrame(() => document.getElementById(`channel-${channel.id}-name`)?.focus());
};
const deleteChannel = (id: string) => {
const channel = config.channels.find((item) => item.id === id);
if (channel?.scope === "system") {
message.warning("系统渠道由管理员维护");
return;
}
updateChannels(config.channels.filter((item) => item.id !== id));
};
const setChannelLoading = (id: string, loading: boolean) => {
setLoadingChannelIds((items) => (loading ? Array.from(new Set([...items, id])) : items.filter((item) => item !== id)));
};
const refreshChannelModels = async (channel: ModelChannel) => {
const connectionError = channelConnectionError(channel);
if (connectionError) {
message.error(`${channel.name || "当前渠道"}:${connectionError}`);
return;
}
setChannelLoading(channel.id, true);
try {
const models = await fetchChannelModels(channel);
if (!models.length) {
message.warning(`${channel.name || "当前渠道"}未返回模型,已保留现有手工模型`);
return;
}
const latestConfig = useConfigStore.getState().config;
const latestChannel = latestConfig.channels.find((item) => item.id === channel.id);
if (!latestChannel) return;
if (channelConnectionSignature(latestChannel) !== channelConnectionSignature(channel)) {
message.warning(`${latestChannel.name || "当前渠道"}的连接配置已改变,已忽略旧的拉取结果`);
return;
}
updateChannels(
latestConfig.channels.map((item) => (item.id === channel.id ? { ...item, models } : item)),
latestConfig,
);
message.success(`${latestChannel.name || "当前渠道"}模型列表已更新`);
} catch (error) {
message.error(error instanceof Error ? `${error.message};也可以直接在模型列表中手动输入模型名` : "读取模型失败,可直接手动输入模型名");
} finally {
setChannelLoading(channel.id, false);
}
};
const refreshAllModels = async () => {
const runnable = userChannels.filter((channel) => !channelConnectionError(channel));
const skipped = userChannels.filter((channel) => channelConnectionError(channel));
if (!runnable.length) {
const detail = skipped.map((channel) => `${channel.name || "未命名渠道"}:${channelConnectionError(channel)}`).join(";");
message.error(detail || "没有可拉取的自定义渠道,请先填写有效 Base URL 和 API Key");
return;
}
setChannelLoading("all", true);
try {
const results = await Promise.all(
runnable.map(async (channel) => {
try {
const models = await fetchChannelModels(channel);
return { channel, models, error: "" };
} catch (error) {
return { channel, models: [] as string[], error: error instanceof Error ? error.message : "读取失败" };
}
}),
);
const latestConfig = useConfigStore.getState().config;
const successful = results.filter((item) => {
const latestChannel = latestConfig.channels.find((channel) => channel.id === item.channel.id);
return Boolean(item.models.length && latestChannel && channelConnectionSignature(latestChannel) === channelConnectionSignature(item.channel));
});
const stale = results.filter((item) => {
const latestChannel = latestConfig.channels.find((channel) => channel.id === item.channel.id);
return Boolean(item.models.length && (!latestChannel || channelConnectionSignature(latestChannel) !== channelConnectionSignature(item.channel)));
});
const failed = results.filter((item) => !item.models.length);
if (successful.length) {
const modelMap = new Map(successful.map((item) => [item.channel.id, item.models] as const));
updateChannels(
latestConfig.channels.map((channel) => (modelMap.has(channel.id) ? { ...channel, models: modelMap.get(channel.id) || channel.models } : channel)),
latestConfig,
);
message.success(`已更新 ${successful.length} 个渠道的模型`);
}
const warnings = [
...failed.map((item) => `${item.channel.name || "未命名渠道"}:${item.error || "未返回模型"}`),
...stale.map((item) => `${item.channel.name || "未命名渠道"}:连接配置已改变,已忽略旧结果`),
...skipped.map((channel) => `${channel.name || "未命名渠道"}:${channelConnectionError(channel)}`),
];
if (warnings.length) {
message.warning(`${warnings.join(";")}。未更新的渠道已保留原有模型列表`);
}
} catch (error) {
message.error(error instanceof Error ? error.message : "批量读取模型失败,原有模型列表未改动");
} finally {
setChannelLoading("all", false);
}
};
const updateCapabilityModels = (group: ModelGroup, models: string[]) => {
const available = new Set(config.models);
const next = uniqueModels(models.map((model) => normalizeModelOptionValue(model, config.channels)).filter((model) => model && available.has(model)));
replaceConfig({ ...config, [group.modelsKey]: next, [group.modelKey]: next.includes(config[group.modelKey]) ? config[group.modelKey] : next[0] || "" });
};
return (
<Modal
rootClassName="app-config-modal"
title={
<div className="flex items-center gap-3">
<span className="grid size-9 shrink-0 place-items-center rounded-md border border-border bg-muted/40"><SlidersHorizontal className="size-4" /></span>
<div>
<div className="text-lg font-semibold">配置与用户偏好</div>
<div className="mt-1 text-xs font-normal text-foreground/55">模型连接、默认选项和个人存储</div>
</div>
</div>
}
open={isConfigOpen}
width="min(1180px, calc(100vw - 32px))"
centered
onCancel={closeConfig}
styles={{ container: { height: "min(760px, calc(100dvh - 64px))", display: "flex", flexDirection: "column", padding: 0, overflow: "hidden" }, header: { margin: 0, padding: "14px 18px", borderBottom: "1px solid var(--border)" }, body: { minHeight: 0, flex: 1, overflow: "hidden", padding: 0 }, footer: { margin: 0, padding: "9px 18px", borderTop: "1px solid var(--border)" } }}
footer={
<div className="flex items-center justify-between gap-4">
<span className="text-xs text-foreground/45">修改会自动保存在当前账号的浏览器配置中</span>
<Button type="primary" onClick={finishConfig}>保存并完成</Button>
</div>
}
>
<div className="flex h-full min-h-0 flex-col md:flex-row">
<aside className="w-full shrink-0 border-b border-border bg-muted/20 p-2 md:w-52 md:border-b-0 md:border-r md:p-3">
<nav className="thin-scrollbar flex gap-1 overflow-x-auto md:block md:space-y-1" aria-label="配置分类">
{configSections.map((item) => {
const selected = item.key === activeTab;
return (
<button
key={item.key}
type="button"
className={`relative flex h-9 shrink-0 items-center gap-2 rounded-md px-3 text-left text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring md:h-auto md:w-full md:items-start md:gap-3 md:py-2.5 ${selected ? "border border-border bg-background text-foreground shadow-sm" : "border border-transparent text-foreground/60 hover:bg-muted/60 hover:text-foreground"}`}
onClick={() => setActiveTab(item.key)}
aria-current={selected ? "page" : undefined}
>
{selected ? <span className="absolute inset-x-3 bottom-0 h-0.5 rounded-full bg-sidebar-primary md:inset-x-auto md:inset-y-3 md:left-0 md:h-auto md:w-0.5" aria-hidden="true" /> : null}
<span className="shrink-0 md:mt-0.5">{item.icon}</span>
<span className="min-w-0"><span className="block whitespace-nowrap text-sm font-medium">{item.label}</span><span className="mt-1 hidden text-[11px] leading-4 text-current opacity-65 md:block">{item.description}</span></span>
</button>
);
})}
</nav>
</aside>
<section className="flex min-w-0 flex-1 flex-col bg-background">
{([
{
key: "channels",
label: "渠道",
children: (
<SettingsPane>
<Form layout="vertical" requiredMark={false}>
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 border-b border-border pb-4">
<div className="min-w-0 flex-1">
<div className="flex w-fit max-w-full flex-wrap items-center gap-1.5 text-xs text-foreground/65">
<Info className="size-3.5 shrink-0" />
<span>新增或拉取模型后,需要到“模型选择”中加入可选项才会显示。</span>
<Button type="link" size="small" className="h-auto p-0 text-xs font-semibold" onClick={() => setActiveTab("models")}>
打开模型选择
</Button>
</div>
</div>
<div className="flex w-full gap-2 sm:w-auto sm:shrink-0">
<Button
className="h-10 flex-1 sm:h-8 sm:flex-none"
icon={<RefreshCw className="size-4" />}
loading={loadingChannelIds.includes("all")}
disabled={loadingChannelIds.some((id) => id !== "all")}
onClick={() => void refreshAllModels()}
>
拉取全部
</Button>
<Button className="h-10 flex-1 sm:h-8 sm:flex-none" type="primary" icon={<Plus className="size-4" />} onClick={addChannel}>
新增渠道
</Button>
</div>
</div>
{userChannels.length ? (
<div className="space-y-3">
{userChannels.map((channel) => (
<section key={channel.id} aria-labelledby={`channel-${channel.id}-title`} className="rounded-md border border-border bg-background p-3">
<div className="mb-3 flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1 basis-52">
<h3 id={`channel-${channel.id}-title`} className="truncate text-sm font-semibold">
{channel.name || "未命名渠道"}
</h3>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-foreground/55">
{channelProtocolLabel(channel)} · 已保存 {channel.models.length} 个模型
<ChannelStatus channel={channel} />
</div>
</div>
<div className="flex w-full justify-end gap-2 sm:w-auto sm:shrink-0">
<Button
className="h-10 sm:h-8"
size="small"
icon={<RefreshCw className="size-3.5" />}
loading={loadingChannelIds.includes(channel.id)}
disabled={loadingChannelIds.includes("all")}
onClick={() => void refreshChannelModels(channel)}
>
拉取模型
</Button>
<Popconfirm title="删除自定义渠道?" description="该渠道关联的模型选择会同时移除。" okText="删除" cancelText="取消" okButtonProps={{ danger: true }} onConfirm={() => deleteChannel(channel.id)}>
<Tooltip title="删除渠道">
<Button
className="size-10 p-0 sm:size-8"
aria-label={`删除渠道 ${channel.name || "未命名渠道"}`}
size="small"
danger
disabled={loadingChannelIds.includes(channel.id) || loadingChannelIds.includes("all")}
icon={<Trash2 className="size-3.5" />}
/>
</Tooltip>
</Popconfirm>
</div>
</div>
<div className="grid gap-x-3 gap-y-2 lg:grid-cols-12">
<Form.Item label="渠道名称" htmlFor={`channel-${channel.id}-name`} className="mb-0 lg:col-span-3">
<Input
id={`channel-${channel.id}-name`}
value={channel.name}
placeholder="例如:我的 NewAPI"
onChange={(event) => updateChannel(channel.id, { name: event.target.value })}
onBlur={(event) => updateChannel(channel.id, { name: event.target.value.trim() || "未命名渠道" })}
/>
</Form.Item>
<Form.Item label="接口协议" htmlFor={`channel-${channel.id}-protocol`} className="mb-0 lg:col-span-3">
<Select<UserChannelProtocol>
id={`channel-${channel.id}-protocol`}
value={channelProtocolValue(channel)}
options={channelProtocolOptions}
onChange={(value) => updateChannelProtocol(channel, value)}
/>
</Form.Item>
<Form.Item label="Base URL" htmlFor={`channel-${channel.id}-base-url`} className="mb-0 lg:col-span-6">
<Input
id={`channel-${channel.id}-base-url`}
inputMode="url"
value={channel.baseUrl}
placeholder="填写渠道 Base URL"
onChange={(event) => updateChannel(channel.id, { baseUrl: event.target.value })}
onBlur={(event) => updateChannel(channel.id, { baseUrl: event.target.value.trim().replace(/\/+$/, "") })}
/>
</Form.Item>
<Form.Item label="API Key" htmlFor={`channel-${channel.id}-api-key`} className="mb-0 lg:col-span-5">
<Input.Password
id={`channel-${channel.id}-api-key`}
autoComplete="new-password"
value={channel.apiKey}
placeholder={channel.apiFormat === "gemini" ? "填写 Gemini API Key" : "填写当前渠道 API Key"}
onChange={(event) => updateChannel(channel.id, { apiKey: event.target.value })}
onBlur={(event) => updateChannel(channel.id, { apiKey: event.target.value.trim() })}
/>
</Form.Item>
<Form.Item label="模型列表" htmlFor={`channel-${channel.id}-models`} className="mb-0 lg:col-span-7">
<Select
id={`channel-${channel.id}-models`}
mode="tags"
showSearch
allowClear
maxTagCount="responsive"
tokenSeparators={[",", "\n"]}
placeholder="输入模型名,或点击拉取模型"
value={channel.models}
onChange={(models) => updateChannel(channel.id, { models: uniqueModels(models) })}
/>
</Form.Item>
</div>
</section>
))}
</div>
) : (
<div className="rounded-md border border-dashed border-border px-4 py-8 text-center text-sm text-foreground/55">
<div>当前没有自定义渠道。管理员配置的系统渠道会出现在“模型”页。</div>
<Button className="mt-3" icon={<Plus className="size-4" />} onClick={addChannel}>
新增自定义渠道
</Button>
</div>
)}
</Form>
</SettingsPane>
),
},
{
key: "models",
label: "模型",
children: (
<SettingsPane>
<Form layout="vertical" requiredMark={false}>
<div className="mb-5 border-b border-border pb-4">
<div className="text-sm font-semibold">可选模型范围</div>
<div className="mt-1 text-xs leading-5 text-foreground/55">先决定各类生成控件中可以选择哪些模型,再为每种能力指定默认值。</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
{modelGroups.map((group) => (
<Form.Item key={group.modelsKey} label={group.optionsLabel} className="mb-0">
<Select
mode="multiple"
showSearch
allowClear
maxTagCount="responsive"
optionFilterProp="title"
placeholder={config.models.length ? `请选择${group.optionsLabel}` : "先到渠道里填写或拉取模型"}
value={config[group.modelsKey].filter((model) => availableModelSet.has(model))}
options={modelOptionGroups}
tagRender={({ value, closable, onClose }) => {
const model = String(value);
const channel = resolveModelChannel(config, model);
return (
<Tag
bordered={false}
closable={closable}
className="m-0 mr-1 max-w-full bg-foreground/[.07] py-0 text-foreground"
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClose={onClose}
>
<span className="inline-flex max-w-[260px] items-center gap-1.5 align-middle">
<span className="truncate">{modelOptionName(model)}</span>
<span className="shrink-0 text-[10px] text-foreground/45">· {channel.name || "未命名渠道"}</span>
</span>
</Tag>
);
}}
onChange={(models) => updateCapabilityModels(group, models)}
/>
</Form.Item>
))}
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{modelGroups.map((group) => (
<Form.Item key={group.modelKey} label={group.defaultLabel} className="mb-0">
<ModelPicker config={config} value={config[group.modelKey]} onChange={(model) => updateConfig(group.modelKey, model)} capability={group.capability} fullWidth />
</Form.Item>
))}
</div>
</Form>
</SettingsPane>
),
},
{
key: "preferences",
label: "生成偏好",
children: (
<SettingsPane>
<Form layout="vertical" requiredMark={false}>
<section className="border-b border-border pb-6">
<div className="mb-4"><h3 className="text-sm font-semibold">画布生成</h3><p className="mt-1 text-xs text-foreground/55">设置新建生成任务时使用的初始值,节点内仍可单独覆盖。</p></div>
<Form.Item label="默认生图张数" className="mb-0 max-w-xs">
<InputNumber
min={1}
max={15}
precision={0}
className="w-full"
value={Number(config.canvasImageCount)}
onChange={(value) => updateConfig("canvasImageCount", normalizeImageCount(String(value ?? defaultConfig.canvasImageCount)))}
/>
</Form.Item>
</section>
<section className="border-b border-border py-6">
<div className="mb-4"><h3 className="text-sm font-semibold">音频默认值</h3><p className="mt-1 text-xs text-foreground/55">用于新建音频节点和未单独设置参数的生成任务。</p></div>
<div className="grid gap-4 md:grid-cols-3">
<Form.Item label="默认声音" className="mb-0">
<Select value={config.audioVoice} options={audioVoiceOptions} onChange={(value) => updateConfig("audioVoice", value)} />
</Form.Item>
<Form.Item label="文件格式" className="mb-0">
<Select value={config.audioFormat} options={audioFormatOptions} onChange={(value) => updateConfig("audioFormat", value)} />
</Form.Item>
<Form.Item label="语速" className="mb-0">
<InputNumber
min={0.25}
max={4}
step={0.05}
precision={2}
className="w-full"
value={Number(config.audioSpeed)}
onChange={(value) => updateConfig("audioSpeed", normalizeAudioSpeedValue(String(value ?? defaultConfig.audioSpeed)))}
/>
</Form.Item>
</div>
</section>
<section className="pt-6">
<div className="mb-4"><h3 className="text-sm font-semibold">默认指令</h3><p className="mt-1 text-xs text-foreground/55">在未单独填写时附加到对应生成请求。</p></div>
<div className="grid gap-5 lg:grid-cols-2">
<Form.Item label="音频指令" className="mb-0">
<Input.TextArea rows={5} value={config.audioInstructions} placeholder="例如:自然、温暖、适合旁白。" onChange={(event) => updateConfig("audioInstructions", event.target.value)} />
</Form.Item>
<Form.Item label="系统提示词" className="mb-0">
<Input.TextArea rows={5} value={config.systemPrompt} placeholder="例如:你是一位擅长电影感写实摄影的视觉导演。" onChange={(event) => updateConfig("systemPrompt", event.target.value)} />
</Form.Item>
</div>
</section>
</Form>
</SettingsPane>
),
},
{
key: "storage",
label: (
<span className="inline-flex items-center gap-2">
<Cloud className="size-4" />
我的 OSS
</span>
),
children: (
<SettingsPane>
<UserOSSSettingsForm />
</SettingsPane>
),
},
] as Array<{ key: ConfigSectionKey; label: ReactNode; children: ReactNode }>).find((item) => item.key === activeTab)?.children}
</section>
</div>
</Modal>
);
}
function SettingsPane({ children }: { children: ReactNode }) {
return <div className="thin-scrollbar h-full min-h-0 overflow-y-auto overscroll-contain p-4 md:p-5">{children}</div>;
}
function ModelOptionLabel({ model, price }: { model: string; price?: number }) {
return (
<span className="flex min-w-0 items-center gap-2 py-0.5">
<span className="grid size-6 shrink-0 place-items-center rounded-md bg-foreground/[.06]">
<ModelIcon model={model} />
</span>
<span className="min-w-0 truncate font-medium">{modelOptionName(model)}</span>
{price !== undefined ? <span className="ml-auto shrink-0 rounded-full border border-amber-400/30 bg-amber-400/10 px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-amber-700 dark:text-amber-300">{price.toLocaleString("zh-CN", { maximumFractionDigits: 6 })} 积分</span> : null}
</span>
);
}
function ChannelStatus({ channel }: { channel: ModelChannel }) {
const error = channelValidationError(channel);
return error ? (
<Tag bordered={false} color="warning" className="m-0">
{error}
</Tag>
) : (
<Tag bordered={false} color="success" icon={<CircleCheck className="size-3" />} className="m-0">
可用
</Tag>
);
}
function buildModelOptionGroups(config: AiConfig): ModelSelectOptionGroup[] {
const orderedChannels = [...config.channels.filter((channel) => channel.scope === "system"), ...config.channels.filter((channel) => channel.scope !== "system")];
return orderedChannels
.map((channel) => {
const options = modelOptionsFromChannels([channel]).map((model) => ({
label: <ModelOptionLabel model={model} price={modelOptionPrice(channel, model)} />,
title: modelOptionLabel(config, model),
value: model,
}));
return {
label: <ModelOptionGroupLabel channel={channel} count={options.length} />,
title: channel.name || "未命名渠道",
options,
};
})
.filter((group) => group.options.length);
}
function modelOptionPrice(channel: ModelChannel, model: string) {
if (channel.scope !== "system") return undefined;
const cost = channel.modelCosts?.find((item) => item.model === modelOptionName(model));
return cost ? cost.unitPriceMicrocredits / 1_000_000 : undefined;
}
function ModelOptionGroupLabel({ channel, count }: { channel: ModelChannel; count: number }) {
return (
<span className="flex min-w-0 items-center gap-2 py-0.5">
<span className="min-w-0 truncate text-xs font-semibold text-foreground/75">{channel.name || "未命名渠道"}</span>
<span className="shrink-0 text-[10px] font-normal text-foreground/40">{channel.scope === "system" ? "系统渠道" : "自定义渠道"}</span>
<span className="ml-auto shrink-0 text-[10px] font-normal tabular-nums text-foreground/35">{count}</span>
</span>
);
}
function withChannels(config: AiConfig, channels: ModelChannel[]): AiConfig {
const models = modelOptionsFromChannels(channels);
const imageModels = keepAvailable(config.imageModels, models);
const videoModels = keepAvailable(config.videoModels, models);
const textModels = keepAvailable(config.textModels, models);
const audioModels = keepAvailable(config.audioModels, models);
return {
...config,
channels,
models,
baseUrl: channels[0]?.baseUrl || config.baseUrl,
apiKey: channels[0]?.apiKey || config.apiKey,
apiFormat: channels[0]?.apiFormat || config.apiFormat,
imageModels,
videoModels,
textModels,
audioModels,
imageModel: normalizeDefaultModel(config.imageModel, imageModels),
videoModel: normalizeDefaultModel(config.videoModel, videoModels),
textModel: normalizeDefaultModel(config.textModel, textModels),
audioModel: normalizeDefaultModel(config.audioModel, audioModels),
};
}
function keepAvailable(current: string[], allModels: string[]) {
const available = new Set(allModels);
return uniqueModels(current).filter((model) => available.has(model));
}
function normalizeDefaultModel(value: string, options: string[]) {
if (options.includes(value)) return value;
return options[0] || "";
}
function normalizeImageCount(value: string) {
return String(Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || Number(defaultConfig.canvasImageCount)))));
}
function uniqueModels(models: string[]) {
return Array.from(new Set(models.map((model) => model.trim()).filter(Boolean)));
}
function channelProtocolValue(channel: ModelChannel): UserChannelProtocol {
if (channel.apiFormat === "gemini") return "gemini";
return channel.interfaceType || "auto";
}
function channelConnectionError(channel: ModelChannel) {
const baseUrl = channel.baseUrl.trim();
if (!baseUrl) return "请填写 Base URL";
try {
const parsed = new URL(baseUrl);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "Base URL 只支持 HTTP 或 HTTPS";
} catch {
return "Base URL 格式不正确";
}
if (!channel.apiKey.trim()) return "请填写 API Key";
return "";
}
function channelConnectionSignature(channel: ModelChannel) {
return [channel.baseUrl.trim(), channel.apiKey.trim(), channel.apiFormat, channel.interfaceType || "auto"].join("\n");
}
function channelValidationError(channel: ModelChannel) {
return channelConnectionError(channel) || (!channel.models.length ? "请添加至少一个模型" : "");
}
function isChannelReady(channel: ModelChannel) {
return !channelValidationError(channel);
}
function focusInvalidChannelField(channel: ModelChannel) {
const baseUrlError = channelConnectionError({ ...channel, apiKey: "valid" });
const field = baseUrlError ? "base-url" : !channel.apiKey.trim() ? "api-key" : "models";
requestAnimationFrame(() => {
const element = document.getElementById(`channel-${channel.id}-${field}`);
element?.scrollIntoView({ behavior: "smooth", block: "center" });
element?.focus({ preventScroll: true });
});
}
function channelProtocolLabel(channel: ModelChannel) {
const protocol = channelProtocolValue(channel);
switch (protocol) {
case "gemini":
return "Gemini 原生";
case "chat-completion":
return "Chat Completions";
case "openai-response":
return "OpenAI Responses";
case "openai-image":
return "OpenAI Images";
case "newapi":
return "NewAPI 视频";
case "newapi-channel-1":
return "NewAPI 渠道 1";
case "newapi-channel-2":
return "NewAPI 渠道 2";
default:
return "OpenAI 自动兼容";
}
}
function isKnownDefaultBaseUrl(value: string) {
const normalized = value.trim().replace(/\/+$/, "");
if (!normalized) return true;
return [defaultBaseUrlForApiFormat("openai"), defaultBaseUrlForApiFormat("gemini")].some((candidate) => candidate.replace(/\/+$/, "") === normalized);
}

View File

@@ -0,0 +1,42 @@
import type { ReactNode } from "react";
import { useEffect } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { App, ConfigProvider } from "antd";
import zhCN from "antd/locale/zh_CN";
import { AuthSessionHydrator } from "@/components/auth/auth-session-hydrator";
import { ClientRootInit } from "@/components/layout/client-root-init";
import { getAntThemeConfig } from "@/lib/app-theme";
import { useThemeStore } from "@/stores/use-theme-store";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: false,
refetchOnWindowFocus: false,
},
},
});
export function AppProviders({ children }: { children: ReactNode }) {
const theme = useThemeStore((state) => state.theme);
const dark = theme === "dark";
useEffect(() => {
document.documentElement.classList.toggle("dark", dark);
document.documentElement.style.colorScheme = theme;
}, [dark, theme]);
return (
<ConfigProvider locale={zhCN} theme={getAntThemeConfig(dark)}>
<App message={{ duration: 3, maxCount: 3 }} notification={{ duration: 4.5, maxCount: 3, placement: "topRight" }}>
<QueryClientProvider client={queryClient}>
<AuthSessionHydrator>
<ClientRootInit>{children}</ClientRootInit>
</AuthSessionHydrator>
</QueryClientProvider>
</App>
</ConfigProvider>
);
}

View File

@@ -0,0 +1,81 @@
import { Menu } from "lucide-react";
import { Link, useLocation } from "react-router";
import { navigationTools, type NavigationToolSlug } from "@/constant/navigation-tools";
import { AppConfigModal } from "@/components/layout/app-config-modal";
import { MobileNavDrawer } from "@/components/layout/mobile-nav-drawer";
import { UserStatusActions } from "@/components/layout/user-status-actions";
import { cn } from "@/lib/utils";
import { useState } from "react";
import { ModelSetupGuide } from "@/components/layout/model-setup-guide";
export function AppTopNav() {
const { pathname } = useLocation();
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const hideHeader = pathname.startsWith("/admin") || /^\/canvas\/[^/]+/.test(pathname);
const slug = pathname.split("/").filter(Boolean)[0];
const activeToolSlug = navigationTools.some((tool) => tool.slug === slug) ? (slug as NavigationToolSlug) : undefined;
return (
<>
{!hideHeader ? (
<header className="app-user-top-nav sticky top-0 z-20 h-14 shrink-0 backdrop-blur-xl">
<div className="mx-auto flex h-full max-w-[1440px] items-stretch justify-between gap-5 px-4 sm:px-6 lg:px-8">
<div className="flex min-w-0 items-center">
<Link to="/" className="flex h-full shrink-0 items-center gap-2 text-sm font-semibold leading-none text-foreground/90 transition-colors hover:text-foreground">
<span
className="size-5 shrink-0 bg-current"
style={{
mask: "url(/logo.svg) center / contain no-repeat",
WebkitMask: "url(/logo.svg) center / contain no-repeat",
}}
/>
<span className="text-[15px] font-semibold">无限画布</span>
</Link>
<button
type="button"
className="ml-3 inline-flex size-8 shrink-0 items-center justify-center rounded-md text-foreground/60 transition-colors hover:bg-foreground/[0.06] hover:text-foreground md:hidden"
onClick={() => setMobileNavOpen(true)}
aria-label="打开导航菜单"
title="导航菜单"
>
<Menu className="size-5" />
</button>
<nav className="hide-scrollbar ml-7 hidden h-14 min-w-0 items-center gap-1 overflow-x-auto md:flex">
{navigationTools.map((tool) => {
const Icon = tool.icon;
const active = tool.slug === activeToolSlug;
return (
<Link
key={tool.slug}
to={`/${tool.slug}`}
className={cn(
"app-top-nav-link relative flex h-9 shrink-0 items-center gap-2 rounded-md px-3 text-sm leading-6 transition-colors",
active
? "is-active font-medium text-foreground"
: "text-foreground/55 hover:bg-foreground/[0.04] hover:text-foreground",
)}
>
<Icon className="size-4" />
<span className="truncate">{tool.label}</span>
</Link>
);
})}
</nav>
</div>
<div className="my-auto flex h-9 min-w-0 items-center justify-end gap-2 justify-self-end whitespace-nowrap">
<UserStatusActions />
</div>
</div>
</header>
) : null}
<MobileNavDrawer open={mobileNavOpen} activeToolSlug={activeToolSlug} onClose={() => setMobileNavOpen(false)} />
<AppConfigModal />
<ModelSetupGuide hidden={pathname === "/login" || pathname === "/register" || pathname.startsWith("/admin")} />
</>
);
}

View File

@@ -0,0 +1,47 @@
import type { ReactNode } from "react";
import { useEffect, useRef } from "react";
import { App } from "antd";
import { createModelChannel, useConfigStore } from "@/stores/use-config-store";
export function ClientRootInit({ children }: { children: ReactNode }) {
const { message } = App.useApp();
const handledConfigParams = useRef(false);
const updateConfig = useConfigStore((state) => state.updateConfig);
const config = useConfigStore((state) => state.config);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
useEffect(() => {
if (handledConfigParams.current) return;
const searchParams = new URLSearchParams(window.location.search);
const baseUrl = searchParams.get("baseUrl") || searchParams.get("baseurl");
const ignoredApiKey = searchParams.has("apiKey") || searchParams.has("apikey");
if (!baseUrl && !ignoredApiKey) return;
handledConfigParams.current = true;
searchParams.delete("baseUrl");
searchParams.delete("baseurl");
searchParams.delete("apiKey");
searchParams.delete("apikey");
window.history.replaceState(null, "", `${window.location.pathname}${searchParams.size ? `?${searchParams}` : ""}${window.location.hash}`);
const firstChannel = config.channels[0];
updateConfig(
"channels",
firstChannel
? config.channels.map((channel, index) =>
index === 0
? {
...channel,
...(baseUrl ? { baseUrl } : {}),
}
: channel,
)
: [createModelChannel({ id: "default", name: "默认渠道", baseUrl: baseUrl || undefined })],
);
if (baseUrl) updateConfig("baseUrl", baseUrl);
openConfigDialog(false);
if (ignoredApiKey) message.warning("出于安全考虑,链接中的 API Key 已忽略,请在配置中手动填写");
else message.success("已导入本地直连地址");
}, [config.channels, message, openConfigDialog, updateConfig]);
return <>{children}</>;
}

View File

@@ -0,0 +1,38 @@
import { Drawer } from "antd";
import { Link } from "react-router";
import { navigationTools, type NavigationToolSlug } from "@/constant/navigation-tools";
import { cn } from "@/lib/utils";
type MobileNavDrawerProps = {
open: boolean;
activeToolSlug?: NavigationToolSlug;
onClose: () => void;
};
export function MobileNavDrawer({ open, activeToolSlug, onClose }: MobileNavDrawerProps) {
return (
<Drawer title="导航" placement="left" size={280} open={open} onClose={onClose} className="md:hidden">
<div className="space-y-1">
{navigationTools.map((tool) => {
const Icon = tool.icon;
const active = tool.slug === activeToolSlug;
return (
<Link
key={tool.slug}
to={`/${tool.slug}`}
onClick={onClose}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-3 text-base transition",
active ? "bg-stone-100 font-medium text-stone-950 dark:bg-stone-800 dark:text-stone-100" : "text-stone-600 hover:bg-stone-100 hover:text-stone-950 dark:text-stone-300 dark:hover:bg-stone-800 dark:hover:text-stone-100",
)}
>
<Icon className="size-5" />
<span>{tool.label}</span>
</Link>
);
})}
</div>
</Drawer>
);
}

View File

@@ -0,0 +1,42 @@
import { motion, useReducedMotion } from "motion/react";
import { ArrowRight, Sparkles, X } from "lucide-react";
import { useState } from "react";
import { aceternityMotion } from "@/lib/aceternity-motion";
import { useConfigStore } from "@/stores/use-config-store";
import { useUserStore } from "@/stores/use-user-store";
export function ModelSetupGuide({ hidden = false }: { hidden?: boolean }) {
const reducedMotion = useReducedMotion();
const [dismissed, setDismissed] = useState(false);
const registrationGuide = typeof window !== "undefined" && window.sessionStorage.getItem("infinite-canvas:model-setup-guide") === "1";
const hydrated = useUserStore((state) => state.hydrated);
const user = useUserStore((state) => state.user);
const models = useConfigStore((state) => state.config.models);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
if (hidden || dismissed || !hydrated || !user || user.role === "admin" || (!registrationGuide && models.length > 0)) return null;
const close = () => {
window.sessionStorage.removeItem("infinite-canvas:model-setup-guide");
setDismissed(true);
};
const openModels = () => {
close();
openConfigDialog(false, "models");
};
return (
<motion.aside initial={reducedMotion ? false : { opacity: 0, y: 14, scale: 0.97 }} animate={{ opacity: 1, y: 0, scale: 1 }} transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }} className="fixed bottom-5 right-5 z-[900] w-[min(360px,calc(100vw-32px))] overflow-hidden rounded-2xl border border-border/70 bg-background/92 shadow-[0_24px_70px_rgba(15,23,42,.20)] backdrop-blur-2xl dark:shadow-[0_28px_80px_rgba(0,0,0,.48)]">
<div className="flex items-start gap-3 p-4">
<span className="grid size-9 shrink-0 place-items-center rounded-xl border border-amber-400/25 bg-amber-400/10 text-amber-600 dark:text-amber-300"><Sparkles className="size-4" /></span>
<button type="button" className="min-w-0 flex-1 text-left" onClick={openModels}>
<span className="block text-sm font-semibold">先选择创作模型</span>
<span className="mt-1 block text-xs leading-5 text-foreground/55">配置生图、视频和文本的默认模型,价格会在选择时显示。</span>
<span className="mt-3 inline-flex items-center gap-1.5 text-xs font-semibold text-foreground">打开模型选择 <ArrowRight className="size-3.5" /></span>
</button>
<button type="button" className="grid size-7 shrink-0 place-items-center rounded-full text-foreground/40 transition hover:bg-muted hover:text-foreground" onClick={close} aria-label="关闭模型配置引导"><X className="size-3.5" /></button>
</div>
</motion.aside>
);
}

View File

@@ -0,0 +1,113 @@
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Bell } from "lucide-react";
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
import { AnnouncementTimelineModal } from "@/components/ui/aceternity/announcement-timeline-modal";
import { aceternityMotion } from "@/lib/aceternity-motion";
import { getAnnouncementFeed, markAnnouncementsRead, type SystemAnnouncement } from "@/services/api/announcements";
const ANNOUNCEMENT_REFRESH_INTERVAL_MS = 60_000;
type SystemAnnouncementCenterProps = {
userId: string;
className?: string;
style?: CSSProperties;
};
export function SystemAnnouncementCenter({ userId, className, style }: SystemAnnouncementCenterProps) {
const reducedMotion = useReducedMotion();
const activeUserIdRef = useRef(userId);
activeUserIdRef.current = userId;
const [open, setOpen] = useState(false);
const [announcements, setAnnouncements] = useState<SystemAnnouncement[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const refresh = useCallback(async (showLoading = false) => {
if (!userId) return null;
const requestUserId = userId;
if (showLoading) setLoading(true);
try {
const feed = await getAnnouncementFeed();
if (activeUserIdRef.current !== requestUserId) return null;
setAnnouncements(feed.announcements || []);
setUnreadCount(Math.max(0, feed.unreadCount || 0));
setError("");
return feed;
} catch (requestError) {
if (activeUserIdRef.current !== requestUserId) return null;
setError(requestError instanceof Error ? requestError.message : "读取公告失败");
return null;
} finally {
if (showLoading && activeUserIdRef.current === requestUserId) setLoading(false);
}
}, [userId]);
useEffect(() => {
setAnnouncements([]);
setUnreadCount(0);
setError("");
if (!userId) return;
void refresh();
const timer = window.setInterval(() => void refresh(), ANNOUNCEMENT_REFRESH_INTERVAL_MS);
const onFocus = () => void refresh();
const onVisibilityChange = () => {
if (document.visibilityState === "visible") void refresh();
};
window.addEventListener("focus", onFocus);
document.addEventListener("visibilitychange", onVisibilityChange);
return () => {
window.clearInterval(timer);
window.removeEventListener("focus", onFocus);
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}, [refresh, userId]);
const openAnnouncements = async () => {
setOpen(true);
const feed = await refresh(announcements.length === 0);
if (!feed?.unreadCount) return;
try {
const result = await markAnnouncementsRead(feed.announcements.map((announcement) => announcement.id));
const nextUnreadCount = Math.max(0, result.unreadCount || 0);
setUnreadCount(nextUnreadCount);
if (nextUnreadCount > 0) void refresh();
} catch {
// 已读状态是辅助读路径,失败时保留角标,下一次打开或轮询会继续尝试同步。
}
};
return (
<>
<motion.button
type="button"
className={className}
style={style}
whileHover={reducedMotion ? undefined : { y: -1, scale: 1.035 }}
whileTap={reducedMotion ? undefined : { scale: 0.94 }}
transition={aceternityMotion.spring.dock}
onClick={() => void openAnnouncements()}
aria-label={unreadCount ? `系统公告,${unreadCount} 条未读` : "系统公告"}
title="系统公告"
>
<Bell className="size-4" />
<AnimatePresence initial={false}>
{unreadCount > 0 ? (
<motion.span
key="badge"
initial={reducedMotion ? false : { opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.6 }}
transition={aceternityMotion.spring.dock}
className="absolute -right-1 -top-1 grid h-[18px] min-w-[18px] place-items-center rounded-full border-2 border-background bg-red-500 px-1 text-[10px] font-semibold leading-none text-white shadow-sm"
>
{unreadCount > 99 ? "99+" : unreadCount}
</motion.span>
) : null}
</AnimatePresence>
</motion.button>
<AnnouncementTimelineModal open={open} announcements={announcements} loading={loading} error={announcements.length ? "" : error} onClose={() => setOpen(false)} onRetry={() => void refresh(true)} />
</>
);
}

View File

@@ -0,0 +1,146 @@
import { App, Button, Form, Input, Select, Switch, Tag } from "antd";
import { Cloud, ShieldCheck } from "lucide-react";
import { useEffect, useState } from "react";
import { getUserOSSSetting, updateUserOSSSetting, type UserOSSSetting } from "@/services/api/resources";
import { useUserStore } from "@/stores/use-user-store";
type OSSFormValues = {
enabled?: boolean;
provider: "aliyun";
region?: string;
endpoint?: string;
bucket?: string;
accessKeyId?: string;
accessKeySecret?: string;
pathPrefix?: string;
};
export function UserOSSSettingsForm() {
const actor = useUserStore((state) => state.user);
const { message } = App.useApp();
const [form] = Form.useForm<OSSFormValues>();
const [setting, setSetting] = useState<UserOSSSetting | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const savedAt = formatSavedAt(setting?.updatedAt);
useEffect(() => {
if (!actor?.id) return;
let active = true;
setLoading(true);
void getUserOSSSetting()
.then((data) => {
if (!active) return;
setSetting(data.setting);
form.setFieldsValue(toFormValues(data.setting));
})
.catch((error) => active && message.error(error instanceof Error ? error.message : "读取个人 OSS 配置失败"))
.finally(() => active && setLoading(false));
return () => {
active = false;
};
}, [actor?.id, form, message]);
if (!actor) {
return <div className="rounded-md border border-dashed border-border px-5 py-10 text-center text-sm text-foreground/55">登录后可配置个人 OSS。</div>;
}
const save = async () => {
const values = await form.validateFields();
setSaving(true);
try {
const data = await updateUserOSSSetting({
enabled: values.enabled === true,
provider: values.provider || "aliyun",
region: values.region?.trim() || "",
endpoint: values.endpoint?.trim() || "",
bucket: values.bucket?.trim() || "",
accessKeyId: values.accessKeyId?.trim() || "",
accessKeySecret: values.accessKeySecret?.trim() || "",
pathPrefix: values.pathPrefix?.trim() || "",
});
setSetting(data.setting);
form.setFieldsValue(toFormValues(data.setting));
message.success(data.setting.enabled ? "个人 OSS 已启用,后续上传将优先使用该存储" : "个人 OSS 已停用,后续上传将使用平台存储");
} catch (error) {
message.error(error instanceof Error ? error.message : "保存个人 OSS 配置失败");
} finally {
setSaving(false);
}
};
return (
<Form form={form} layout="vertical" requiredMark={false} disabled={loading}>
<div className="mb-3 flex flex-wrap items-start justify-between gap-3 border-b border-border pb-3">
<div className="min-w-0">
<div className="flex items-center gap-2 text-sm font-semibold">
<Cloud className="size-4" />
我的 OSS
</div>
<p className="mt-1 max-w-3xl text-xs leading-5 text-foreground/55">启用后,新上传和新生成的媒体优先写入你的 OSS;停用时回退到平台存储。历史资源固定使用创建时的配置版本。</p>
</div>
<div className="flex shrink-0 gap-2">
<Tag color={setting?.enabled ? "success" : "default"}>{setting?.enabled ? "已启用" : "未启用"}</Tag>
<Tag color={setting?.hasAccessKeySecret ? "processing" : "warning"} icon={<ShieldCheck className="size-3" />}>
{setting?.hasAccessKeySecret ? "密钥已加密" : "未保存密钥"}
</Tag>
</div>
</div>
<div className="grid grid-cols-1 gap-x-4 md:grid-cols-2 xl:grid-cols-3">
<Form.Item name="enabled" label="启用个人 OSS" valuePropName="checked" className="mb-3">
<Switch checkedChildren="启用" unCheckedChildren="停用" />
</Form.Item>
<Form.Item name="provider" label="存储服务" rules={[{ required: true, message: "请选择存储服务" }]} className="mb-3">
<Select options={[{ label: "阿里云 OSS", value: "aliyun" }]} />
</Form.Item>
<Form.Item name="region" label="Region" className="mb-3">
<Input spellCheck={false} placeholder="oss-cn-hangzhou" />
</Form.Item>
<Form.Item name="endpoint" label="Endpoint" className="mb-3">
<Input inputMode="url" spellCheck={false} placeholder="https://oss-cn-hangzhou.aliyuncs.com" />
</Form.Item>
<Form.Item name="bucket" label="Bucket" className="mb-3">
<Input spellCheck={false} placeholder="my-canvas-assets" />
</Form.Item>
<Form.Item name="pathPrefix" label="路径前缀" className="mb-3">
<Input spellCheck={false} placeholder="infinite-canvas" />
</Form.Item>
<Form.Item name="accessKeyId" label="AccessKey ID" className="mb-3 xl:col-span-1">
<Input autoComplete="off" spellCheck={false} placeholder="阿里云 AccessKey ID" />
</Form.Item>
<Form.Item name="accessKeySecret" label={setting?.hasAccessKeySecret ? "AccessKey Secret(留空保留)" : "AccessKey Secret"} className="mb-3 xl:col-span-2">
<Input.Password autoComplete="new-password" spellCheck={false} placeholder={setting?.hasAccessKeySecret ? "留空保留已加密密钥" : "阿里云 AccessKey Secret"} />
</Form.Item>
</div>
<div className="mt-2 flex flex-wrap items-center justify-between gap-3 border-t border-border pt-3">
<span className="text-xs text-foreground/50">{savedAt ? `上次保存:${savedAt}` : "尚未保存个人 OSS 配置"}</span>
<Button type="primary" loading={saving} onClick={() => void save()}>
保存个人 OSS
</Button>
</div>
</Form>
);
}
function formatSavedAt(value?: string) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime()) || date.getFullYear() < 2000) return "";
return date.toLocaleString("zh-CN");
}
function toFormValues(setting: UserOSSSetting): OSSFormValues {
return {
enabled: setting.enabled,
provider: setting.provider || "aliyun",
region: setting.region,
endpoint: setting.endpoint,
bucket: setting.bucket,
accessKeyId: setting.accessKeyId,
accessKeySecret: "",
pathPrefix: setting.pathPrefix,
};
}

View File

@@ -0,0 +1,136 @@
import { useEffect, useState, type CSSProperties } from "react";
import { App, Dropdown } from "antd";
import { Coins, Keyboard, LogIn, LogOut, Settings2, ShieldCheck, UserRound } from "lucide-react";
import { Link, useNavigate } from "react-router";
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
import { AppChangelogButton } from "@/components/layout/app-changelog-modal";
import { SystemAnnouncementCenter } from "@/components/layout/system-announcement-center";
import { canvasThemes } from "@/lib/canvas-theme";
import { applyUserSession } from "@/lib/user-session";
import { useWalletBalance } from "@/hooks/use-wallet-balance";
import { logout } from "@/services/api/auth";
import { useConfigStore } from "@/stores/use-config-store";
import { useUserStore, type LocalUser } from "@/stores/use-user-store";
import { useThemeStore } from "@/stores/use-theme-store";
type UserStatusActionsProps = {
showConfig?: boolean;
variant?: "default" | "canvas";
onOpenShortcuts?: () => void;
};
export function UserStatusActions({ showConfig = true, variant = "default", onOpenShortcuts }: UserStatusActionsProps) {
const theme = useThemeStore((state) => state.theme);
const setTheme = useThemeStore((state) => state.setTheme);
const user = useUserStore((state) => state.user);
const hydrated = useUserStore((state) => state.hydrated);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const navigate = useNavigate();
const { message } = App.useApp();
const canvasTheme = canvasThemes[theme];
const { availableMicrocredits } = useWalletBalance(user?.id, variant !== "canvas");
const naturalIconClass = "inline-flex size-7 shrink-0 items-center justify-center text-stone-600 transition hover:text-stone-950 dark:text-stone-300 dark:hover:text-white [&_svg]:size-4";
const announcementIconClass = "relative grid size-9 shrink-0 place-items-center rounded-full border border-stone-300/80 bg-white/80 text-stone-600 outline-none transition hover:border-stone-500 hover:text-stone-950 focus-visible:ring-2 focus-visible:ring-stone-500/50 dark:border-white/15 dark:bg-white/8 dark:text-stone-200 dark:hover:border-white/35 dark:hover:text-white";
const iconStyle: CSSProperties | undefined = variant === "canvas" ? { color: canvasTheme.node.text } : undefined;
const handleLogout = async () => {
try {
await logout();
await applyUserSession({ user: null, systemChannels: [] });
message.success("已退出登录");
navigate("/login", { replace: true });
} catch (error) {
message.error(error instanceof Error ? error.message : "退出失败");
}
};
return (
<div className="inline-flex shrink-0 items-center gap-1">
{variant !== "canvas" && user && availableMicrocredits !== null ? (
<Link to="/wallet" className="mr-1 inline-flex h-7 items-center gap-1 text-xs font-medium tabular-nums text-stone-600 transition hover:text-stone-950 dark:text-stone-300 dark:hover:text-white" title="积分中心">
<Coins className="size-3.5" />
{(availableMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 3 })}
</Link>
) : null}
<AppChangelogButton className={naturalIconClass} style={iconStyle} />
{showConfig ? (
<button type="button" className={naturalIconClass} style={iconStyle} onClick={() => openConfigDialog(false)} aria-label="配置" title="配置">
<Settings2 className="size-4" />
</button>
) : null}
<AnimatedThemeToggler theme={theme} onThemeChange={setTheme} className={naturalIconClass} style={iconStyle} aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} title={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} />
{onOpenShortcuts ? (
<button type="button" className={naturalIconClass} style={iconStyle} onClick={onOpenShortcuts} aria-label="快捷键" title="快捷键">
<Keyboard className="size-4" />
</button>
) : null}
{hydrated && user ? <SystemAnnouncementCenter userId={user.id} className={announcementIconClass} style={variant === "canvas" ? { color: canvasTheme.node.text, borderColor: canvasTheme.toolbar.border, background: canvasTheme.toolbar.panel } : undefined} /> : null}
{hydrated && user ? (
<Dropdown
trigger={["click", "hover"]}
menu={{
items: [
{
key: "profile",
disabled: true,
label: (
<div className="flex min-w-56 items-start gap-3 py-1.5 text-stone-900 dark:text-white">
<UserAvatar user={user} className="size-10" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold">{user.displayName || user.username}</div>
<div className="mt-0.5 truncate text-xs text-stone-500 dark:text-stone-400">站内账号 · @{user.username}</div>
{user.identityProvider === "linuxdo" && user.identityId ? (
<div className="mt-2 border-t border-stone-200 pt-2 text-[11px] leading-4 text-stone-500 dark:border-stone-700 dark:text-stone-400">
<div className="flex items-start gap-1.5">
<span className="shrink-0 font-medium text-stone-700 dark:text-stone-200">Linux.do ID</span>
<span className="min-w-0 break-all" title={user.identityId}>{user.identityId}</span>
</div>
{user.identityUsername ? <div className="mt-0.5 truncate">Linux.do 账号 · @{user.identityUsername}</div> : null}
</div>
) : null}
</div>
</div>
),
},
...(user.role === "admin" ? [{ key: "admin", icon: <ShieldCheck className="size-4" />, label: "管理员后台", onClick: () => navigate("/admin") }] : []),
{ key: "wallet", icon: <Coins className="size-4" />, label: "积分中心", onClick: () => navigate("/wallet") },
{ key: "logout", icon: <LogOut className="size-4" />, label: "退出登录", danger: true, onClick: () => void handleLogout() },
],
}}
>
<button
type="button"
className="grid size-8 shrink-0 place-items-center overflow-hidden rounded-full border border-stone-300/80 bg-stone-100 text-stone-600 outline-none transition hover:border-stone-500 focus-visible:ring-2 focus-visible:ring-stone-500/50 dark:border-white/15 dark:bg-white/8 dark:text-stone-200 dark:hover:border-white/35"
style={variant === "canvas" ? { color: canvasTheme.node.text, borderColor: canvasTheme.toolbar.border, background: canvasTheme.toolbar.panel } : undefined}
aria-label="用户菜单"
title={user.displayName || user.username}
>
<UserAvatar user={user} className="size-full" decorative />
</button>
</Dropdown>
) : hydrated ? (
<Link className={naturalIconClass} style={iconStyle} to="/login" aria-label="登录" title="登录">
<LogIn className="size-4" />
</Link>
) : null}
</div>
);
}
function UserAvatar({ user, className, decorative = false }: { user: LocalUser; className: string; decorative?: boolean }) {
const [failed, setFailed] = useState(false);
const avatarUrl = /^https?:\/\//i.test(user.avatarUrl || "") ? user.avatarUrl : "";
useEffect(() => setFailed(false), [avatarUrl]);
return (
<span className={`grid shrink-0 place-items-center overflow-hidden rounded-full bg-stone-200 text-stone-600 dark:bg-stone-800 dark:text-stone-200 ${className}`}>
{avatarUrl && !failed ? (
<img src={avatarUrl} alt={decorative ? "" : `${user.displayName || user.username}的头像`} referrerPolicy="no-referrer" className="size-full object-cover" onError={() => setFailed(true)} />
) : (
<UserRound className="size-[52%]" aria-hidden />
)}
</span>
);
}

View File

@@ -0,0 +1,66 @@
import { Button, Pagination } from "antd";
import { RotateCcw } from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export function WorkspacePage({ children, className, grid = false }: { children: ReactNode; className?: string; grid?: boolean }) {
return (
<main className={cn("app-user-content thin-scrollbar h-full overflow-y-auto text-foreground", grid && "app-workspace-grid", className)}>
<div className="mx-auto w-full max-w-[1440px] px-4 py-6 sm:px-6 lg:px-8">{children}</div>
</main>
);
}
export function PageHeader({ title, description, meta, actions }: { title: string; description?: string; meta?: ReactNode; actions?: ReactNode }) {
return (
<header className="app-page-header flex flex-col gap-4 pb-6 sm:flex-row sm:items-end sm:justify-between">
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-center gap-2.5">
<h1 className="truncate text-2xl font-semibold leading-8">{title}</h1>
{meta}
</div>
{description ? <p className="mt-1.5 text-sm leading-5 text-foreground/62">{description}</p> : null}
</div>
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
</header>
);
}
export function ListToolbar({ children, trailing, active, onReset }: { children: ReactNode; trailing?: ReactNode; active?: boolean; onReset?: () => void }) {
return (
<div className="mt-4 flex flex-col gap-3 border-b border-border pb-4 xl:flex-row xl:items-center xl:justify-between">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">{children}</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
{active && onReset ? <Button icon={<RotateCcw className="size-3.5" />} onClick={onReset}>重置筛选</Button> : null}
{trailing}
</div>
</div>
);
}
export function TableSurface({ children, className }: { children: ReactNode; className?: string }) {
return <div className={cn("app-table-surface mt-4 min-w-0 overflow-hidden rounded-lg border border-border bg-background", className)}>{children}</div>;
}
export function CollectionGrid({ children, className }: { children: ReactNode; className?: string }) {
return <div className={cn("mt-4 grid grid-cols-1 gap-3 sm:grid-cols-[repeat(auto-fill,minmax(220px,1fr))]", className)}>{children}</div>;
}
export function PaginationBar({ current, pageSize, total, onChange, pageSizeOptions = [20, 50, 100] }: { current: number; pageSize: number; total: number; onChange: (page: number, pageSize: number) => void; pageSizeOptions?: number[] }) {
if (total <= pageSize && current === 1) return null;
return (
<div className="app-pagination-bar sticky bottom-0 z-10 flex min-w-0 justify-end border-t border-border bg-background/95 px-4 py-3 backdrop-blur">
<Pagination
current={current}
pageSize={pageSize}
total={total}
showSizeChanger
responsive
pageSizeOptions={pageSizeOptions.map(String)}
showTotal={(value, range) => `${range[0]}-${range[1]} / 共 ${value} 条`}
onChange={onChange}
/>
</div>
);
}