feat: 语言国际化重构

This commit is contained in:
DEV_DSW
2026-04-21 16:52:45 +08:00
parent 0c068e9f4d
commit 3349d41881
76 changed files with 4440 additions and 3232 deletions

View File

@@ -1,4 +1,5 @@
import type { ReactNode } from 'react';
import { useI18n } from '../../i18n';
import type { ChannelConfigFieldMeta, ChannelConfigFieldValueMap } from '../../lib/channel-types';
import ChannelTokenField from './ChannelTokenField';
@@ -145,10 +146,16 @@ export default function ChannelConfigFields({
showSecretLabel,
hideSecretLabel,
}: ChannelConfigFieldsProps) {
const { t } = useI18n();
const resolvedEmptyStateText = emptyStateText ?? t('channels.modal.noAdditionalFields');
const resolvedEnvVarLabel = envVarLabel ?? t('channels.modal.envVar');
const resolvedShowSecretLabel = showSecretLabel ?? t('channels.modal.showSecret');
const resolvedHideSecretLabel = hideSecretLabel ?? t('channels.modal.hideSecret');
if (fields.length === 0) {
return (
<div className="rounded-[20px] border border-dashed border-black/10 bg-[#fbf8f1] px-5 py-4 text-[14px] leading-7 text-[#667085] dark:border-white/10 dark:bg-[#1a1a1e] dark:text-gray-300">
{emptyStateText ?? 'No additional fields are required for this channel.'}
{resolvedEmptyStateText}
</div>
);
}
@@ -164,9 +171,9 @@ export default function ChannelConfigFields({
disabled,
showSecretMap,
onToggleSecret,
envVarLabel,
showSecretLabel,
hideSecretLabel,
envVarLabel: resolvedEnvVarLabel,
showSecretLabel: resolvedShowSecretLabel,
hideSecretLabel: resolvedHideSecretLabel,
})}
</div>
))}

View File

