feat(i18n): implement internationalization framework and update UI components for language support
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user