feat(i18n): implement internationalization framework and update UI components for language support

This commit is contained in:
HouYunFei
2026-08-05 14:08:03 +08:00
parent 9bccd0ff1a
commit 0f6809251a
116 changed files with 3269 additions and 1870 deletions
+105 -71
View File
@@ -1,10 +1,13 @@
import { App, Button, Form, Input, Modal, Progress, Select, Tabs } from "antd";
import type { TFunction } from "i18next";
import { Cloud, Download, Pencil, Plus, RefreshCw, Trash2, Upload, Wifi } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ModelPicker } from "@/components/model-picker";
import { ChannelEditorDrawer } from "@/components/layout/channel-editor-drawer";
import { ConfigPromptSources } from "@/components/layout/config-prompt-sources";
import { changeAppLocale, type AppLocale } from "@/i18n";
import { exportAppConfig, importAppConfig } from "@/services/config-file";
import { syncAppDataToWebdav, type AppSyncDomainKey, type AppSyncProgressEvent } from "@/services/app-sync";
import { testWebdavConnection, WEBDAV_MANIFEST_FILE_NAME } from "@/services/webdav-sync";
@@ -14,11 +17,10 @@ import { createModelChannel, modelOptionsFromChannels, normalizeModelOptionValue
type ModelGroup = {
capability: ModelCapability;
modelKey: "imageModel" | "videoModel" | "textModel" | "audioModel";
defaultLabel: string;
labelKey: string;
};
type WebdavDomainProgress = {
label: string;
stage: string;
current?: number;
total?: number;
@@ -26,25 +28,18 @@ type WebdavDomainProgress = {
};
const modelGroups: ModelGroup[] = [
{ capability: "image", modelKey: "imageModel", defaultLabel: "默认生图模型" },
{ capability: "video", modelKey: "videoModel", defaultLabel: "默认视频模型" },
{ capability: "text", modelKey: "textModel", defaultLabel: "默认文本模型" },
{ capability: "audio", modelKey: "audioModel", defaultLabel: "默认音频模型" },
{ capability: "image", modelKey: "imageModel", labelKey: "config.preferences.defaultImageModel" },
{ capability: "video", modelKey: "videoModel", labelKey: "config.preferences.defaultVideoModel" },
{ capability: "text", modelKey: "textModel", labelKey: "config.preferences.defaultTextModel" },
{ capability: "audio", modelKey: "audioModel", labelKey: "config.preferences.defaultAudioModel" },
];
const webdavDomainKeys: AppSyncDomainKey[] = ["canvas", "assets", "image-workbench", "video-workbench"];
const webdavDomainLabels: Record<AppSyncDomainKey, string> = {
canvas: "画布",
assets: "我的资产",
"image-workbench": "生图工作台",
"video-workbench": "视频创作台",
};
function createWebdavDomainProgress(): Record<AppSyncDomainKey, WebdavDomainProgress> {
return webdavDomainKeys.reduce(
(progress, key) => ({
...progress,
[key]: { label: webdavDomainLabels[key], stage: "等待同步" },
[key]: { stage: "等待同步" },
}),
{} as Record<AppSyncDomainKey, WebdavDomainProgress>,
);
@@ -52,6 +47,7 @@ function createWebdavDomainProgress(): Record<AppSyncDomainKey, WebdavDomainProg
export function AppConfigPanel({ showDoneButton = false, initialTab = "channels" }: { showDoneButton?: boolean; initialTab?: ConfigTabKey }) {
const { message } = App.useApp();
const { i18n, t } = useTranslation();
const configInputRef = useRef<HTMLInputElement>(null);
const [activeTab, setActiveTab] = useState<ConfigTabKey>(initialTab);
const [editingChannelId, setEditingChannelId] = useState("");
@@ -68,6 +64,7 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
const clearPromptContinue = useConfigStore((state) => state.clearPromptContinue);
const webdavReady = Boolean(webdav.url.trim());
const editingChannel = config.channels.find((channel) => channel.id === editingChannelId) || null;
const locale = i18n.resolvedLanguage as AppLocale;
useEffect(() => setActiveTab(initialTab), [initialTab]);
const saveConfig = (nextConfig: AiConfig) => {
@@ -78,16 +75,16 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
const ready = config.channels.some((channel) => channel.baseUrl.trim() && channel.apiKey.trim() && channel.models.length);
setConfigDialogOpen(false);
if (!ready) return;
message.success(shouldPromptContinue ? "配置已保存,请继续刚才的请求" : "配置已保存");
message.success(t(shouldPromptContinue ? "config.savedContinue" : "config.saved"));
clearPromptContinue();
};
const loadConfigFile = async (file: File) => {
try {
await importAppConfig(file);
message.success("配置与用户偏好已导入");
message.success(t("config.imported"));
} catch (error) {
message.error(error instanceof Error ? error.message : "配置文件读取失败");
message.error(error instanceof Error ? error.message : t("config.importFailed"));
} finally {
if (configInputRef.current) configInputRef.current.value = "";
}
@@ -96,14 +93,14 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
const updateChannels = (channels: ModelChannel[]) => saveConfig(withChannels(config, channels));
const addChannel = () => {
const channel = createModelChannel({ name: `渠道 ${config.channels.length + 1}` });
const channel = createModelChannel({ name: t("config.channels.numberedName", { count: config.channels.length + 1 }) });
updateChannels([...config.channels, channel]);
setEditingChannelId(channel.id);
};
const deleteChannel = (id: string) => {
if (config.channels.length <= 1) {
message.warning("至少保留一个渠道");
message.warning(t("config.channels.keepOne"));
return;
}
updateChannels(config.channels.filter((channel) => channel.id !== id));
@@ -115,15 +112,15 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
const testWebdav = async () => {
if (!webdavReady) {
message.error("请先填写 WebDAV 地址");
message.error(t("config.webdav.missingUrl"));
return;
}
setTestingWebdav(true);
try {
await testWebdavConnection(webdav);
message.success("WebDAV 连接可用");
message.success(t("config.webdav.available"));
} catch (error) {
message.error(error instanceof Error ? error.message : "WebDAV 连接测试失败");
message.error(error instanceof Error ? error.message : t("config.webdav.testFailed"));
} finally {
setTestingWebdav(false);
}
@@ -135,7 +132,6 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
setWebdavDomainProgress((current) => ({
...current,
[event.domain as AppSyncDomainKey]: {
label: event.label || webdavDomainLabels[event.domain as AppSyncDomainKey],
stage: event.stage,
current: event.current,
total: event.total,
@@ -146,19 +142,19 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
const syncWebdav = async () => {
if (!webdavReady) {
message.error("请先填写 WebDAV 地址");
message.error(t("config.webdav.missingUrl"));
return;
}
setSyncingWebdav(true);
setWebdavDomainProgress(createWebdavDomainProgress());
setWebdavSyncStatus("准备同步");
setWebdavSyncStatus(t("config.webdav.preparing"));
try {
const result = await syncAppDataToWebdav(webdav, updateWebdavProgress);
updateWebdavConfig("lastSyncedAt", result.syncedAt);
message.success(`同步完成:${result.projects} 个画布,${result.assets} 个资产,${result.imageLogs + result.videoLogs} 条记录,本次上传 ${result.uploadedFiles} 个文件 ${formatBytes(result.uploadedBytes)}`);
message.success(t("config.webdav.completed", { projects: result.projects, assets: result.assets, records: result.imageLogs + result.videoLogs, files: result.uploadedFiles, bytes: formatBytes(result.uploadedBytes) }));
} catch (error) {
setWebdavSyncStatus(error instanceof Error ? error.message : "WebDAV 同步失败");
message.error(error instanceof Error ? error.message : "WebDAV 同步失败");
setWebdavSyncStatus(error instanceof Error ? error.message : t("config.webdav.failed"));
message.error(error instanceof Error ? error.message : t("config.webdav.failed"));
} finally {
setSyncingWebdav(false);
}
@@ -167,13 +163,13 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
return (
<>
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-stone-200 pb-3 dark:border-stone-800">
<div className="text-xs text-stone-500">JSON 文件包含 API Key 和 WebDAV 凭据,请妥善保管。</div>
<div className="text-xs text-stone-500">{t("config.fileSecurity")}</div>
<div className="flex gap-2">
<Button icon={<Upload className="size-4" />} onClick={() => configInputRef.current?.click()}>
导入配置
{t("config.import")}
</Button>
<Button icon={<Download className="size-4" />} onClick={exportAppConfig}>
导出配置
{t("config.export")}
</Button>
<input ref={configInputRef} type="file" accept="application/json,.json" className="hidden" onChange={(event) => event.target.files?.[0] && void loadConfigFile(event.target.files[0])} />
</div>
@@ -184,27 +180,27 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
items={[
{
key: "channels",
label: "渠道",
label: t("config.tabs.channels"),
children: (
<div>
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="text-xs text-stone-500">每个渠道选择一个协议并拉取模型,为每个模型指定能力(生图/视频/文本/音频),并可自定义调用脚本。</div>
<div className="text-xs text-stone-500">{t("config.channels.description")}</div>
<Button type="primary" icon={<Plus className="size-4" />} onClick={addChannel}>
新增渠道
{t("config.channels.add")}
</Button>
</div>
<div className="space-y-2">
{config.channels.map((channel) => (
<div key={channel.id} className="flex items-center justify-between gap-3 rounded-lg border border-stone-200 px-4 py-3 dark:border-stone-800">
<div className="min-w-0">
<div className="truncate text-sm font-semibold">{channel.name || "未命名渠道"}</div>
<div className="truncate text-sm font-semibold">{channel.name || t("config.channels.unnamed")}</div>
<div className="mt-1 truncate text-xs text-stone-500">
{apiFormatLabel(channel.apiFormat)} · {channel.models.length} 个模型 · {channel.baseUrl || "未填写接口地址"}
{apiFormatLabel(channel.apiFormat, t)} · {t("config.channels.modelCount", { count: channel.models.length })} · {channel.baseUrl || t("config.channels.missingUrl")}
</div>
</div>
<div className="flex shrink-0 gap-2">
<Button size="small" icon={<Pencil className="size-3.5" />} onClick={() => setEditingChannelId(channel.id)}>
编辑
{t("common.edit")}
</Button>
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={() => deleteChannel(channel.id)} />
</div>
@@ -216,20 +212,31 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
},
{
key: "preferences",
label: "偏好设置",
label: t("config.tabs.preferences"),
children: (
<Form layout="vertical" requiredMark={false}>
<div className="mb-2 text-sm font-semibold">默认模型</div>
<div className="mb-2 text-sm font-semibold">{t("config.preferences.interface")}</div>
<Form.Item label={t("config.preferences.language")} extra={t("config.preferences.languageDescription")} className="mb-4 max-w-sm">
<Select
value={locale}
options={[
{ label: t("locale.zhCN"), value: "zh-CN" },
{ label: t("locale.enUS"), value: "en-US" },
]}
onChange={(value: AppLocale) => void changeAppLocale(value)}
/>
</Form.Item>
<div className="mb-2 text-sm font-semibold">{t("config.preferences.defaultModels")}</div>
<div className="mb-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">
<Form.Item key={group.modelKey} label={t(group.labelKey)} className="mb-0">
<ModelPicker config={config} value={config[group.modelKey]} onChange={(model) => updateConfig(group.modelKey, model)} capability={group.capability} fullWidth />
</Form.Item>
))}
</div>
<div className="mb-2 text-sm font-semibold">生成偏好</div>
<div className="mb-2 text-sm font-semibold">{t("config.preferences.generation")}</div>
<div className="grid gap-4 md:grid-cols-4">
<Form.Item label="画布默认生图张数" extra="新建画布生图和配置节点默认使用,单个节点仍可单独覆盖。" className="mb-4">
<Form.Item label={t("config.preferences.canvasImageCount")} extra={t("config.preferences.canvasImageCountDescription")} className="mb-4">
<Input
type="number"
min={1}
@@ -239,13 +246,13 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
onBlur={(event) => updateConfig("canvasImageCount", normalizeImageCount(event.target.value))}
/>
</Form.Item>
<Form.Item label="默认音频声音" className="mb-4">
<Form.Item label={t("config.preferences.audioVoice")} className="mb-4">
<Select value={config.audioVoice} options={audioVoiceOptions} onChange={(value) => updateConfig("audioVoice", value)} />
</Form.Item>
<Form.Item label="默认音频格式" className="mb-4">
<Form.Item label={t("config.preferences.audioFormat")} className="mb-4">
<Select value={config.audioFormat} options={audioFormatOptions} onChange={(value) => updateConfig("audioFormat", value)} />
</Form.Item>
<Form.Item label="默认音频语速" className="mb-4">
<Form.Item label={t("config.preferences.audioSpeed")} className="mb-4">
<Input
type="number"
min={0.25}
@@ -257,18 +264,18 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
/>
</Form.Item>
</div>
<Form.Item label="默认音频指令" className="mb-4">
<Input.TextArea rows={2} value={config.audioInstructions} placeholder="例如:自然、温暖、适合旁白。" onChange={(event) => updateConfig("audioInstructions", event.target.value)} />
<Form.Item label={t("config.preferences.audioInstructions")} className="mb-4">
<Input.TextArea rows={2} value={config.audioInstructions} placeholder={t("config.preferences.audioInstructionsPlaceholder")} onChange={(event) => updateConfig("audioInstructions", event.target.value)} />
</Form.Item>
<Form.Item label="系统提示词" className="mb-0">
<Input.TextArea rows={4} value={config.systemPrompt} placeholder="例如:你是一位擅长电影感写实摄影的视觉导演。" onChange={(event) => updateConfig("systemPrompt", event.target.value)} />
<Form.Item label={t("config.preferences.systemPrompt")} className="mb-0">
<Input.TextArea rows={4} value={config.systemPrompt} placeholder={t("config.preferences.systemPromptPlaceholder")} onChange={(event) => updateConfig("systemPrompt", event.target.value)} />
</Form.Item>
</Form>
),
},
{
key: "prompt-sources",
label: "提示词来源",
label: t("config.tabs.promptSources"),
children: <ConfigPromptSources />,
},
{
@@ -281,36 +288,36 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
<div>
<div className="flex items-center gap-2 text-sm font-semibold">
<Cloud className="size-4" />
WebDAV 同步
{t("config.webdav.title")}
</div>
<div className="mt-1 text-xs text-stone-500">同步画布、我的资产、生成记录和本地媒体文件,不包含 AI API Key;浏览器会直接连接 WebDAV 服务。</div>
<div className="mt-1 text-xs text-stone-500">{t("config.webdav.description")}</div>
</div>
<div className="text-xs text-stone-500">{webdav.lastSyncedAt ? `上次同步 ${formatWebdavTime(webdav.lastSyncedAt)}` : "尚未同步"}</div>
<div className="text-xs text-stone-500">{webdav.lastSyncedAt ? t("config.webdav.lastSynced", { time: formatWebdavTime(webdav.lastSyncedAt, locale) }) : t("config.webdav.neverSynced")}</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Form.Item label="WebDAV 地址" className="mb-4">
<Form.Item label={t("config.webdav.url")} className="mb-4">
<Input value={webdav.url} placeholder="https://nas.example.com/webdav" onChange={(event) => updateWebdavConfig("url", event.target.value)} />
</Form.Item>
<Form.Item label="远程目录" extra={`会在该目录下分业务目录保存,每个目录包含 ${WEBDAV_MANIFEST_FILE_NAME} 和 files/`} className="mb-4">
<Form.Item label={t("config.webdav.directory")} extra={t("config.webdav.directoryDescription", { manifest: WEBDAV_MANIFEST_FILE_NAME })} className="mb-4">
<Input value={webdav.directory} placeholder="infinite-canvas" onChange={(event) => updateWebdavConfig("directory", event.target.value)} />
</Form.Item>
<Form.Item label="用户名" className="mb-0">
<Form.Item label={t("config.webdav.username")} className="mb-0">
<Input value={webdav.username} autoComplete="username" onChange={(event) => updateWebdavConfig("username", event.target.value)} />
</Form.Item>
<Form.Item label="密码 / 应用密码" className="mb-0">
<Form.Item label={t("config.webdav.password")} className="mb-0">
<Input.Password value={webdav.password} autoComplete="current-password" onChange={(event) => updateWebdavConfig("password", event.target.value)} />
</Form.Item>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2">
<Button icon={<Wifi className="size-4" />} disabled={!webdavReady || syncingWebdav} loading={testingWebdav} onClick={() => void testWebdav()}>
测试连接
{t("config.webdav.test")}
</Button>
<Button type="primary" icon={<RefreshCw className="size-4" />} disabled={!webdavReady || testingWebdav} loading={syncingWebdav} onClick={() => void syncWebdav()}>
{syncingWebdav ? "同步中" : "立即同步"}
{t(syncingWebdav ? "config.webdav.syncing" : "config.webdav.syncNow")}
</Button>
{webdavSyncStatus ? <span className="text-xs text-stone-500">{webdavSyncStatus}</span> : null}
{webdavSyncStatus ? <span className="text-xs text-stone-500">{syncStageLabel(webdavSyncStatus, t)}</span> : null}
</div>
{syncingWebdav || webdavSyncStatus ? <WebdavProgressGrid progress={webdavDomainProgress} /> : null}
{syncingWebdav || webdavSyncStatus ? <WebdavProgressGrid progress={webdavDomainProgress} t={t} /> : null}
</section>
</Form>
),
@@ -320,7 +327,7 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
{showDoneButton ? (
<div className="mt-4 flex justify-end">
<Button type="primary" onClick={finishConfig}>
完成
{t("common.done")}
</Button>
</div>
) : null}
@@ -330,6 +337,7 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
}
export function AppConfigModal() {
const { t } = useTranslation();
const isConfigOpen = useConfigStore((state) => state.isConfigOpen);
const configTab = useConfigStore((state) => state.configTab);
const setConfigDialogOpen = useConfigStore((state) => state.setConfigDialogOpen);
@@ -337,8 +345,8 @@ export function AppConfigModal() {
<Modal
title={
<div>
<div className="text-lg font-semibold">配置与用户偏好</div>
<div className="mt-1 text-xs font-normal text-stone-500">渠道聚合、默认模型和同步偏好</div>
<div className="text-lg font-semibold">{t("config.title")}</div>
<div className="mt-1 text-xs font-normal text-stone-500">{t("config.modalDescription")}</div>
</div>
}
open={isConfigOpen}
@@ -381,17 +389,17 @@ function normalizeImageCount(value: string) {
return String(Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || 3))));
}
function apiFormatLabel(apiFormat: ApiCallFormat) {
function apiFormatLabel(apiFormat: ApiCallFormat, t: TFunction) {
if (apiFormat === "gemini") return "Gemini";
if (apiFormat === "ark") return "火山方舟";
if (apiFormat === "ark") return t("config.protocols.ark");
return "OpenAI";
}
function formatWebdavTime(value: string) {
return new Date(value).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
function formatWebdavTime(value: string, locale: AppLocale) {
return new Date(value).toLocaleString(locale, { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
}
function WebdavProgressGrid({ progress }: { progress: Record<AppSyncDomainKey, WebdavDomainProgress> }) {
function WebdavProgressGrid({ progress, t }: { progress: Record<AppSyncDomainKey, WebdavDomainProgress>; t: TFunction }) {
return (
<div className="mt-3 grid gap-2">
{webdavDomainKeys.map((key) => {
@@ -400,9 +408,9 @@ function WebdavProgressGrid({ progress }: { progress: Record<AppSyncDomainKey, W
return (
<div key={key} className="rounded-md border border-stone-200 px-3 py-2 dark:border-stone-800">
<div className="mb-1 flex min-w-0 items-center justify-between gap-3 text-xs">
<span className="shrink-0 font-medium text-stone-700 dark:text-stone-200">{item.label}</span>
<span className="shrink-0 font-medium text-stone-700 dark:text-stone-200">{t(`config.webdav.domains.${domainTranslationKey(key)}`)}</span>
<span className="min-w-0 truncate text-right text-stone-500">
{item.stage}
{syncStageLabel(item.stage, t)}
{count ? ` · ${count}` : ""}
</span>
</div>
@@ -414,6 +422,32 @@ function WebdavProgressGrid({ progress }: { progress: Record<AppSyncDomainKey, W
);
}
function domainTranslationKey(domain: AppSyncDomainKey) {
if (domain === "image-workbench") return "imageWorkbench";
if (domain === "video-workbench") return "videoWorkbench";
return domain;
}
function syncStageLabel(stage: string, t: TFunction) {
if (stage === "等待本地数据加载") return t("config.webdav.stages.localWaiting");
if (stage === "同步完成") return t("config.webdav.stages.syncComplete");
if (stage === "等待同步") return t("config.webdav.stages.waiting");
if (stage === "读取远端清单") return t("config.webdav.stages.remoteManifest");
if (stage === "读取本地数据") return t("config.webdav.stages.localData");
if (stage === "下载缺失媒体") return t("config.webdav.stages.downloadMedia");
if (stage === "写入本地合并结果") return t("config.webdav.stages.writeMerge");
if (stage === "上传新增媒体") return t("config.webdav.stages.uploadMedia");
if (stage === "媒体已齐全") return t("config.webdav.stages.mediaReady");
if (stage === "媒体无需上传") return t("config.webdav.stages.mediaSkipped");
if (stage === "检查缺失媒体") return t("config.webdav.stages.checkMissingMedia");
if (stage === "下载媒体") return t("config.webdav.stages.downloadMediaFile");
if (stage === "检查本地媒体") return t("config.webdav.stages.checkLocalMedia");
if (stage.startsWith("上传媒体 ")) return t("config.webdav.stages.uploadMediaFile", { size: stage.slice(5) });
if (stage === "完成") return t("config.webdav.stages.complete");
if (stage.startsWith("上传清单 ")) return t("config.webdav.stages.uploadManifest", { size: stage.slice(5) });
return stage;
}
function getWebdavProgressPercent(item: WebdavDomainProgress) {
if (item.status === "success") return 100;
if (item.total) return Math.min(100, Math.round(((item.current || 0) / item.total) * 100));
+16 -2
View File
@@ -3,9 +3,14 @@ import { useEffect } from "react";
import { ProConfigProvider } from "@ant-design/pro-components";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { App, ConfigProvider } from "antd";
import zhCN from "antd/locale/zh_CN";
import enUS from "antd/es/locale/en_US";
import zhCN from "antd/es/locale/zh_CN";
import dayjs from "dayjs";
import "dayjs/locale/zh-cn";
import { useTranslation } from "react-i18next";
import { ClientRootInit } from "@/components/layout/client-root-init";
import type { AppLocale } from "@/i18n";
import { getAntThemeConfig } from "@/lib/app-theme";
import { useThemeStore } from "@/stores/use-theme-store";
@@ -20,16 +25,25 @@ const queryClient = new QueryClient({
});
export function AppProviders({ children }: { children: ReactNode }) {
const { i18n, t } = useTranslation();
const theme = useThemeStore((state) => state.theme);
const dark = theme === "dark";
const locale = i18n.resolvedLanguage as AppLocale;
useEffect(() => {
document.documentElement.classList.toggle("dark", dark);
document.documentElement.style.colorScheme = theme;
}, [dark, theme]);
useEffect(() => {
document.documentElement.lang = locale;
document.title = t("meta.title");
document.querySelector('meta[name="description"]')?.setAttribute("content", t("meta.description"));
dayjs.locale(locale === "zh-CN" ? "zh-cn" : "en");
}, [locale, t]);
return (
<ConfigProvider locale={zhCN} theme={getAntThemeConfig(dark)}>
<ConfigProvider locale={locale === "zh-CN" ? zhCN : enUS} theme={getAntThemeConfig(dark)}>
<ProConfigProvider dark={dark}>
<App>
<QueryClientProvider client={queryClient}>
+8 -6
View File
@@ -1,6 +1,7 @@
import { Bot, Menu } from "lucide-react";
import { Button, Tooltip } from "antd";
import { Link, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { navigationTools, type NavigationToolSlug } from "@/constant/navigation-tools";
import { AppConfigModal } from "@/components/layout/app-config-modal";
@@ -11,6 +12,7 @@ import { useEffect, useRef, useState } from "react";
import { useAgentStore } from "@/stores/use-agent-store";
export function AppTopNav() {
const { t } = useTranslation();
const { pathname } = useLocation();
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const autoConnectRef = useRef(false);
@@ -44,15 +46,15 @@ export function AppTopNav() {
WebkitMask: "url(/logo.svg) center / contain no-repeat",
}}
/>
<span className="text-base font-medium">无限画布</span>
<span className="text-base font-medium">{t("meta.title")}</span>
</Link>
<button
type="button"
className="ml-3 inline-flex size-8 shrink-0 items-center justify-center text-stone-600 transition hover:text-stone-950 md:hidden dark:text-stone-300 dark:hover:text-white"
onClick={() => setMobileNavOpen(true)}
aria-label="打开导航菜单"
title="导航菜单"
aria-label={t("topNav.openMenu")}
title={t("topNav.menu")}
>
<Menu className="size-5" />
</button>
@@ -73,7 +75,7 @@ export function AppTopNav() {
)}
>
<Icon className="size-4" />
<span className="truncate">{tool.label}</span>
<span className="truncate">{t(`navigation.${tool.slug}`)}</span>
</Link>
);
})}
@@ -81,8 +83,8 @@ export function AppTopNav() {
</div>
<div className="my-auto flex h-9 min-w-0 items-center justify-end gap-2 justify-self-end whitespace-nowrap">
<Tooltip title={panelOpen ? "收起 Agent" : "打开 Agent"}>
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" icon={<Bot className="size-4" />} onClick={togglePanel} aria-label="打开 Agent" />
<Tooltip title={t(panelOpen ? "topNav.closeAgent" : "topNav.openAgent")}>
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" icon={<Bot className="size-4" />} onClick={togglePanel} aria-label={t(panelOpen ? "topNav.closeAgent" : "topNav.openAgent")} />
</Tooltip>
<UserStatusActions />
</div>
@@ -1,30 +1,25 @@
import { Button, Drawer, Input, Segmented, Select, Space } from "antd";
import { ListPlus, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { defaultBaseUrlForApiFormat, guessCapability, normalizeChannelModels, type ApiCallFormat, type ChannelModel, type ModelCapability, type ModelChannel } from "@/stores/use-config-store";
import { ModelScriptEditor } from "./model-script-editor";
import { ModelSelectModal } from "./model-select-modal";
const apiFormatOptions: Array<{ label: string; value: ApiCallFormat }> = [
{ label: "OpenAI", value: "openai" },
{ label: "Gemini", value: "gemini" },
{ label: "火山方舟", value: "ark" },
];
const capabilityOptions: Array<{ label: string; value: ModelCapability }> = [
{ label: "生图", value: "image" },
{ label: "视频", value: "video" },
{ label: "文本", value: "text" },
{ label: "音频", value: "audio" },
];
type ScriptTarget = { name: string; capability: ModelCapability; value: string };
export function ChannelEditorDrawer({ open, channel, onSave, onClose }: { open: boolean; channel: ModelChannel | null; onSave: (channel: ModelChannel) => void; onClose: () => void }) {
const { t } = useTranslation();
const [draft, setDraft] = useState<ModelChannel | null>(channel);
const [selectOpen, setSelectOpen] = useState(false);
const [scriptTarget, setScriptTarget] = useState<ScriptTarget | null>(null);
const apiFormatOptions: Array<{ label: string; value: ApiCallFormat }> = [
{ label: "OpenAI", value: "openai" },
{ label: "Gemini", value: "gemini" },
{ label: t("config.protocols.ark"), value: "ark" },
];
const capabilityOptions: Array<{ label: string; value: ModelCapability }> = ["image", "video", "text", "audio"].map((value) => ({ label: t(`config.channelEditor.capabilities.${value}`), value: value as ModelCapability }));
useEffect(() => {
if (open && channel) setDraft(channel);
@@ -50,7 +45,7 @@ export function ChannelEditorDrawer({ open, channel, onSave, onClose }: { open:
const removeModel = (name: string) => setModels(draft.models.filter((model) => model.name !== name));
const save = () => {
onSave({ ...draft, name: draft.name.trim() || "未命名渠道", models: normalizeChannelModels(draft.models) });
onSave({ ...draft, name: draft.name.trim() || t("config.channels.unnamed"), models: normalizeChannelModels(draft.models) });
onClose();
};
@@ -58,29 +53,29 @@ export function ChannelEditorDrawer({ open, channel, onSave, onClose }: { open:
<Drawer
open={open}
width={640}
title="编辑渠道"
title={t("config.channelEditor.title")}
onClose={onClose}
styles={{ body: { paddingTop: 16 } }}
extra={
<Space>
<Button onClick={onClose}>取消</Button>
<Button onClick={onClose}>{t("common.cancel")}</Button>
<Button type="primary" onClick={save}>
保存
{t("common.save")}
</Button>
</Space>
}
>
<div className="grid gap-4 md:grid-cols-2">
<label className="block">
<span className="mb-1 block text-sm font-medium">渠道名称</span>
<span className="mb-1 block text-sm font-medium">{t("config.channelEditor.name")}</span>
<Input value={draft.name} onChange={(event) => patch({ name: event.target.value })} />
</label>
<label className="block">
<span className="mb-1 block text-sm font-medium">协议</span>
<span className="mb-1 block text-sm font-medium">{t("config.channelEditor.protocol")}</span>
<Select className="w-full" value={draft.apiFormat} options={apiFormatOptions} onChange={changeApiFormat} />
</label>
<label className="block md:col-span-2">
<span className="mb-1 block text-sm font-medium">接口地址</span>
<span className="mb-1 block text-sm font-medium">{t("config.channelEditor.baseUrl")}</span>
<Input value={draft.baseUrl} onChange={(event) => patch({ baseUrl: event.target.value })} placeholder="https://api.example.com" />
</label>
<label className="block md:col-span-2">
@@ -91,11 +86,11 @@ export function ChannelEditorDrawer({ open, channel, onSave, onClose }: { open:
<div className="mt-6 mb-3 flex flex-wrap items-center justify-between gap-2">
<div>
<div className="text-sm font-semibold">渠道模型</div>
<div className="mt-0.5 text-xs text-stone-500">已选 {draft.models.length} 个;为每个模型指定能力并可自定义调用脚本。</div>
<div className="text-sm font-semibold">{t("config.channelEditor.models")}</div>
<div className="mt-0.5 text-xs text-stone-500">{t("config.channelEditor.modelDescription", { count: draft.models.length })}</div>
</div>
<Button type="primary" icon={<ListPlus className="size-4" />} onClick={() => setSelectOpen(true)}>
选择模型
{t("config.channelEditor.selectModels")}
</Button>
</div>
@@ -109,14 +104,14 @@ export function ChannelEditorDrawer({ open, channel, onSave, onClose }: { open:
<div className="flex shrink-0 items-center gap-2">
<Segmented size="small" value={model.capability} options={capabilityOptions} onChange={(value) => setCapability(model.name, value as ModelCapability)} />
<Button size="small" type={model.script ? "primary" : "default"} ghost={Boolean(model.script)} onClick={() => setScriptTarget({ name: model.name, capability: model.capability, value: model.script || "" })}>
{model.script ? "脚本已设" : "调用脚本"}
{t(model.script ? "config.channelEditor.scriptReady" : "config.channelEditor.script")}
</Button>
<Button size="small" danger type="text" icon={<Trash2 className="size-3.5" />} onClick={() => removeModel(model.name)} />
</div>
</div>
))
) : (
<div className="px-2 py-8 text-center text-sm text-stone-500">点击「选择模型」拉取或手动增加模型。</div>
<div className="px-2 py-8 text-center text-sm text-stone-500">{t("config.channelEditor.empty")}</div>
)}
</div>
@@ -1,12 +1,14 @@
import type { ReactNode } from "react";
import { useEffect, useRef } from "react";
import { App } from "antd";
import { useTranslation } from "react-i18next";
import { createModelChannel, useConfigStore } from "@/stores/use-config-store";
import { usePromptSourceScheduler } from "@/hooks/use-prompt-source-scheduler";
export function ClientRootInit({ children }: { children: ReactNode }) {
const { message } = App.useApp();
const { t } = useTranslation();
const handledConfigParams = useRef(false);
const updateConfig = useConfigStore((state) => state.updateConfig);
const config = useConfigStore((state) => state.config);
@@ -39,13 +41,13 @@ export function ClientRootInit({ children }: { children: ReactNode }) {
}
: channel,
)
: [createModelChannel({ id: "default", name: "默认渠道", baseUrl: baseUrl || undefined, apiKey: apiKey || "" })],
: [createModelChannel({ id: "default", name: t("config.channels.defaultName"), baseUrl: baseUrl || undefined, apiKey: apiKey || "" })],
);
if (baseUrl) updateConfig("baseUrl", baseUrl);
if (apiKey) updateConfig("apiKey", apiKey);
openConfigDialog(false);
message.success("已导入本地直连配置");
}, [config.channels, message, openConfigDialog, updateConfig]);
message.success(t("config.importedDirectConfig"));
}, [config.channels, message, openConfigDialog, t, updateConfig]);
return <>{children}</>;
}
@@ -2,17 +2,19 @@ import { App, Button, Select, Switch, Tag } from "antd";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Eye, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { PromptSourceEditorDrawer } from "./prompt-source-editor-drawer";
import { PromptSourceContentModal } from "./prompt-source-content-modal";
import { fetchPromptSourceStatuses, refreshAllSources, refreshSource } from "@/services/api/prompts";
import { PROMPT_SOURCE_INTERVAL_OPTIONS, usePromptSourceStore } from "@/stores/use-prompt-source-store";
import { PROMPT_SOURCE_INTERVALS, usePromptSourceStore } from "@/stores/use-prompt-source-store";
import type { PromptSource } from "@/services/api/prompt-source-presets";
const STATUS_QUERY_KEY = ["prompt-source-statuses"];
export function ConfigPromptSources() {
const { message, modal } = App.useApp();
const { i18n, t } = useTranslation();
const queryClient = useQueryClient();
const sources = usePromptSourceStore((state) => state.sources);
const schedule = usePromptSourceStore((state) => state.schedule);
@@ -28,6 +30,7 @@ export function ConfigPromptSources() {
const [refreshingId, setRefreshingId] = useState("");
const [refreshingAll, setRefreshingAll] = useState(false);
const viewingSource = sources.find((item) => item.id === viewingId) || null;
const intervalOptions = PROMPT_SOURCE_INTERVALS.map((value) => ({ value, label: t(`config.promptSources.intervals.${intervalKey(value)}`) }));
const invalidatePrompts = async () => {
await Promise.all([
@@ -44,11 +47,11 @@ export function ConfigPromptSources() {
const handleDelete = (source: PromptSource) => {
modal.confirm({
title: `删除「${source.name}」?`,
content: "来源配置会被移除,已经加入我的资产的内容不受影响。",
okText: "删除",
title: t("config.promptSources.deleteTitle", { name: source.name }),
content: t("config.promptSources.deleteDescription"),
okText: t("common.delete"),
okButtonProps: { danger: true },
cancelText: "取消",
cancelText: t("common.cancel"),
onOk: async () => {
removeSource(source.id);
await invalidatePrompts();
@@ -61,10 +64,10 @@ export function ConfigPromptSources() {
try {
const result = await refreshSource(source.id);
await invalidatePrompts();
message.success(`「${source.name}」已更新 ${result.count} 条`);
message.success(t("config.promptSources.refreshed", { name: source.name, count: result.count }));
} catch (error) {
await queryClient.invalidateQueries({ queryKey: STATUS_QUERY_KEY });
message.error(error instanceof Error ? error.message : "更新失败,已保留旧缓存");
message.error(error instanceof Error ? error.message : t("config.promptSources.refreshFailedCached"));
} finally {
setRefreshingId("");
}
@@ -76,10 +79,10 @@ export function ConfigPromptSources() {
const result = await refreshAllSources();
updateSchedule("lastFetchedAt", new Date().toISOString());
await invalidatePrompts();
if (result.failureCount) message.warning(`更新完成:${result.successCount} 个成功,${result.failureCount} 个失败,失败来源已保留旧缓存`);
else message.success(`已更新 ${result.successCount} 个来源,共 ${result.total} 条`);
if (result.failureCount) message.warning(t("config.promptSources.refreshPartial", { success: result.successCount, failed: result.failureCount }));
else message.success(t("config.promptSources.refreshAllSuccess", { sources: result.successCount, total: result.total }));
} catch (error) {
message.error(error instanceof Error ? error.message : "更新失败");
message.error(error instanceof Error ? error.message : t("config.promptSources.refreshFailed"));
} finally {
setRefreshingAll(false);
}
@@ -89,7 +92,7 @@ export function ConfigPromptSources() {
<div>
<div className="mb-4 flex flex-wrap items-center justify-end gap-3">
<Button type="primary" icon={<Plus className="size-4" />} onClick={() => setEditingSource(addSource())}>
新增来源
{t("config.promptSources.add")}
</Button>
</div>
@@ -102,26 +105,26 @@ export function ConfigPromptSources() {
<div className="min-w-[220px] flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-semibold">{source.name}</span>
{source.builtIn ? <Tag className="m-0 shrink-0 text-[10px]">内置</Tag> : null}
{source.builtIn ? <Tag className="m-0 shrink-0 text-[10px]">{t("config.promptSources.builtIn")}</Tag> : null}
</div>
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-500">
<a className="max-w-full truncate hover:text-stone-800 hover:underline dark:hover:text-stone-200" href={source.homepage || source.url} target="_blank" rel="noreferrer">
{source.homepage || source.url}
</a>
<span className="tabular-nums">{status?.count ?? 0} 条</span>
{status?.lastError ? <Tag color="error" className="m-0 text-[10px]" title={status.lastError}>失败</Tag> : status?.lastSuccessAt ? <Tag color="success" className="m-0 text-[10px]">正常</Tag> : <Tag className="m-0 text-[10px]">未同步</Tag>}
<span>{status?.lastSuccessAt ? `上次成功 ${formatTime(status.lastSuccessAt)}` : "尚未拉取"}</span>
<span className="tabular-nums">{t("config.promptSources.itemCount", { count: status?.count ?? 0 })}</span>
{status?.lastError ? <Tag color="error" className="m-0 text-[10px]" title={status.lastError}>{t("config.promptSources.failed")}</Tag> : status?.lastSuccessAt ? <Tag color="success" className="m-0 text-[10px]">{t("config.promptSources.healthy")}</Tag> : <Tag className="m-0 text-[10px]">{t("config.promptSources.unsynced")}</Tag>}
<span>{status?.lastSuccessAt ? t("config.promptSources.lastSuccess", { time: formatTime(status.lastSuccessAt, i18n.resolvedLanguage) }) : t("config.promptSources.neverFetched")}</span>
</div>
</div>
<div className="ml-auto flex flex-wrap justify-end gap-2">
<Button size="small" icon={<Eye className="size-3.5" />} onClick={() => setViewingId(source.id)}>
查看内容
{t("config.promptSources.view")}
</Button>
<Button size="small" icon={<RefreshCw className="size-3.5" />} loading={refreshingId === source.id} onClick={() => void handleRefreshOne(source)}>
立即拉取
{t("config.promptSources.refresh")}
</Button>
{!source.builtIn ? <Button size="small" icon={<Pencil className="size-3.5" />} onClick={() => setEditingSource(source)}>编辑来源</Button> : null}
{!source.builtIn ? <Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={() => handleDelete(source)}>删除</Button> : null}
{!source.builtIn ? <Button size="small" icon={<Pencil className="size-3.5" />} onClick={() => setEditingSource(source)}>{t("config.promptSources.edit")}</Button> : null}
{!source.builtIn ? <Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={() => handleDelete(source)}>{t("common.delete")}</Button> : null}
</div>
</div>
);
@@ -129,18 +132,18 @@ export function ConfigPromptSources() {
</div>
<section className="mt-5 rounded-lg border border-stone-200 p-4 dark:border-stone-800">
<div className="mb-3 text-sm font-semibold">定时拉取</div>
<div className="mb-3 text-sm font-semibold">{t("config.promptSources.schedule")}</div>
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<span className="text-xs text-stone-500">拉取周期</span>
<Select size="small" className="w-36" value={schedule.intervalMinutes} options={PROMPT_SOURCE_INTERVAL_OPTIONS} onChange={(value) => updateSchedule("intervalMinutes", value)} />
<span className="text-xs text-stone-500">{t("config.promptSources.interval")}</span>
<Select size="small" className="w-36" value={schedule.intervalMinutes} options={intervalOptions} onChange={(value) => updateSchedule("intervalMinutes", value)} />
</div>
<Button size="small" type="primary" icon={<RefreshCw className="size-3.5" />} loading={refreshingAll} onClick={() => void handleRefreshAll()}>
全部立即拉取
{t("config.promptSources.refreshAll")}
</Button>
<span className="text-xs text-stone-500">{schedule.lastFetchedAt ? `上次拉取 ${formatTime(schedule.lastFetchedAt)}` : "尚未定时拉取"}</span>
<span className="text-xs text-stone-500">{schedule.lastFetchedAt ? t("config.promptSources.lastFetched", { time: formatTime(schedule.lastFetchedAt, i18n.resolvedLanguage) }) : t("config.promptSources.neverScheduled")}</span>
</div>
<div className="mt-2 text-xs text-stone-400">开启周期后,页面打开期间会按周期自动拉取所有启用的来源。</div>
<div className="mt-2 text-xs text-stone-400">{t("config.promptSources.scheduleDescription")}</div>
</section>
<PromptSourceEditorDrawer open={Boolean(editingSource)} source={editingSource} onSave={handleSave} onClose={() => setEditingSource(null)} />
@@ -149,7 +152,15 @@ export function ConfigPromptSources() {
);
}
function formatTime(value: string) {
function formatTime(value: string, locale?: string) {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "-" : date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
return Number.isNaN(date.getTime()) ? "-" : date.toLocaleString(locale, { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
}
function intervalKey(value: number) {
if (value === 30) return "minutes30";
if (value === 60) return "hour1";
if (value === 360) return "hours6";
if (value === 1440) return "hours24";
return "disabled";
}
@@ -1,5 +1,6 @@
import { Drawer } from "antd";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { navigationTools, type NavigationToolSlug } from "@/constant/navigation-tools";
import { cn } from "@/lib/utils";
@@ -11,8 +12,10 @@ type MobileNavDrawerProps = {
};
export function MobileNavDrawer({ open, activeToolSlug, onClose }: MobileNavDrawerProps) {
const { t } = useTranslation();
return (
<Drawer title="导航" placement="left" size={280} open={open} onClose={onClose} className="md:hidden">
<Drawer title={t("topNav.navigation")} placement="left" size={280} open={open} onClose={onClose} className="md:hidden">
<div className="space-y-1">
{navigationTools.map((tool) => {
const Icon = tool.icon;
@@ -28,7 +31,7 @@ export function MobileNavDrawer({ open, activeToolSlug, onClose }: MobileNavDraw
)}
>
<Icon className="size-5" />
<span>{tool.label}</span>
<span>{t(`navigation.${tool.slug}`)}</span>
</Link>
);
})}
@@ -2,23 +2,24 @@ import { javascript } from "@codemirror/lang-javascript";
import CodeMirror from "@uiw/react-codemirror";
import { Button, Modal } from "antd";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { PLUGIN_RETURNS, PLUGIN_TEMPLATES, PLUGIN_VARIABLES } from "@/services/api/model-plugin";
import { getPluginReturn, getPluginTemplates, getPluginVariables } from "@/services/api/model-plugin";
import type { ModelCapability } from "@/stores/use-config-store";
const capabilityLabels: Record<ModelCapability, string> = { image: "生图", video: "视频", text: "文本", audio: "音频" };
function isDarkMode() {
return typeof document !== "undefined" && document.documentElement.classList.contains("dark");
}
export function ModelScriptEditor({ open, capability, modelName, value, onSave, onClose }: { open: boolean; capability: ModelCapability; modelName: string; value: string; onSave: (script: string) => void; onClose: () => void }) {
const { t } = useTranslation();
const [draft, setDraft] = useState(value);
useEffect(() => {
if (open) setDraft(value);
}, [open, value]);
const variables = PLUGIN_VARIABLES.filter((variable) => !variable.capabilities || variable.capabilities.includes(capability));
const variables = getPluginVariables().filter((variable) => !variable.capabilities || variable.capabilities.includes(capability));
const templates = getPluginTemplates()[capability];
return (
<Modal
@@ -26,10 +27,10 @@ export function ModelScriptEditor({ open, capability, modelName, value, onSave,
title={
<div>
<div className="text-base font-semibold">
{capabilityLabels[capability]}
{t(`config.channelEditor.capabilities.${capability}`)}
{modelName ? ` - ${modelName}` : ""}
</div>
<div className="mt-1 text-xs font-normal text-stone-500">脚本是一段异步函数体,直接使用下方变量,最后 return 结果;留空则使用系统默认调用。</div>
<div className="mt-1 text-xs font-normal text-stone-500">{t("config.scriptEditor.description")}</div>
</div>
}
width={1080}
@@ -39,17 +40,17 @@ export function ModelScriptEditor({ open, capability, modelName, value, onSave,
footer={
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
{PLUGIN_TEMPLATES[capability].map((template) => (
{templates.map((template) => (
<Button key={template.label} size="small" onClick={() => setDraft(template.script)}>
插入{template.label}模板
{t("config.scriptEditor.insertTemplate", { name: template.label })}
</Button>
))}
<Button size="small" danger onClick={() => setDraft("")}>
恢复默认调用
{t("config.scriptEditor.restoreDefault")}
</Button>
</div>
<div className="flex items-center gap-2">
<Button onClick={onClose}>取消</Button>
<Button onClick={onClose}>{t("common.cancel")}</Button>
<Button
type="primary"
onClick={() => {
@@ -57,7 +58,7 @@ export function ModelScriptEditor({ open, capability, modelName, value, onSave,
onClose();
}}
>
保存
{t("common.save")}
</Button>
</div>
</div>
@@ -66,13 +67,13 @@ export function ModelScriptEditor({ open, capability, modelName, value, onSave,
<div className="flex h-[60vh] min-h-[420px] border-t border-stone-200 dark:border-stone-800">
<aside className="flex w-[320px] shrink-0 flex-col overflow-y-auto border-r border-stone-200 bg-stone-50/80 dark:border-stone-800 dark:bg-stone-900/40">
<div className="border-b border-stone-200/70 px-4 py-3 dark:border-stone-800/70">
<div className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-stone-400">返回要求</div>
<div className="text-xs leading-6 text-stone-600 dark:text-stone-300">{PLUGIN_RETURNS[capability]}</div>
<div className="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-stone-400">{t("config.scriptEditor.returnRequirements")}</div>
<div className="text-xs leading-6 text-stone-600 dark:text-stone-300">{getPluginReturn(capability)}</div>
</div>
<div className="px-4 py-3">
<div className="mb-2.5 flex items-center justify-between">
<span className="text-[11px] font-semibold uppercase tracking-wide text-stone-400">可用变量</span>
<span className="text-[10px] text-stone-400">点击插入</span>
<span className="text-[11px] font-semibold uppercase tracking-wide text-stone-400">{t("config.scriptEditor.variables")}</span>
<span className="text-[10px] text-stone-400">{t("config.scriptEditor.insert")}</span>
</div>
<div className="space-y-1.5">
{variables.map((variable) => (
@@ -101,7 +102,7 @@ export function ModelScriptEditor({ open, capability, modelName, value, onSave,
height="100%"
theme={isDarkMode() ? "dark" : "light"}
extensions={[javascript()]}
placeholder={"// 留空使用系统默认调用;点击右下角「插入模板」查看示例。"}
placeholder={t("config.scriptEditor.placeholder")}
style={{ height: "100%", fontSize: 13 }}
className="h-full [&_.cm-editor]:h-full [&_.cm-gutters]:border-none [&_.cm-scroller]:overflow-auto"
/>
@@ -1,6 +1,7 @@
import { App, Button, Checkbox, Input, Modal, Tabs } from "antd";
import { RefreshCw, Search } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { fetchChannelModels } from "@/services/api/image";
import type { ModelChannel } from "@/stores/use-config-store";
@@ -8,6 +9,7 @@ import type { ModelChannel } from "@/stores/use-config-store";
// 选择渠道模型弹窗:拉取上游模型列表或手动增加,勾选后才会进入渠道模型列表。
export function ModelSelectModal({ open, channel, selectedNames, onConfirm, onClose }: { open: boolean; channel: ModelChannel | null; selectedNames: string[]; onConfirm: (names: string[]) => void; onClose: () => void }) {
const { message } = App.useApp();
const { t } = useTranslation();
const [existing, setExisting] = useState<string[]>([]);
const [fetched, setFetched] = useState<string[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
@@ -60,7 +62,7 @@ export function ModelSelectModal({ open, channel, selectedNames, onConfirm, onCl
const fetchModels = async () => {
if (!channel) return;
if (!channel.baseUrl.trim() || !channel.apiKey.trim()) {
message.error("请先填写接口地址和 API Key");
message.error(t("config.modelSelect.missingConfig"));
return;
}
setLoading(true);
@@ -68,9 +70,9 @@ export function ModelSelectModal({ open, channel, selectedNames, onConfirm, onCl
const models = await fetchChannelModels(channel);
setFetched(models);
setActiveTab("new");
message.success(`已拉取 ${models.length} 个模型`);
message.success(t("config.modelSelect.fetched", { count: models.length }));
} catch (error) {
message.error(error instanceof Error ? error.message : "拉取模型失败");
message.error(error instanceof Error ? error.message : t("config.modelSelect.fetchFailed"));
} finally {
setLoading(false);
}
@@ -90,47 +92,47 @@ export function ModelSelectModal({ open, channel, selectedNames, onConfirm, onCl
onCancel={onClose}
title={
<span>
选择渠道模型 <span className="ml-2 text-xs font-normal text-stone-500">已选择 {selected.size} / {new Set([...existing, ...fetched]).size}</span>
{t("config.modelSelect.title")} <span className="ml-2 text-xs font-normal text-stone-500">{t("config.modelSelect.selected", { selected: selected.size, total: new Set([...existing, ...fetched]).size })}</span>
</span>
}
styles={{ body: { maxHeight: "62vh", overflowY: "auto" } }}
footer={[
<Button key="cancel" onClick={onClose}>
取消
{t("common.cancel")}
</Button>,
<Button key="confirm" type="primary" onClick={confirm}>
确定
{t("config.modelSelect.confirm")}
</Button>,
]}
>
<div className="flex flex-wrap items-center gap-3">
<Input className="min-w-[200px] flex-1" value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索模型" prefix={<Search className="size-4 text-stone-400" />} allowClear />
<Input className="min-w-[180px] flex-1" value={manual} onChange={(event) => setManual(event.target.value)} onPressEnter={addManual} placeholder="输入模型名称" />
<Button onClick={addManual}>增加模型</Button>
<Input className="min-w-[200px] flex-1" value={search} onChange={(event) => setSearch(event.target.value)} placeholder={t("config.modelSelect.search")} prefix={<Search className="size-4 text-stone-400" />} allowClear />
<Input className="min-w-[180px] flex-1" value={manual} onChange={(event) => setManual(event.target.value)} onPressEnter={addManual} placeholder={t("config.modelSelect.modelName")} />
<Button onClick={addManual}>{t("config.modelSelect.add")}</Button>
<Button icon={<RefreshCw className="size-4" />} loading={loading} onClick={() => void fetchModels()}>
拉取模型列表
{t("config.modelSelect.fetch")}
</Button>
</div>
<div className="mt-2 text-xs text-stone-500">如果上游不提供 OpenAI /models 模型列表接口,请在这里手动增加模型名称。</div>
<div className="mt-2 text-xs text-stone-500">{t("config.modelSelect.description")}</div>
<Tabs
className="mt-3"
activeKey={activeTab}
onChange={setActiveTab}
items={[
{ key: "new", label: `新获取的模型 (${fetched.length})` },
{ key: "existing", label: `已有的模型 (${existing.length})` },
{ key: "new", label: t("config.modelSelect.fetchedTab", { count: fetched.length }) },
{ key: "existing", label: t("config.modelSelect.existingTab", { count: existing.length }) },
]}
/>
<div className="mb-3 flex items-center justify-between gap-2">
<span className="text-xs text-stone-500">当前列表已选择 {visibleSelectedCount} / {visibleList.length}</span>
<span className="text-xs text-stone-500">{t("config.modelSelect.visibleSelected", { selected: visibleSelectedCount, total: visibleList.length })}</span>
<div className="flex gap-2">
<Button size="small" disabled={!visibleList.length} onClick={() => selectVisible(true)}>
全选当前列表
{t("config.modelSelect.selectVisible")}
</Button>
<Button size="small" disabled={!visibleSelectedCount} onClick={() => selectVisible(false)}>
取消当前列表
{t("config.modelSelect.clearVisible")}
</Button>
</div>
</div>
@@ -146,7 +148,7 @@ export function ModelSelectModal({ open, channel, selectedNames, onConfirm, onCl
))}
</div>
) : (
<div className="py-8 text-center text-sm text-stone-500">{activeTab === "new" ? "点击「拉取模型列表」获取上游模型,或手动增加模型名称。" : "暂无已选择的模型。"}</div>
<div className="py-8 text-center text-sm text-stone-500">{t(activeTab === "new" ? "config.modelSelect.fetchedEmpty" : "config.modelSelect.existingEmpty")}</div>
)}
</Modal>
);
@@ -1,6 +1,7 @@
import { App, Button, Empty, Modal, Space, Table, Tag } from "antd";
import { Copy, FolderPlus, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { PromptDetailDialog } from "@/pages/prompts/components/prompt-detail-dialog";
import { useCopyText } from "@/hooks/use-copy-text";
@@ -10,6 +11,7 @@ import type { PromptSource } from "@/services/api/prompt-source-presets";
export function PromptSourceContentModal({ source, onClose }: { source: PromptSource | null; onClose: () => void }) {
const { message } = App.useApp();
const { t } = useTranslation();
const [items, setItems] = useState<Prompt[]>([]);
const [loading, setLoading] = useState(false);
const [detail, setDetail] = useState<Prompt | null>(null);
@@ -23,12 +25,12 @@ export function PromptSourceContentModal({ source, onClose }: { source: PromptSo
try {
setItems(force ? await refreshSourceItems(source.id) : await fetchSourcePrompts(source.id));
} catch (error) {
message.error(error instanceof Error ? error.message : "拉取提示词失败");
message.error(error instanceof Error ? error.message : t("config.promptSources.content.loadFailed"));
} finally {
setLoading(false);
}
},
[source, message],
[source, message, t],
);
useEffect(() => {
@@ -38,7 +40,7 @@ export function PromptSourceContentModal({ source, onClose }: { source: PromptSo
const saveAsset = (item: Prompt) => {
addAsset({ kind: "text", title: item.title, coverUrl: item.coverUrl, tags: item.tags, source: item.category, data: { content: item.prompt }, metadata: { source: "prompt-library", promptId: item.id, githubUrl: item.githubUrl } });
message.success("已加入我的资产");
message.success(t("common.addedToAssets"));
};
return (
@@ -51,11 +53,11 @@ export function PromptSourceContentModal({ source, onClose }: { source: PromptSo
title={
<div className="flex flex-wrap items-center justify-between gap-2 pr-6">
<div>
<div className="text-base font-semibold">{source?.name || ""} · 提示词内容</div>
<div className="mt-0.5 text-xs font-normal text-stone-500">共 {items.length} 条</div>
<div className="text-base font-semibold">{t("config.promptSources.content.title", { name: source?.name || "" })}</div>
<div className="mt-0.5 text-xs font-normal text-stone-500">{t("config.promptSources.content.count", { count: items.length })}</div>
</div>
<Button size="small" icon={<RefreshCw className="size-3.5" />} loading={loading} onClick={() => void load(true)}>
立即更新
{t("config.promptSources.content.refresh")}
</Button>
</div>
}
@@ -67,16 +69,16 @@ export function PromptSourceContentModal({ source, onClose }: { source: PromptSo
dataSource={items}
pagination={{ pageSize: 10, showSizeChanger: false, size: "small" }}
scroll={{ y: "56vh" }}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无提示词" /> }}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t("config.promptSources.content.empty")} /> }}
columns={[
{
title: "封面",
title: t("config.promptSources.content.cover"),
dataIndex: "coverUrl",
width: 72,
render: (coverUrl: string) => (coverUrl ? <img src={coverUrl} alt="" className="size-12 rounded object-cover" /> : <div className="size-12 rounded bg-stone-100 dark:bg-stone-800" />),
},
{
title: "标题",
title: t("config.promptSources.content.titleColumn"),
dataIndex: "title",
render: (title: string, item) => (
<div className="min-w-0">
@@ -86,7 +88,7 @@ export function PromptSourceContentModal({ source, onClose }: { source: PromptSo
),
},
{
title: "标签",
title: t("config.promptSources.content.tags"),
dataIndex: "tags",
width: 200,
render: (tags: string[]) => (
@@ -100,18 +102,18 @@ export function PromptSourceContentModal({ source, onClose }: { source: PromptSo
),
},
{
title: "操作",
title: t("config.promptSources.content.actions"),
width: 210,
render: (_, item) => (
<Space size={4} wrap>
<Button size="small" type="text" icon={<Copy className="size-3.5" />} onClick={() => copyText(item.prompt, "提示词已复制")}>
复制
<Button size="small" type="text" icon={<Copy className="size-3.5" />} onClick={() => copyText(item.prompt, t("common.promptCopied"))}>
{t("common.copy")}
</Button>
<Button size="small" type="text" onClick={() => setDetail(item)}>
详情
{t("common.details")}
</Button>
<Button size="small" type="text" icon={<FolderPlus className="size-3.5" />} onClick={() => saveAsset(item)}>
加入资产
{t("common.addToAssets")}
</Button>
</Space>
),
@@ -119,7 +121,7 @@ export function PromptSourceContentModal({ source, onClose }: { source: PromptSo
]}
/>
</Modal>
<PromptDetailDialog prompt={detail} onClose={() => setDetail(null)} onCopy={(prompt) => copyText(prompt, "提示词已复制")} onSaveAsset={saveAsset} />
<PromptDetailDialog prompt={detail} onClose={() => setDetail(null)} onCopy={(prompt) => copyText(prompt, t("common.promptCopied"))} onSaveAsset={saveAsset} />
</>
);
}
@@ -1,10 +1,12 @@
import { App, Button, Drawer, Input, Space, Switch } from "antd";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import type { PromptSource } from "@/services/api/prompt-source-presets";
export function PromptSourceEditorDrawer({ open, source, onSave, onClose }: { open: boolean; source: PromptSource | null; onSave: (source: PromptSource) => void; onClose: () => void }) {
const { message } = App.useApp();
const { t } = useTranslation();
const [draft, setDraft] = useState<PromptSource | null>(source);
useEffect(() => {
@@ -18,9 +20,9 @@ export function PromptSourceEditorDrawer({ open, source, onSave, onClose }: { op
const save = () => {
const name = draft.name.trim();
const url = draft.url.trim();
if (!name) return message.warning("请输入来源名称");
if (!isHttpUrl(url)) return message.warning("请输入有效的 JSON URL");
if (draft.homepage.trim() && !isHttpUrl(draft.homepage.trim())) return message.warning("请输入有效的主页地址");
if (!name) return message.warning(t("config.promptSources.editor.nameRequired"));
if (!isHttpUrl(url)) return message.warning(t("config.promptSources.editor.invalidUrl"));
if (draft.homepage.trim() && !isHttpUrl(draft.homepage.trim())) return message.warning(t("config.promptSources.editor.invalidHomepage"));
onSave({ ...draft, name, url, homepage: draft.homepage.trim(), builtIn: false });
onClose();
};
@@ -29,46 +31,46 @@ export function PromptSourceEditorDrawer({ open, source, onSave, onClose }: { op
<Drawer
open={open}
width={560}
title={source?.name === "新来源" ? "新增提示词来源" : "编辑提示词来源"}
title={t(source?.name ? "config.promptSources.editor.editTitle" : "config.promptSources.editor.addTitle")}
onClose={onClose}
styles={{ body: { paddingTop: 16 } }}
extra={
<Space>
<Button onClick={onClose}>取消</Button>
<Button onClick={onClose}>{t("common.cancel")}</Button>
<Button type="primary" onClick={save}>
保存
{t("common.save")}
</Button>
</Space>
}
>
<div className="space-y-5">
<label className="block">
<span className="mb-1.5 block text-sm font-medium">来源名称</span>
<Input value={draft.name} onChange={(event) => patch({ name: event.target.value })} placeholder="用于分类展示" />
<span className="mb-1.5 block text-sm font-medium">{t("config.promptSources.editor.name")}</span>
<Input value={draft.name} onChange={(event) => patch({ name: event.target.value })} placeholder={t("config.promptSources.editor.namePlaceholder")} />
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-medium">JSON URL</span>
<Input value={draft.url} onChange={(event) => patch({ url: event.target.value })} placeholder="https://example.com/prompts.json" />
</label>
<label className="block">
<span className="mb-1.5 block text-sm font-medium">来源主页(可选)</span>
<span className="mb-1.5 block text-sm font-medium">{t("config.promptSources.editor.homepage")}</span>
<Input value={draft.homepage} onChange={(event) => patch({ homepage: event.target.value })} placeholder="https://example.com" />
</label>
<div className="flex items-center justify-between border-y border-stone-200 py-3 dark:border-stone-800">
<span className="text-sm font-medium">启用来源</span>
<span className="text-sm font-medium">{t("config.promptSources.editor.enabled")}</span>
<Switch checked={draft.enabled} onChange={(enabled) => patch({ enabled })} />
</div>
<div>
<div className="mb-2 text-sm font-medium">JSON 格式</div>
<div className="mb-2 text-sm font-medium">{t("config.promptSources.editor.jsonFormat")}</div>
<pre className="overflow-x-auto rounded-md bg-stone-100 p-3 text-xs leading-5 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{`[
{
"id": "product-photo-1",
"title": "白底商品图",
"prompt": "生成专业白底商品摄影图",
"title": "Product photo",
"prompt": "Generate a professional product photo on a white background",
"description": "",
"coverUrl": "",
"referenceImageUrls": [],
"tags": ["商品", "摄影"]
"tags": ["product", "photography"]
}
]`}</pre>
</div>
@@ -1,5 +1,6 @@
import type { CSSProperties } from "react";
import { BookOpen, Keyboard, Puzzle, Settings2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
import { GitHubLink } from "@/components/layout/github-link";
@@ -18,6 +19,7 @@ type UserStatusActionsProps = {
};
export function UserStatusActions({ showConfig = true, variant = "default", onOpenShortcuts, onOpenPlugins }: UserStatusActionsProps) {
const { t } = useTranslation();
const theme = useThemeStore((state) => state.theme);
const setTheme = useThemeStore((state) => state.setTheme);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
@@ -31,23 +33,23 @@ export function UserStatusActions({ showConfig = true, variant = "default", onOp
return (
<div className="inline-flex shrink-0 items-center gap-1">
{onOpenPlugins ? (
<button type="button" className={naturalIconClass} style={iconStyle} onClick={onOpenPlugins} aria-label="节点插件" title="节点插件">
<button type="button" className={naturalIconClass} style={iconStyle} onClick={onOpenPlugins} aria-label={t("topNav.plugins")} title={t("topNav.plugins")}>
<Puzzle className="size-4" />
</button>
) : null}
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className={naturalIconClass} style={iconStyle} aria-label="文档" title="文档">
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className={naturalIconClass} style={iconStyle} aria-label={t("topNav.docs")} title={t("topNav.docs")}>
<BookOpen className="size-4" />
</a>
{showConfig ? (
<button type="button" className={naturalIconClass} style={iconStyle} onClick={() => openConfigDialog(false)} aria-label="配置" title="配置">
<button type="button" className={naturalIconClass} style={iconStyle} onClick={() => openConfigDialog(false)} aria-label={t("navigation.config")} title={t("navigation.config")}>
<Settings2 className="size-4" />
</button>
) : null}
<AnimatedThemeToggler theme={theme} onThemeChange={setTheme} className={naturalIconClass} style={iconStyle} aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} title={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} />
<AnimatedThemeToggler theme={theme} onThemeChange={setTheme} className={naturalIconClass} style={iconStyle} aria-label={t(theme === "dark" ? "topNav.lightTheme" : "topNav.darkTheme")} title={t(theme === "dark" ? "topNav.lightTheme" : "topNav.darkTheme")} />
<VersionReleaseModal style={versionStyle} />
<GitHubLink className={cn("bg-transparent hover:bg-transparent dark:hover:bg-transparent", gitHubClassName)} style={gitHubStyle} />
{onOpenShortcuts ? (
<button type="button" className={naturalIconClass} style={iconStyle} onClick={onOpenShortcuts} aria-label="快捷键" title="快捷键">
<button type="button" className={naturalIconClass} style={iconStyle} onClick={onOpenShortcuts} aria-label={t("topNav.shortcuts")} title={t("topNav.shortcuts")}>
<Keyboard className="size-4" />
</button>
) : null}
@@ -1,18 +1,21 @@
import type { CSSProperties } from "react";
import { Modal, Tag, Timeline } from "antd";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { useVersionCheck } from "@/hooks/use-version-check";
import { APP_VERSION } from "@/constant/env";
function getTagColor(type: string) {
if (type === "新增") return "green";
if (type === "修复") return "red";
if (type === "调整") return "blue";
if (type === "文档") return "purple";
if (type === "新增" || type === "Added") return "green";
if (type === "修复" || type === "Fixed") return "red";
if (type === "调整" || type === "Changed") return "blue";
if (type === "文档" || type === "Docs") return "purple";
return "default";
}
function getReleaseTitle(version: string) {
return version === "Unreleased" ? "未发布" : version;
function releaseTypeLabel(type: string, t: TFunction) {
const key = ({ 新增: "added", 修复: "fixed", 调整: "changed", 优化: "optimized", 文档: "docs" } as Record<string, string>)[type];
return key ? t(`version.types.${key}`) : type;
}
type VersionReleaseModalProps = {
@@ -21,6 +24,7 @@ type VersionReleaseModalProps = {
};
export function VersionReleaseModal({ className, style }: VersionReleaseModalProps) {
const { t } = useTranslation();
const { open, setOpen, openReleaseModal, latestVersion, releases, checking, hasNewVersion, checkLatestRelease } = useVersionCheck();
return (
@@ -30,28 +34,28 @@ export function VersionReleaseModal({ className, style }: VersionReleaseModalPro
className={className || "shrink-0 cursor-pointer text-xs font-medium text-stone-500 transition hover:text-stone-950 dark:text-stone-400 dark:hover:text-white"}
style={style}
onClick={openReleaseModal}
title="查看版本更新"
title={t("version.viewUpdates")}
>
<span className="relative inline-flex">
{APP_VERSION}
{hasNewVersion ? <span className="absolute -right-1.5 -top-1 size-1.5 rounded-full bg-green-500" /> : null}
</span>
</button>
<Modal title="版本更新" open={open} width={680} centered footer={null} onCancel={() => setOpen(false)}>
<Modal title={t("version.title")} open={open} width={680} centered footer={null} onCancel={() => setOpen(false)}>
<div className="mb-5 grid grid-cols-2 gap-3">
<div className="rounded-lg border border-stone-200 p-3 dark:border-stone-800">
<div className="text-xs text-stone-500 dark:text-stone-400">当前版本</div>
<div className="text-xs text-stone-500 dark:text-stone-400">{t("version.currentVersion")}</div>
<div className="mt-1 text-base font-semibold text-stone-950 dark:text-stone-100">{APP_VERSION}</div>
</div>
<div className="rounded-lg border border-stone-200 p-3 dark:border-stone-800">
<div className="flex items-center justify-between gap-3">
<div className="text-xs text-stone-500 dark:text-stone-400">最新版本</div>
<div className="text-xs text-stone-500 dark:text-stone-400">{t("version.latestVersion")}</div>
<button
type="button"
className="cursor-pointer bg-transparent p-0 text-[11px] font-normal text-stone-400 underline-offset-2 transition hover:text-stone-700 hover:underline dark:text-stone-500 dark:hover:text-stone-300"
onClick={() => void checkLatestRelease(true)}
>
{checking ? "检查中..." : "检查更新"}
{t(checking ? "version.checking" : "version.checkUpdates")}
</button>
</div>
<div className="mt-1 text-base font-semibold text-stone-950 dark:text-stone-100">{latestVersion}</div>
@@ -63,18 +67,18 @@ export function VersionReleaseModal({ className, style }: VersionReleaseModalPro
content: (
<div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-stone-950 dark:text-stone-100">{getReleaseTitle(release.version)}</span>
<span className="text-sm font-semibold text-stone-950 dark:text-stone-100">{release.version === "Unreleased" ? t("version.unreleased") : release.version}</span>
<span className="text-xs text-stone-500 dark:text-stone-400">{release.date}</span>
<div className="flex min-w-0 items-center gap-1.5">
{release.version === latestVersion ? <Tag color="green">最新</Tag> : null}
{release.version === APP_VERSION ? <Tag>当前</Tag> : null}
{release.version === latestVersion ? <Tag color="green">{t("version.latest")}</Tag> : null}
{release.version === APP_VERSION ? <Tag>{t("version.current")}</Tag> : null}
</div>
</div>
<div className="mt-2 space-y-1.5">
{release.items.map((item, index) => (
<div key={`${release.version}-${index}`} className="flex items-start gap-2 text-sm leading-6 text-stone-700 dark:text-stone-300">
<Tag color={getTagColor(item.type)} className="m-0 mt-0.5 shrink-0 whitespace-nowrap">
{item.type}
{releaseTypeLabel(item.type, t)}
</Tag>
<span className="min-w-0 flex-1">{item.content}</span>
</div>