@@ -1,8 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AlertCircle, CheckCircle2, X } from 'lucide-react';
import { useI18n } from '../../i18n';
import type { ChannelConfigFieldMeta, ChannelConfigFieldValueMap } from '../../lib/channel-types';
import { getChannelMeta, type ChannelMeta } from '../../lib/channel-meta';
import type { ChannelConfigFieldValueMap } from '../../lib/channel-types';
import { getChannelMeta, localizeChannelMeta } from '../../lib/channel-meta';
import ChannelConfigActions from './ChannelConfigActions';
import ChannelConfigFields from './ChannelConfigFields';
import ChannelInstructionsPanel from './ChannelInstructionsPanel';
@@ -51,44 +51,6 @@ export type ChannelConfigModalProps = {
validatingLabel?: string;
};
function withTranslatedMeta(
meta: ChannelMeta,
translate: (path: string, fallback: string) => string,
): ChannelMeta {
return {
...meta,
name: translate(`channels.meta.${meta.type}.name`, meta.name),
description: translate(`channels.meta.${meta.type}.description`, meta.description),
docsUrl: meta.docsUrl
? translate(`channels.meta.${meta.type}.docsUrl`, meta.docsUrl)
: meta.docsUrl,
instructions: meta.instructions.map((instruction, index) => (
translate(`channels.meta.${meta.type}.instructions.${index}`, instruction)
)),
configFields: meta.configFields.map((field) => withTranslatedField(meta.type, field, translate)),
};
}
function withTranslatedField(
channelType: string,
field: ChannelConfigFieldMeta,
translate: (path: string, fallback: string) => string,
): ChannelConfigFieldMeta {
return {
...field,
label: translate(`channels.meta.${channelType}.fields.${field.key}.label`, field.label),
placeholder: field.placeholder
? translate(`channels.meta.${channelType}.fields.${field.key}.placeholder`, field.placeholder)
: field.placeholder,
description: field.description
? translate(`channels.meta.${channelType}.fields.${field.key}.description`, field.description)
: field.description,
docsUrl: field.docsUrl
? translate(`channels.meta.${channelType}.fields.${field.key}.docsUrl`, field.docsUrl)
: field.docsUrl,
};
}
export default function ChannelConfigModal({
open,
selectedChannelType,
@@ -107,16 +69,16 @@ export default function ChannelConfigModal({
validateLabel,
validatingLabel,
}: ChannelConfigModalProps) {
const { t, hasMessage } = useI18n();
const { t } = useI18n();
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
const firstFieldRef = useRef<HTMLDivElement>(null);
const translate = useCallback((path: string, fallback: string) => (
hasMessage(path) ? t(path) : fallback
), [hasMessage, t]);
t(path, undefined, fallback)
), [t]);
const activeMeta = useMemo(
() => withTranslatedMeta(getChannelMeta(selectedChannelType), translate),
() => localizeChannelMeta(getChannelMeta(selectedChannelType), translate),
[selectedChannelType, translate],
);
const supportedFields = activeMeta.configFields.filter((field) => field.key !== 'accountId');

View File

@@ -1,4 +1,5 @@
import { Eye, EyeOff } from 'lucide-react';
import { useI18n } from '../../i18n';
type ChannelTokenFieldProps = {
label: string;
@@ -27,7 +28,10 @@ export default function ChannelTokenField({
hideSecretLabel,
required,
}: ChannelTokenFieldProps) {
const { t } = useI18n();
const isSecretField = typeof showSecret === 'boolean' && typeof onToggleSecret === 'function';
const resolvedShowSecretLabel = showSecretLabel ?? t('channels.modal.showSecret');
const resolvedHideSecretLabel = hideSecretLabel ?? t('channels.modal.hideSecret');
return (
<div className="space-y-3">
@@ -55,7 +59,7 @@ export default function ChannelTokenField({
onClick={onToggleSecret}
disabled={disabled}
className="inline-flex h-[56px] w-[56px] shrink-0 items-center justify-center rounded-[18px] border border-black/10 bg-[#fbf8f1] text-[#667085] transition-colors hover:bg-black/5 hover:text-[#171717] disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/10 dark:bg-[#1a1a1e] dark:text-gray-400 dark:hover:bg-white/10 dark:hover:text-gray-100"
aria-label={showSecret ? (hideSecretLabel ?? 'Hide secret') : (showSecretLabel ?? 'Show secret')}
aria-label={showSecret ? resolvedHideSecretLabel : resolvedShowSecretLabel}
>
{showSecret ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button>

View File

@@ -1,5 +1,7 @@
import { useCallback, useMemo } from 'react';
import { BadgeCheck, ChevronDown, LayoutGrid, Plug, QrCode, Webhook } from 'lucide-react';
import type { ChannelMeta } from '../../lib/channel-meta';
import { useI18n } from '../../i18n';
import { localizeChannelMeta, type ChannelMeta } from '../../lib/channel-meta';
type ChannelTypeSelectorProps = {
label: string;
@@ -31,9 +33,29 @@ export default function ChannelTypeSelector({
disabled,
helpText,
}: ChannelTypeSelectorProps) {
const selected = options.find((item) => item.type === value);
const { t } = useI18n();
const translate = useCallback((path: string, fallback: string) => (
t(path, undefined, fallback)
), [t]);
const localizedOptions = useMemo(
() => options.map((option) => localizeChannelMeta(option, translate)),
[options, translate],
);
const selected = localizedOptions.find((item) => item.type === value);
const ConnectionIcon = getConnectionIcon(selected?.connectionType ?? helpText);
const connectionLabel = selected?.connectionType === 'qr'
? t('channels.connectionType.qr')
: selected?.connectionType === 'webhook'
? t('channels.connectionType.webhook')
: selected?.connectionType === 'plugin'
? t('channels.page.pluginBadge')
: selected?.connectionType
? t('channels.connectionType.token')
: helpText;
return (
<section className="rounded-[22px] border border-black/10 bg-[#faf9f3] p-4 shadow-sm dark:border-white/10 dark:bg-[#222228]">
<div className="flex items-center justify-between gap-3">
@@ -44,7 +66,7 @@ export default function ChannelTypeSelector({
<div className="inline-flex items-center gap-1 rounded-full border border-black/10 bg-white px-2.5 py-1 text-[11px] font-medium text-foreground/65 dark:border-white/10 dark:bg-white/5 dark:text-gray-300">
<ConnectionIcon className="h-3 w-3" />
<span>{selected?.connectionType ?? helpText}</span>
<span>{connectionLabel}</span>
</div>
</div>
@@ -56,7 +78,7 @@ export default function ChannelTypeSelector({
onChange={(event) => onChange(event.target.value)}
className="h-[44px] w-full appearance-none rounded-[14px] border border-black/10 bg-white px-3 pr-10 text-[13px] text-foreground outline-none transition-colors focus:border-blue-500 disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/10 dark:bg-[#101013] dark:text-gray-100"
>
{options.map((option) => (
{localizedOptions.map((option) => (
<option key={option.type} value={option.type}>
{option.name}
</option>

View File

@@ -1,5 +1,5 @@
import { useRef } from 'react';
import { SendHorizontal, Square, X, Paperclip, FileText, Film, Music, FileArchive, File, Loader2, AtSign } from 'lucide-react';
import { Paperclip, SendHorizontal, Square } from 'lucide-react';
import type { AttachedFileMeta } from '../../shared/chat-model';
import { useI18n } from '../../i18n';
@@ -63,7 +63,7 @@ export default function ChatComposer({
}}
/>
{attachments.length > 0 ? (
<div className="w-full flex justify-between">
<div className="flex w-full justify-between">
{attachments.map((attachment, index) => (
<div
key={attachment.filePath || `${attachment.fileName}-${index}`}
@@ -74,6 +74,7 @@ export default function ChatComposer({
type="button"
className="text-[#99A0AE] transition-colors hover:text-[#ef4444]"
onClick={() => onRemoveAttachment(index)}
aria-label={t('conversation.composer.removeAttachment', { name: attachment.fileName })}
>
x
</button>
@@ -81,7 +82,7 @@ export default function ChatComposer({
))}
</div>
) : null}
<div className="w-full flex justify-between">
<div className="flex w-full justify-between">
<input
ref={fileInputRef}
hidden
@@ -99,6 +100,8 @@ export default function ChatComposer({
type="button"
className="p-3 text-xs text-[#525866] transition-colors hover:border-[#2B7FFF] hover:text-[#2B7FFF] dark:border-[#2a2a2d] dark:text-gray-300"
onClick={() => fileInputRef.current?.click()}
aria-label={t('conversation.composer.attachAria')}
title={t('conversation.composer.attachAria')}
>
<Paperclip className="h-4 w-4" />
</button>
@@ -106,6 +109,8 @@ export default function ChatComposer({
type="button"
className="rounded-lg border border-[#E5E8EE] p-3 text-xs text-[#525866] transition-colors hover:border-[#2B7FFF] hover:text-[#2B7FFF] dark:border-[#2a2a2d] dark:text-gray-300"
onClick={isSending ? onStop : onSend}
aria-label={isSending ? t('conversation.composer.stopAria') : t('conversation.composer.sendAria')}
title={isSending ? t('conversation.composer.stopAria') : t('conversation.composer.sendAria')}
>
{isSending ? <Square className="h-4 w-4" /> : <SendHorizontal className="h-4 w-4" />}
</button>

View File

@@ -1,17 +1,17 @@
import { memo, useEffect, useRef, useState } from 'react';
import type { ChatHistoryBucket } from './types';
import {
ChevronDown,
LoaderCircle,
Plus,
MoreHorizontal,
PanelLeftClose,
PanelLeftOpen,
PencilLine,
Plus,
Trash2,
} from 'lucide-react';
import type { ChatHistoryBucket } from './types';
import blueLogo from '../../assets/images/login/blue_logo.png';
import { useI18n } from '../../i18n';
type ChatHistoryPanelProps = {
buckets: ChatHistoryBucket[];
@@ -40,6 +40,7 @@ function ChatHistoryPanel({
onRenameConversation,
onDeleteConversation,
}: ChatHistoryPanelProps) {
const { t } = useI18n();
const panelRef = useRef<HTMLElement | null>(null);
const [collapsedBuckets, setCollapsedBuckets] = useState<Record<string, boolean>>({});
const [menuState, setMenuState] = useState<MenuState>(null);
@@ -104,6 +105,9 @@ function ChatHistoryPanel({
}, [menuState]);
const panelWidthClass = isCompact ? 'md:w-[70px] lg:w-[70px]' : 'md:w-[240px] lg:w-[252px]';
const toggleSidebarLabel = isCompact
? t('conversation.historyPanel.expandSidebar')
: t('conversation.historyPanel.collapseSidebar');
return (
<aside
@@ -113,31 +117,33 @@ function ChatHistoryPanel({
panelWidthClass,
)}
>
<div className="flex h-full min-h-0 flex-col bg-[#fbfcfe] px-3 py-3 dark:border-[#2a2a2d] dark:bg-[#1b1b1d]">
<div className="flex h-full min-h-0 flex-col bg-[#fbfcfe] px-3 py-3 dark:border-[#2a2a2d] dark:bg-[#1b1b1d]">
<div className={cx('flex items-center justify-between gap-3', isCompact && 'flex-col items-center')}>
{!isCompact ? (
<div className={cx('flex min-w-0 items-center gap-3', isCompact && 'flex-col gap-2')}>
<div className="flex h-12 w-12 flex-none items-center justify-center overflow-hidden rounded-2xl border border-white bg-white shadow-[0_6px_16px_rgba(15,23,42,0.08)]">
<img className="h-full w-full object-cover" src={blueLogo} alt="YINIAN" />
</div>
<div className="truncate text-[20px] font-semibold whitespace-nowrap tracking-[0.06em] text-[#111827] dark:text-gray-50">
<div className="truncate whitespace-nowrap text-[20px] font-semibold tracking-[0.06em] text-[#111827] dark:text-gray-50">
YINIAN
</div>
</div>
) : null}
) : null}
<button
type="button"
className={cx('inline-flex h-12 w-12 items-center justify-center text-[#64748b] transition-colors border hover:text-[#111827] rounded-lg dark:border-[#2a2a2d] dark:text-gray-300 dark:hover:border-[#3a3a3f] dark:hover:text-gray-100', isCompact ? 'border-[#e3eaf3]' : 'border-transparent')}
title={isCompact ? '展开侧栏' : '收起侧栏'}
className={cx(
'inline-flex h-12 w-12 items-center justify-center rounded-lg border text-[#64748b] transition-colors hover:text-[#111827] dark:border-[#2a2a2d] dark:text-gray-300 dark:hover:border-[#3a3a3f] dark:hover:text-gray-100',
isCompact ? 'border-[#e3eaf3]' : 'border-transparent',
)}
title={toggleSidebarLabel}
aria-label={toggleSidebarLabel}
onClick={() => {
setMenuState(null);
setIsCompact((current) => !current);
}}
>
{isCompact ? <PanelLeftOpen /> : <PanelLeftClose />}
{isCompact ? <PanelLeftOpen /> : <PanelLeftClose />}
</button>
</div>
@@ -147,18 +153,19 @@ function ChatHistoryPanel({
'mt-4 inline-flex h-12 items-center justify-center gap-2 rounded-lg border border-[#e3eaf3] bg-white px-4 text-[15px] font-medium text-[#111827] shadow-[0_4px_14px_rgba(15,23,42,0.05)] transition-all hover:-translate-y-px hover:border-[#d5e3f4] hover:shadow-[0_10px_24px_rgba(15,23,42,0.08)] dark:border-[#2a2a2d] dark:bg-[#202024] dark:text-gray-100 dark:hover:border-[#3a3a3f]',
isCompact && 'px-0',
)}
title="新对话"
title={t('conversation.newConversation')}
aria-label={t('conversation.newConversation')}
onClick={onNewChat}
>
<Plus className="h-5 w-5 flex-none" />
{!isCompact ? <span className='whitespace-nowrap'></span> : null}
{!isCompact ? <span className="whitespace-nowrap">{t('conversation.newConversation')}</span> : null}
</button>
<div className="min-h-0 flex-1 overflow-y-auto pt-4">
{loading ? (
<div className="flex flex-col items-center justify-center gap-3 rounded-[18px] border border-dashed border-[#dbe7f4] bg-white px-4 py-8 text-sm text-[#94a3b8] shadow-[0_4px_14px_rgba(15,23,42,0.04)] dark:border-[#2a2a2d] dark:bg-[#202024] dark:text-gray-400">
<LoaderCircle className="h-5 w-5 animate-spin text-[#8bb7ff]" />
...
{t('conversation.historyPanel.loading')}
</div>
) : null}
@@ -168,30 +175,31 @@ function ChatHistoryPanel({
return (
<section key={bucket.key} className="mb-4 last:mb-0">
{bucket.sessions.length ? (<button
type="button"
className="flex w-full items-center justify-between rounded-lg px-4 py-4 text-left transition-colors border border-[#e5edf7] dark:border-[#2f3136] dark:bg-[#202024]"
aria-expanded={!isCollapsed}
onClick={() => {
setMenuState(null);
setCollapsedBuckets((current) => ({
...current,
[bucket.key]: !current[bucket.key],
}));
}}
>
<div className="flex items-center gap-2">
<span className="text-[14px] font-medium text-[#94a3b8] dark:text-gray-400">{bucket.label}</span>
<span className="text-[11px] text-[#c2cad6] dark:text-gray-600">{bucket.sessions.length}</span>
</div>
<ChevronDown
className={cx(
'h-4 w-4 text-[#b2bccb] transition-transform duration-200 dark:text-gray-500',
isCollapsed && '-rotate-90',
)}
/>
</button>) : null}
{bucket.sessions.length ? (
<button
type="button"
className="flex w-full items-center justify-between rounded-lg border border-[#e5edf7] px-4 py-4 text-left transition-colors dark:border-[#2f3136] dark:bg-[#202024]"
aria-expanded={!isCollapsed}
onClick={() => {
setMenuState(null);
setCollapsedBuckets((current) => ({
...current,
[bucket.key]: !current[bucket.key],
}));
}}
>
<div className="flex items-center gap-2">
<span className="text-[14px] font-medium text-[#94a3b8] dark:text-gray-400">{bucket.label}</span>
<span className="text-[11px] text-[#c2cad6] dark:text-gray-600">{bucket.sessions.length}</span>
</div>
<ChevronDown
className={cx(
'h-4 w-4 text-[#b2bccb] transition-transform duration-200 dark:text-gray-500',
isCollapsed && '-rotate-90',
)}
/>
</button>
) : null}
{!isCollapsed ? (
<ul className="mt-2 space-y-2">
@@ -233,15 +241,14 @@ function ChatHistoryPanel({
? 'opacity-100'
: 'pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100',
)}
title="更多操作"
title={t('conversation.historyPanel.moreActions')}
aria-label={t('conversation.historyPanel.moreActions')}
onClick={(event) => {
event.stopPropagation();
setMenuState((current) =>
current?.conversationId === session.conversationId
? null
: {
conversationId: session.conversationId,
},
: { conversationId: session.conversationId },
);
}}
>
@@ -264,7 +271,7 @@ function ChatHistoryPanel({
}}
>
<PencilLine className="h-4 w-4 text-[#94a3b8]" />
{t('conversation.historyPanel.rename')}
</button>
<button
type="button"
@@ -277,7 +284,7 @@ function ChatHistoryPanel({
}}
>
<Trash2 className="h-4 w-4" />
{t('conversation.historyPanel.delete')}
</button>
</div>
) : null}

View File

@@ -41,7 +41,9 @@ function ChatMessageList({ messages, loading }: ChatMessageListProps) {
].join(' ')}
>
<div className="flex h-10 w-10 flex-none items-center justify-center rounded-full bg-[#eff6ff] text-sm font-bold text-[#2B7FFF] dark:bg-[#222225]">
{message.role === 'assistant' ? 'AI' : 'ME'}
{message.role === 'assistant'
? t('conversation.messageList.assistantBadge')
: t('conversation.messageList.userBadge')}
</div>
<div className="min-w-0 flex-1">

View File

@@ -1,5 +1,6 @@
import { memo } from 'react';
import type { TaskItem } from './types';
import { useI18n } from '../../i18n';
type TaskBoardProps = {
activeTab: 'pending' | 'completed';
@@ -22,6 +23,7 @@ function TaskBoard({
currentDateLabel,
currentTime,
}: TaskBoardProps) {
const { t } = useI18n();
const items = activeTab === 'pending' ? pendingItems : completedItems;
return (
@@ -38,7 +40,7 @@ function TaskBoard({
].join(' ')}
onClick={() => onTabChange('pending')}
>
{pendingItems.length > 0 ? `${pendingItems.length}` : ''}
{t('task.board.pendingTab')}{pendingItems.length > 0 ? ` (${pendingItems.length})` : ''}
</button>
<button
type="button"
@@ -50,13 +52,13 @@ function TaskBoard({
].join(' ')}
onClick={() => onTabChange('completed')}
>
{completedItems.length > 0 ? `${completedItems.length}` : ''}
{t('task.board.completedTab')}{completedItems.length > 0 ? ` (${completedItems.length})` : ''}
</button>
</div>
{(currentDateLabel || currentTime) ? (
<div className="flex items-center justify-between px-1 pb-3 pt-3 text-[13px] text-[#99A0AE] dark:text-gray-500">
<span>{currentDateLabel || '执行时段'}</span>
<span>{currentDateLabel || t('task.board.currentPeriod')}</span>
<span>{currentTime || '--'}</span>
</div>
) : null}
@@ -76,7 +78,7 @@ function TaskBoard({
className="text-[11px] text-[#2B7FFF] transition-colors hover:text-[#1d4ed8]"
onClick={() => onRetryTask(item.id)}
>
{t('task.board.retry')}
</button>
) : null}
{onRemoveTask ? (
@@ -85,13 +87,13 @@ function TaskBoard({
className="text-[11px] text-[#99A0AE] transition-colors hover:text-[#ef4444]"
onClick={() => onRemoveTask(item.removeTaskId || item.id)}
>
{t('task.board.remove')}
</button>
) : null}
</div>
) : null}
<div className="flex h-11 w-11 items-center justify-center rounded-lg border border-dashed border-[#9fc0e8] bg-[#EFF6FF] text-[23px] text-[#3b82f6] dark:border-gray-700 dark:bg-[#222225]">
...
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-[#171717] dark:text-gray-100">{item.title}</div>
@@ -99,13 +101,15 @@ function TaskBoard({
{item.meta ? (
<div className="mt-1 text-[11px] text-[#99A0AE] dark:text-gray-500">{item.meta}</div>
) : null}
<div className="mt-2 text-xs font-medium text-[#2B7FFF]">{item.status}</div>
<div className="mt-2 text-xs font-medium text-[#2B7FFF]">
{t(`task.status.${item.status}`, undefined, item.status)}
</div>
</div>
</article>
))
) : (
<div className="rounded-[12px] border border-dashed border-[#dfeaf6] bg-[#f8fbff] px-4 py-6 text-sm text-[#99A0AE] dark:border-[#2a2a2d] dark:bg-[#1f1f22] dark:text-gray-400">
{t('task.board.empty')}
</div>
)}
</div>