mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-30 14:26:36 +08:00
1241 lines
53 KiB
TypeScript
1241 lines
53 KiB
TypeScript
'use client';
|
|
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { marked } from 'marked';
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
|
|
import { EmptyState } from '@/components/feedback/empty-state';
|
|
import { ErrorState } from '@/components/feedback/error-state';
|
|
import { InlineMessage } from '@/components/feedback/inline-message';
|
|
import { LoadingState } from '@/components/feedback/loading-state';
|
|
import { TurnstileWidget } from '@/components/forms/turnstile-widget';
|
|
import { useAuth } from '@/components/providers/auth-provider';
|
|
import { PageHeader } from '@/components/layout/page-header';
|
|
import { AppCard } from '@/components/ui/app-card';
|
|
import { StatusBadge } from '@/components/ui/status-badge';
|
|
import { sendEmailVerification } from '@/features/auth/api/auth';
|
|
import { getPublicStatus } from '@/features/auth/api/public';
|
|
import {
|
|
bindEmail,
|
|
bindWeChat,
|
|
generateAccessToken,
|
|
getBootstrapToken,
|
|
getLatestRelease,
|
|
getOptions,
|
|
getSettingsProfile,
|
|
rotateBootstrapToken,
|
|
updateOption,
|
|
updateSelf,
|
|
} from '@/features/settings/api/settings';
|
|
import type {
|
|
LatestReleaseInfo,
|
|
OptionItem,
|
|
UpdateSelfPayload,
|
|
} from '@/features/settings/types';
|
|
import {
|
|
CodeBlock,
|
|
PrimaryButton,
|
|
ResourceField,
|
|
ResourceInput,
|
|
ResourceTextarea,
|
|
SecondaryButton,
|
|
ToggleField,
|
|
} from '@/features/shared/components/resource-primitives';
|
|
import { publicEnv } from '@/lib/env/public-env';
|
|
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
|
|
|
|
const installerScriptUrl = 'https://raw.githubusercontent.com/Rain-kl/ATSFlare/main/scripts/install-agent.sh';
|
|
const settingsQueryKey = ['settings', 'options'] as const;
|
|
|
|
const defaultSystemFields = {
|
|
ServerAddress: '',
|
|
PasswordLoginEnabled: true,
|
|
PasswordRegisterEnabled: true,
|
|
EmailVerificationEnabled: false,
|
|
GitHubOAuthEnabled: false,
|
|
WeChatAuthEnabled: false,
|
|
TurnstileCheckEnabled: false,
|
|
RegisterEnabled: true,
|
|
SMTPServer: '',
|
|
SMTPPort: '587',
|
|
SMTPAccount: '',
|
|
SMTPToken: '',
|
|
GitHubClientId: '',
|
|
GitHubClientSecret: '',
|
|
WeChatServerAddress: '',
|
|
WeChatServerToken: '',
|
|
WeChatAccountQRCodeImageURL: '',
|
|
TurnstileSiteKey: '',
|
|
TurnstileSecretKey: '',
|
|
};
|
|
|
|
const defaultOperationFields = {
|
|
AgentHeartbeatInterval: '30000',
|
|
AgentSyncInterval: '30000',
|
|
NodeOfflineThreshold: '120000',
|
|
AgentUpdateRepo: 'Rain-kl/ATSFlare',
|
|
ServerAddress: '',
|
|
};
|
|
|
|
const defaultOtherFields = {
|
|
Notice: '',
|
|
SystemName: '',
|
|
HomePageLink: '',
|
|
About: '',
|
|
Footer: '',
|
|
};
|
|
|
|
const defaultProfileFields: UpdateSelfPayload = {
|
|
username: '',
|
|
display_name: '',
|
|
password: '',
|
|
};
|
|
|
|
type FeedbackState = {
|
|
tone: 'info' | 'success' | 'danger';
|
|
message: string;
|
|
};
|
|
|
|
type SettingsTab = 'personal' | 'operation' | 'system' | 'other';
|
|
|
|
function getErrorMessage(error: unknown) {
|
|
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
|
}
|
|
|
|
function optionsToMap(options: OptionItem[] | undefined) {
|
|
return (options ?? []).reduce<Record<string, string>>((accumulator, option) => {
|
|
accumulator[option.key] = option.value;
|
|
return accumulator;
|
|
}, {});
|
|
}
|
|
|
|
function toBoolean(value: string | undefined, fallback: boolean) {
|
|
if (value === undefined) {
|
|
return fallback;
|
|
}
|
|
|
|
return value === 'true';
|
|
}
|
|
|
|
function normalizeServerUrl(value: string) {
|
|
return value.trim().replace(/\/+$/, '');
|
|
}
|
|
|
|
function formatDurationLabel(value: string) {
|
|
const milliseconds = Number.parseInt(value, 10);
|
|
if (Number.isNaN(milliseconds)) {
|
|
return value;
|
|
}
|
|
|
|
if (milliseconds >= 60000) {
|
|
return `${milliseconds / 60000} 分钟`;
|
|
}
|
|
|
|
return `${milliseconds / 1000} 秒`;
|
|
}
|
|
|
|
function buildDiscoveryCommand(serverUrl: string, discoveryToken: string) {
|
|
return [
|
|
`curl -fsSL ${installerScriptUrl} | bash -s -- \\`,
|
|
` --server-url ${normalizeServerUrl(serverUrl)} \\`,
|
|
` --discovery-token ${discoveryToken}`,
|
|
].join('\n');
|
|
}
|
|
|
|
async function copyToClipboard(value: string) {
|
|
await navigator.clipboard.writeText(value);
|
|
}
|
|
|
|
export function SettingsPage() {
|
|
const queryClient = useQueryClient();
|
|
const { refreshUser, user } = useAuth();
|
|
const [activeTab, setActiveTab] = useState<SettingsTab>('personal');
|
|
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
|
const [busyKey, setBusyKey] = useState<string | null>(null);
|
|
const [profileFields, setProfileFields] = useState(defaultProfileFields);
|
|
const [systemFields, setSystemFields] = useState(defaultSystemFields);
|
|
const [operationFields, setOperationFields] = useState(defaultOperationFields);
|
|
const [otherFields, setOtherFields] = useState(defaultOtherFields);
|
|
const [accessToken, setAccessToken] = useState('');
|
|
const [wechatCode, setWeChatCode] = useState('');
|
|
const [emailAddress, setEmailAddress] = useState('');
|
|
const [emailCode, setEmailCode] = useState('');
|
|
const [emailTurnstileToken, setEmailTurnstileToken] = useState('');
|
|
const [latestRelease, setLatestRelease] = useState<LatestReleaseInfo | null>(null);
|
|
|
|
const isRoot = (user?.role ?? 0) >= 100;
|
|
|
|
const publicStatusQuery = useQuery({
|
|
queryKey: ['public-status'],
|
|
queryFn: getPublicStatus,
|
|
});
|
|
|
|
const profileQuery = useQuery({
|
|
queryKey: ['settings', 'profile'],
|
|
queryFn: getSettingsProfile,
|
|
});
|
|
|
|
const optionsQuery = useQuery({
|
|
queryKey: settingsQueryKey,
|
|
queryFn: getOptions,
|
|
enabled: isRoot,
|
|
});
|
|
|
|
const bootstrapQuery = useQuery({
|
|
queryKey: ['settings', 'bootstrap-token'],
|
|
queryFn: getBootstrapToken,
|
|
enabled: isRoot,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (profileQuery.data) {
|
|
setProfileFields({
|
|
username: profileQuery.data.username,
|
|
display_name: profileQuery.data.display_name || '',
|
|
password: '',
|
|
});
|
|
setEmailAddress(profileQuery.data.email || '');
|
|
}
|
|
}, [profileQuery.data]);
|
|
|
|
useEffect(() => {
|
|
const publicStatus = publicStatusQuery.data;
|
|
if (!publicStatus) {
|
|
return;
|
|
}
|
|
|
|
setSystemFields((previous) => ({
|
|
...previous,
|
|
ServerAddress: publicStatus.server_address || previous.ServerAddress,
|
|
GitHubClientId: publicStatus.github_client_id || previous.GitHubClientId,
|
|
WeChatAccountQRCodeImageURL: publicStatus.wechat_qrcode || previous.WeChatAccountQRCodeImageURL,
|
|
TurnstileSiteKey: publicStatus.turnstile_site_key || previous.TurnstileSiteKey,
|
|
}));
|
|
setOtherFields((previous) => ({
|
|
...previous,
|
|
SystemName: publicStatus.system_name || previous.SystemName,
|
|
HomePageLink: publicStatus.home_page_link || previous.HomePageLink,
|
|
Footer: publicStatus.footer_html || previous.Footer,
|
|
}));
|
|
setOperationFields((previous) => ({
|
|
...previous,
|
|
ServerAddress: publicStatus.server_address || previous.ServerAddress,
|
|
}));
|
|
}, [publicStatusQuery.data]);
|
|
|
|
useEffect(() => {
|
|
if (!optionsQuery.data) {
|
|
return;
|
|
}
|
|
|
|
const optionMap = optionsToMap(optionsQuery.data);
|
|
|
|
setSystemFields({
|
|
ServerAddress: optionMap.ServerAddress ?? '',
|
|
PasswordLoginEnabled: toBoolean(optionMap.PasswordLoginEnabled, true),
|
|
PasswordRegisterEnabled: toBoolean(optionMap.PasswordRegisterEnabled, true),
|
|
EmailVerificationEnabled: toBoolean(optionMap.EmailVerificationEnabled, false),
|
|
GitHubOAuthEnabled: toBoolean(optionMap.GitHubOAuthEnabled, false),
|
|
WeChatAuthEnabled: toBoolean(optionMap.WeChatAuthEnabled, false),
|
|
TurnstileCheckEnabled: toBoolean(optionMap.TurnstileCheckEnabled, false),
|
|
RegisterEnabled: toBoolean(optionMap.RegisterEnabled, true),
|
|
SMTPServer: optionMap.SMTPServer ?? '',
|
|
SMTPPort: optionMap.SMTPPort ?? '587',
|
|
SMTPAccount: optionMap.SMTPAccount ?? '',
|
|
SMTPToken: '',
|
|
GitHubClientId: optionMap.GitHubClientId ?? '',
|
|
GitHubClientSecret: '',
|
|
WeChatServerAddress: optionMap.WeChatServerAddress ?? '',
|
|
WeChatServerToken: '',
|
|
WeChatAccountQRCodeImageURL: optionMap.WeChatAccountQRCodeImageURL ?? '',
|
|
TurnstileSiteKey: optionMap.TurnstileSiteKey ?? '',
|
|
TurnstileSecretKey: '',
|
|
});
|
|
|
|
setOperationFields({
|
|
AgentHeartbeatInterval: optionMap.AgentHeartbeatInterval ?? '30000',
|
|
AgentSyncInterval: optionMap.AgentSyncInterval ?? '30000',
|
|
NodeOfflineThreshold: optionMap.NodeOfflineThreshold ?? '120000',
|
|
AgentUpdateRepo: optionMap.AgentUpdateRepo ?? 'Rain-kl/ATSFlare',
|
|
ServerAddress: optionMap.ServerAddress ?? publicStatusQuery.data?.server_address ?? '',
|
|
});
|
|
|
|
setOtherFields({
|
|
Notice: optionMap.Notice ?? '',
|
|
SystemName: optionMap.SystemName ?? '',
|
|
HomePageLink: optionMap.HomePageLink ?? '',
|
|
About: optionMap.About ?? '',
|
|
Footer: optionMap.Footer ?? '',
|
|
});
|
|
}, [optionsQuery.data, publicStatusQuery.data?.server_address]);
|
|
|
|
const rotateTokenMutation = useMutation({
|
|
mutationFn: rotateBootstrapToken,
|
|
onSuccess: async (data) => {
|
|
setFeedback({ tone: 'success', message: 'Discovery Token 已重新生成。' });
|
|
await queryClient.invalidateQueries({ queryKey: ['settings', 'bootstrap-token'] });
|
|
if (data.discovery_token) {
|
|
try {
|
|
await copyToClipboard(data.discovery_token);
|
|
} catch {
|
|
// ignore clipboard errors
|
|
}
|
|
}
|
|
},
|
|
onError: (error) => {
|
|
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
|
},
|
|
});
|
|
|
|
const accessTokenMutation = useMutation({
|
|
mutationFn: generateAccessToken,
|
|
onSuccess: (token) => {
|
|
setAccessToken(token);
|
|
setFeedback({ tone: 'success', message: '访问令牌已重置,并已在当前页面展示。' });
|
|
},
|
|
onError: (error) => {
|
|
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
|
},
|
|
});
|
|
|
|
const discoveryToken = bootstrapQuery.data?.discovery_token ?? '';
|
|
const discoveryCommand =
|
|
isRoot && operationFields.ServerAddress && discoveryToken
|
|
? buildDiscoveryCommand(operationFields.ServerAddress, discoveryToken)
|
|
: '';
|
|
|
|
const tabs = useMemo(
|
|
() => [
|
|
{ key: 'personal' as const, label: '个人设置', description: '更新个人资料、绑定账号与访问令牌。' },
|
|
...(isRoot
|
|
? [
|
|
{ key: 'operation' as const, label: '运维设置', description: 'Agent 参数、Discovery Token 与部署命令。' },
|
|
{ key: 'system' as const, label: '系统设置', description: '登录注册、SMTP、OAuth 与风控开关。' },
|
|
{ key: 'other' as const, label: '其他设置', description: '公告、关于、品牌信息与版本检查。' },
|
|
]
|
|
: []),
|
|
],
|
|
[isRoot],
|
|
);
|
|
|
|
const runBusyAction = async (key: string, action: () => Promise<void>) => {
|
|
setBusyKey(key);
|
|
setFeedback(null);
|
|
|
|
try {
|
|
await action();
|
|
} catch (error) {
|
|
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
|
} finally {
|
|
setBusyKey(null);
|
|
}
|
|
};
|
|
|
|
const saveOptionEntries = async (entries: Array<[string, string]>, successMessage: string) => {
|
|
for (const [key, value] of entries) {
|
|
await updateOption(key, value);
|
|
}
|
|
|
|
await queryClient.invalidateQueries({ queryKey: settingsQueryKey });
|
|
await queryClient.invalidateQueries({ queryKey: ['public-status'] });
|
|
setFeedback({ tone: 'success', message: successMessage });
|
|
};
|
|
|
|
const handleProfileSave = () => {
|
|
void runBusyAction('profile', async () => {
|
|
await updateSelf({
|
|
username: profileFields.username.trim(),
|
|
display_name: profileFields.display_name.trim(),
|
|
password: profileFields.password,
|
|
});
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: ['settings', 'profile'] }),
|
|
refreshUser(),
|
|
]);
|
|
setProfileFields((previous) => ({ ...previous, password: '' }));
|
|
setFeedback({ tone: 'success', message: '个人资料已更新。' });
|
|
});
|
|
};
|
|
|
|
const handleEmailVerification = () => {
|
|
if (!emailAddress.trim()) {
|
|
setFeedback({ tone: 'danger', message: '请输入要绑定的邮箱地址。' });
|
|
return;
|
|
}
|
|
|
|
if (publicStatusQuery.data?.turnstile_check && !emailTurnstileToken) {
|
|
setFeedback({ tone: 'info', message: '请先完成人机验证。' });
|
|
return;
|
|
}
|
|
|
|
void runBusyAction('email-send', async () => {
|
|
await sendEmailVerification(emailAddress.trim(), emailTurnstileToken || undefined);
|
|
setFeedback({ tone: 'success', message: '验证码已发送,请检查邮箱。' });
|
|
});
|
|
};
|
|
|
|
const handleBindEmail = () => {
|
|
if (!emailAddress.trim() || !emailCode.trim()) {
|
|
setFeedback({ tone: 'danger', message: '请输入邮箱地址和验证码。' });
|
|
return;
|
|
}
|
|
|
|
void runBusyAction('email-bind', async () => {
|
|
await bindEmail(emailAddress.trim(), emailCode.trim());
|
|
setEmailCode('');
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: ['settings', 'profile'] }),
|
|
refreshUser(),
|
|
]);
|
|
setFeedback({ tone: 'success', message: '邮箱已绑定。' });
|
|
});
|
|
};
|
|
|
|
const handleBindWeChat = () => {
|
|
if (!wechatCode.trim()) {
|
|
setFeedback({ tone: 'danger', message: '请输入微信验证码。' });
|
|
return;
|
|
}
|
|
|
|
void runBusyAction('wechat-bind', async () => {
|
|
await bindWeChat(wechatCode.trim());
|
|
setWeChatCode('');
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: ['settings', 'profile'] }),
|
|
refreshUser(),
|
|
]);
|
|
setFeedback({ tone: 'success', message: '微信账号已绑定。' });
|
|
});
|
|
};
|
|
|
|
const handleToggleOption = (key: keyof typeof systemFields, nextValue: boolean) => {
|
|
setSystemFields((previous) => ({ ...previous, [key]: nextValue }));
|
|
|
|
void runBusyAction(`toggle-${key}`, async () => {
|
|
await saveOptionEntries([[key, String(nextValue)]], '系统开关已更新。');
|
|
});
|
|
};
|
|
|
|
const handleCheckLatestRelease = () => {
|
|
void runBusyAction('latest-release', async () => {
|
|
const release = await getLatestRelease();
|
|
setLatestRelease(release);
|
|
setFeedback({ tone: 'success', message: `已获取最新版本信息:${release.tag_name}` });
|
|
});
|
|
};
|
|
|
|
const renderTabContent = () => {
|
|
if (profileQuery.isLoading || publicStatusQuery.isLoading) {
|
|
return <LoadingState />;
|
|
}
|
|
|
|
if (profileQuery.isError) {
|
|
return <ErrorState title='个人设置加载失败' description={getErrorMessage(profileQuery.error)} />;
|
|
}
|
|
|
|
if (publicStatusQuery.isError) {
|
|
return <ErrorState title='系统状态加载失败' description={getErrorMessage(publicStatusQuery.error)} />;
|
|
}
|
|
|
|
const publicStatus = publicStatusQuery.data;
|
|
const profile = profileQuery.data;
|
|
|
|
if (!publicStatus || !profile) {
|
|
return <EmptyState title='设置暂不可用' description='未获取到当前用户或系统状态信息。' />;
|
|
}
|
|
|
|
if (activeTab === 'personal') {
|
|
return (
|
|
<div className='space-y-6'>
|
|
<div className='grid gap-6 xl:grid-cols-[1.1fr_0.9fr]'>
|
|
<AppCard
|
|
title='个人资料'
|
|
description='可更新用户名、显示名称和密码。留空密码表示保持当前密码不变。'
|
|
action={
|
|
<PrimaryButton type='button' onClick={handleProfileSave} disabled={busyKey === 'profile'}>
|
|
{busyKey === 'profile' ? '保存中...' : '保存资料'}
|
|
</PrimaryButton>
|
|
}
|
|
>
|
|
<div className='space-y-5'>
|
|
<ResourceField label='用户名'>
|
|
<ResourceInput
|
|
value={profileFields.username}
|
|
onChange={(event) =>
|
|
setProfileFields((previous) => ({ ...previous, username: event.target.value }))
|
|
}
|
|
placeholder='请输入用户名'
|
|
/>
|
|
</ResourceField>
|
|
|
|
<ResourceField label='显示名称'>
|
|
<ResourceInput
|
|
value={profileFields.display_name}
|
|
onChange={(event) =>
|
|
setProfileFields((previous) => ({ ...previous, display_name: event.target.value }))
|
|
}
|
|
placeholder='请输入显示名称'
|
|
/>
|
|
</ResourceField>
|
|
|
|
<ResourceField label='新密码' hint='留空表示不修改密码。'>
|
|
<ResourceInput
|
|
type='password'
|
|
value={profileFields.password}
|
|
onChange={(event) =>
|
|
setProfileFields((previous) => ({ ...previous, password: event.target.value }))
|
|
}
|
|
placeholder='请输入新密码'
|
|
/>
|
|
</ResourceField>
|
|
</div>
|
|
</AppCard>
|
|
|
|
<AppCard
|
|
title='访问令牌'
|
|
description='重置后会立即生成新的访问令牌,可用于自动化请求。'
|
|
action={
|
|
<PrimaryButton
|
|
type='button'
|
|
onClick={() => accessTokenMutation.mutate()}
|
|
disabled={accessTokenMutation.isPending}
|
|
>
|
|
{accessTokenMutation.isPending ? '生成中...' : '重置令牌'}
|
|
</PrimaryButton>
|
|
}
|
|
>
|
|
<div className='space-y-4'>
|
|
<div className='grid gap-4 md:grid-cols-2'>
|
|
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
|
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>当前角色</p>
|
|
<div className='mt-2'>
|
|
<StatusBadge
|
|
label={user?.role === 100 ? '超级管理员' : user?.role === 10 ? '管理员' : '普通用户'}
|
|
variant={user?.role === 100 ? 'warning' : user?.role === 10 ? 'info' : 'success'}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
|
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>已绑定邮箱</p>
|
|
<p className='mt-2 break-all text-sm text-[var(--foreground-primary)]'>
|
|
{profile.email || '未绑定'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{accessToken ? (
|
|
<div className='space-y-3'>
|
|
<CodeBlock className='break-all whitespace-pre-wrap'>{accessToken}</CodeBlock>
|
|
<SecondaryButton
|
|
type='button'
|
|
onClick={() => void copyToClipboard(accessToken)}
|
|
>
|
|
复制令牌
|
|
</SecondaryButton>
|
|
</div>
|
|
) : (
|
|
<EmptyState title='尚未生成令牌' description='点击“重置令牌”后,新的访问令牌会显示在这里。' />
|
|
)}
|
|
</div>
|
|
</AppCard>
|
|
</div>
|
|
|
|
<AppCard title='账号绑定' description='支持绑定 GitHub、微信和邮箱地址,用于统一个人身份入口。'>
|
|
<div className='grid gap-6 xl:grid-cols-3'>
|
|
<div className='space-y-4 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
|
<div className='space-y-1'>
|
|
<p className='text-base font-semibold text-[var(--foreground-primary)]'>GitHub 账号</p>
|
|
<p className='text-sm leading-6 text-[var(--foreground-secondary)]'>
|
|
当前状态:{profile.github_id ? `已绑定 ${profile.github_id}` : '未绑定'}
|
|
</p>
|
|
</div>
|
|
<PrimaryButton
|
|
type='button'
|
|
onClick={() =>
|
|
window.open(
|
|
`https://github.com/login/oauth/authorize?client_id=${publicStatus.github_client_id}&scope=user:email`,
|
|
'_blank',
|
|
'noopener,noreferrer',
|
|
)
|
|
}
|
|
disabled={!publicStatus.github_oauth || !publicStatus.github_client_id}
|
|
>
|
|
{publicStatus.github_oauth ? '绑定 GitHub' : '未启用 GitHub OAuth'}
|
|
</PrimaryButton>
|
|
</div>
|
|
|
|
<div className='space-y-4 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
|
<div className='space-y-1'>
|
|
<p className='text-base font-semibold text-[var(--foreground-primary)]'>微信账号</p>
|
|
<p className='text-sm leading-6 text-[var(--foreground-secondary)]'>
|
|
当前状态:{profile.wechat_id ? `已绑定 ${profile.wechat_id}` : '未绑定'}
|
|
</p>
|
|
</div>
|
|
{publicStatus.wechat_login && publicStatus.wechat_qrcode ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={publicStatus.wechat_qrcode}
|
|
alt='微信绑定二维码'
|
|
className='h-40 w-40 rounded-2xl border border-[var(--border-default)] object-cover'
|
|
/>
|
|
) : null}
|
|
<ResourceField label='验证码' hint='扫码关注后输入“验证码”获取绑定码。'>
|
|
<ResourceInput
|
|
value={wechatCode}
|
|
onChange={(event) => setWeChatCode(event.target.value)}
|
|
placeholder='请输入微信验证码'
|
|
/>
|
|
</ResourceField>
|
|
<PrimaryButton type='button' onClick={handleBindWeChat} disabled={!publicStatus.wechat_login || busyKey === 'wechat-bind'}>
|
|
{busyKey === 'wechat-bind' ? '绑定中...' : '绑定微信'}
|
|
</PrimaryButton>
|
|
</div>
|
|
|
|
<div className='space-y-4 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
|
<div className='space-y-1'>
|
|
<p className='text-base font-semibold text-[var(--foreground-primary)]'>邮箱地址</p>
|
|
<p className='text-sm leading-6 text-[var(--foreground-secondary)]'>
|
|
当前状态:{profile.email ? `已绑定 ${profile.email}` : '未绑定'}
|
|
</p>
|
|
</div>
|
|
<div className='space-y-4'>
|
|
<ResourceField label='邮箱地址'>
|
|
<ResourceInput
|
|
value={emailAddress}
|
|
onChange={(event) => setEmailAddress(event.target.value)}
|
|
placeholder='请输入邮箱地址'
|
|
/>
|
|
</ResourceField>
|
|
<ResourceField label='验证码'>
|
|
<ResourceInput
|
|
value={emailCode}
|
|
onChange={(event) => setEmailCode(event.target.value)}
|
|
placeholder='请输入邮箱验证码'
|
|
/>
|
|
</ResourceField>
|
|
{publicStatus.turnstile_check ? (
|
|
publicStatus.turnstile_site_key ? (
|
|
<TurnstileWidget
|
|
siteKey={publicStatus.turnstile_site_key}
|
|
onVerify={(token) => setEmailTurnstileToken(token)}
|
|
onExpire={() => setEmailTurnstileToken('')}
|
|
onError={() => setEmailTurnstileToken('')}
|
|
/>
|
|
) : (
|
|
<EmptyState
|
|
title='Turnstile 配置不完整'
|
|
description='当前系统已启用 Turnstile,但未配置 Site Key,邮箱绑定暂不可用。'
|
|
/>
|
|
)
|
|
) : null}
|
|
<div className='flex flex-wrap gap-2'>
|
|
<SecondaryButton type='button' onClick={handleEmailVerification} disabled={busyKey === 'email-send'}>
|
|
{busyKey === 'email-send' ? '发送中...' : '发送验证码'}
|
|
</SecondaryButton>
|
|
<PrimaryButton type='button' onClick={handleBindEmail} disabled={busyKey === 'email-bind'}>
|
|
{busyKey === 'email-bind' ? '绑定中...' : '绑定邮箱'}
|
|
</PrimaryButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</AppCard>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!isRoot) {
|
|
return <EmptyState title='权限不足' description='只有超级管理员可以访问系统级设置。' />;
|
|
}
|
|
|
|
if (optionsQuery.isLoading) {
|
|
return <LoadingState />;
|
|
}
|
|
|
|
if (optionsQuery.isError) {
|
|
return <ErrorState title='设置项加载失败' description={getErrorMessage(optionsQuery.error)} />;
|
|
}
|
|
|
|
if (activeTab === 'operation') {
|
|
return (
|
|
<div className='space-y-6'>
|
|
<div className='grid gap-6 xl:grid-cols-[1fr_1fr]'>
|
|
<AppCard
|
|
title='接口文档与 Agent 运行参数'
|
|
description='这些参数会通过心跳响应下发到 Agent,修改后下个周期即可生效。'
|
|
action={
|
|
<div className='flex flex-wrap gap-2'>
|
|
<SecondaryButton type='button' onClick={() => window.open('/swagger/index.html', '_blank', 'noopener,noreferrer')}>
|
|
打开接口文档
|
|
</SecondaryButton>
|
|
<PrimaryButton
|
|
type='button'
|
|
onClick={() =>
|
|
void runBusyAction('operation-intervals', async () => {
|
|
const heartbeat = Number.parseInt(operationFields.AgentHeartbeatInterval, 10);
|
|
const sync = Number.parseInt(operationFields.AgentSyncInterval, 10);
|
|
const offline = Number.parseInt(operationFields.NodeOfflineThreshold, 10);
|
|
|
|
if (Number.isNaN(heartbeat) || heartbeat < 5000) {
|
|
throw new Error('心跳间隔不能小于 5000 毫秒。');
|
|
}
|
|
if (Number.isNaN(sync) || sync < 5000) {
|
|
throw new Error('同步间隔不能小于 5000 毫秒。');
|
|
}
|
|
if (Number.isNaN(offline) || offline < 10000) {
|
|
throw new Error('离线阈值不能小于 10000 毫秒。');
|
|
}
|
|
|
|
await saveOptionEntries(
|
|
[
|
|
['AgentHeartbeatInterval', String(heartbeat)],
|
|
['AgentSyncInterval', String(sync)],
|
|
['NodeOfflineThreshold', String(offline)],
|
|
],
|
|
'Agent 运行参数已保存。',
|
|
);
|
|
})
|
|
}
|
|
disabled={busyKey === 'operation-intervals'}
|
|
>
|
|
{busyKey === 'operation-intervals' ? '保存中...' : '保存运行参数'}
|
|
</PrimaryButton>
|
|
</div>
|
|
}
|
|
>
|
|
<div className='grid gap-5 md:grid-cols-3'>
|
|
<ResourceField label={`心跳间隔 (${formatDurationLabel(operationFields.AgentHeartbeatInterval)})`}>
|
|
<ResourceInput
|
|
type='number'
|
|
value={operationFields.AgentHeartbeatInterval}
|
|
onChange={(event) =>
|
|
setOperationFields((previous) => ({ ...previous, AgentHeartbeatInterval: event.target.value }))
|
|
}
|
|
/>
|
|
</ResourceField>
|
|
<ResourceField label={`同步间隔 (${formatDurationLabel(operationFields.AgentSyncInterval)})`}>
|
|
<ResourceInput
|
|
type='number'
|
|
value={operationFields.AgentSyncInterval}
|
|
onChange={(event) =>
|
|
setOperationFields((previous) => ({ ...previous, AgentSyncInterval: event.target.value }))
|
|
}
|
|
/>
|
|
</ResourceField>
|
|
<ResourceField label={`离线阈值 (${formatDurationLabel(operationFields.NodeOfflineThreshold)})`}>
|
|
<ResourceInput
|
|
type='number'
|
|
value={operationFields.NodeOfflineThreshold}
|
|
onChange={(event) =>
|
|
setOperationFields((previous) => ({ ...previous, NodeOfflineThreshold: event.target.value }))
|
|
}
|
|
/>
|
|
</ResourceField>
|
|
</div>
|
|
</AppCard>
|
|
|
|
<AppCard
|
|
title='Agent 更新仓库'
|
|
description='自动更新和手动更新动作在节点页触发,这里只维护 Agent 自更新时使用的仓库地址。'
|
|
action={
|
|
<PrimaryButton
|
|
type='button'
|
|
onClick={() =>
|
|
void runBusyAction('operation-repo', async () => {
|
|
await saveOptionEntries(
|
|
[['AgentUpdateRepo', operationFields.AgentUpdateRepo.trim()]],
|
|
'Agent 更新仓库已保存。',
|
|
);
|
|
})
|
|
}
|
|
disabled={busyKey === 'operation-repo'}
|
|
>
|
|
{busyKey === 'operation-repo' ? '保存中...' : '保存更新仓库'}
|
|
</PrimaryButton>
|
|
}
|
|
>
|
|
<ResourceField label='GitHub 仓库'>
|
|
<ResourceInput
|
|
value={operationFields.AgentUpdateRepo}
|
|
onChange={(event) =>
|
|
setOperationFields((previous) => ({ ...previous, AgentUpdateRepo: event.target.value }))
|
|
}
|
|
placeholder='Rain-kl/ATSFlare'
|
|
/>
|
|
</ResourceField>
|
|
</AppCard>
|
|
</div>
|
|
|
|
<div className='grid gap-6 xl:grid-cols-[1fr_1fr]'>
|
|
<AppCard
|
|
title='Discovery Token 与部署命令'
|
|
description='适用于新节点首次接入。可直接复制一键安装命令。'
|
|
action={
|
|
<div className='flex flex-wrap gap-2'>
|
|
<SecondaryButton
|
|
type='button'
|
|
onClick={() => rotateTokenMutation.mutate()}
|
|
disabled={rotateTokenMutation.isPending}
|
|
>
|
|
{rotateTokenMutation.isPending ? '生成中...' : '重新生成 Token'}
|
|
</SecondaryButton>
|
|
{discoveryCommand ? (
|
|
<PrimaryButton type='button' onClick={() => void copyToClipboard(discoveryCommand)}>
|
|
复制命令
|
|
</PrimaryButton>
|
|
) : null}
|
|
</div>
|
|
}
|
|
>
|
|
{bootstrapQuery.isLoading ? (
|
|
<LoadingState />
|
|
) : bootstrapQuery.isError ? (
|
|
<ErrorState title='Discovery Token 加载失败' description={getErrorMessage(bootstrapQuery.error)} />
|
|
) : (
|
|
<div className='space-y-4'>
|
|
<ResourceField label='Server URL' hint='默认使用当前 ServerAddress,可按需改为外部访问地址。'>
|
|
<ResourceInput
|
|
value={operationFields.ServerAddress}
|
|
onChange={(event) =>
|
|
setOperationFields((previous) => ({ ...previous, ServerAddress: event.target.value }))
|
|
}
|
|
/>
|
|
</ResourceField>
|
|
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
|
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>Discovery Token</p>
|
|
<p className='mt-2 break-all text-sm text-[var(--foreground-primary)]'>
|
|
{discoveryToken || '未生成'}
|
|
</p>
|
|
</div>
|
|
{discoveryCommand ? <CodeBlock className='whitespace-pre-wrap'>{discoveryCommand}</CodeBlock> : null}
|
|
</div>
|
|
)}
|
|
</AppCard>
|
|
|
|
<AppCard title='版本与构建信息' description='用于确认当前前端与后端版本,以及静态导出构建模式。'>
|
|
<div className='grid gap-4 md:grid-cols-2'>
|
|
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
|
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>前端版本</p>
|
|
<p className='mt-2 text-sm text-[var(--foreground-primary)]'>{publicEnv.appVersion}</p>
|
|
</div>
|
|
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
|
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>服务端版本</p>
|
|
<p className='mt-2 text-sm text-[var(--foreground-primary)]'>{publicStatus.version}</p>
|
|
</div>
|
|
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
|
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>Server 启动时间</p>
|
|
<p className='mt-2 text-sm text-[var(--foreground-primary)]'>
|
|
{formatDateTime(new Date(publicStatus.start_time * 1000))}
|
|
</p>
|
|
</div>
|
|
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
|
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>运行模式</p>
|
|
<p className='mt-2 text-sm text-[var(--foreground-primary)]'>静态导出 + Go Server 托管</p>
|
|
</div>
|
|
</div>
|
|
</AppCard>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (activeTab === 'system') {
|
|
return (
|
|
<div className='space-y-6'>
|
|
<AppCard title='登录与注册开关' description='切换后立即生效,无需重启服务。'>
|
|
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
|
|
<ToggleField
|
|
label='允许密码登录'
|
|
description='关闭后将无法使用用户名密码登录。'
|
|
checked={systemFields.PasswordLoginEnabled}
|
|
onChange={(checked) => handleToggleOption('PasswordLoginEnabled', checked)}
|
|
disabled={busyKey === 'toggle-PasswordLoginEnabled'}
|
|
/>
|
|
<ToggleField
|
|
label='允许密码注册'
|
|
description='关闭后新用户不能通过密码方式注册。'
|
|
checked={systemFields.PasswordRegisterEnabled}
|
|
onChange={(checked) => handleToggleOption('PasswordRegisterEnabled', checked)}
|
|
disabled={busyKey === 'toggle-PasswordRegisterEnabled'}
|
|
/>
|
|
<ToggleField
|
|
label='注册需要邮箱验证'
|
|
description='开启后,新用户注册必须先完成邮箱验证码校验。'
|
|
checked={systemFields.EmailVerificationEnabled}
|
|
onChange={(checked) => handleToggleOption('EmailVerificationEnabled', checked)}
|
|
disabled={busyKey === 'toggle-EmailVerificationEnabled'}
|
|
/>
|
|
<ToggleField
|
|
label='启用 GitHub OAuth'
|
|
description='允许用户通过 GitHub 登录与注册。'
|
|
checked={systemFields.GitHubOAuthEnabled}
|
|
onChange={(checked) => handleToggleOption('GitHubOAuthEnabled', checked)}
|
|
disabled={busyKey === 'toggle-GitHubOAuthEnabled'}
|
|
/>
|
|
<ToggleField
|
|
label='启用微信登录'
|
|
description='允许用户通过微信入口登录与注册。'
|
|
checked={systemFields.WeChatAuthEnabled}
|
|
onChange={(checked) => handleToggleOption('WeChatAuthEnabled', checked)}
|
|
disabled={busyKey === 'toggle-WeChatAuthEnabled'}
|
|
/>
|
|
<ToggleField
|
|
label='启用 Turnstile'
|
|
description='开启后注册、邮箱验证码等流程需要先通过人机验证。'
|
|
checked={systemFields.TurnstileCheckEnabled}
|
|
onChange={(checked) => handleToggleOption('TurnstileCheckEnabled', checked)}
|
|
disabled={busyKey === 'toggle-TurnstileCheckEnabled'}
|
|
/>
|
|
<ToggleField
|
|
label='允许新用户注册'
|
|
description='关闭后将禁止所有新用户注册入口。'
|
|
checked={systemFields.RegisterEnabled}
|
|
onChange={(checked) => handleToggleOption('RegisterEnabled', checked)}
|
|
disabled={busyKey === 'toggle-RegisterEnabled'}
|
|
/>
|
|
</div>
|
|
</AppCard>
|
|
|
|
<div className='grid gap-6 xl:grid-cols-[1fr_1fr]'>
|
|
<AppCard
|
|
title='通用与 SMTP 设置'
|
|
description='服务器地址会影响邮件链接、OAuth 回调和部署命令展示。'
|
|
action={
|
|
<PrimaryButton
|
|
type='button'
|
|
onClick={() =>
|
|
void runBusyAction('system-core', async () => {
|
|
await saveOptionEntries(
|
|
[
|
|
['ServerAddress', normalizeServerUrl(systemFields.ServerAddress)],
|
|
['SMTPServer', systemFields.SMTPServer.trim()],
|
|
['SMTPPort', systemFields.SMTPPort.trim()],
|
|
['SMTPAccount', systemFields.SMTPAccount.trim()],
|
|
['SMTPToken', systemFields.SMTPToken.trim()],
|
|
],
|
|
'通用与 SMTP 设置已保存。',
|
|
);
|
|
})
|
|
}
|
|
disabled={busyKey === 'system-core'}
|
|
>
|
|
{busyKey === 'system-core' ? '保存中...' : '保存通用设置'}
|
|
</PrimaryButton>
|
|
}
|
|
>
|
|
<div className='space-y-5'>
|
|
<ResourceField label='服务器地址'>
|
|
<ResourceInput
|
|
value={systemFields.ServerAddress}
|
|
onChange={(event) =>
|
|
setSystemFields((previous) => ({ ...previous, ServerAddress: event.target.value }))
|
|
}
|
|
placeholder='https://yourdomain.com'
|
|
/>
|
|
</ResourceField>
|
|
<div className='grid gap-5 md:grid-cols-2'>
|
|
<ResourceField label='SMTP 服务器'>
|
|
<ResourceInput
|
|
value={systemFields.SMTPServer}
|
|
onChange={(event) =>
|
|
setSystemFields((previous) => ({ ...previous, SMTPServer: event.target.value }))
|
|
}
|
|
placeholder='smtp.qq.com'
|
|
/>
|
|
</ResourceField>
|
|
<ResourceField label='SMTP 端口'>
|
|
<ResourceInput
|
|
value={systemFields.SMTPPort}
|
|
onChange={(event) =>
|
|
setSystemFields((previous) => ({ ...previous, SMTPPort: event.target.value }))
|
|
}
|
|
placeholder='587'
|
|
/>
|
|
</ResourceField>
|
|
<ResourceField label='SMTP 账户'>
|
|
<ResourceInput
|
|
value={systemFields.SMTPAccount}
|
|
onChange={(event) =>
|
|
setSystemFields((previous) => ({ ...previous, SMTPAccount: event.target.value }))
|
|
}
|
|
placeholder='name@example.com'
|
|
/>
|
|
</ResourceField>
|
|
<ResourceField label='SMTP 凭证' hint='因安全原因不会回显历史密钥,留空表示不更新。'>
|
|
<ResourceInput
|
|
type='password'
|
|
value={systemFields.SMTPToken}
|
|
onChange={(event) =>
|
|
setSystemFields((previous) => ({ ...previous, SMTPToken: event.target.value }))
|
|
}
|
|
placeholder='请输入新的 SMTP 凭证'
|
|
/>
|
|
</ResourceField>
|
|
</div>
|
|
</div>
|
|
</AppCard>
|
|
|
|
<AppCard
|
|
title='OAuth / WeChat / Turnstile'
|
|
description='敏感密钥不会从后端回显,留空即保持原值。'
|
|
action={
|
|
<PrimaryButton
|
|
type='button'
|
|
onClick={() =>
|
|
void runBusyAction('system-integrations', async () => {
|
|
await saveOptionEntries(
|
|
[
|
|
['GitHubClientId', systemFields.GitHubClientId.trim()],
|
|
['GitHubClientSecret', systemFields.GitHubClientSecret.trim()],
|
|
['WeChatServerAddress', normalizeServerUrl(systemFields.WeChatServerAddress)],
|
|
['WeChatServerToken', systemFields.WeChatServerToken.trim()],
|
|
['WeChatAccountQRCodeImageURL', systemFields.WeChatAccountQRCodeImageURL.trim()],
|
|
['TurnstileSiteKey', systemFields.TurnstileSiteKey.trim()],
|
|
['TurnstileSecretKey', systemFields.TurnstileSecretKey.trim()],
|
|
],
|
|
'第三方集成设置已保存。',
|
|
);
|
|
})
|
|
}
|
|
disabled={busyKey === 'system-integrations'}
|
|
>
|
|
{busyKey === 'system-integrations' ? '保存中...' : '保存集成设置'}
|
|
</PrimaryButton>
|
|
}
|
|
>
|
|
<div className='space-y-5'>
|
|
<div className='grid gap-5 md:grid-cols-2'>
|
|
<ResourceField label='GitHub Client ID'>
|
|
<ResourceInput
|
|
value={systemFields.GitHubClientId}
|
|
onChange={(event) =>
|
|
setSystemFields((previous) => ({ ...previous, GitHubClientId: event.target.value }))
|
|
}
|
|
/>
|
|
</ResourceField>
|
|
<ResourceField label='GitHub Client Secret'>
|
|
<ResourceInput
|
|
type='password'
|
|
value={systemFields.GitHubClientSecret}
|
|
onChange={(event) =>
|
|
setSystemFields((previous) => ({ ...previous, GitHubClientSecret: event.target.value }))
|
|
}
|
|
/>
|
|
</ResourceField>
|
|
<ResourceField label='WeChat Server 地址'>
|
|
<ResourceInput
|
|
value={systemFields.WeChatServerAddress}
|
|
onChange={(event) =>
|
|
setSystemFields((previous) => ({ ...previous, WeChatServerAddress: event.target.value }))
|
|
}
|
|
/>
|
|
</ResourceField>
|
|
<ResourceField label='WeChat Server Token'>
|
|
<ResourceInput
|
|
type='password'
|
|
value={systemFields.WeChatServerToken}
|
|
onChange={(event) =>
|
|
setSystemFields((previous) => ({ ...previous, WeChatServerToken: event.target.value }))
|
|
}
|
|
/>
|
|
</ResourceField>
|
|
<ResourceField label='公众号二维码链接'>
|
|
<ResourceInput
|
|
value={systemFields.WeChatAccountQRCodeImageURL}
|
|
onChange={(event) =>
|
|
setSystemFields((previous) => ({ ...previous, WeChatAccountQRCodeImageURL: event.target.value }))
|
|
}
|
|
/>
|
|
</ResourceField>
|
|
<ResourceField label='Turnstile Site Key'>
|
|
<ResourceInput
|
|
value={systemFields.TurnstileSiteKey}
|
|
onChange={(event) =>
|
|
setSystemFields((previous) => ({ ...previous, TurnstileSiteKey: event.target.value }))
|
|
}
|
|
/>
|
|
</ResourceField>
|
|
<ResourceField label='Turnstile Secret Key'>
|
|
<ResourceInput
|
|
type='password'
|
|
value={systemFields.TurnstileSecretKey}
|
|
onChange={(event) =>
|
|
setSystemFields((previous) => ({ ...previous, TurnstileSecretKey: event.target.value }))
|
|
}
|
|
/>
|
|
</ResourceField>
|
|
</div>
|
|
</div>
|
|
</AppCard>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className='space-y-6'>
|
|
<div className='grid gap-6 xl:grid-cols-[1fr_1fr]'>
|
|
<AppCard
|
|
title='公告与品牌信息'
|
|
description='用于控制首页公告、系统名称、默认首页链接和页脚展示。'
|
|
action={
|
|
<PrimaryButton
|
|
type='button'
|
|
onClick={() =>
|
|
void runBusyAction('other-brand', async () => {
|
|
await saveOptionEntries(
|
|
[
|
|
['Notice', otherFields.Notice],
|
|
['SystemName', otherFields.SystemName.trim()],
|
|
['HomePageLink', otherFields.HomePageLink.trim()],
|
|
['Footer', otherFields.Footer],
|
|
],
|
|
'公告与品牌设置已保存。',
|
|
);
|
|
})
|
|
}
|
|
disabled={busyKey === 'other-brand'}
|
|
>
|
|
{busyKey === 'other-brand' ? '保存中...' : '保存基础信息'}
|
|
</PrimaryButton>
|
|
}
|
|
>
|
|
<div className='space-y-5'>
|
|
<ResourceField label='系统名称'>
|
|
<ResourceInput
|
|
value={otherFields.SystemName}
|
|
onChange={(event) =>
|
|
setOtherFields((previous) => ({ ...previous, SystemName: event.target.value }))
|
|
}
|
|
placeholder='ATSFlare'
|
|
/>
|
|
</ResourceField>
|
|
<ResourceField label='首页链接'>
|
|
<ResourceInput
|
|
value={otherFields.HomePageLink}
|
|
onChange={(event) =>
|
|
setOtherFields((previous) => ({ ...previous, HomePageLink: event.target.value }))
|
|
}
|
|
placeholder='https://example.com'
|
|
/>
|
|
</ResourceField>
|
|
<ResourceField label='公告'>
|
|
<ResourceTextarea
|
|
value={otherFields.Notice}
|
|
onChange={(event) =>
|
|
setOtherFields((previous) => ({ ...previous, Notice: event.target.value }))
|
|
}
|
|
placeholder='可在此编写首页公告内容'
|
|
/>
|
|
</ResourceField>
|
|
<ResourceField label='页脚 HTML'>
|
|
<ResourceTextarea
|
|
value={otherFields.Footer}
|
|
onChange={(event) =>
|
|
setOtherFields((previous) => ({ ...previous, Footer: event.target.value }))
|
|
}
|
|
placeholder='留空则使用默认页脚'
|
|
/>
|
|
</ResourceField>
|
|
</div>
|
|
</AppCard>
|
|
|
|
<AppCard
|
|
title='关于页内容与版本检查'
|
|
description='支持 Markdown / HTML 内容编辑,并可直接检查 GitHub 最新发行版。'
|
|
action={
|
|
<div className='flex flex-wrap gap-2'>
|
|
<SecondaryButton type='button' onClick={handleCheckLatestRelease} disabled={busyKey === 'latest-release'}>
|
|
{busyKey === 'latest-release' ? '检查中...' : '检查更新'}
|
|
</SecondaryButton>
|
|
<PrimaryButton
|
|
type='button'
|
|
onClick={() =>
|
|
void runBusyAction('other-about', async () => {
|
|
await saveOptionEntries([['About', otherFields.About]], '关于页内容已保存。');
|
|
})
|
|
}
|
|
disabled={busyKey === 'other-about'}
|
|
>
|
|
{busyKey === 'other-about' ? '保存中...' : '保存关于内容'}
|
|
</PrimaryButton>
|
|
</div>
|
|
}
|
|
>
|
|
<div className='space-y-5'>
|
|
<ResourceField label='关于内容' hint='支持 Markdown 和 HTML,保存后会同步到公开关于页。'>
|
|
<ResourceTextarea
|
|
value={otherFields.About}
|
|
onChange={(event) =>
|
|
setOtherFields((previous) => ({ ...previous, About: event.target.value }))
|
|
}
|
|
placeholder='在这里编写关于 ATSFlare 的介绍内容'
|
|
className='min-h-48'
|
|
/>
|
|
</ResourceField>
|
|
|
|
{latestRelease ? (
|
|
<div className='space-y-4 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
|
<div className='flex flex-wrap items-center gap-3'>
|
|
<p className='text-base font-semibold text-[var(--foreground-primary)]'>最新版本:{latestRelease.tag_name}</p>
|
|
<StatusBadge label='GitHub Release' variant='info' />
|
|
</div>
|
|
<p className='text-sm text-[var(--foreground-secondary)]'>
|
|
发布时间:{formatRelativeTime(latestRelease.published_at)} · {formatDateTime(latestRelease.published_at)}
|
|
</p>
|
|
<div
|
|
className='prose prose-sm max-w-none text-[var(--foreground-primary)] [&_a]:text-[var(--brand-primary)]'
|
|
dangerouslySetInnerHTML={{ __html: marked.parse(latestRelease.body || '暂无更新说明') as string }}
|
|
/>
|
|
<a
|
|
href={latestRelease.html_url}
|
|
target='_blank'
|
|
rel='noreferrer'
|
|
className='text-sm font-medium text-[var(--brand-primary)] transition hover:opacity-80'
|
|
>
|
|
查看发布详情
|
|
</a>
|
|
</div>
|
|
) : (
|
|
<EmptyState title='尚未检查更新' description='点击“检查更新”后会在这里展示最新 GitHub Release 信息。' />
|
|
)}
|
|
</div>
|
|
</AppCard>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className='space-y-6'>
|
|
<PageHeader
|
|
title='设置'
|
|
description='阶段 4 已迁移个人设置、系统设置与运维设置入口,并补齐部署命令复制和边缘配置展示。'
|
|
/>
|
|
|
|
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null}
|
|
|
|
<div className='flex flex-wrap gap-3'>
|
|
{tabs.map((tab) => (
|
|
<button
|
|
key={tab.key}
|
|
type='button'
|
|
onClick={() => setActiveTab(tab.key)}
|
|
className={[
|
|
'rounded-2xl border px-4 py-3 text-left transition',
|
|
activeTab === tab.key
|
|
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
|
|
: 'border-[var(--border-default)] bg-[var(--surface-muted)] text-[var(--foreground-secondary)] hover:border-[var(--border-strong)] hover:text-[var(--foreground-primary)]',
|
|
].join(' ')}
|
|
>
|
|
<p className='text-sm font-semibold'>{tab.label}</p>
|
|
<p className='mt-1 text-xs leading-5 text-inherit/80'>{tab.description}</p>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{renderTabContent()}
|
|
</div>
|
|
);
|
|
}
|