feat: 完成短剧项目空间与结构化资产创作闭环 (#38)
* feat(canvas): 短剧项目空间 - 建立项目章节与画布关联 MVP * feat(canvas): 短剧项目空间 - 完善项目资产、镜头与流程闭环 Refs #30 * fix(deploy): 本地部署 - 修复前端构建并忽略本地 PostgreSQL 配置 * feat(*): 短剧项目空间 - 完成项目、章节、资产、角色与画布创作闭环 Refs #30 --------- Co-authored-by: ddCat <ddcat666@88.com>
This commit is contained in:
@@ -12,16 +12,20 @@ type AppChangelogButtonProps = {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
showVersion?: boolean;
|
||||
showLabel?: boolean;
|
||||
labelClassName?: string;
|
||||
versionClassName?: string;
|
||||
};
|
||||
|
||||
export function AppChangelogButton({ className, style, showVersion = false }: AppChangelogButtonProps) {
|
||||
export function AppChangelogButton({ className, style, showVersion = false, showLabel = false, labelClassName, versionClassName }: 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}
|
||||
{showLabel ? <span className={`whitespace-nowrap ${labelClassName || ""}`}>更新日志</span> : null}
|
||||
{showVersion ? <span className={versionClassName}>v{APP_VERSION.replace(/^v/, "")}</span> : null}
|
||||
</button>
|
||||
<AppChangelogModal open={open} onClose={() => setOpen(false)} />
|
||||
</>
|
||||
@@ -34,7 +38,7 @@ function AppChangelogModal({ open, onClose }: { open: boolean; onClose: () => vo
|
||||
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>}
|
||||
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 variant="filled">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}
|
||||
|
||||
@@ -1,765 +0,0 @@
|
||||
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" },
|
||||
{ label: "xAI / Sub2API 视频", value: "xai-video" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
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";
|
||||
case "xai-video":
|
||||
return "xAI / Sub2API 视频";
|
||||
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);
|
||||
}
|
||||
@@ -1,80 +1,113 @@
|
||||
import { Menu } from "lucide-react";
|
||||
import { Link, useLocation } from "react-router";
|
||||
import { Infinity as InfinityIcon, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Link, useLocation, useNavigate } 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";
|
||||
import { WORKSPACE_SIDEBAR_STORAGE_KEY } from "@/components/layout/workspace-sidebar-state";
|
||||
import { WorkspaceSidebarFooter } from "@/components/layout/workspace-sidebar-footer";
|
||||
import { navigationTools, type NavigationToolSlug } from "@/constant/navigation-tools";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function AppTopNav() {
|
||||
export function AppWorkspaceShell({ children }: { children: ReactNode }) {
|
||||
const { pathname } = useLocation();
|
||||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||||
const hideHeader = pathname.startsWith("/admin") || /^\/canvas\/[^/]+/.test(pathname);
|
||||
const navigate = useNavigate();
|
||||
const [mobileSidebarExpanded, setMobileSidebarExpanded] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => window.localStorage.getItem(WORKSPACE_SIDEBAR_STORAGE_KEY) === "1");
|
||||
const hideChrome = pathname.startsWith("/admin") || /^\/canvas\/[^/]+/.test(pathname);
|
||||
const compactSidebar = sidebarCollapsed;
|
||||
const slug = pathname.split("/").filter(Boolean)[0];
|
||||
const activeToolSlug = navigationTools.some((tool) => tool.slug === slug) ? (slug as NavigationToolSlug) : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(WORKSPACE_SIDEBAR_STORAGE_KEY, sidebarCollapsed ? "1" : "0");
|
||||
}, [sidebarCollapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleWorkspaceNavigation = (rawEvent: Event) => {
|
||||
const event = rawEvent as CustomEvent<{ to?: string }>;
|
||||
if (!event.detail?.to) return;
|
||||
event.preventDefault();
|
||||
navigate(event.detail.to);
|
||||
};
|
||||
window.addEventListener("workspace:navigate", handleWorkspaceNavigation);
|
||||
return () => window.removeEventListener("workspace:navigate", handleWorkspaceNavigation);
|
||||
}, [navigate]);
|
||||
|
||||
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>
|
||||
<div className="flex h-dvh min-h-0 w-full overflow-hidden">
|
||||
{!hideChrome ? (
|
||||
<aside className={cn("app-workspace-sidebar flex shrink-0 flex-col overflow-hidden", mobileSidebarExpanded ? "w-[196px]" : "w-[52px]", compactSidebar ? "lg:w-[60px]" : "lg:w-[220px]")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-14 shrink-0 items-center border-b border-border/55 text-foreground",
|
||||
mobileSidebarExpanded ? "gap-2 px-3" : "justify-center",
|
||||
compactSidebar ? "lg:justify-center lg:px-0" : "lg:justify-start lg:gap-2 lg:px-3",
|
||||
)}
|
||||
>
|
||||
<Link to="/" className={cn("min-w-0 items-center gap-2", mobileSidebarExpanded ? "flex" : "hidden", compactSidebar ? "lg:hidden" : "lg:flex")} title="影策">
|
||||
<span className="grid size-7 shrink-0 place-items-center rounded-md bg-foreground text-background"><InfinityIcon className="size-4" /></span>
|
||||
<span className="truncate text-[13px] 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="导航菜单"
|
||||
className={cn("app-workspace-icon-button shrink-0", mobileSidebarExpanded ? "ml-auto" : !compactSidebar ? "lg:ml-auto" : undefined)}
|
||||
onClick={() => {
|
||||
if (window.innerWidth < 1024) setMobileSidebarExpanded((value) => !value);
|
||||
else setSidebarCollapsed((value) => !value);
|
||||
}}
|
||||
aria-label="切换侧栏"
|
||||
title="切换侧栏"
|
||||
>
|
||||
<Menu className="size-5" />
|
||||
{mobileSidebarExpanded ? <PanelLeftClose className="size-4 lg:hidden" /> : <PanelLeftOpen className="size-4 lg:hidden" />}
|
||||
{compactSidebar ? <PanelLeftOpen className="hidden size-4 lg:block" /> : <PanelLeftClose className="hidden size-4 lg:block" />}
|
||||
</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 />
|
||||
<nav className="flex min-h-0 flex-1 flex-col overflow-y-auto px-2 py-3">
|
||||
<div className={cn("mb-2 px-2 text-[10px] font-medium text-foreground/34", mobileSidebarExpanded ? "block" : "hidden", compactSidebar ? "lg:hidden" : "lg:block")}>创作空间</div>
|
||||
{navigationTools.map((tool) => {
|
||||
const Icon = tool.icon;
|
||||
const active = tool.slug === activeToolSlug;
|
||||
return (
|
||||
<Link
|
||||
key={tool.slug}
|
||||
to={`/${tool.slug}`}
|
||||
title={tool.label}
|
||||
onClick={() => {
|
||||
if (window.innerWidth < 1024) setMobileSidebarExpanded(false);
|
||||
}}
|
||||
className={cn(
|
||||
"app-workspace-nav-link relative mb-1 flex h-9 shrink-0 items-center rounded-md text-[13px] transition-colors",
|
||||
mobileSidebarExpanded ? "gap-3 px-2.5" : "justify-center px-0",
|
||||
compactSidebar ? "lg:justify-center lg:px-0" : "lg:justify-start lg:gap-3 lg:px-2.5",
|
||||
active ? "is-active font-medium" : "text-foreground/55 hover:bg-foreground/[0.045] hover:text-foreground/85",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
<span className={cn("truncate", mobileSidebarExpanded ? "inline" : "hidden", compactSidebar ? "lg:hidden" : "lg:inline")}>{tool.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="shrink-0 border-t border-border/55 p-2">
|
||||
<WorkspaceSidebarFooter
|
||||
collapsedClassName={cn(
|
||||
mobileSidebarExpanded ? "justify-start gap-2 px-2" : "justify-center gap-0 px-0",
|
||||
compactSidebar ? "lg:justify-center lg:gap-0 lg:px-0" : "lg:justify-start lg:gap-2 lg:px-2",
|
||||
)}
|
||||
expandedClassName={cn(mobileSidebarExpanded ? "flex" : "hidden", compactSidebar ? "lg:hidden" : "lg:flex")}
|
||||
accountClassName={cn(
|
||||
mobileSidebarExpanded ? "flex-row gap-2 px-2" : "flex-col gap-0.5 px-0 py-1",
|
||||
compactSidebar ? "lg:flex-col lg:gap-0.5 lg:px-0 lg:py-1" : "lg:flex-row lg:gap-2 lg:px-2 lg:py-0",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
) : null}
|
||||
</aside>
|
||||
) : null}
|
||||
|
||||
<MobileNavDrawer open={mobileNavOpen} activeToolSlug={activeToolSlug} onClose={() => setMobileNavOpen(false)} />
|
||||
<AppConfigModal />
|
||||
<div className="relative min-h-0 min-w-0 flex-1 overflow-hidden">{children}</div>
|
||||
</div>
|
||||
<ModelSetupGuide hidden={pathname === "/login" || pathname === "/register" || pathname.startsWith("/admin")} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -3,13 +3,13 @@ import { useEffect, useRef } from "react";
|
||||
import { App } from "antd";
|
||||
|
||||
import { createModelChannel, useConfigStore } from "@/stores/use-config-store";
|
||||
import { navigateToSettings } from "@/lib/settings-navigation";
|
||||
|
||||
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;
|
||||
@@ -38,10 +38,10 @@ export function ClientRootInit({ children }: { children: ReactNode }) {
|
||||
: [createModelChannel({ id: "default", name: "默认渠道", baseUrl: baseUrl || undefined })],
|
||||
);
|
||||
if (baseUrl) updateConfig("baseUrl", baseUrl);
|
||||
openConfigDialog(false);
|
||||
navigateToSettings({ section: "channels" });
|
||||
if (ignoredApiKey) message.warning("出于安全考虑,链接中的 API Key 已忽略,请在配置中手动填写");
|
||||
else message.success("已导入本地直连地址");
|
||||
}, [config.channels, message, openConfigDialog, updateConfig]);
|
||||
}, [config.channels, message, updateConfig]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
23
web/src/components/layout/identity-provider-badge.tsx
Normal file
23
web/src/components/layout/identity-provider-badge.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type IdentityUser = { identityProvider?: string; identityUsername?: string };
|
||||
|
||||
export function isLinuxDOIdentity(user?: IdentityUser | null) {
|
||||
return user?.identityProvider?.trim().toLowerCase() === "linuxdo";
|
||||
}
|
||||
|
||||
export function IdentityProviderBadge({ user, compact = false, className }: { user: IdentityUser; compact?: boolean; className?: string }) {
|
||||
if (!isLinuxDOIdentity(user)) return null;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center justify-center border border-border bg-background font-semibold text-foreground/65",
|
||||
compact ? "size-3.5 rounded-full text-[8px] leading-none" : "h-4 rounded px-1 text-[9px] leading-none",
|
||||
className,
|
||||
)}
|
||||
title={user.identityUsername ? `Linux.do · @${user.identityUsername}` : "Linux.do 用户"}
|
||||
>
|
||||
{compact ? "L" : "Linux.do"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { ArrowRight, Sparkles, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { aceternityMotion } from "@/lib/aceternity-motion";
|
||||
import { navigateToSettings } from "@/lib/settings-navigation";
|
||||
import { useConfigStore } from "@/stores/use-config-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
@@ -13,7 +14,6 @@ export function ModelSetupGuide({ hidden = false }: { hidden?: boolean }) {
|
||||
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 = () => {
|
||||
@@ -23,7 +23,7 @@ export function ModelSetupGuide({ hidden = false }: { hidden?: boolean }) {
|
||||
|
||||
const openModels = () => {
|
||||
close();
|
||||
openConfigDialog(false, "models");
|
||||
navigateToSettings({ section: "models" });
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { Tag } from "antd";
|
||||
import { Bell } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
|
||||
|
||||
@@ -12,9 +13,12 @@ type SystemAnnouncementCenterProps = {
|
||||
userId: string;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
showLabel?: boolean;
|
||||
labelClassName?: string;
|
||||
staticMotion?: boolean;
|
||||
};
|
||||
|
||||
export function SystemAnnouncementCenter({ userId, className, style }: SystemAnnouncementCenterProps) {
|
||||
export function SystemAnnouncementCenter({ userId, className, style, showLabel = false, labelClassName, staticMotion = false }: SystemAnnouncementCenterProps) {
|
||||
const reducedMotion = useReducedMotion();
|
||||
const activeUserIdRef = useRef(userId);
|
||||
activeUserIdRef.current = userId;
|
||||
@@ -84,28 +88,35 @@ export function SystemAnnouncementCenter({ userId, className, style }: SystemAnn
|
||||
type="button"
|
||||
className={className}
|
||||
style={style}
|
||||
whileHover={reducedMotion ? undefined : { y: -1, scale: 1.035 }}
|
||||
whileTap={reducedMotion ? undefined : { scale: 0.94 }}
|
||||
whileHover={reducedMotion || staticMotion ? undefined : { y: -1, scale: 1.035 }}
|
||||
whileTap={reducedMotion || staticMotion ? 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>
|
||||
<span className="relative shrink-0">
|
||||
<Bell className="size-4" />
|
||||
<AnimatePresence initial={false}>
|
||||
{unreadCount > 0 ? (
|
||||
<motion.span
|
||||
key="unread-dot"
|
||||
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 size-2 rounded-full border border-background bg-red-500"
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</span>
|
||||
{showLabel ? (
|
||||
<span className={`min-w-0 flex-1 items-center justify-between gap-2 whitespace-nowrap ${labelClassName || ""}`}>
|
||||
<span>系统公告</span>
|
||||
<Tag color={unreadCount > 0 ? "gold" : undefined} className="!m-0 !min-w-6 !px-1.5 !text-center !text-[9px] !font-medium !leading-[18px] tabular-nums">{announcements.length}</Tag>
|
||||
</span>
|
||||
) : null}
|
||||
</motion.button>
|
||||
<AnnouncementTimelineModal open={open} announcements={announcements} loading={loading} error={announcements.length ? "" : error} onClose={() => setOpen(false)} onRetry={() => void refresh(true)} />
|
||||
</>
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -2,25 +2,29 @@ import { Button, Pagination } from "antd";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { WorkspaceSignalIcon, type WorkspaceSignalIconVariant } from "@/components/ui/aceternity/workspace-signal-icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function WorkspacePage({ children, className, grid = false }: { children: ReactNode; className?: string; grid?: boolean }) {
|
||||
export function WorkspacePage({ children, className, grid = false, fluid = false }: { children: ReactNode; className?: string; grid?: boolean; fluid?: 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>
|
||||
<div className={fluid ? "h-full w-full" : "w-full px-3 py-3 sm:px-4 sm:py-4 xl:px-5"}>{children}</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageHeader({ title, description, meta, actions }: { title: string; description?: string; meta?: ReactNode; actions?: ReactNode }) {
|
||||
export function PageHeader({ title, description, meta, actions, icon }: { title: string; description?: string; meta?: ReactNode; actions?: ReactNode; icon?: WorkspaceSignalIconVariant }) {
|
||||
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}
|
||||
<header className="app-page-header flex min-h-14 flex-col gap-3 border-b border-border/80 pb-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{icon ? <WorkspaceSignalIcon variant={icon} /> : null}
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-baseline gap-2.5">
|
||||
<h1 className="truncate text-[22px] font-semibold leading-7">{title}</h1>
|
||||
{meta}
|
||||
</div>
|
||||
{description ? <p className="mt-1 text-xs leading-5 text-foreground/58">{description}</p> : null}
|
||||
</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>
|
||||
@@ -29,10 +33,10 @@ export function PageHeader({ title, description, meta, actions }: { title: strin
|
||||
|
||||
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="mt-3 flex min-h-12 flex-col gap-2 border-b border-border/75 pb-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2.5">{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}
|
||||
{active && onReset ? <Button type="text" icon={<RotateCcw className="size-3.5" />} onClick={onReset}>重置</Button> : null}
|
||||
{trailing}
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,7 +48,7 @@ export function TableSurface({ children, className }: { children: ReactNode; cla
|
||||
}
|
||||
|
||||
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>;
|
||||
return <div className={cn("mt-4 grid grid-cols-1 gap-x-4 gap-y-5 sm:grid-cols-[repeat(auto-fill,minmax(248px,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[] }) {
|
||||
|
||||
136
web/src/components/layout/workspace-sidebar-footer.tsx
Normal file
136
web/src/components/layout/workspace-sidebar-footer.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { App, Popover, Switch } from "antd";
|
||||
import { BookOpenCheck, ChevronRight, Coins, LogIn, LogOut, Moon, Settings2, ShieldCheck, Sun, UserRound } from "lucide-react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
|
||||
import { AppChangelogButton } from "@/components/layout/app-changelog-modal";
|
||||
import { IdentityProviderBadge } from "@/components/layout/identity-provider-badge";
|
||||
import { SystemAnnouncementCenter } from "@/components/layout/system-announcement-center";
|
||||
import { useWalletBalance } from "@/hooks/use-wallet-balance";
|
||||
import { applyUserSession } from "@/lib/user-session";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { logout } from "@/services/api/auth";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useUserStore, type LocalUser } from "@/stores/use-user-store";
|
||||
|
||||
type WorkspaceSidebarFooterProps = {
|
||||
expandedClassName: string;
|
||||
collapsedClassName: string;
|
||||
accountClassName: string;
|
||||
};
|
||||
|
||||
export function WorkspaceSidebarFooter({ expandedClassName, collapsedClassName, accountClassName }: WorkspaceSidebarFooterProps) {
|
||||
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 navigate = useNavigate();
|
||||
const { message } = App.useApp();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const { availableMicrocredits } = useWalletBalance(user?.id, true);
|
||||
const balance = availableMicrocredits === null
|
||||
? "--"
|
||||
: (availableMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logout();
|
||||
await applyUserSession({ user: null, systemChannels: [] });
|
||||
setMenuOpen(false);
|
||||
message.success("已退出登录");
|
||||
navigate("/login", { replace: true });
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "退出失败");
|
||||
}
|
||||
};
|
||||
|
||||
if (!hydrated) return <div className="h-24 animate-pulse rounded-md bg-foreground/[.035]" />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{user ? (
|
||||
<SystemAnnouncementCenter
|
||||
userId={user.id}
|
||||
className={cn("relative mb-1 flex h-9 w-full items-center rounded-md text-xs text-foreground/55 transition-colors hover:bg-foreground/[.05] hover:text-foreground", collapsedClassName)}
|
||||
showLabel
|
||||
labelClassName={expandedClassName}
|
||||
staticMotion
|
||||
/>
|
||||
) : null}
|
||||
{user ? (
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="rightBottom"
|
||||
open={menuOpen}
|
||||
onOpenChange={setMenuOpen}
|
||||
content={(
|
||||
<div className="w-[232px] py-0.5">
|
||||
<div className="flex items-center gap-3 border-b border-border/65 px-1 pb-3">
|
||||
<UserAvatar user={user} className="size-8" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5"><span className="truncate text-sm font-medium">{user.displayName || user.username}</span><IdentityProviderBadge user={user} /></div>
|
||||
<div className="mt-0.5 truncate text-[11px] tabular-nums text-foreground/45">可用 {balance} 积分</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="py-2" aria-label="账号与工具">
|
||||
<MenuLink to="/wallet" icon={<Coins />} label="积分与账单" onNavigate={() => setMenuOpen(false)} />
|
||||
<MenuLink to="/skills" icon={<BookOpenCheck />} label="技能库" onNavigate={() => setMenuOpen(false)} />
|
||||
<MenuLink to="/settings" icon={<Settings2 />} label="设置" onNavigate={() => setMenuOpen(false)} />
|
||||
{user.role === "admin" ? <MenuLink to="/admin" icon={<ShieldCheck />} label="管理员后台" onNavigate={() => setMenuOpen(false)} /> : null}
|
||||
</nav>
|
||||
|
||||
<div className="border-y border-border/65 py-2">
|
||||
<AppChangelogButton className="flex h-8 w-full items-center gap-2 rounded px-2 text-[11px] text-foreground/58 hover:bg-foreground/[.055] hover:text-foreground [&_svg]:size-3.5" showLabel showVersion versionClassName="ml-auto text-[9px] tabular-nums text-foreground/32" />
|
||||
</div>
|
||||
|
||||
<div className="flex h-10 items-center px-2">
|
||||
{theme === "dark" ? <Moon className="size-3.5 text-foreground/45" /> : <Sun className="size-3.5 text-foreground/45" />}
|
||||
<span className="ml-2 flex-1 text-xs text-foreground/65">深色模式</span>
|
||||
<Switch size="small" checked={theme === "dark"} onChange={(checked) => setTheme(checked ? "dark" : "light")} aria-label="深色模式" />
|
||||
</div>
|
||||
<button type="button" className="flex h-9 w-full items-center gap-2 rounded px-2 text-xs text-foreground/55 hover:bg-red-500/[.08] hover:text-red-600" onClick={() => void handleLogout()}><LogOut className="size-3.5" />退出登录</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<button type="button" className={cn("flex min-h-10 w-full min-w-0 items-center overflow-hidden rounded-md text-left transition-colors hover:bg-foreground/[.045]", accountClassName)} title={`${user.displayName || user.username} · ${balance} 积分`}>
|
||||
<UserAvatar user={user} className="size-7" />
|
||||
<span className={cn("min-w-0 flex-1 flex-col", expandedClassName)}>
|
||||
<span className="truncate text-xs font-medium">{user.displayName || user.username}</span>
|
||||
<span className="mt-0.5 block truncate text-[9px] tabular-nums text-foreground/42">{balance} 积分</span>
|
||||
</span>
|
||||
<ChevronRight className={cn("size-3.5 shrink-0 text-foreground/30", expandedClassName)} />
|
||||
</button>
|
||||
</Popover>
|
||||
) : (
|
||||
<Link to="/login" className={cn("flex h-10 items-center rounded-md text-xs text-foreground/65 hover:bg-foreground/[.05] hover:text-foreground", collapsedClassName)} title="登录">
|
||||
<LogIn className="size-4 shrink-0" /><span className={expandedClassName}>登录</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuLink({ to, icon, label, onNavigate }: { to: string; icon: ReactNode; label: string; onNavigate: () => void }) {
|
||||
return <Link to={to} onClick={onNavigate} className="flex h-9 items-center gap-2.5 rounded px-2 text-xs text-foreground/62 hover:bg-foreground/[.055] hover:text-foreground [&_svg]:size-3.5 [&_svg]:shrink-0">{icon}<span className="flex-1">{label}</span><ChevronRight className="!size-3 text-foreground/25" /></Link>;
|
||||
}
|
||||
|
||||
function UserAvatar({ user, className }: { user: LocalUser; className: string }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const avatarUrl = /^https?:\/\//i.test(user.avatarUrl || "") ? user.avatarUrl : "";
|
||||
|
||||
useEffect(() => setFailed(false), [avatarUrl]);
|
||||
|
||||
return (
|
||||
<span className={`relative grid shrink-0 place-items-center ${className}`}>
|
||||
<span className="grid size-full place-items-center overflow-hidden rounded-full bg-muted text-foreground/55">
|
||||
{avatarUrl && !failed ? (
|
||||
<img src={avatarUrl} alt="" referrerPolicy="no-referrer" className="size-full object-cover" onError={() => setFailed(true)} />
|
||||
) : (
|
||||
<UserRound className="size-[52%]" aria-hidden />
|
||||
)}
|
||||
</span>
|
||||
<IdentityProviderBadge user={user} compact className="absolute -bottom-1 -right-1" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
1
web/src/components/layout/workspace-sidebar-state.ts
Normal file
1
web/src/components/layout/workspace-sidebar-state.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const WORKSPACE_SIDEBAR_STORAGE_KEY = "infinite-canvas:workspace-sidebar-collapsed";
|
||||
34
web/src/components/layout/workspace-state.tsx
Normal file
34
web/src/components/layout/workspace-state.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Button, Skeleton } from "antd";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { WorkspaceSignalIcon, type WorkspaceSignalIconVariant } from "@/components/ui/aceternity/workspace-signal-icon";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function WorkspaceState({ icon = "empty", title, description, action, compact = false, className }: { icon?: WorkspaceSignalIconVariant; title: string; description?: string; action?: ReactNode; compact?: boolean; className?: string }) {
|
||||
return (
|
||||
<section className={cn("workspace-state flex flex-col items-center justify-center text-center", compact ? "min-h-44 py-8" : "min-h-[320px] py-12", className)}>
|
||||
<WorkspaceSignalIcon variant={icon} size={compact ? "md" : "lg"} />
|
||||
<h2 className="mt-4 text-[15px] font-semibold leading-6 text-foreground">{title}</h2>
|
||||
{description ? <p className="mt-1.5 max-w-[42ch] text-xs leading-5 text-foreground/58">{description}</p> : null}
|
||||
{action ? <div className="mt-5">{action}</div> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceErrorState({ title = "暂时无法加载", description, actionLabel = "重新加载", onRetry, compact = false }: { title?: string; description?: string; actionLabel?: string; onRetry?: () => void; compact?: boolean }) {
|
||||
return <WorkspaceState icon="error" title={title} description={description || "请检查网络连接后重试,当前内容不会被覆盖。"} compact={compact} action={onRetry ? <Button onClick={onRetry}>{actionLabel}</Button> : undefined} />;
|
||||
}
|
||||
|
||||
export function WorkspaceLoadingState({ label = "正在加载内容", detail, rows = 3, className }: { label?: string; detail?: string; rows?: number; className?: string }) {
|
||||
return (
|
||||
<section className={cn("workspace-loading-state py-8", className)} aria-busy="true" aria-live="polite">
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<WorkspaceSignalIcon variant="loading" size="sm" />
|
||||
<div><div className="text-sm font-medium">{label}</div>{detail ? <div className="mt-0.5 text-xs text-foreground/50">{detail}</div> : null}</div>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: rows }, (_, index) => <div key={index} className="rounded-md border border-border/75 p-4"><Skeleton active title={{ width: `${48 + index * 8}%` }} paragraph={{ rows: 3 }} /></div>)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user