Files
miaojingAI/src/components/admin/settings-tab.tsx

1014 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { useSiteConfig } from '@/lib/site-config';
import { useAuth } from '@/lib/auth-store';
import { Crown, Globe, Loader2, Logs, Mail, Save, Send, ToggleLeft, Upload } from 'lucide-react';
import { toast } from 'sonner';
// ============================================================
// Tab 6: Settings
// ============================================================
type EmailSettingsForm = {
enabled: boolean;
smtpHost: string;
smtpPort: number;
smtpSecure: boolean;
smtpUser: string;
smtpPassword: string;
smtpPasswordPreview: string;
fromEmail: string;
fromName: string;
replyTo: string;
appName: string;
appBaseUrl: string;
logoUrl: string;
contactEmail: string;
copyright: string;
codeLength: number;
codeCharset: 'alphanumeric' | 'numeric' | 'letters';
codeTtlMinutes: number;
};
type EmailRecipient = {
id: string;
email: string;
nickname: string;
phone: string | null;
avatarUrl: string | null;
emailVerified: boolean;
};
type SettingsSection = 'site' | 'footer' | 'logs' | 'email' | 'mail' | 'features';
const DEFAULT_EMAIL_SETTINGS: EmailSettingsForm = {
enabled: false,
smtpHost: '',
smtpPort: 465,
smtpSecure: true,
smtpUser: '',
smtpPassword: '',
smtpPasswordPreview: '****',
fromEmail: '',
fromName: '妙境官方通知',
replyTo: '',
appName: '妙境',
appBaseUrl: '',
logoUrl: '/logo.png',
contactEmail: '',
copyright: '',
codeLength: 6,
codeCharset: 'alphanumeric',
codeTtlMinutes: 5,
};
function SectionMenu<T extends string>({
items,
activeValue,
onChange,
}: {
items: Array<{ value: T; label: string; description?: string }>;
activeValue: T;
onChange: (value: T) => void;
}) {
return (
<div className="overflow-x-auto rounded-lg border border-border bg-card p-1">
<div className="flex min-w-max gap-1">
{items.map(item => {
const active = activeValue === item.value;
return (
<button
key={item.value}
type="button"
onClick={() => onChange(item.value)}
className={`min-w-40 rounded-md px-4 py-3 text-left transition-colors ${
active ? 'bg-primary/10 text-primary' : 'text-muted-foreground hover:bg-muted/60 hover:text-foreground'
}`}
>
<span className="block text-sm font-semibold">{item.label}</span>
{item.description && <span className="mt-1 block text-xs opacity-75">{item.description}</span>}
</button>
);
})}
</div>
</div>
);
}
export default function SettingsTab() {
const { config: siteConfig, loaded: siteConfigLoaded, saveSiteConfig } = useSiteConfig();
const { accessToken } = useAuth();
const logoInputRef = useRef<HTMLInputElement>(null);
const faviconInputRef = useRef<HTMLInputElement>(null);
// Local form state (not committed until save)
const [formSiteName, setFormSiteName] = useState('');
const [formTabTitle, setFormTabTitle] = useState('');
const [formLogoBase64, setFormLogoBase64] = useState<string | null>(null);
const [formFaviconBase64, setFormFaviconBase64] = useState<string | null>(null);
const [formMembershipEnabled, setFormMembershipEnabled] = useState(true);
const [formTermsOfService, setFormTermsOfService] = useState('');
const [formPrivacyPolicy, setFormPrivacyPolicy] = useState('');
const [formAboutUs, setFormAboutUs] = useState('');
const [formHelpCenter, setFormHelpCenter] = useState('');
const [formFilingInfo, setFormFilingInfo] = useState('');
const [formFilingUrl, setFormFilingUrl] = useState('');
const [formPublicSecurityFilingInfo, setFormPublicSecurityFilingInfo] = useState('');
const [formPublicSecurityFilingUrl, setFormPublicSecurityFilingUrl] = useState('');
const [formLogRetentionDays, setFormLogRetentionDays] = useState(30);
const [saving, setSaving] = useState(false);
const [initialized, setInitialized] = useState(false);
const [emailSettings, setEmailSettings] = useState<EmailSettingsForm>(DEFAULT_EMAIL_SETTINGS);
const [emailPreviewHtml, setEmailPreviewHtml] = useState('');
const [emailLoading, setEmailLoading] = useState(false);
const [emailSaving, setEmailSaving] = useState(false);
const [emailTesting, setEmailTesting] = useState(false);
const [testEmail, setTestEmail] = useState('');
const [mailMode, setMailMode] = useState<'all' | 'selected'>('selected');
const [mailKind, setMailKind] = useState<'notification' | 'admin'>('notification');
const [mailTitle, setMailTitle] = useState('');
const [mailContent, setMailContent] = useState('');
const [mailButtonText, setMailButtonText] = useState('');
const [mailButtonUrl, setMailButtonUrl] = useState('');
const [recipientQuery, setRecipientQuery] = useState('');
const [recipientTotal, setRecipientTotal] = useState(0);
const [recipientResults, setRecipientResults] = useState<EmailRecipient[]>([]);
const [selectedRecipients, setSelectedRecipients] = useState<EmailRecipient[]>([]);
const [recipientsLoading, setRecipientsLoading] = useState(false);
const [mailSending, setMailSending] = useState(false);
const [activeSection, setActiveSection] = useState<SettingsSection>('site');
// Sync site config to form when loaded
useEffect(() => {
if (siteConfigLoaded && !initialized) {
setFormSiteName(siteConfig.siteName);
setFormTabTitle(siteConfig.siteTabTitle);
setFormTermsOfService(siteConfig.termsOfService);
setFormPrivacyPolicy(siteConfig.privacyPolicy);
setFormAboutUs(siteConfig.aboutUs);
setFormHelpCenter(siteConfig.helpCenter);
setFormFilingInfo(siteConfig.filingInfo);
setFormFilingUrl(siteConfig.filingUrl);
setFormPublicSecurityFilingInfo(siteConfig.publicSecurityFilingInfo);
setFormPublicSecurityFilingUrl(siteConfig.publicSecurityFilingUrl);
setFormLogRetentionDays(siteConfig.logRetentionDays);
setInitialized(true);
}
}, [siteConfig, siteConfigLoaded, initialized]);
useEffect(() => {
setFormMembershipEnabled(siteConfig.membershipEnabled !== false);
}, [siteConfig.membershipEnabled]);
const loadEmailSettings = useCallback(async () => {
if (!accessToken) return;
setEmailLoading(true);
try {
const response = await fetch('/api/admin/email-settings', {
headers: { Authorization: `Bearer ${accessToken}` },
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || '邮箱配置加载失败');
setEmailSettings({ ...DEFAULT_EMAIL_SETTINGS, ...(data.settings || {}), smtpPassword: '' });
setEmailPreviewHtml(data.preview || '');
} catch (error) {
toast.error(error instanceof Error ? error.message : '邮箱配置加载失败');
} finally {
setEmailLoading(false);
}
}, [accessToken]);
useEffect(() => {
loadEmailSettings();
}, [loadEmailSettings]);
const loadEmailRecipients = useCallback(async (query = '') => {
if (!accessToken) return;
setRecipientsLoading(true);
try {
const params = new URLSearchParams();
if (query.trim()) params.set('q', query.trim());
params.set('limit', '40');
const response = await fetch(`/api/admin/email-recipients?${params.toString()}`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || '收件用户加载失败');
setRecipientResults(Array.isArray(data.users) ? data.users : []);
setRecipientTotal(Number(data.total || 0));
} catch (error) {
toast.error(error instanceof Error ? error.message : '收件用户加载失败');
} finally {
setRecipientsLoading(false);
}
}, [accessToken]);
useEffect(() => {
if (mailMode !== 'selected') return;
const timer = window.setTimeout(() => {
loadEmailRecipients(recipientQuery);
}, 250);
return () => window.clearTimeout(timer);
}, [mailMode, recipientQuery, loadEmailRecipients]);
useEffect(() => {
if (mailMode === 'all') {
loadEmailRecipients('');
}
}, [mailMode, loadEmailRecipients]);
const handleFileUpload = async (
file: File,
setter: (val: string | null) => void,
maxSizeKB: number = 2048,
targetSize: number = 64,
) => {
if (file.size > maxSizeKB * 1024) {
toast.error(`文件大小不能超过 ${maxSizeKB >= 1024 ? `${maxSizeKB / 1024}MB` : `${maxSizeKB}KB`}`);
return;
}
// SVG: read as text data URL directly
if (file.type === 'image/svg+xml') {
const reader = new FileReader();
reader.onload = (e) => {
const result = e.target?.result as string;
if (result) setter(result);
};
reader.readAsDataURL(file);
return;
}
// PNG/JPG: resize to target dimensions
try {
const bitmap = await createImageBitmap(file);
const canvas = document.createElement('canvas');
canvas.width = targetSize;
canvas.height = targetSize;
const ctx = canvas.getContext('2d');
if (!ctx) { toast.error('浏览器不支持 Canvas'); return; }
ctx.drawImage(bitmap, 0, 0, targetSize, targetSize);
bitmap.close();
setter(canvas.toDataURL('image/png'));
} catch {
const reader = new FileReader();
reader.onload = (e) => {
const result = e.target?.result as string;
if (result) setter(result);
};
reader.readAsDataURL(file);
}
};
const handleSave = async () => {
setSaving(true);
try {
await saveSiteConfig({
siteName: formSiteName,
siteTabTitle: formTabTitle,
logoBase64: formLogoBase64 || undefined,
faviconBase64: formFaviconBase64 || undefined,
membershipEnabled: formMembershipEnabled,
termsOfService: formTermsOfService,
privacyPolicy: formPrivacyPolicy,
aboutUs: formAboutUs,
helpCenter: formHelpCenter,
filingInfo: formFilingInfo,
filingUrl: formFilingUrl,
publicSecurityFilingInfo: formPublicSecurityFilingInfo,
publicSecurityFilingUrl: formPublicSecurityFilingUrl,
logRetentionDays: formLogRetentionDays,
});
// Clear pending uploads after save
setFormLogoBase64(null);
setFormFaviconBase64(null);
toast.success('网站配置已保存,所有访客将看到更新');
} catch (err) {
toast.error(err instanceof Error ? err.message : '保存失败');
} finally {
setSaving(false);
}
};
const handleMembershipToggle = async (checked: boolean) => {
setFormMembershipEnabled(checked);
try {
await saveSiteConfig({ membershipEnabled: checked });
toast.success(checked ? '会员功能已开启' : '会员功能已关闭');
} catch (err) {
setFormMembershipEnabled(!checked);
toast.error(err instanceof Error ? err.message : '会员功能保存失败');
}
};
const handleEmailSettingChange = <K extends keyof EmailSettingsForm>(key: K, value: EmailSettingsForm[K]) => {
setEmailSettings(prev => ({ ...prev, [key]: value }));
};
const handleSaveEmailSettings = async () => {
if (!accessToken) return;
setEmailSaving(true);
try {
const payload = {
...emailSettings,
smtpPassword: emailSettings.smtpPassword || undefined,
};
const response = await fetch('/api/admin/email-settings', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(payload),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || '邮箱配置保存失败');
setEmailSettings({ ...DEFAULT_EMAIL_SETTINGS, ...(data.settings || {}), smtpPassword: '' });
toast.success(data.message || '邮箱配置已保存');
loadEmailSettings();
} catch (error) {
toast.error(error instanceof Error ? error.message : '邮箱配置保存失败');
} finally {
setEmailSaving(false);
}
};
const handleSendTestEmail = async () => {
if (!accessToken) return;
if (!testEmail) {
toast.error('请填写测试收件邮箱');
return;
}
setEmailTesting(true);
try {
const response = await fetch('/api/admin/email-settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ to: testEmail }),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || '测试邮件发送失败');
toast.success(data.message || '测试邮件已发送');
} catch (error) {
toast.error(error instanceof Error ? error.message : '测试邮件发送失败');
} finally {
setEmailTesting(false);
}
};
const selectedRecipientIds = useMemo(
() => new Set(selectedRecipients.map(user => user.id)),
[selectedRecipients],
);
const toggleRecipient = (user: EmailRecipient) => {
setSelectedRecipients(prev => {
if (prev.some(item => item.id === user.id)) {
return prev.filter(item => item.id !== user.id);
}
return [...prev, user];
});
};
const handleSendUserEmail = async () => {
if (!accessToken) return;
if (!mailTitle.trim() || !mailContent.trim()) {
toast.error('请填写邮件标题和正文内容');
return;
}
if (mailMode === 'selected' && selectedRecipients.length === 0) {
toast.error('请至少选择一个收件用户');
return;
}
const confirmed = window.confirm(
mailMode === 'all'
? `确定要发送给全部 ${recipientTotal || '非管理员'} 用户吗?`
: `确定要发送给 ${selectedRecipients.length} 个指定用户吗?`,
);
if (!confirmed) return;
setMailSending(true);
try {
const response = await fetch('/api/admin/send-email', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
mode: mailMode,
userIds: mailMode === 'selected' ? selectedRecipients.map(user => user.id) : undefined,
mailKind,
title: mailTitle,
content: mailContent,
buttonText: mailButtonText || undefined,
buttonUrl: mailButtonUrl || undefined,
}),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || data.message || '邮件发送失败');
toast.success(data.message || '邮件已发送');
if (!data.failedCount) {
setMailTitle('');
setMailContent('');
setMailButtonText('');
setMailButtonUrl('');
setSelectedRecipients([]);
}
} catch (error) {
toast.error(error instanceof Error ? error.message : '邮件发送失败');
} finally {
setMailSending(false);
}
};
const currentLogoSrc = formLogoBase64 || siteConfig.logoUrl || '/logo.png';
const currentFaviconSrc = formFaviconBase64 || siteConfig.faviconUrl || '/favicon.png';
return (
<div className="space-y-6">
<SectionMenu
items={[
{ value: 'site', label: '网站配置', description: '名称、Logo、备案' },
{ value: 'footer', label: '页脚页面', description: '关于、条款、隐私、帮助' },
{ value: 'logs', label: '日志设置', description: `保存 ${formLogRetentionDays}` },
{ value: 'email', label: '邮箱服务', description: emailSettings.enabled ? '已启用' : '未启用' },
{ value: 'mail', label: '用户邮件', description: mailMode === 'all' ? '全部用户' : `${selectedRecipients.length} 个收件人` },
{ value: 'features', label: '功能开关', description: formMembershipEnabled ? '会员功能开启' : '会员功能关闭' },
]}
activeValue={activeSection}
onChange={setActiveSection}
/>
{/* Site Config */}
{activeSection === 'site' && (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Globe className="h-5 w-5 text-primary" />
</CardTitle>
<CardDescription>Logo 访</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Site Name */}
<div className="space-y-2">
<Label></Label>
<p className="text-xs text-muted-foreground"></p>
<Input
value={formSiteName}
onChange={e => setFormSiteName(e.target.value)}
placeholder="妙境"
/>
</div>
{/* Browser Tab Title */}
<div className="space-y-2">
<Label></Label>
<p className="text-xs text-muted-foreground"></p>
<Input
value={formTabTitle}
onChange={e => setFormTabTitle(e.target.value)}
placeholder="妙境 - AI创作平台"
/>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div className="space-y-2">
<Label></Label>
<p className="text-xs text-muted-foreground">ICP备XXXXXXXX号</p>
<Input
value={formFilingInfo}
onChange={e => setFormFilingInfo(e.target.value)}
placeholder="京ICP备XXXXXXXX号"
/>
</div>
<div className="space-y-2">
<Label></Label>
<p className="text-xs text-muted-foreground"></p>
<Input
value={formFilingUrl}
onChange={e => setFormFilingUrl(e.target.value)}
placeholder="https://beian.miit.gov.cn/"
/>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div className="space-y-2">
<Label></Label>
<p className="text-xs text-muted-foreground"> XXXXXXXXXXXXXX号</p>
<Input
value={formPublicSecurityFilingInfo}
onChange={e => setFormPublicSecurityFilingInfo(e.target.value)}
placeholder="京公网安备 XXXXXXXXXXXXXX号"
/>
</div>
<div className="space-y-2">
<Label></Label>
<p className="text-xs text-muted-foreground"></p>
<Input
value={formPublicSecurityFilingUrl}
onChange={e => setFormPublicSecurityFilingUrl(e.target.value)}
placeholder="https://www.beian.gov.cn/portal/registerSystemInfo"
/>
</div>
</div>
{/* Logo Upload */}
<div className="space-y-2">
<Label> Logo</Label>
<p className="text-xs text-muted-foreground">
PNG / JPG / SVG 64×64px 2MB
</p>
<div className="flex items-center gap-4">
<div className="h-16 w-16 rounded-lg border border-border bg-muted flex items-center justify-center overflow-hidden shrink-0">
<img src={currentLogoSrc} alt="Logo" className="h-full w-full object-contain" />
</div>
<div className="flex flex-col gap-2">
<input
ref={logoInputRef}
type="file"
accept=".png,.jpg,.jpeg,.svg"
className="hidden"
onChange={e => {
const file = e.target.files?.[0];
if (file) handleFileUpload(file, setFormLogoBase64, 2048, 64);
e.target.value = '';
}}
/>
<Button
size="sm"
variant="outline"
onClick={() => logoInputRef.current?.click()}
>
<Upload className="h-3.5 w-3.5 mr-1.5" />
Logo
</Button>
</div>
</div>
</div>
{/* Favicon Upload */}
<div className="space-y-2">
<Label> (Favicon)</Label>
<p className="text-xs text-muted-foreground">
PNG / JPG / SVG 32×32px 64×64px 1MB
</p>
<div className="flex items-center gap-4">
<div className="h-12 w-12 rounded border border-border bg-muted flex items-center justify-center overflow-hidden shrink-0">
<img src={currentFaviconSrc} alt="Favicon" className="h-full w-full object-contain" />
</div>
<div className="flex flex-col gap-2">
<input
ref={faviconInputRef}
type="file"
accept=".png,.jpg,.jpeg,.svg"
className="hidden"
onChange={e => {
const file = e.target.files?.[0];
if (file) handleFileUpload(file, setFormFaviconBase64, 1024, 32);
e.target.value = '';
}}
/>
<Button
size="sm"
variant="outline"
onClick={() => faviconInputRef.current?.click()}
>
<Upload className="h-3.5 w-3.5 mr-1.5" />
</Button>
</div>
</div>
</div>
{/* Save Button */}
<div className="pt-2">
<Button onClick={handleSave} disabled={saving} className="gap-1.5">
<Save className="h-4 w-4" />
{saving ? '保存中...' : '保存网站配置'}
</Button>
</div>
</CardContent>
</Card>
)}
{activeSection === 'footer' && (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Globe className="h-5 w-5 text-primary" />
</CardTitle>
<CardDescription>使</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 lg:grid-cols-2">
<div className="space-y-2">
<Label></Label>
<Textarea
value={formAboutUs}
onChange={e => setFormAboutUs(e.target.value)}
placeholder="请输入关于我们页面内容"
className="min-h-64 resize-y"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Textarea
value={formHelpCenter}
onChange={e => setFormHelpCenter(e.target.value)}
placeholder="请输入帮助中心页面内容"
className="min-h-64 resize-y"
/>
</div>
<div className="space-y-2">
<Label>使</Label>
<p className="text-xs text-muted-foreground"></p>
<Textarea
value={formTermsOfService}
onChange={e => setFormTermsOfService(e.target.value)}
placeholder="请输入使用条款内容"
className="min-h-64 resize-y"
/>
</div>
<div className="space-y-2">
<Label></Label>
<p className="text-xs text-muted-foreground"></p>
<Textarea
value={formPrivacyPolicy}
onChange={e => setFormPrivacyPolicy(e.target.value)}
placeholder="请输入隐私政策内容"
className="min-h-64 resize-y"
/>
</div>
</div>
<div className="pt-2">
<Button onClick={handleSave} disabled={saving} className="gap-1.5">
<Save className="h-4 w-4" />
{saving ? '保存中...' : '保存页脚页面'}
</Button>
</div>
</CardContent>
</Card>
)}
{activeSection === 'logs' && (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Logs className="h-5 w-5 text-primary" />
</CardTitle>
<CardDescription> 90 </CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="max-w-md space-y-2">
<Label></Label>
<Input
type="number"
min={1}
max={90}
value={formLogRetentionDays}
onChange={e => setFormLogRetentionDays(Math.min(90, Math.max(1, Number(e.target.value || 30))))}
/>
<p className="text-xs text-muted-foreground"></p>
</div>
<div className="pt-2">
<Button onClick={handleSave} disabled={saving} className="gap-1.5">
<Save className="h-4 w-4" />
{saving ? '保存中...' : '保存日志设置'}
</Button>
</div>
</CardContent>
</Card>
)}
{/* Email SMTP Config */}
{activeSection === 'email' && (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Mail className="h-5 w-5 text-primary" />
</CardTitle>
<CardDescription> service@你的火山引擎托管域名.com </CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center justify-between rounded-lg border border-border p-4">
<div>
<p className="font-medium text-sm"></p>
<p className="text-xs text-muted-foreground"></p>
</div>
<Switch
checked={emailSettings.enabled}
onCheckedChange={(checked) => handleEmailSettingChange('enabled', checked)}
disabled={emailLoading}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>SMTP </Label>
<Input value={emailSettings.smtpHost} onChange={e => handleEmailSettingChange('smtpHost', e.target.value)} placeholder="smtp.your-domain.com / smtp.exmail.qq.com" />
</div>
<div className="space-y-2">
<Label>SMTP </Label>
<Input type="number" value={emailSettings.smtpPort} onChange={e => handleEmailSettingChange('smtpPort', Number(e.target.value) || 465)} placeholder="465" />
</div>
<div className="space-y-2">
<Label>SMTP </Label>
<Input value={emailSettings.smtpUser} onChange={e => handleEmailSettingChange('smtpUser', e.target.value)} placeholder="service@your-domain.com" />
</div>
<div className="space-y-2">
<Label>SMTP </Label>
<Input
type="password"
value={emailSettings.smtpPassword}
onChange={e => handleEmailSettingChange('smtpPassword', e.target.value)}
placeholder={emailSettings.smtpPasswordPreview ? `已保存:${emailSettings.smtpPasswordPreview}` : '输入授权码'}
/>
<p className="text-xs text-muted-foreground"></p>
</div>
<div className="space-y-2">
<Label></Label>
<Input value={emailSettings.fromEmail} onChange={e => handleEmailSettingChange('fromEmail', e.target.value)} placeholder="service@your-domain.com" />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={emailSettings.fromName} onChange={e => handleEmailSettingChange('fromName', e.target.value)} placeholder="妙境官方通知" />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={emailSettings.replyTo} onChange={e => handleEmailSettingChange('replyTo', e.target.value)} placeholder="support@your-domain.com" />
</div>
<div className="space-y-2">
<Label>访</Label>
<Input value={emailSettings.appBaseUrl} onChange={e => handleEmailSettingChange('appBaseUrl', e.target.value)} placeholder="http://192.168.217.130:5000" />
</div>
<div className="space-y-2">
<Label> Logo </Label>
<Input value={emailSettings.logoUrl} onChange={e => handleEmailSettingChange('logoUrl', e.target.value)} placeholder="/logo.png" />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={emailSettings.contactEmail} onChange={e => handleEmailSettingChange('contactEmail', e.target.value)} placeholder="support@your-domain.com" />
</div>
<div className="space-y-2">
<Label></Label>
<Input type="number" min={4} max={10} value={emailSettings.codeLength} onChange={e => handleEmailSettingChange('codeLength', Number(e.target.value) || 6)} />
</div>
<div className="space-y-2">
<Label></Label>
<Input type="number" min={1} max={30} value={emailSettings.codeTtlMinutes} onChange={e => handleEmailSettingChange('codeTtlMinutes', Number(e.target.value) || 5)} />
</div>
<div className="space-y-2">
<Label></Label>
<Select value={emailSettings.codeCharset} onValueChange={value => handleEmailSettingChange('codeCharset', value as EmailSettingsForm['codeCharset'])}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="alphanumeric"> + </SelectItem>
<SelectItem value="numeric"></SelectItem>
<SelectItem value="letters"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>使 SSL/TLS</Label>
<div className="flex h-10 items-center gap-3 rounded-md border border-border px-3">
<Switch checked={emailSettings.smtpSecure} onCheckedChange={(checked) => handleEmailSettingChange('smtpSecure', checked)} />
<span className="text-sm text-muted-foreground">465 587 使 STARTTLS</span>
</div>
</div>
</div>
<div className="space-y-2">
<Label></Label>
<Input value={emailSettings.copyright} onChange={e => handleEmailSettingChange('copyright', e.target.value)} placeholder="© 2026 妙境. All rights reserved." />
</div>
<div className="flex flex-col gap-3 rounded-lg border border-border p-4 md:flex-row md:items-end">
<div className="flex-1 space-y-2">
<Label></Label>
<Input value={testEmail} onChange={e => setTestEmail(e.target.value)} placeholder="your@email.com" />
</div>
<Button variant="outline" className="gap-2" onClick={handleSendTestEmail} disabled={emailTesting}>
{emailTesting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
</Button>
<Button className="gap-2" onClick={handleSaveEmailSettings} disabled={emailSaving}>
{emailSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
</Button>
</div>
</CardContent>
</Card>
)}
{activeSection === 'mail' && (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Send className="h-5 w-5 text-primary" />
</CardTitle>
<CardDescription>使 UI </CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-4 rounded-lg border border-border p-4">
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<p className="text-sm font-medium"></p>
<p className="text-xs text-muted-foreground">使 UI /</p>
</div>
<div className="flex rounded-lg border border-border bg-muted/30 p-1">
<button
type="button"
className={`rounded-md px-3 py-1.5 text-sm font-medium transition ${mailMode === 'selected' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'}`}
onClick={() => setMailMode('selected')}
>
</button>
<button
type="button"
className={`rounded-md px-3 py-1.5 text-sm font-medium transition ${mailMode === 'all' ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'}`}
onClick={() => setMailMode('all')}
>
</button>
</div>
</div>
{mailMode === 'selected' ? (
<div className="space-y-3">
<div className="grid gap-3 md:grid-cols-[1fr_auto] md:items-end">
<div className="space-y-2">
<Label></Label>
<Input
value={recipientQuery}
onChange={e => setRecipientQuery(e.target.value)}
placeholder="搜索邮箱、昵称、手机号"
/>
</div>
<Button variant="outline" onClick={() => loadEmailRecipients(recipientQuery)} disabled={recipientsLoading}>
{recipientsLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
</Button>
</div>
<div className="max-h-56 space-y-2 overflow-y-auto rounded-lg border border-border bg-muted/20 p-2">
{recipientResults.length > 0 ? recipientResults.map(user => {
const selected = selectedRecipientIds.has(user.id);
return (
<button
key={user.id}
type="button"
className={`flex w-full items-center justify-between gap-3 rounded-md border px-3 py-2 text-left transition ${selected ? 'border-primary/70 bg-primary/10' : 'border-border bg-background/70 hover:bg-muted/50'}`}
onClick={() => toggleRecipient(user)}
>
<span className="min-w-0">
<span className="block truncate text-sm font-medium">{user.nickname}</span>
<span className="block truncate text-xs text-muted-foreground">{user.email}</span>
</span>
<span className="shrink-0 text-xs text-muted-foreground">{selected ? '已选择' : user.emailVerified ? '已验证' : '未验证'}</span>
</button>
);
}) : (
<div className="flex h-20 items-center justify-center text-sm text-muted-foreground">
{recipientsLoading ? '正在加载用户...' : '暂无可选用户'}
</div>
)}
</div>
{selectedRecipients.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedRecipients.map(user => (
<button
key={user.id}
type="button"
className="rounded-full border border-primary/30 bg-primary/10 px-3 py-1 text-xs text-foreground"
onClick={() => toggleRecipient(user)}
title="点击移除"
>
{user.nickname} · {user.email} ×
</button>
))}
</div>
)}
</div>
) : (
<div className="rounded-lg border border-primary/20 bg-primary/5 p-3 text-sm text-muted-foreground">
{recipientTotal || 0}
</div>
)}
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label></Label>
<Select value={mailKind} onValueChange={value => setMailKind(value as 'notification' | 'admin')}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="notification"></SelectItem>
<SelectItem value="admin"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Input value={mailTitle} onChange={e => setMailTitle(e.target.value)} maxLength={120} placeholder="例如:平台功能更新通知" />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={mailButtonText} onChange={e => setMailButtonText(e.target.value)} maxLength={40} placeholder="查看详情" />
</div>
<div className="space-y-2 md:col-span-2">
<Label></Label>
<Input value={mailButtonUrl} onChange={e => setMailButtonUrl(e.target.value)} placeholder="https:// 或 http:// 开头" />
</div>
<div className="space-y-2 md:col-span-2">
<Label></Label>
<Textarea
value={mailContent}
onChange={e => setMailContent(e.target.value)}
maxLength={5000}
className="min-h-36"
placeholder="填写需要发送给用户的通知内容,支持换行。"
/>
</div>
</div>
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<p className="text-xs text-muted-foreground">
{mailMode === 'all' ? '批量发送可能需要一些时间,请避免重复点击。' : `已选择 ${selectedRecipients.length} 个收件用户。`}
</p>
<Button className="gap-2" onClick={handleSendUserEmail} disabled={mailSending}>
{mailSending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
{mailMode === 'all' ? '发送给全部用户' : '发送给指定用户'}
</Button>
</div>
</div>
<div className="rounded-lg border border-border bg-muted/30 p-4">
<p className="mb-3 text-sm font-medium"></p>
<div className="overflow-hidden rounded-lg border border-border bg-background">
{emailPreviewHtml ? (
<iframe title="邮件模板预览" srcDoc={emailPreviewHtml} className="h-[420px] w-full bg-background" />
) : (
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground"></div>
)}
</div>
</div>
<div className="rounded-lg border border-primary/20 bg-primary/5 p-4 text-sm text-muted-foreground">
<p className="font-medium text-foreground"></p>
<p className="mt-2"> DNS DNS / / / / </p>
<p className="mt-1"> DNSSMTP smtp.your-domain.com smtp.exmail.qq.com smtp.mxhichina.com</p>
<p className="mt-1"> SPFDKIMDMARCSMTP DNS </p>
</div>
</CardContent>
</Card>
)}
{/* Feature Toggles */}
{activeSection === 'features' && (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<ToggleLeft className="h-5 w-5 text-primary" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between p-4 rounded-lg border border-border">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Crown className="h-5 w-5" />
</div>
<div>
<p className="font-medium text-sm"></p>
<p className="text-xs text-muted-foreground"> API </p>
</div>
</div>
<Switch
checked={formMembershipEnabled}
onCheckedChange={handleMembershipToggle}
/>
</div>
</CardContent>
</Card>
)}
</div>
);
}