feat: 全面支持中英文多语言切换
将翻译文件拆分为独立的 en.ts/zh.ts,为 t() 函数添加插值支持, 国际化 Dashboard 全部页面和组件(登录、注册、项目管理、设置、 MCP 集成等),修复 ThemeToggle 仅中文标签的 bug, 在 Dashboard header 中添加 LanguageToggle 组件。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import Modal from './Modal';
|
||||
import { useI18n } from '../lib/i18n';
|
||||
|
||||
type ConfirmDialogProps = {
|
||||
open: boolean;
|
||||
@@ -11,6 +12,7 @@ type ConfirmDialogProps = {
|
||||
};
|
||||
|
||||
export default function ConfirmDialog({ open, onConfirm, onCancel, title, description, confirmText = 'Confirm', variant = 'danger' }: ConfirmDialogProps) {
|
||||
const { t } = useI18n();
|
||||
const iconColor = variant === 'danger' ? 'text-danger bg-danger-muted' : 'text-warning bg-warning-muted';
|
||||
|
||||
return (
|
||||
@@ -28,7 +30,7 @@ export default function ConfirmDialog({ open, onConfirm, onCancel, title, descri
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2.5 pt-1">
|
||||
<button onClick={onCancel} className="btn-ghost">Cancel</button>
|
||||
<button onClick={onCancel} className="btn-ghost">{t('common.cancel')}</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={variant === 'danger' ? 'btn-danger' : 'btn-primary'}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* Replaces raw JSON.stringify output with readable tables and schema trees.
|
||||
*/
|
||||
|
||||
import { useI18n } from '../lib/i18n';
|
||||
|
||||
/* ===== Helpers ===== */
|
||||
|
||||
type SchemaObj = {
|
||||
@@ -79,21 +81,22 @@ function InBadge({ location }: { location: string }) {
|
||||
/* ===== Parameters Table ===== */
|
||||
|
||||
export function ParametersView({ parameters }: { parameters: unknown }) {
|
||||
const { t } = useI18n();
|
||||
if (!Array.isArray(parameters) || parameters.length === 0) return null;
|
||||
const params = parameters as Parameter[];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="section-label mb-2">Parameters</p>
|
||||
<p className="section-label mb-2">{t('dashboard.schema.parameters')}</p>
|
||||
<div className="border border-border-default rounded-lg overflow-hidden">
|
||||
<table className="w-full text-[13px]">
|
||||
<thead>
|
||||
<tr className="bg-bg-tertiary/50 text-text-muted text-[11px] uppercase tracking-wider">
|
||||
<th className="text-left px-3 py-2 font-medium">Name</th>
|
||||
<th className="text-left px-3 py-2 font-medium">In</th>
|
||||
<th className="text-left px-3 py-2 font-medium">Type</th>
|
||||
<th className="text-left px-3 py-2 font-medium">Required</th>
|
||||
<th className="text-left px-3 py-2 font-medium">Description</th>
|
||||
<th className="text-left px-3 py-2 font-medium">{t('dashboard.schema.name')}</th>
|
||||
<th className="text-left px-3 py-2 font-medium">{t('dashboard.schema.in')}</th>
|
||||
<th className="text-left px-3 py-2 font-medium">{t('dashboard.schema.type')}</th>
|
||||
<th className="text-left px-3 py-2 font-medium">{t('dashboard.schema.required')}</th>
|
||||
<th className="text-left px-3 py-2 font-medium">{t('dashboard.schema.descriptionCol')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-muted">
|
||||
@@ -119,9 +122,9 @@ export function ParametersView({ parameters }: { parameters: unknown }) {
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
{p.required ? (
|
||||
<span className="text-[11px] font-medium text-danger">required</span>
|
||||
<span className="text-[11px] font-medium text-danger">{t('dashboard.schema.required')}</span>
|
||||
) : (
|
||||
<span className="text-[11px] text-text-muted">optional</span>
|
||||
<span className="text-[11px] text-text-muted">{t('dashboard.schema.optional')}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-text-secondary max-w-xs">
|
||||
@@ -129,7 +132,7 @@ export function ParametersView({ parameters }: { parameters: unknown }) {
|
||||
{p.description && <span>{p.description}</span>}
|
||||
{enumVals && enumVals.length > 0 && (
|
||||
<div className="mt-1 flex items-center gap-1 flex-wrap">
|
||||
<span className="text-[11px] text-text-muted">enum:</span>
|
||||
<span className="text-[11px] text-text-muted">{t('dashboard.schema.enum')}</span>
|
||||
{enumVals.map((v, j) => (
|
||||
<code key={j} className="text-[11px] font-mono bg-bg-tertiary px-1 py-0.5 rounded text-text-secondary">
|
||||
{String(v)}
|
||||
@@ -139,7 +142,7 @@ export function ParametersView({ parameters }: { parameters: unknown }) {
|
||||
)}
|
||||
{p.schema?.default !== undefined && (
|
||||
<div className="mt-0.5 text-[11px] text-text-muted">
|
||||
default: <code className="font-mono">{JSON.stringify(p.schema.default)}</code>
|
||||
{t('dashboard.schema.default')} <code className="font-mono">{JSON.stringify(p.schema.default)}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -157,6 +160,7 @@ export function ParametersView({ parameters }: { parameters: unknown }) {
|
||||
/* ===== Schema Properties Tree ===== */
|
||||
|
||||
function SchemaProperties({ schema, depth = 0 }: { schema: SchemaObj; depth?: number }) {
|
||||
const { t } = useI18n();
|
||||
const properties = schema.properties;
|
||||
const requiredSet = new Set(schema.required || []);
|
||||
|
||||
@@ -189,10 +193,10 @@ function SchemaProperties({ schema, depth = 0 }: { schema: SchemaObj; depth?: nu
|
||||
<span className="text-[11px] text-text-muted">({prop.format})</span>
|
||||
)}
|
||||
{requiredSet.has(name) && (
|
||||
<span className="text-[11px] font-medium text-danger">required</span>
|
||||
<span className="text-[11px] font-medium text-danger">{t('dashboard.schema.required')}</span>
|
||||
)}
|
||||
{prop.nullable && (
|
||||
<span className="text-[11px] text-text-muted">nullable</span>
|
||||
<span className="text-[11px] text-text-muted">{t('dashboard.schema.nullable')}</span>
|
||||
)}
|
||||
{prop.description && (
|
||||
<span className="text-text-secondary text-[12px] leading-snug">{prop.description}</span>
|
||||
@@ -200,7 +204,7 @@ function SchemaProperties({ schema, depth = 0 }: { schema: SchemaObj; depth?: nu
|
||||
</div>
|
||||
{prop.enum && prop.enum.length > 0 && (
|
||||
<div className="ml-0 mt-0.5 flex items-center gap-1 flex-wrap">
|
||||
<span className="text-[11px] text-text-muted">enum:</span>
|
||||
<span className="text-[11px] text-text-muted">{t('dashboard.schema.enum')}</span>
|
||||
{prop.enum.map((v, j) => (
|
||||
<code key={j} className="text-[11px] font-mono bg-bg-tertiary px-1 py-0.5 rounded text-text-secondary">
|
||||
{String(v)}
|
||||
@@ -210,7 +214,7 @@ function SchemaProperties({ schema, depth = 0 }: { schema: SchemaObj; depth?: nu
|
||||
)}
|
||||
{prop.default !== undefined && (
|
||||
<div className="text-[11px] text-text-muted mt-0.5">
|
||||
default: <code className="font-mono">{JSON.stringify(prop.default)}</code>
|
||||
{t('dashboard.schema.default')} <code className="font-mono">{JSON.stringify(prop.default)}</code>
|
||||
</div>
|
||||
)}
|
||||
{hasChildren && <SchemaProperties schema={prop} depth={depth + 1} />}
|
||||
@@ -225,6 +229,7 @@ function SchemaProperties({ schema, depth = 0 }: { schema: SchemaObj; depth?: nu
|
||||
/* ===== Request Body ===== */
|
||||
|
||||
export function RequestBodyView({ requestBody }: { requestBody: unknown }) {
|
||||
const { t } = useI18n();
|
||||
if (!requestBody || typeof requestBody !== 'object') return null;
|
||||
const body = requestBody as {
|
||||
required?: boolean;
|
||||
@@ -238,8 +243,8 @@ export function RequestBodyView({ requestBody }: { requestBody: unknown }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="section-label mb-2">
|
||||
Request Body
|
||||
{body.required && <span className="text-danger ml-2 normal-case tracking-normal text-[11px]">required</span>}
|
||||
{t('dashboard.schema.requestBody')}
|
||||
{body.required && <span className="text-danger ml-2 normal-case tracking-normal text-[11px]">{t('dashboard.schema.required')}</span>}
|
||||
</p>
|
||||
<div className="border border-border-default rounded-lg p-3">
|
||||
<SchemaProperties schema={body.schema} />
|
||||
@@ -255,8 +260,8 @@ export function RequestBodyView({ requestBody }: { requestBody: unknown }) {
|
||||
return (
|
||||
<div>
|
||||
<p className="section-label mb-2">
|
||||
Request Body
|
||||
{body.required && <span className="text-danger ml-2 normal-case tracking-normal text-[11px]">required</span>}
|
||||
{t('dashboard.schema.requestBody')}
|
||||
{body.required && <span className="text-danger ml-2 normal-case tracking-normal text-[11px]">{t('dashboard.schema.required')}</span>}
|
||||
</p>
|
||||
{body.description && (
|
||||
<p className="text-[13px] text-text-secondary mb-2">{body.description}</p>
|
||||
@@ -277,7 +282,7 @@ export function RequestBodyView({ requestBody }: { requestBody: unknown }) {
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<span className="text-[13px] text-text-muted">No schema</span>
|
||||
<span className="text-[13px] text-text-muted">{t('dashboard.schema.noSchema')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -303,13 +308,14 @@ function StatusBadge({ code }: { code: string }) {
|
||||
}
|
||||
|
||||
export function ResponsesView({ responses }: { responses: unknown }) {
|
||||
const { t } = useI18n();
|
||||
if (!responses || typeof responses !== 'object') return null;
|
||||
const entries = Object.entries(responses as Record<string, unknown>);
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="section-label mb-2">Responses</p>
|
||||
<p className="section-label mb-2">{t('dashboard.schema.responses')}</p>
|
||||
<div className="space-y-2">
|
||||
{entries.map(([code, resp]) => {
|
||||
const response = resp as {
|
||||
@@ -349,7 +355,7 @@ export function ResponsesView({ responses }: { responses: unknown }) {
|
||||
) : schema.type === 'array' && schema.items?.properties ? (
|
||||
<div>
|
||||
<div className="text-[11px] text-text-muted mb-1">
|
||||
<TypeBadge type="array" /> of objects:
|
||||
<TypeBadge type="array" /> {t('dashboard.schema.ofObjects')}
|
||||
</div>
|
||||
<SchemaProperties schema={schema.items} />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { useI18n } from '../lib/i18n';
|
||||
import { apiFetch } from '../lib/api';
|
||||
import ConfirmDialog from './ConfirmDialog';
|
||||
|
||||
@@ -8,6 +9,7 @@ type ApiKeyStatus = { hasKey: boolean; prefix: string | null };
|
||||
|
||||
export default function SettingsDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const { user, updateUser } = useAuth();
|
||||
const { t } = useI18n();
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -29,8 +31,8 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
|
||||
queryFn: () => apiFetch<ApiKeyStatus>('/auth/api-key/status'),
|
||||
enabled: open,
|
||||
});
|
||||
const [freshKey, setFreshKey] = useState<string | null>(null); // just generated/rotated
|
||||
const [revealedKey, setRevealedKey] = useState<string | null>(null); // revealed via password
|
||||
const [freshKey, setFreshKey] = useState<string | null>(null);
|
||||
const [revealedKey, setRevealedKey] = useState<string | null>(null);
|
||||
const [keyLoading, setKeyLoading] = useState(false);
|
||||
const [keyError, setKeyError] = useState('');
|
||||
const [keyCopied, setKeyCopied] = useState(false);
|
||||
@@ -74,7 +76,7 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
|
||||
method: 'PUT', body: JSON.stringify({ name }),
|
||||
});
|
||||
updateUser({ name: data.name });
|
||||
setProfileMsg({ type: 'success', text: 'Profile updated' });
|
||||
setProfileMsg({ type: 'success', text: t('dashboard.settings.profileUpdated') });
|
||||
setTimeout(() => setProfileMsg(null), 3000);
|
||||
} catch (err) {
|
||||
setProfileMsg({ type: 'error', text: err instanceof Error ? err.message : 'Failed to update profile' });
|
||||
@@ -85,7 +87,7 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
|
||||
|
||||
const handlePasswordChange = async () => {
|
||||
if (newPassword !== confirmPassword) {
|
||||
setPasswordMsg({ type: 'error', text: 'Passwords do not match' });
|
||||
setPasswordMsg({ type: 'error', text: t('dashboard.settings.passwordMismatch') });
|
||||
return;
|
||||
}
|
||||
setPasswordLoading(true);
|
||||
@@ -94,7 +96,7 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
|
||||
await apiFetch('/auth/change-password', {
|
||||
method: 'POST', body: JSON.stringify({ currentPassword, newPassword }),
|
||||
});
|
||||
setPasswordMsg({ type: 'success', text: 'Password changed successfully' });
|
||||
setPasswordMsg({ type: 'success', text: t('dashboard.settings.passwordChanged') });
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
@@ -181,7 +183,7 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
|
||||
className="max-w-[560px] w-[90vw] rounded-2xl bg-bg-elevated border border-border-default shadow-lg p-0 backdrop:bg-overlay backdrop:backdrop-blur-sm"
|
||||
>
|
||||
<div className="px-6 py-4 border-b border-border-muted flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-text-primary">Settings</h2>
|
||||
<h2 className="text-base font-semibold text-text-primary">{t('dashboard.settings.title')}</h2>
|
||||
<button onClick={onClose} className="p-1.5 rounded-lg text-text-muted hover:text-text-primary hover:bg-bg-tertiary transition-colors">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M6 18L18 6M6 6l12 12" />
|
||||
@@ -192,8 +194,8 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
|
||||
<div className="px-6 py-5 space-y-6 max-h-[70vh] overflow-y-auto">
|
||||
{/* Profile */}
|
||||
<section>
|
||||
<p className="section-title">Profile</p>
|
||||
<p className="section-desc mb-4">Manage your personal information.</p>
|
||||
<p className="section-title">{t('dashboard.settings.profileTitle')}</p>
|
||||
<p className="section-desc mb-4">{t('dashboard.settings.profileDesc')}</p>
|
||||
<div className="flex items-center gap-3.5 mb-5">
|
||||
<div className="w-12 h-12 rounded-full bg-accent-subtle text-accent flex items-center justify-center text-sm font-bold tracking-wide">{initials}</div>
|
||||
<div>
|
||||
@@ -203,7 +205,7 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-[13px] text-text-secondary mb-1.5">Display Name</label>
|
||||
<label className="block text-[13px] text-text-secondary mb-1.5">{t('dashboard.settings.displayName')}</label>
|
||||
<input type="text" value={name} onChange={(e) => setName(e.target.value)} className="input-base" />
|
||||
</div>
|
||||
{profileMsg && (
|
||||
@@ -215,15 +217,15 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
|
||||
</div>
|
||||
)}
|
||||
<button onClick={handleProfileSave} disabled={profileLoading || !name.trim()} className="btn-primary">
|
||||
{profileLoading ? 'Saving...' : 'Save Profile'}
|
||||
{profileLoading ? t('dashboard.settings.saving') : t('dashboard.settings.saveProfile')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* API Key */}
|
||||
<section className="border-t border-border-default pt-5">
|
||||
<p className="section-title">API Key</p>
|
||||
<p className="section-desc mb-4">Used to authenticate all MCP requests across your projects.</p>
|
||||
<p className="section-title">{t('dashboard.settings.apiKeyTitle')}</p>
|
||||
<p className="section-desc mb-4">{t('dashboard.settings.apiKeyDesc')}</p>
|
||||
|
||||
{/* Fresh key display (just generated or rotated) */}
|
||||
{freshKey ? (
|
||||
@@ -231,19 +233,19 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
|
||||
<div className="p-4 rounded-lg bg-warning-muted border border-warning/20 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-warning shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" /></svg>
|
||||
<p className="text-[13px] font-medium text-warning">Save this key now — you won't be able to see it again.</p>
|
||||
<p className="text-[13px] font-medium text-warning">{t('dashboard.settings.keySaveWarning')}</p>
|
||||
</div>
|
||||
<code className="block text-xs break-all text-text-primary font-mono bg-bg-primary/50 rounded p-2.5">{freshKey}</code>
|
||||
<button onClick={copyFreshKey} className="btn-outline w-full text-[13px]">
|
||||
{keyCopied ? (
|
||||
<><svg className="w-3.5 h-3.5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M5 13l4 4L19 7" /></svg> Copied</>
|
||||
<><svg className="w-3.5 h-3.5 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M5 13l4 4L19 7" /></svg> {t('common.copied')}</>
|
||||
) : (
|
||||
<><svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg> Copy to Clipboard</>
|
||||
<><svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg> {t('dashboard.settings.copyToClipboard')}</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={() => setFreshKey(null)} className="text-[13px] text-text-muted hover:text-text-secondary transition-colors">
|
||||
I've saved it, continue
|
||||
{t('dashboard.settings.keySaved')}
|
||||
</button>
|
||||
</div>
|
||||
) : !keyStatus?.hasKey ? (
|
||||
@@ -251,13 +253,13 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 px-3.5 py-3 rounded-lg bg-bg-tertiary border border-border-muted">
|
||||
<svg className="w-4 h-4 text-text-muted shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg>
|
||||
<p className="text-[13px] text-text-secondary">No API key generated yet. Generate one to use MCP services.</p>
|
||||
<p className="text-[13px] text-text-secondary">{t('dashboard.settings.noKey')}</p>
|
||||
</div>
|
||||
<button onClick={handleGenerateKey} disabled={keyLoading} className="btn-primary">
|
||||
{keyLoading ? (
|
||||
<><svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg> Generating...</>
|
||||
<><svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg> {t('dashboard.settings.generating')}</>
|
||||
) : (
|
||||
<><svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg> Generate API Key</>
|
||||
<><svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg> {t('dashboard.settings.generateKey')}</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -275,7 +277,6 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
|
||||
else { setShowPasswordPrompt('reveal'); setVerifyPassword(''); setVerifyError(''); }
|
||||
}}
|
||||
className="btn-outline shrink-0 px-2.5"
|
||||
title={revealedKey ? 'Hide' : 'Reveal'}
|
||||
>
|
||||
{revealedKey ? (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.878 9.878L3 3m6.878 6.878L21 21" /></svg>
|
||||
@@ -297,7 +298,6 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
|
||||
}
|
||||
}}
|
||||
className="btn-outline shrink-0 px-2.5"
|
||||
title="Copy"
|
||||
>
|
||||
{keyCopied ? (
|
||||
<svg className="w-4 h-4 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}><path d="M5 13l4 4L19 7" /></svg>
|
||||
@@ -310,22 +310,28 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
|
||||
{/* Password prompt inline */}
|
||||
{showPasswordPrompt && (
|
||||
<div className="p-3 rounded-lg border border-border-default bg-bg-primary space-y-2 animate-fade-in">
|
||||
<p className="text-[13px] text-text-secondary">Enter your password to {showPasswordPrompt === 'copy' ? 'copy' : 'reveal'} the API key.</p>
|
||||
<p className="text-[13px] text-text-secondary">
|
||||
{t('dashboard.settings.passwordPrompt', {
|
||||
action: showPasswordPrompt === 'copy'
|
||||
? t('dashboard.settings.passwordPromptCopy')
|
||||
: t('dashboard.settings.passwordPromptReveal'),
|
||||
})}
|
||||
</p>
|
||||
<input
|
||||
type="password"
|
||||
value={verifyPassword}
|
||||
onChange={(e) => setVerifyPassword(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && verifyPassword) handleVerifyAndAction(); }}
|
||||
className="input-base"
|
||||
placeholder="Current password"
|
||||
placeholder={t('dashboard.settings.currentPassword')}
|
||||
autoFocus
|
||||
/>
|
||||
{verifyError && <p className="text-[12px] text-danger">{verifyError}</p>}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleVerifyAndAction} disabled={verifyLoading || !verifyPassword} className="btn-primary text-[13px] py-1.5">
|
||||
{verifyLoading ? 'Verifying...' : 'Confirm'}
|
||||
{verifyLoading ? t('dashboard.settings.verifying') : t('common.confirm')}
|
||||
</button>
|
||||
<button onClick={() => setShowPasswordPrompt(null)} className="btn-ghost text-[13px] py-1.5">Cancel</button>
|
||||
<button onClick={() => setShowPasswordPrompt(null)} className="btn-ghost text-[13px] py-1.5">{t('common.cancel')}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -338,7 +344,7 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Rotate API Key
|
||||
{t('dashboard.settings.rotateKey')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -353,20 +359,20 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
|
||||
|
||||
{/* Password */}
|
||||
<section className="border-t border-border-default pt-5">
|
||||
<p className="section-title">Change Password</p>
|
||||
<p className="section-desc mb-4">Update your password to keep your account secure.</p>
|
||||
<p className="section-title">{t('dashboard.settings.changePasswordTitle')}</p>
|
||||
<p className="section-desc mb-4">{t('dashboard.settings.changePasswordDesc')}</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-[13px] text-text-secondary mb-1.5">Current Password</label>
|
||||
<input type="password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} className="input-base" placeholder="Enter current password" />
|
||||
<label className="block text-[13px] text-text-secondary mb-1.5">{t('dashboard.settings.currentPasswordLabel')}</label>
|
||||
<input type="password" value={currentPassword} onChange={(e) => setCurrentPassword(e.target.value)} className="input-base" placeholder={t('dashboard.settings.enterCurrentPassword')} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[13px] text-text-secondary mb-1.5">New Password</label>
|
||||
<input type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} className="input-base" placeholder="At least 8 characters" minLength={8} />
|
||||
<label className="block text-[13px] text-text-secondary mb-1.5">{t('dashboard.settings.newPasswordLabel')}</label>
|
||||
<input type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} className="input-base" placeholder={t('dashboard.settings.atLeast8Chars')} minLength={8} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[13px] text-text-secondary mb-1.5">Confirm New Password</label>
|
||||
<input type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} className="input-base" placeholder="Confirm new password" />
|
||||
<label className="block text-[13px] text-text-secondary mb-1.5">{t('dashboard.settings.confirmPasswordLabel')}</label>
|
||||
<input type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} className="input-base" placeholder={t('dashboard.settings.confirmNewPassword')} />
|
||||
</div>
|
||||
{passwordMsg && (
|
||||
<div className={`p-3 rounded-lg text-[13px] flex items-center gap-2 ${passwordMsg.type === 'success' ? 'bg-success-muted text-success' : 'bg-danger-muted text-danger'}`}>
|
||||
@@ -381,7 +387,7 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
|
||||
disabled={passwordLoading || !currentPassword || !newPassword || newPassword.length < 8}
|
||||
className="btn-primary"
|
||||
>
|
||||
{passwordLoading ? 'Changing...' : 'Change Password'}
|
||||
{passwordLoading ? t('dashboard.settings.changingPassword') : t('dashboard.settings.changePassword')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -392,9 +398,9 @@ export default function SettingsDialog({ open, onClose }: { open: boolean; onClo
|
||||
open={showRotateConfirm}
|
||||
onCancel={() => setShowRotateConfirm(false)}
|
||||
onConfirm={handleRotateKey}
|
||||
title="Rotate API Key"
|
||||
description="The current API key will be invalidated immediately. All MCP clients using the old key will stop working. A new key will be generated."
|
||||
confirmText="Rotate Key"
|
||||
title={t('dashboard.settings.rotateTitle')}
|
||||
description={t('dashboard.settings.rotateDesc')}
|
||||
confirmText={t('dashboard.settings.rotateConfirm')}
|
||||
variant="warning"
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useTheme } from '../lib/theme';
|
||||
import { useI18n } from '../lib/i18n';
|
||||
|
||||
const icons = {
|
||||
light: (
|
||||
@@ -18,26 +19,26 @@ const icons = {
|
||||
),
|
||||
};
|
||||
|
||||
const labels = { light: '浅色', dark: '深色', system: '跟随系统' } as const;
|
||||
const order: Array<'light' | 'dark' | 'system'> = ['light', 'dark', 'system'];
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 p-1 rounded-lg bg-bg-tertiary">
|
||||
{order.map((t) => (
|
||||
{order.map((key) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTheme(t)}
|
||||
title={labels[t]}
|
||||
key={key}
|
||||
onClick={() => setTheme(key)}
|
||||
title={t(`theme.${key}`)}
|
||||
className={`flex items-center justify-center w-8 h-7 rounded-md transition-all duration-150 ${
|
||||
theme === t
|
||||
theme === key
|
||||
? 'bg-bg-elevated text-text-primary shadow-sm'
|
||||
: 'text-text-muted hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{icons[t]}
|
||||
{icons[key]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user