feat(platform): 开源发布 - 初始化 Open AI Canvas
This commit is contained in:
66
web/src/components/layout/app-changelog-modal.tsx
Normal file
66
web/src/components/layout/app-changelog-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
762
web/src/components/layout/app-config-modal.tsx
Normal file
762
web/src/components/layout/app-config-modal.tsx
Normal 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);
|
||||
}
|
||||
42
web/src/components/layout/app-providers.tsx
Normal file
42
web/src/components/layout/app-providers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
81
web/src/components/layout/app-top-nav.tsx
Normal file
81
web/src/components/layout/app-top-nav.tsx
Normal 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")} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
47
web/src/components/layout/client-root-init.tsx
Normal file
47
web/src/components/layout/client-root-init.tsx
Normal 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}</>;
|
||||
}
|
||||
38
web/src/components/layout/mobile-nav-drawer.tsx
Normal file
38
web/src/components/layout/mobile-nav-drawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
web/src/components/layout/model-setup-guide.tsx
Normal file
42
web/src/components/layout/model-setup-guide.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
113
web/src/components/layout/system-announcement-center.tsx
Normal file
113
web/src/components/layout/system-announcement-center.tsx
Normal 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)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
146
web/src/components/layout/user-oss-settings-form.tsx
Normal file
146
web/src/components/layout/user-oss-settings-form.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
136
web/src/components/layout/user-status-actions.tsx
Normal file
136
web/src/components/layout/user-status-actions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
web/src/components/layout/workspace-page.tsx
Normal file
66
web/src/components/layout/workspace-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user