[重构] 前端改造v5

This commit is contained in:
ryan
2026-03-11 17:01:24 +08:00
parent 5d6898b633
commit e1a9cc738e
62 changed files with 212 additions and 5282 deletions
+31
View File
@@ -5,6 +5,7 @@ import (
"atsflare/controller"
"atsflare/middleware"
"embed"
"fmt"
"github.com/gin-contrib/static"
"github.com/gin-gonic/gin"
"io/fs"
@@ -22,6 +23,7 @@ func setWebRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
router.Use(middleware.GlobalWebRateLimit())
fileDownloadRoute := router.Group("/")
fileDownloadRoute.GET("/upload/:file", middleware.DownloadRateLimit(), controller.DownloadFile)
registerLegacyWebRedirects(router)
router.Use(middleware.Cache())
router.Use(static.Serve("/", common.EmbedFolder(buildFS, "web/build")))
router.NoRoute(func(c *gin.Context) {
@@ -38,6 +40,35 @@ func setWebRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
})
}
func registerLegacyWebRedirects(router *gin.Engine) {
legacyRoutes := map[string]string{
"/proxy-route": "/proxy-routes",
"/config-version": "/config-versions",
"/node": "/nodes",
"/apply-log": "/apply-logs",
"/managed-domain": "/managed-domains",
"/tls-certificate": "/tls-certificates",
"/file": "/files",
"/user": "/users",
"/user/add": "/users?mode=create",
"/user/edit": "/users",
"/setting": "/settings",
}
for source, target := range legacyRoutes {
router.GET(source, func(target string) gin.HandlerFunc {
return func(c *gin.Context) {
c.Redirect(http.StatusFound, target)
}
}(target))
}
router.GET("/user/edit/:id", func(c *gin.Context) {
target := fmt.Sprintf("/users?edit=%s", c.Param("id"))
c.Redirect(http.StatusFound, target)
})
}
func serveExportedPage(c *gin.Context, buildFS fs.FS) bool {
requestPath := strings.Trim(c.Request.URL.Path, "/")
+11 -11
View File
@@ -1,14 +1,14 @@
import { DashboardOverview } from '@/features/dashboard/components/dashboard-overview';
import { PageHeader } from '@/components/layout/page-header';
export default function DashboardPage() {
return (
<div className='space-y-6'>
<PageHeader
title='ATSFlare 管理端迁移进度'
description='新版前端已完成认证、核心链路与阶段 4 边缘模块迁移,当前可继续推进联调与回归。'
/>
<DashboardOverview />
</div>
);
}
export default function DashboardPage() {
return (
<div className='space-y-6'>
<PageHeader
title='ATSFlare 管理端'
description='新版前端已统一承接全部页面入口和迁移期兼容路由,当前可继续推进联调与回归。'
/>
<DashboardOverview />
</div>
);
}
@@ -0,0 +1,5 @@
import { LegacyRouteRedirect } from '@/features/shared/components/legacy-route-redirect';
export default function LegacyConfigVersionPage() {
return <LegacyRouteRedirect href='/config-versions' />;
}
+5
View File
@@ -0,0 +1,5 @@
import { LegacyRouteRedirect } from '@/features/shared/components/legacy-route-redirect';
export default function LegacyFilePage() {
return <LegacyRouteRedirect href='/files' />;
}
+5
View File
@@ -0,0 +1,5 @@
import { LegacyRouteRedirect } from '@/features/shared/components/legacy-route-redirect';
export default function LegacyNodePage() {
return <LegacyRouteRedirect href='/nodes' />;
}
+5
View File
@@ -0,0 +1,5 @@
import { LegacyRouteRedirect } from '@/features/shared/components/legacy-route-redirect';
export default function LegacyProxyRoutePage() {
return <LegacyRouteRedirect href='/proxy-routes' />;
}
+5
View File
@@ -0,0 +1,5 @@
import { LegacyRouteRedirect } from '@/features/shared/components/legacy-route-redirect';
export default function LegacySettingPage() {
return <LegacyRouteRedirect href='/settings' />;
}
+5
View File
@@ -0,0 +1,5 @@
import { LegacyRouteRedirect } from '@/features/shared/components/legacy-route-redirect';
export default function LegacyUserAddPage() {
return <LegacyRouteRedirect href='/users?mode=create' />;
}
+5
View File
@@ -0,0 +1,5 @@
import { LegacyRouteRedirect } from '@/features/shared/components/legacy-route-redirect';
export default function LegacyUserEditPage() {
return <LegacyRouteRedirect href='/users' />;
}
+5
View File
@@ -0,0 +1,5 @@
import { LegacyRouteRedirect } from '@/features/shared/components/legacy-route-redirect';
export default function LegacyUserPage() {
return <LegacyRouteRedirect href='/users' />;
}
@@ -64,11 +64,11 @@ export function DashboardSidebar() {
})}
</nav>
{!isSidebarCollapsed ? (
<div className='rounded-2xl border border-[var(--status-success-border)] bg-[var(--status-success-soft)] px-4 py-4 text-xs leading-6 text-[var(--status-success-foreground)]'>
阶段 4 已接入设置、用户、文件等边缘模块,旧前端仅保留少量兼容兜底价值。
</div>
) : null}
{!isSidebarCollapsed ? (
<div className='rounded-2xl border border-[var(--status-success-border)] bg-[var(--status-success-soft)] px-4 py-4 text-xs leading-6 text-[var(--status-success-foreground)]'>
新版管理端已覆盖认证、核心链路和边缘模块,旧版前端代码已完成移除。
</div>
) : null}
</div>
</aside>
);
@@ -19,13 +19,13 @@ const readinessItems = [
},
];
export function DashboardOverview() {
return (
<div className='space-y-6'>
<AppCard
title='阶段 4 进行中'
description='当前已完成新版管理端基础工程、核心模块和大部分边缘模块迁移,可继续推进联调与回归。'
action={<StatusBadge label='准备联调回归' variant='success' />}
export function DashboardOverview() {
return (
<div className='space-y-6'>
<AppCard
title='前端迁移完成'
description='新版管理端已承接全部页面入口、旧路由兼容和核心交互,当前可继续推进联调与测试补齐。'
action={<StatusBadge label='统一 Next.js 前端' variant='success' />}
>
<div className='grid gap-4 lg:grid-cols-3'>
{readinessItems.map((item) => (
@@ -60,14 +60,14 @@ export function DashboardOverview() {
</div>
</AppCard>
<AppCard title='下一步建议' description='按前端改造计划,当前可以开始阶段 5 的联调、回归与默认入口切换准备。'>
<ol className='space-y-3 text-sm leading-6 text-[var(--foreground-secondary)]'>
<li>1. 对照旧前端完成设置、用户、文件与关于页的联调回归。</li>
<li>2. 补齐关键页面的测试覆盖与构建验收。</li>
<li>3. 评估默认入口切换与旧前端回滚预案。</li>
</ol>
</AppCard>
</div>
</div>
);
}
<AppCard title='下一步建议' description='当前重点转向联调回归、测试覆盖和发布验收。'>
<ol className='space-y-3 text-sm leading-6 text-[var(--foreground-secondary)]'>
<li>1. 对照后端接口完成设置、节点、证书和用户链路联调。</li>
<li>2. 补齐关键页面的测试覆盖与构建验收。</li>
<li>3. 清理迁移期文档和发布说明中的旧前端表述。</li>
</ol>
</AppCard>
</div>
</div>
);
}
@@ -4,11 +4,12 @@ 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 { useAuth } from '@/components/providers/auth-provider';
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';
@@ -156,10 +157,11 @@ export function SettingsPage() {
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 [latestRelease, setLatestRelease] = useState<LatestReleaseInfo | null>(null);
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;
@@ -355,17 +357,22 @@ export function SettingsPage() {
});
};
const handleEmailVerification = () => {
if (!emailAddress.trim()) {
setFeedback({ tone: 'danger', message: '请输入要绑定的邮箱地址。' });
return;
}
void runBusyAction('email-send', async () => {
await sendEmailVerification(emailAddress.trim());
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()) {
@@ -591,38 +598,46 @@ export function SettingsPage() {
当前状态:{profile.email ? `已绑定 ${profile.email}` : '未绑定'}
</p>
</div>
{publicStatus.turnstile_check ? (
<EmptyState
title='当前已启用 Turnstile'
description='邮箱绑定在旧版前端依赖人机校验组件。若需要绑定邮箱,请暂时使用旧前端作为兼容兜底。'
/>
) : (
<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>
<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 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>
@@ -1,10 +1,11 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useSearchParams } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
@@ -92,10 +93,11 @@ function toPayload(values: UserFormValues): UserMutationPayload {
};
}
export function UsersPage() {
const queryClient = useQueryClient();
const { user: currentUser } = useAuth();
const [page, setPage] = useState(0);
export function UsersPage() {
const queryClient = useQueryClient();
const searchParams = useSearchParams();
const { user: currentUser } = useAuth();
const [page, setPage] = useState(0);
const [searchInput, setSearchInput] = useState('');
const [searchKeyword, setSearchKeyword] = useState('');
const [editingUserId, setEditingUserId] = useState<number | null>(null);
@@ -127,15 +129,35 @@ export function UsersPage() {
enabled: isAdmin && editingUserId !== null,
});
useEffect(() => {
if (editingUserQuery.data) {
form.reset({
useEffect(() => {
if (editingUserQuery.data) {
form.reset({
username: editingUserQuery.data.username,
display_name: editingUserQuery.data.display_name || '',
password: '',
});
}
}, [editingUserQuery.data, form]);
}
}, [editingUserQuery.data, form]);
useEffect(() => {
const editParam = searchParams?.get('edit');
const modeParam = searchParams?.get('mode');
if (editParam) {
const parsedUserId = Number.parseInt(editParam, 10);
if (!Number.isNaN(parsedUserId) && parsedUserId > 0) {
setFeedback(null);
setEditingUserId(parsedUserId);
}
return;
}
if (modeParam === 'create') {
setFeedback(null);
setEditingUserId(null);
form.reset(defaultValues);
}
}, [form, searchParams]);
const saveMutation = useMutation({
mutationFn: async (values: UserFormValues) => {
-1
View File
@@ -1,6 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
-240
View File
@@ -1,240 +0,0 @@
import React, { lazy, Suspense, useContext, useEffect } from 'react';
import { Route, Routes } from 'react-router-dom';
import Loading from './components/Loading';
import User from './pages/User';
import { PrivateRoute } from './components/PrivateRoute';
import RegisterForm from './components/RegisterForm';
import LoginForm from './components/LoginForm';
import NotFound from './pages/NotFound';
import Setting from './pages/Setting';
import EditUser from './pages/User/EditUser';
import AddUser from './pages/User/AddUser';
import {
API,
getBuildVersion,
hasBuildVersion,
normalizeVersion,
showError,
showNotice,
} from './helpers';
import PasswordResetForm from './components/PasswordResetForm';
import GitHubOAuth from './components/GitHubOAuth';
import PasswordResetConfirm from './components/PasswordResetConfirm';
import { UserContext } from './context/User';
import { StatusContext } from './context/Status';
import File from './pages/File';
import ProxyRoute from './pages/ProxyRoute';
import ConfigVersion from './pages/ConfigVersion';
import Node from './pages/Node';
import ApplyLog from './pages/ApplyLog';
import TLSCertificate from './pages/TLSCertificate';
import ManagedDomain from './pages/ManagedDomain';
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
function App() {
const [userState, userDispatch] = useContext(UserContext);
const [statusState, statusDispatch] = useContext(StatusContext);
const loadUser = () => {
let user = localStorage.getItem('user');
if (user) {
let data = JSON.parse(user);
userDispatch({ type: 'login', payload: data });
}
};
const loadStatus = async () => {
const res = await API.get('/api/status');
const { success, data } = res.data;
if (success) {
console.log(`GitHub 仓库地址:https://github.com/Rain-kl/ATSFlare`);
localStorage.setItem('status', JSON.stringify(data));
statusDispatch({ type: 'set', payload: data });
localStorage.setItem('system_name', data.system_name);
localStorage.setItem('footer_html', data.footer_html);
localStorage.setItem('home_page_link', data.home_page_link);
const currentBuildVersion = getBuildVersion();
const latestServerVersion = normalizeVersion(data.version);
if (
hasBuildVersion() &&
latestServerVersion !== '' &&
latestServerVersion !== '0.0.0' &&
latestServerVersion !== currentBuildVersion
) {
showNotice(
`新版本可用:${data.version},请使用快捷键 Shift + F5 刷新页面`
);
}
} else {
showError('无法正常连接至服务器!');
}
};
useEffect(() => {
loadUser();
loadStatus().then();
}, []);
return (
<Routes>
<Route
path='/'
element={
<Suspense fallback={<Loading></Loading>}>
<Home />
</Suspense>
}
/>
<Route
path='/proxy-route'
element={
<PrivateRoute>
<ProxyRoute />
</PrivateRoute>
}
/>
<Route
path='/config-version'
element={
<PrivateRoute>
<ConfigVersion />
</PrivateRoute>
}
/>
<Route
path='/node'
element={
<PrivateRoute>
<Node />
</PrivateRoute>
}
/>
<Route
path='/apply-log'
element={
<PrivateRoute>
<ApplyLog />
</PrivateRoute>
}
/>
<Route
path='/managed-domain'
element={
<PrivateRoute>
<ManagedDomain />
</PrivateRoute>
}
/>
<Route
path='/tls-certificate'
element={
<PrivateRoute>
<TLSCertificate />
</PrivateRoute>
}
/>
<Route
path='/file'
element={
<PrivateRoute>
<File />
</PrivateRoute>
}
/>
<Route
path='/user'
element={
<PrivateRoute>
<User />
</PrivateRoute>
}
/>
<Route
path='/user/edit/:id'
element={
<Suspense fallback={<Loading></Loading>}>
<EditUser />
</Suspense>
}
/>
<Route
path='/user/edit'
element={
<Suspense fallback={<Loading></Loading>}>
<EditUser />
</Suspense>
}
/>
<Route
path='/user/add'
element={
<Suspense fallback={<Loading></Loading>}>
<AddUser />
</Suspense>
}
/>
<Route
path='/user/reset'
element={
<Suspense fallback={<Loading></Loading>}>
<PasswordResetConfirm />
</Suspense>
}
/>
<Route
path='/login'
element={
<Suspense fallback={<Loading></Loading>}>
<LoginForm />
</Suspense>
}
/>
<Route
path='/register'
element={
<Suspense fallback={<Loading></Loading>}>
<RegisterForm />
</Suspense>
}
/>
<Route
path='/reset'
element={
<Suspense fallback={<Loading></Loading>}>
<PasswordResetForm />
</Suspense>
}
/>
<Route
path='/oauth/github'
element={
<Suspense fallback={<Loading></Loading>}>
<GitHubOAuth />
</Suspense>
}
/>
<Route
path='/setting'
element={
<PrivateRoute>
<Suspense fallback={<Loading></Loading>}>
<Setting />
</Suspense>
</PrivateRoute>
}
/>
<Route
path='/about'
element={
<Suspense fallback={<Loading></Loading>}>
<About />
</Suspense>
}
/>
<Route path='*' element={<NotFound />} />
</Routes>
);
}
export default App;
@@ -1,303 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
Button,
Form,
Header,
Icon,
Pagination,
Popup,
Progress,
Segment,
Table,
} from 'semantic-ui-react';
import { API, copy, showError, showSuccess } from '../helpers';
import { useDropzone } from 'react-dropzone';
import { ITEMS_PER_PAGE } from '../constants';
const FilesTable = () => {
const [files, setFiles] = useState([]);
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
const [searchKeyword, setSearchKeyword] = useState('');
const [searching, setSearching] = useState(false);
const { acceptedFiles, getRootProps, getInputProps } = useDropzone();
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState('0');
const loadFiles = async (startIdx) => {
const res = await API.get(`/api/file/?p=${startIdx}`);
const { success, message, data } = res.data;
if (success) {
if (startIdx === 0) {
setFiles(data);
} else {
let newFiles = files;
newFiles.push(...data);
setFiles(newFiles);
}
} else {
showError(message);
}
setLoading(false);
};
const onPaginationChange = (e, { activePage }) => {
(async () => {
if (activePage === Math.ceil(files.length / ITEMS_PER_PAGE) + 1) {
// In this case we have to load more data and then append them.
await loadFiles(activePage - 1);
}
setActivePage(activePage);
})();
};
useEffect(() => {
loadFiles(0)
.then()
.catch((reason) => {
showError(reason);
});
}, []);
const downloadFile = (link, filename) => {
let linkElement = document.createElement('a');
linkElement.download = filename;
linkElement.href = '/upload/' + link;
linkElement.click();
};
const copyLink = (link) => {
let url = window.location.origin + '/upload/' + link;
copy(url).then();
showSuccess('链接已复制到剪贴板');
};
const deleteFile = async (id, idx) => {
const res = await API.delete(`/api/file/${id}`);
const { success, message } = res.data;
if (success) {
let newFiles = [...files];
let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
newFiles[realIdx].deleted = true;
// newFiles.splice(idx, 1);
setFiles(newFiles);
showSuccess('文件已删除!');
} else {
showError(message);
}
};
const searchFiles = async () => {
if (searchKeyword === '') {
// if keyword is blank, load files instead.
await loadFiles(0);
setActivePage(1);
return;
}
setSearching(true);
const res = await API.get(`/api/file/search?keyword=${searchKeyword}`);
const { success, message, data } = res.data;
if (success) {
setFiles(data);
setActivePage(1);
} else {
showError(message);
}
setSearching(false);
};
const handleKeywordChange = async (e, { value }) => {
setSearchKeyword(value.trim());
};
const sortFile = (key) => {
if (files.length === 0) return;
setLoading(true);
let sortedUsers = [...files];
sortedUsers.sort((a, b) => {
return ('' + a[key]).localeCompare(b[key]);
});
if (sortedUsers[0].id === files[0].id) {
sortedUsers.reverse();
}
setFiles(sortedUsers);
setLoading(false);
};
const uploadFiles = async () => {
if (acceptedFiles.length === 0) return;
setUploading(true);
let formData = new FormData();
for (let i = 0; i < acceptedFiles.length; i++) {
formData.append('file', acceptedFiles[i]);
}
const res = await API.post(`/api/file`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (e) => {
let uploadProgress = ((e.loaded / e.total) * 100).toFixed(2);
setUploadProgress(uploadProgress);
},
});
const { success, message } = res.data;
if (success) {
showSuccess(`${acceptedFiles.length} 个文件上传成功!`);
} else {
showError(message);
}
setUploading(false);
setUploadProgress('0');
setSearchKeyword('');
loadFiles(0).then();
setActivePage(1);
};
useEffect(() => {
uploadFiles().then();
}, [acceptedFiles]);
return (
<>
<Segment
placeholder
{...getRootProps({ className: 'dropzone' })}
loading={uploading || loading}
style={{ cursor: 'pointer' }}
>
<Header icon>
<Icon name='file outline' />
拖拽上传或点击上传
<input {...getInputProps()} />
</Header>
</Segment>
{uploading ? (
<Progress
percent={uploadProgress}
success
progress='percent'
></Progress>
) : (
<></>
)}
<Form onSubmit={searchFiles}>
<Form.Input
icon='search'
fluid
iconPosition='left'
placeholder='搜索文件的名称,上传者以及描述信息 ...'
value={searchKeyword}
loading={searching}
onChange={handleKeywordChange}
/>
</Form>
<Table basic>
<Table.Header>
<Table.Row>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortFile('filename');
}}
>
文件名
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortFile('uploader_id');
}}
>
上传者
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortFile('email');
}}
>
上传时间
</Table.HeaderCell>
<Table.HeaderCell>操作</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{files
.slice(
(activePage - 1) * ITEMS_PER_PAGE,
activePage * ITEMS_PER_PAGE
)
.map((file, idx) => {
if (file.deleted) return <></>;
return (
<Table.Row key={file.id}>
<Table.Cell>
<a href={'/upload/' + file.link} target='_blank'>
{file.filename}
</a>
</Table.Cell>
<Popup
content={'上传者 ID:' + file.uploader_id}
trigger={<Table.Cell>{file.uploader}</Table.Cell>}
/>
<Table.Cell>{file.upload_time}</Table.Cell>
<Table.Cell>
<div>
<Button
size={'small'}
positive
onClick={() => {
downloadFile(file.link, file.filename);
}}
>
下载
</Button>
<Button
size={'small'}
negative
onClick={() => {
deleteFile(file.id, idx).then();
}}
>
删除
</Button>
<Button
size={'small'}
onClick={() => {
copyLink(file.link);
}}
>
复制链接
</Button>
</div>
</Table.Cell>
</Table.Row>
);
})}
</Table.Body>
<Table.Footer>
<Table.Row>
<Table.HeaderCell colSpan='6'>
<Pagination
floated='right'
activePage={activePage}
onPageChange={onPaginationChange}
size='small'
siblingRange={1}
totalPages={
Math.ceil(files.length / ITEMS_PER_PAGE) +
(files.length % ITEMS_PER_PAGE === 0 ? 1 : 0)
}
/>
</Table.HeaderCell>
</Table.Row>
</Table.Footer>
</Table>
</>
);
};
export default FilesTable;
-38
View File
@@ -1,38 +0,0 @@
import React from 'react';
import { Container, Segment } from 'semantic-ui-react';
import { getFooterHTML, getSystemName } from '../helpers';
const Footer = () => {
const systemName = getSystemName();
const footer = getFooterHTML();
return (
<Segment vertical>
<Container textAlign='center'>
{footer ? (
<div
className='custom-footer'
dangerouslySetInnerHTML={{ __html: footer }}
></div>
) : (
<div className='custom-footer'>
<a
href='https://github.com/Rain-kl/ATSFlare'
target='_blank'
rel='noreferrer'
>
{systemName} {process.env.REACT_APP_VERSION}{' '}
</a>
开源于 GitHub,源代码遵循{' '}
<a href='https://opensource.org/licenses/mit-license.php'>
MIT 协议
</a>
</div>
)}
</Container>
</Segment>
);
};
export default Footer;
@@ -1,57 +0,0 @@
import React, { useContext, useEffect, useState } from 'react';
import { Dimmer, Loader, Segment } from 'semantic-ui-react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { API, showError, showSuccess } from '../helpers';
import { UserContext } from '../context/User';
const GitHubOAuth = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [userState, userDispatch] = useContext(UserContext);
const [prompt, setPrompt] = useState('处理中...');
const [processing, setProcessing] = useState(true);
let navigate = useNavigate();
const sendCode = async (code, count) => {
const res = await API.get(`/api/oauth/github?code=${code}`);
const { success, message, data } = res.data;
if (success) {
if (message === 'bind') {
showSuccess('绑定成功!');
navigate('/setting');
} else {
userDispatch({ type: 'login', payload: data });
localStorage.setItem('user', JSON.stringify(data));
showSuccess('登录成功!');
navigate('/');
}
} else {
showError(message);
if (count === 0) {
setPrompt(`操作失败,重定向至登录界面中...`);
navigate('/setting'); // in case this is failed to bind GitHub
return;
}
count++;
setPrompt(`出现错误,第 ${count} 次重试中...`);
await new Promise((resolve) => setTimeout(resolve, count * 2000));
await sendCode(code, count);
}
};
useEffect(() => {
let code = searchParams.get('code');
sendCode(code, 0).then();
}, []);
return (
<Segment style={{ minHeight: '300px' }}>
<Dimmer active inverted>
<Loader size='large'>{prompt}</Loader>
</Dimmer>
</Segment>
);
};
export default GitHubOAuth;
-230
View File
@@ -1,230 +0,0 @@
import React, { useContext, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { UserContext } from '../context/User';
import {
Button,
Container,
Dropdown,
Icon,
Menu,
Segment,
} from 'semantic-ui-react';
import { API, getSystemName, isAdmin, isMobile, showSuccess } from '../helpers';
import '../index.css';
// Header Buttons
const headerButtons = [
{
name: '首页',
to: '/',
icon: 'home',
},
{
name: '规则',
to: '/proxy-route',
icon: 'sitemap',
admin: true,
},
{
name: '版本',
to: '/config-version',
icon: 'history',
admin: true,
},
{
name: '节点',
to: '/node',
icon: 'server',
admin: true,
},
{
name: '记录',
to: '/apply-log',
icon: 'tasks',
admin: true,
},
{
name: '证书',
to: '/tls-certificate',
icon: 'lock',
admin: true,
},
{
name: '域名',
to: '/managed-domain',
icon: 'globe',
admin: true,
},
{
name: '用户',
to: '/user',
icon: 'user',
admin: true,
},
{
name: '设置',
to: '/setting',
icon: 'setting',
},
{
name: '关于',
to: '/about',
icon: 'info circle',
},
];
const Header = () => {
const [userState, userDispatch] = useContext(UserContext);
let navigate = useNavigate();
const [showSidebar, setShowSidebar] = useState(false);
const systemName = getSystemName();
async function logout() {
setShowSidebar(false);
await API.get('/api/user/logout');
showSuccess('注销成功!');
userDispatch({ type: 'logout' });
localStorage.removeItem('user');
navigate('/login');
}
const toggleSidebar = () => {
setShowSidebar(!showSidebar);
};
const renderButtons = (isMobile) => {
return headerButtons.map((button) => {
if (button.admin && !isAdmin()) return <></>;
if (isMobile) {
return (
<Menu.Item
onClick={() => {
navigate(button.to);
setShowSidebar(false);
}}
>
{button.name}
</Menu.Item>
);
}
return (
<Menu.Item key={button.name} as={Link} to={button.to}>
<Icon name={button.icon} />
{button.name}
</Menu.Item>
);
});
};
if (isMobile()) {
return (
<>
<Menu
borderless
size='large'
style={
showSidebar
? {
borderBottom: 'none',
marginBottom: '0',
borderTop: 'none',
height: '51px',
}
: { borderTop: 'none', height: '52px' }
}
>
<Container>
<Menu.Item as={Link} to='/'>
<img
src='/logo.png'
alt='logo'
style={{ marginRight: '0.75em' }}
/>
<div style={{ fontSize: '20px' }}>
<b>{systemName}</b>
</div>
</Menu.Item>
<Menu.Menu position='right'>
<Menu.Item onClick={toggleSidebar}>
<Icon name={showSidebar ? 'close' : 'sidebar'} />
</Menu.Item>
</Menu.Menu>
</Container>
</Menu>
{showSidebar ? (
<Segment style={{ marginTop: 0, borderTop: '0' }}>
<Menu secondary vertical style={{ width: '100%', margin: 0 }}>
{renderButtons(true)}
<Menu.Item>
{userState.user ? (
<Button onClick={logout}>注销</Button>
) : (
<>
<Button
onClick={() => {
setShowSidebar(false);
navigate('/login');
}}
>
登录
</Button>
<Button
onClick={() => {
setShowSidebar(false);
navigate('/register');
}}
>
注册
</Button>
</>
)}
</Menu.Item>
</Menu>
</Segment>
) : (
<></>
)}
</>
);
}
return (
<>
<Menu borderless style={{ borderTop: 'none' }}>
<Container>
<Menu.Item as={Link} to='/' className={'hide-on-mobile'}>
<img src='/logo.png' alt='logo' style={{ marginRight: '0.75em' }} />
<div style={{ fontSize: '20px' }}>
<b>{systemName}</b>
</div>
</Menu.Item>
{renderButtons(false)}
<Menu.Menu position='right'>
{userState.user ? (
<Dropdown
text={userState.user.username}
pointing
className='link item'
>
<Dropdown.Menu>
<Dropdown.Item onClick={logout}>注销</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
) : (
<Menu.Item
name='登录'
as={Link}
to='/login'
className='btn btn-link'
/>
)}
</Menu.Menu>
</Container>
</Menu>
</>
);
};
export default Header;
-14
View File
@@ -1,14 +0,0 @@
import React from 'react';
import { Segment, Dimmer, Loader } from 'semantic-ui-react';
const Loading = ({ prompt: name = 'page' }) => {
return (
<Segment style={{ height: 100 }}>
<Dimmer active inverted>
<Loader indeterminate>加载{name}中...</Loader>
</Dimmer>
</Segment>
);
};
export default Loading;
-202
View File
@@ -1,202 +0,0 @@
import React, { useContext, useEffect, useState } from 'react';
import {
Button,
Divider,
Form,
Grid,
Header,
Image,
Message,
Modal,
Segment,
} from 'semantic-ui-react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { UserContext } from '../context/User';
import { API, showError, showSuccess } from '../helpers';
const LoginForm = () => {
const [inputs, setInputs] = useState({
username: '',
password: '',
wechat_verification_code: '',
});
const [searchParams, setSearchParams] = useSearchParams();
const [submitted, setSubmitted] = useState(false);
const { username, password } = inputs;
const [userState, userDispatch] = useContext(UserContext);
let navigate = useNavigate();
const [status, setStatus] = useState({});
useEffect(() => {
if (searchParams.get("expired")) {
showError('未登录或登录已过期,请重新登录!');
}
let status = localStorage.getItem('status');
if (status) {
status = JSON.parse(status);
setStatus(status);
}
}, []);
const [showWeChatLoginModal, setShowWeChatLoginModal] = useState(false);
const onGitHubOAuthClicked = () => {
window.open(
`https://github.com/login/oauth/authorize?client_id=${status.github_client_id}&scope=user:email`
);
};
const onWeChatLoginClicked = () => {
setShowWeChatLoginModal(true);
};
const onSubmitWeChatVerificationCode = async () => {
const res = await API.get(
`/api/oauth/wechat?code=${inputs.wechat_verification_code}`
);
const { success, message, data } = res.data;
if (success) {
userDispatch({ type: 'login', payload: data });
localStorage.setItem('user', JSON.stringify(data));
navigate('/');
showSuccess('登录成功!');
setShowWeChatLoginModal(false);
} else {
showError(message);
}
};
function handleChange(e) {
const { name, value } = e.target;
setInputs((inputs) => ({ ...inputs, [name]: value }));
}
async function handleSubmit(e) {
setSubmitted(true);
if (username && password) {
const res = await API.post('/api/user/login', {
username,
password,
});
const { success, message, data } = res.data;
if (success) {
userDispatch({ type: 'login', payload: data });
localStorage.setItem('user', JSON.stringify(data));
navigate('/');
showSuccess('登录成功!');
} else {
showError(message);
}
}
}
return (
<Grid textAlign="center" style={{ marginTop: '48px' }}>
<Grid.Column style={{ maxWidth: 450 }}>
<Header as="h2" color="" textAlign="center">
<Image src="/logo.png" /> 用户登录
</Header>
<Form size="large">
<Segment>
<Form.Input
fluid
icon="user"
iconPosition="left"
placeholder="用户名"
name="username"
value={username}
onChange={handleChange}
/>
<Form.Input
fluid
icon="lock"
iconPosition="left"
placeholder="密码"
name="password"
type="password"
value={password}
onChange={handleChange}
/>
<Button color="" fluid size="large" onClick={handleSubmit}>
登录
</Button>
</Segment>
</Form>
<Message>
忘记密码?
<Link to="/reset" className="btn btn-link">
点击重置
</Link>
; 没有账户?
<Link to="/register" className="btn btn-link">
点击注册
</Link>
</Message>
{status.github_oauth || status.wechat_login ? (
<>
<Divider horizontal>Or</Divider>
{status.github_oauth ? (
<Button
circular
color="black"
icon="github"
onClick={onGitHubOAuthClicked}
/>
) : (
<></>
)}
{status.wechat_login ? (
<Button
circular
color="green"
icon="wechat"
onClick={onWeChatLoginClicked}
/>
) : (
<></>
)}
</>
) : (
<></>
)}
<Modal
onClose={() => setShowWeChatLoginModal(false)}
onOpen={() => setShowWeChatLoginModal(true)}
open={showWeChatLoginModal}
size={'mini'}
>
<Modal.Content>
<Modal.Description>
<Image src={status.wechat_qrcode} fluid />
<div style={{ textAlign: 'center' }}>
<p>
微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)
</p>
</div>
<Form size="large">
<Form.Input
fluid
placeholder="验证码"
name="wechat_verification_code"
value={inputs.wechat_verification_code}
onChange={handleChange}
/>
<Button
color=""
fluid
size="large"
onClick={onSubmitWeChatVerificationCode}
>
登录
</Button>
</Form>
</Modal.Description>
</Modal.Content>
</Modal>
</Grid.Column>
</Grid>
);
};
export default LoginForm;
@@ -1,270 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
Button,
Divider,
Form,
Grid,
Header,
Icon,
Label,
Message,
Segment,
} from 'semantic-ui-react';
import { API, copy, showError, showSuccess } from '../helpers';
const OperationSetting = () => {
const [inputs, setInputs] = useState({
AgentHeartbeatInterval: '30000',
AgentSyncInterval: '30000',
NodeOfflineThreshold: '120000',
AgentUpdateRepo: 'Rain-kl/ATSFlare',
ServerAddress: '',
AgentDiscoveryToken: '',
});
const [loading, setLoading] = useState(false);
const getOptions = async () => {
const res = await API.get('/api/option/');
const { success, message, data } = res.data;
if (success) {
let newInputs = {};
data.forEach((item) => {
if (item.key in inputs) {
newInputs[item.key] = item.value;
}
});
setInputs((prev) => ({ ...prev, ...newInputs }));
} else {
showError(message);
}
};
useEffect(() => {
getOptions();
getDiscoveryToken();
}, []);
const getDiscoveryToken = async () => {
try {
const res = await API.get('/api/nodes/bootstrap-token');
const { success, data } = res.data;
if (success && data) {
setInputs((prev) => ({
...prev,
AgentDiscoveryToken: data.discovery_token,
}));
}
} catch (e) {
// ignore
}
};
const updateOption = async (key, value) => {
setLoading(true);
const res = await API.put('/api/option', { key, value });
const { success, message } = res.data;
if (success) {
showSuccess('设置已保存');
setInputs((prev) => ({ ...prev, [key]: value }));
} else {
showError(message);
}
setLoading(false);
};
const handleInputChange = (e, { name, value }) => {
setInputs((prev) => ({ ...prev, [name]: value }));
};
const submitAgentIntervals = async () => {
const hb = parseInt(inputs.AgentHeartbeatInterval, 10);
const sync = parseInt(inputs.AgentSyncInterval, 10);
const offline = parseInt(inputs.NodeOfflineThreshold, 10);
if (isNaN(hb) || hb < 5000) {
showError('心跳间隔不能小于 5000 毫秒');
return;
}
if (isNaN(sync) || sync < 5000) {
showError('同步间隔不能小于 5000 毫秒');
return;
}
if (isNaN(offline) || offline < 10000) {
showError('离线阈值不能小于 10000 毫秒');
return;
}
await updateOption('AgentHeartbeatInterval', String(hb));
await updateOption('AgentSyncInterval', String(sync));
await updateOption('NodeOfflineThreshold', String(offline));
};
const submitUpdateRepo = async () => {
await updateOption('AgentUpdateRepo', inputs.AgentUpdateRepo);
};
const rotateDiscoveryToken = async () => {
setLoading(true);
try {
const res = await API.post('/api/nodes/bootstrap-token/rotate');
const { success, message, data } = res.data;
if (success) {
showSuccess('Discovery Token 已重新生成');
setInputs((prev) => ({
...prev,
AgentDiscoveryToken: data.discovery_token,
}));
} else {
showError(message);
}
} catch (e) {
showError('操作失败');
}
setLoading(false);
};
const formatMs = (ms) => {
const val = parseInt(ms, 10);
if (isNaN(val)) return ms;
if (val >= 60000) return `${val / 60000} 分钟`;
return `${val / 1000} 秒`;
};
const serverAddr = inputs.ServerAddress || window.location.origin;
const curlCommand = inputs.AgentDiscoveryToken
? `curl -fsSL https://raw.githubusercontent.com/Rain-kl/ATSFlare/main/scripts/install-agent.sh | bash -s -- --server-url ${serverAddr} --discovery-token ${inputs.AgentDiscoveryToken}`
: '';
const openSwaggerDoc = () => {
window.open('/swagger/index.html', '_blank', 'noopener,noreferrer');
};
return (
<Grid columns={1}>
<Grid.Column>
<Form loading={loading}>
<Header as='h3'>接口文档</Header>
<Message info size='small'>
<p>通过 Swagger UI 查看当前 Server API,并直接在浏览器中调试接口。</p>
</Message>
<Button onClick={openSwaggerDoc} primary>
打开接口文档
</Button>
<Divider />
<Header as='h3'>Agent 运行参数</Header>
<Message info size='small'>
<p>这些参数通过心跳响应下发到所有 Agent,修改后下次心跳即生效。</p>
</Message>
<Form.Group widths='equal'>
<Form.Input
label={`心跳间隔 (${formatMs(inputs.AgentHeartbeatInterval)})`}
placeholder='30000'
value={inputs.AgentHeartbeatInterval}
name='AgentHeartbeatInterval'
onChange={handleInputChange}
type='number'
/>
<Form.Input
label={`同步间隔 (${formatMs(inputs.AgentSyncInterval)})`}
placeholder='30000'
value={inputs.AgentSyncInterval}
name='AgentSyncInterval'
onChange={handleInputChange}
type='number'
/>
<Form.Input
label={`离线阈值 (${formatMs(inputs.NodeOfflineThreshold)})`}
placeholder='120000'
value={inputs.NodeOfflineThreshold}
name='NodeOfflineThreshold'
onChange={handleInputChange}
type='number'
/>
</Form.Group>
<Form.Button onClick={submitAgentIntervals} primary>
保存运行参数
</Form.Button>
<Divider />
<Header as='h3'>Agent 更新源</Header>
<Message info size='small'>
<p>
自动更新开关和手动更新由节点页单独控制,这里只配置 Agent
更新仓库。
</p>
</Message>
<Form.Group widths='equal'>
<Form.Input
label='更新仓库'
placeholder='Rain-kl/ATSFlare'
value={inputs.AgentUpdateRepo}
name='AgentUpdateRepo'
onChange={handleInputChange}
/>
</Form.Group>
<Form.Button onClick={submitUpdateRepo} primary>
保存更新设置
</Form.Button>
<Divider />
<Header as='h3'>节点接入</Header>
<Form.Group widths='equal'>
<Form.Field>
<label>全局 Discovery Token</label>
<Segment>
<Label basic>{inputs.AgentDiscoveryToken || '(未设置)'}</Label>
<Button
size='mini'
icon
style={{ marginLeft: '8px' }}
onClick={() =>
copy(inputs.AgentDiscoveryToken, 'Discovery Token')
}
title='复制'
>
<Icon name='copy' />
</Button>
<Button
size='mini'
color='orange'
style={{ marginLeft: '4px' }}
onClick={rotateDiscoveryToken}
>
重新生成
</Button>
</Segment>
</Form.Field>
</Form.Group>
{curlCommand && (
<>
<Header as='h4'>Agent 一键部署命令</Header>
<Message>
<pre
style={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
fontFamily: 'JetBrains Mono, Consolas, monospace',
fontSize: '12px',
margin: 0,
}}
>
{curlCommand}
</pre>
</Message>
<Button
size='small'
icon
labelPosition='left'
onClick={() => copy(curlCommand, '部署命令')}
>
<Icon name='copy' />
复制部署命令
</Button>
</>
)}
</Form>
</Grid.Column>
</Grid>
);
};
export default OperationSetting;
@@ -1,202 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Button, Divider, Form, Grid, Header, Modal } from 'semantic-ui-react';
import {
API,
getBuildVersion,
hasBuildVersion,
normalizeVersion,
showError,
showSuccess,
} from '../helpers';
import { marked } from 'marked';
const OtherSetting = () => {
let [inputs, setInputs] = useState({
Footer: '',
Notice: '',
About: '',
SystemName: '',
HomePageLink: '',
});
let [loading, setLoading] = useState(false);
const [showUpdateModal, setShowUpdateModal] = useState(false);
const [updateData, setUpdateData] = useState({
tag_name: '',
content: '',
});
const getOptions = async () => {
const res = await API.get('/api/option/');
const { success, message, data } = res.data;
if (success) {
let newInputs = {};
data.forEach((item) => {
if (item.key in inputs) {
newInputs[item.key] = item.value;
}
});
setInputs(newInputs);
} else {
showError(message);
}
};
useEffect(() => {
getOptions().then();
}, []);
const updateOption = async (key, value) => {
setLoading(true);
const res = await API.put('/api/option', {
key,
value,
});
const { success, message } = res.data;
if (success) {
setInputs((inputs) => ({ ...inputs, [key]: value }));
} else {
showError(message);
}
setLoading(false);
};
const handleInputChange = async (e, { name, value }) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
const submitNotice = async () => {
await updateOption('Notice', inputs.Notice);
};
const submitFooter = async () => {
await updateOption('Footer', inputs.Footer);
};
const submitSystemName = async () => {
await updateOption('SystemName', inputs.SystemName);
};
const submitHomePageLink = async () => {
await updateOption('HomePageLink', inputs.HomePageLink);
};
const submitAbout = async () => {
await updateOption('About', inputs.About);
};
const openGitHubRelease = () => {
window.location = 'https://github.com/Rain-kl/ATSFlare/releases/latest';
};
const checkUpdate = async () => {
const res = await API.get('/api/update/latest-release');
const { success, message, data } = res.data;
if (!success) {
showError(message);
return;
}
const { tag_name, body } = data;
if (
hasBuildVersion() &&
normalizeVersion(tag_name) === getBuildVersion()
) {
showSuccess(`已是最新版本:${tag_name}`);
} else {
setUpdateData({
tag_name: tag_name,
content: marked.parse(body),
});
setShowUpdateModal(true);
}
};
return (
<Grid columns={1}>
<Grid.Column>
<Form loading={loading}>
<Header as='h3'>通用设置</Header>
<Form.Button onClick={checkUpdate}>检查更新</Form.Button>
<Form.Group widths='equal'>
<Form.TextArea
label='公告'
placeholder='在此输入新的公告内容'
value={inputs.Notice}
name='Notice'
onChange={handleInputChange}
style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }}
/>
</Form.Group>
<Form.Button onClick={submitNotice}>保存公告</Form.Button>
<Divider />
<Header as='h3'>个性化设置</Header>
<Form.Group widths='equal'>
<Form.Input
label='系统名称'
placeholder='在此输入系统名称'
value={inputs.SystemName}
name='SystemName'
onChange={handleInputChange}
/>
</Form.Group>
<Form.Button onClick={submitSystemName}>设置系统名称</Form.Button>
<Form.Group widths='equal'>
<Form.Input
label='首页链接'
placeholder='在此输入首页链接,设置后将通过 iframe 方式嵌入该网页'
value={inputs.HomePageLink}
name='HomePageLink'
onChange={handleInputChange}
type='url'
/>
</Form.Group>
<Form.Button onClick={submitHomePageLink}>设置首页链接</Form.Button>
<Form.Group widths='equal'>
<Form.TextArea
label='关于'
placeholder='在此输入新的关于内容,支持 Markdown & HTML 代码'
value={inputs.About}
name='About'
onChange={handleInputChange}
style={{ minHeight: 150, fontFamily: 'JetBrains Mono, Consolas' }}
/>
</Form.Group>
<Form.Button onClick={submitAbout}>保存关于</Form.Button>
<Form.Group widths='equal'>
<Form.Input
label='页脚'
placeholder='在此输入新的页脚,留空则使用默认页脚,支持 HTML 代码'
value={inputs.Footer}
name='Footer'
onChange={handleInputChange}
/>
</Form.Group>
<Form.Button onClick={submitFooter}>设置页脚</Form.Button>
</Form>
</Grid.Column>
<Modal
onClose={() => setShowUpdateModal(false)}
onOpen={() => setShowUpdateModal(true)}
open={showUpdateModal}
>
<Modal.Header>新版本:{updateData.tag_name}</Modal.Header>
<Modal.Content>
<Modal.Description>
<div dangerouslySetInnerHTML={{ __html: updateData.content }}></div>
</Modal.Description>
</Modal.Content>
<Modal.Actions>
<Button onClick={() => setShowUpdateModal(false)}>关闭</Button>
<Button
content='详情'
onClick={() => {
setShowUpdateModal(false);
openGitHubRelease();
}}
/>
</Modal.Actions>
</Modal>
</Grid>
);
};
export default OtherSetting;
@@ -1,76 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react';
import { API, copy, showError, showSuccess } from '../helpers';
import { useSearchParams } from 'react-router-dom';
const PasswordResetConfirm = () => {
const [inputs, setInputs] = useState({
email: '',
token: '',
});
const { email, token } = inputs;
const [loading, setLoading] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
useEffect(() => {
let token = searchParams.get('token');
let email = searchParams.get('email');
setInputs({
token,
email,
});
}, []);
async function handleSubmit(e) {
if (!email) return;
setLoading(true);
const res = await API.post(`/api/user/reset`, {
email,
token,
});
const { success, message } = res.data;
if (success) {
let password = res.data.data;
await copy(password);
showSuccess(`密码已重置并已复制到剪贴板:${password}`);
} else {
showError(message);
}
setLoading(false);
}
return (
<Grid textAlign='center' style={{ marginTop: '48px' }}>
<Grid.Column style={{ maxWidth: 450 }}>
<Header as='h2' color='' textAlign='center'>
<Image src='/logo.png' /> 密码重置确认
</Header>
<Form size='large'>
<Segment>
<Form.Input
fluid
icon='mail'
iconPosition='left'
placeholder='邮箱地址'
name='email'
value={email}
readOnly
/>
<Button
color=''
fluid
size='large'
onClick={handleSubmit}
loading={loading}
>
提交
</Button>
</Segment>
</Form>
</Grid.Column>
</Grid>
);
};
export default PasswordResetConfirm;
@@ -1,96 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Button, Form, Grid, Header, Image, Segment } from 'semantic-ui-react';
import { API, showError, showInfo, showSuccess } from '../helpers';
import Turnstile from 'react-turnstile';
const PasswordResetForm = () => {
const [inputs, setInputs] = useState({
email: '',
});
const { email } = inputs;
const [loading, setLoading] = useState(false);
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
const [turnstileToken, setTurnstileToken] = useState('');
useEffect(() => {
let status = localStorage.getItem('status');
if (status) {
status = JSON.parse(status);
if (status.turnstile_check) {
setTurnstileEnabled(true);
setTurnstileSiteKey(status.turnstile_site_key);
}
}
}, []);
function handleChange(e) {
const { name, value } = e.target;
setInputs((inputs) => ({ ...inputs, [name]: value }));
}
async function handleSubmit(e) {
if (!email) return;
if (turnstileEnabled && turnstileToken === '') {
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
return;
}
setLoading(true);
const res = await API.get(
`/api/reset_password?email=${email}&turnstile=${turnstileToken}`
);
const { success, message } = res.data;
if (success) {
showSuccess('重置邮件发送成功,请检查邮箱!');
setInputs({ ...inputs, email: '' });
} else {
showError(message);
}
setLoading(false);
}
return (
<Grid textAlign='center' style={{ marginTop: '48px' }}>
<Grid.Column style={{ maxWidth: 450 }}>
<Header as='h2' color='' textAlign='center'>
<Image src='/logo.png' /> 密码重置
</Header>
<Form size='large'>
<Segment>
<Form.Input
fluid
icon='mail'
iconPosition='left'
placeholder='邮箱地址'
name='email'
value={email}
onChange={handleChange}
/>
{turnstileEnabled ? (
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {
setTurnstileToken(token);
}}
/>
) : (
<></>
)}
<Button
color=''
fluid
size='large'
onClick={handleSubmit}
loading={loading}
>
提交
</Button>
</Segment>
</Form>
</Grid.Column>
</Grid>
);
};
export default PasswordResetForm;
@@ -1,217 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Button, Divider, Form, Header, Image, Modal } from 'semantic-ui-react';
import { Link } from 'react-router-dom';
import { API, copy, showError, showInfo, showSuccess } from '../helpers';
import Turnstile from 'react-turnstile';
const PersonalSetting = () => {
const [inputs, setInputs] = useState({
wechat_verification_code: '',
email_verification_code: '',
email: '',
});
const [status, setStatus] = useState({});
const [showWeChatBindModal, setShowWeChatBindModal] = useState(false);
const [showEmailBindModal, setShowEmailBindModal] = useState(false);
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
const [turnstileToken, setTurnstileToken] = useState('');
const [loading, setLoading] = useState(false);
useEffect(() => {
let status = localStorage.getItem('status');
if (status) {
status = JSON.parse(status);
setStatus(status);
if (status.turnstile_check) {
setTurnstileEnabled(true);
setTurnstileSiteKey(status.turnstile_site_key);
}
}
}, []);
const handleInputChange = (e, { name, value }) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
const generateToken = async () => {
const res = await API.get('/api/user/token');
const { success, message, data } = res.data;
if (success) {
await copy(data);
showSuccess(`令牌已重置并已复制到剪贴板:${data}`);
} else {
showError(message);
}
};
const bindWeChat = async () => {
if (inputs.wechat_verification_code === '') return;
const res = await API.get(
`/api/oauth/wechat/bind?code=${inputs.wechat_verification_code}`
);
const { success, message } = res.data;
if (success) {
showSuccess('微信账户绑定成功!');
setShowWeChatBindModal(false);
} else {
showError(message);
}
};
const openGitHubOAuth = () => {
window.open(
`https://github.com/login/oauth/authorize?client_id=${status.github_client_id}&scope=user:email`
);
};
const sendVerificationCode = async () => {
if (inputs.email === '') return;
if (turnstileEnabled && turnstileToken === '') {
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
return;
}
setLoading(true);
const res = await API.get(
`/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`
);
const { success, message } = res.data;
if (success) {
showSuccess('验证码发送成功,请检查邮箱!');
} else {
showError(message);
}
setLoading(false);
};
const bindEmail = async () => {
if (inputs.email_verification_code === '') return;
setLoading(true);
const res = await API.get(
`/api/oauth/email/bind?email=${inputs.email}&code=${inputs.email_verification_code}`
);
const { success, message } = res.data;
if (success) {
showSuccess('邮箱账户绑定成功!');
setShowEmailBindModal(false);
} else {
showError(message);
}
setLoading(false);
};
return (
<div style={{ lineHeight: '40px' }}>
<Header as='h3'>通用设置</Header>
<Button as={Link} to={`/user/edit/`}>
更新个人信息
</Button>
<Button onClick={generateToken}>生成访问令牌</Button>
<Divider />
<Header as='h3'>账号绑定</Header>
{status.wechat_login && (
<Button
onClick={() => {
setShowWeChatBindModal(true);
}}
>
绑定微信账号
</Button>
)}
<Modal
onClose={() => setShowWeChatBindModal(false)}
onOpen={() => setShowWeChatBindModal(true)}
open={showWeChatBindModal}
size={'mini'}
>
<Modal.Content>
<Modal.Description>
<Image src={status.wechat_qrcode} fluid />
<div style={{ textAlign: 'center' }}>
<p>
微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)
</p>
</div>
<Form size='large'>
<Form.Input
fluid
placeholder='验证码'
name='wechat_verification_code'
value={inputs.wechat_verification_code}
onChange={handleInputChange}
/>
<Button color='' fluid size='large' onClick={bindWeChat}>
绑定
</Button>
</Form>
</Modal.Description>
</Modal.Content>
</Modal>
{status.github_oauth && (
<Button onClick={openGitHubOAuth}>绑定 GitHub 账号</Button>
)}
<Button
onClick={() => {
setShowEmailBindModal(true);
}}
>
绑定邮箱地址
</Button>
<Modal
onClose={() => setShowEmailBindModal(false)}
onOpen={() => setShowEmailBindModal(true)}
open={showEmailBindModal}
size={'tiny'}
style={{ maxWidth: '450px' }}
>
<Modal.Header>绑定邮箱地址</Modal.Header>
<Modal.Content>
<Modal.Description>
<Form size='large'>
<Form.Input
fluid
placeholder='输入邮箱地址'
onChange={handleInputChange}
name='email'
type='email'
action={
<Button onClick={sendVerificationCode} disabled={loading}>
获取验证码
</Button>
}
/>
<Form.Input
fluid
placeholder='验证码'
name='email_verification_code'
value={inputs.email_verification_code}
onChange={handleInputChange}
/>
{turnstileEnabled ? (
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {
setTurnstileToken(token);
}}
/>
) : (
<></>
)}
<Button
color=''
fluid
size='large'
onClick={bindEmail}
loading={loading}
>
绑定
</Button>
</Form>
</Modal.Description>
</Modal.Content>
</Modal>
</div>
);
};
export default PersonalSetting;
@@ -1,13 +0,0 @@
import { Navigate } from 'react-router-dom';
import { history } from '../helpers';
function PrivateRoute({ children }) {
if (!localStorage.getItem('user')) {
return <Navigate to='/login' state={{ from: history.location }} />;
}
return children;
}
export { PrivateRoute };
@@ -1,193 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
Button,
Form,
Grid,
Header,
Image,
Message,
Segment,
} from 'semantic-ui-react';
import { Link, useNavigate } from 'react-router-dom';
import { API, showError, showInfo, showSuccess } from '../helpers';
import Turnstile from 'react-turnstile';
const RegisterForm = () => {
const [inputs, setInputs] = useState({
username: '',
password: '',
password2: '',
email: '',
verification_code: '',
});
const { username, password, password2 } = inputs;
const [showEmailVerification, setShowEmailVerification] = useState(false);
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
const [turnstileToken, setTurnstileToken] = useState('');
const [loading, setLoading] = useState(false);
useEffect(() => {
let status = localStorage.getItem('status');
if (status) {
status = JSON.parse(status);
setShowEmailVerification(status.email_verification);
if (status.turnstile_check) {
setTurnstileEnabled(true);
setTurnstileSiteKey(status.turnstile_site_key);
}
}
});
let navigate = useNavigate();
function handleChange(e) {
const { name, value } = e.target;
console.log(name, value);
setInputs((inputs) => ({ ...inputs, [name]: value }));
}
async function handleSubmit(e) {
if (password.length < 8) {
showInfo('密码长度不得小于 8 位!');
return;
}
if (password !== password2) {
showInfo('两次输入的密码不一致');
return;
}
if (username && password) {
if (turnstileEnabled && turnstileToken === '') {
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
return;
}
setLoading(true);
const res = await API.post(
`/api/user/register?turnstile=${turnstileToken}`,
inputs
);
const { success, message } = res.data;
if (success) {
navigate('/login');
showSuccess('注册成功!');
} else {
showError(message);
}
setLoading(false);
}
}
const sendVerificationCode = async () => {
if (inputs.email === '') return;
if (turnstileEnabled && turnstileToken === '') {
showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
return;
}
setLoading(true);
const res = await API.get(
`/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`
);
const { success, message } = res.data;
if (success) {
showSuccess('验证码发送成功,请检查你的邮箱!');
} else {
showError(message);
}
setLoading(false);
};
return (
<Grid textAlign='center' style={{ marginTop: '48px' }}>
<Grid.Column style={{ maxWidth: 450 }}>
<Header as='h2' color='' textAlign='center'>
<Image src='/logo.png' /> 新用户注册
</Header>
<Form size='large'>
<Segment>
<Form.Input
fluid
icon='user'
iconPosition='left'
placeholder='输入用户名,最长 12 位'
onChange={handleChange}
name='username'
/>
<Form.Input
fluid
icon='lock'
iconPosition='left'
placeholder='输入密码,最短 8 位,最长 20 位'
onChange={handleChange}
name='password'
type='password'
/>
<Form.Input
fluid
icon='lock'
iconPosition='left'
placeholder='输入密码,最短 8 位,最长 20 位'
onChange={handleChange}
name='password2'
type='password'
/>
{showEmailVerification ? (
<>
<Form.Input
fluid
icon='mail'
iconPosition='left'
placeholder='输入邮箱地址'
onChange={handleChange}
name='email'
type='email'
action={
<Button onClick={sendVerificationCode} disabled={loading}>
获取验证码
</Button>
}
/>
<Form.Input
fluid
icon='lock'
iconPosition='left'
placeholder='输入验证码'
onChange={handleChange}
name='verification_code'
/>
</>
) : (
<></>
)}
{turnstileEnabled ? (
<Turnstile
sitekey={turnstileSiteKey}
onVerify={(token) => {
setTurnstileToken(token);
}}
/>
) : (
<></>
)}
<Button
color=''
fluid
size='large'
onClick={handleSubmit}
loading={loading}
>
注册
</Button>
</Segment>
</Form>
<Message>
已有账户?
<Link to='/login' className='btn btn-link'>
点击登录
</Link>
</Message>
</Grid.Column>
</Grid>
);
};
export default RegisterForm;
@@ -1,399 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Divider, Form, Grid, Header, Message } from 'semantic-ui-react';
import { API, removeTrailingSlash, showError } from '../helpers';
const SystemSetting = () => {
let [inputs, setInputs] = useState({
PasswordLoginEnabled: '',
PasswordRegisterEnabled: '',
EmailVerificationEnabled: '',
GitHubOAuthEnabled: '',
GitHubClientId: '',
GitHubClientSecret: '',
Notice: '',
SMTPServer: '',
SMTPPort: '',
SMTPAccount: '',
SMTPToken: '',
ServerAddress: '',
Footer: '',
WeChatAuthEnabled: '',
WeChatServerAddress: '',
WeChatServerToken: '',
WeChatAccountQRCodeImageURL: '',
TurnstileCheckEnabled: '',
TurnstileSiteKey: '',
TurnstileSecretKey: '',
RegisterEnabled: '',
});
const [originInputs, setOriginInputs] = useState({});
let [loading, setLoading] = useState(false);
const getOptions = async () => {
const res = await API.get('/api/option/');
const { success, message, data } = res.data;
if (success) {
let newInputs = {};
data.forEach((item) => {
newInputs[item.key] = item.value;
});
setInputs(newInputs);
setOriginInputs(newInputs);
} else {
showError(message);
}
};
useEffect(() => {
getOptions().then();
}, []);
const updateOption = async (key, value) => {
setLoading(true);
switch (key) {
case 'PasswordLoginEnabled':
case 'PasswordRegisterEnabled':
case 'EmailVerificationEnabled':
case 'GitHubOAuthEnabled':
case 'WeChatAuthEnabled':
case 'TurnstileCheckEnabled':
case 'RegisterEnabled':
value = inputs[key] === 'true' ? 'false' : 'true';
break;
default:
break;
}
const res = await API.put('/api/option/', {
key,
value,
});
const { success, message } = res.data;
if (success) {
setInputs((inputs) => ({ ...inputs, [key]: value }));
} else {
showError(message);
}
setLoading(false);
};
const handleInputChange = async (e, { name, value }) => {
if (
name === 'Notice' ||
name.startsWith('SMTP') ||
name === 'ServerAddress' ||
name === 'GitHubClientId' ||
name === 'GitHubClientSecret' ||
name === 'WeChatServerAddress' ||
name === 'WeChatServerToken' ||
name === 'WeChatAccountQRCodeImageURL' ||
name === 'TurnstileSiteKey' ||
name === 'TurnstileSecretKey'
) {
setInputs((inputs) => ({ ...inputs, [name]: value }));
} else {
await updateOption(name, value);
}
};
const submitServerAddress = async () => {
let ServerAddress = removeTrailingSlash(inputs.ServerAddress);
await updateOption('ServerAddress', ServerAddress);
};
const submitSMTP = async () => {
if (originInputs['SMTPServer'] !== inputs.SMTPServer) {
await updateOption('SMTPServer', inputs.SMTPServer);
}
if (originInputs['SMTPAccount'] !== inputs.SMTPAccount) {
await updateOption('SMTPAccount', inputs.SMTPAccount);
}
if (
originInputs['SMTPPort'] !== inputs.SMTPPort &&
inputs.SMTPPort !== ''
) {
await updateOption('SMTPPort', inputs.SMTPPort);
}
if (
originInputs['SMTPToken'] !== inputs.SMTPToken &&
inputs.SMTPToken !== ''
) {
await updateOption('SMTPToken', inputs.SMTPToken);
}
};
const submitWeChat = async () => {
if (originInputs['WeChatServerAddress'] !== inputs.WeChatServerAddress) {
await updateOption(
'WeChatServerAddress',
removeTrailingSlash(inputs.WeChatServerAddress)
);
}
if (
originInputs['WeChatAccountQRCodeImageURL'] !==
inputs.WeChatAccountQRCodeImageURL
) {
await updateOption(
'WeChatAccountQRCodeImageURL',
inputs.WeChatAccountQRCodeImageURL
);
}
if (
originInputs['WeChatServerToken'] !== inputs.WeChatServerToken &&
inputs.WeChatServerToken !== ''
) {
await updateOption('WeChatServerToken', inputs.WeChatServerToken);
}
};
const submitGitHubOAuth = async () => {
if (originInputs['GitHubClientId'] !== inputs.GitHubClientId) {
await updateOption('GitHubClientId', inputs.GitHubClientId);
}
if (
originInputs['GitHubClientSecret'] !== inputs.GitHubClientSecret &&
inputs.GitHubClientSecret !== ''
) {
await updateOption('GitHubClientSecret', inputs.GitHubClientSecret);
}
};
const submitTurnstile = async () => {
if (originInputs['TurnstileSiteKey'] !== inputs.TurnstileSiteKey) {
await updateOption('TurnstileSiteKey', inputs.TurnstileSiteKey);
}
if (
originInputs['TurnstileSecretKey'] !== inputs.TurnstileSecretKey &&
inputs.TurnstileSecretKey !== ''
) {
await updateOption('TurnstileSecretKey', inputs.TurnstileSecretKey);
}
};
return (
<Grid columns={1}>
<Grid.Column>
<Form loading={loading}>
<Header as='h3'>通用设置</Header>
<Form.Group widths='equal'>
<Form.Input
label='服务器地址'
placeholder='例如:https://yourdomain.com'
value={inputs.ServerAddress}
name='ServerAddress'
onChange={handleInputChange}
/>
</Form.Group>
<Form.Button onClick={submitServerAddress}>
更新服务器地址
</Form.Button>
<Divider />
<Header as='h3'>配置登录注册</Header>
<Form.Group inline>
<Form.Checkbox
checked={inputs.PasswordLoginEnabled === 'true'}
label='允许通过密码进行登录'
name='PasswordLoginEnabled'
onChange={handleInputChange}
/>
<Form.Checkbox
checked={inputs.PasswordRegisterEnabled === 'true'}
label='允许通过密码进行注册'
name='PasswordRegisterEnabled'
onChange={handleInputChange}
/>
<Form.Checkbox
checked={inputs.EmailVerificationEnabled === 'true'}
label='通过密码注册时需要进行邮箱验证'
name='EmailVerificationEnabled'
onChange={handleInputChange}
/>
<Form.Checkbox
checked={inputs.GitHubOAuthEnabled === 'true'}
label='允许通过 GitHub 账户登录 & 注册'
name='GitHubOAuthEnabled'
onChange={handleInputChange}
/>
<Form.Checkbox
checked={inputs.WeChatAuthEnabled === 'true'}
label='允许通过微信登录 & 注册'
name='WeChatAuthEnabled'
onChange={handleInputChange}
/>
</Form.Group>
<Form.Group inline>
<Form.Checkbox
checked={inputs.RegisterEnabled === 'true'}
label='允许新用户注册(此项为否时,新用户将无法以任何方式进行注册)'
name='RegisterEnabled'
onChange={handleInputChange}
/>
<Form.Checkbox
checked={inputs.TurnstileCheckEnabled === 'true'}
label='启用 Turnstile 用户校验'
name='TurnstileCheckEnabled'
onChange={handleInputChange}
/>
</Form.Group>
<Divider />
<Header as='h3'>
配置 SMTP
<Header.Subheader>用以支持系统的邮件发送</Header.Subheader>
</Header>
<Form.Group widths={4}>
<Form.Input
label='SMTP 服务器地址'
name='SMTPServer'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.SMTPServer}
placeholder='例如:smtp.qq.com'
/>
<Form.Input
label='SMTP 端口'
name='SMTPPort'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.SMTPPort}
placeholder='默认: 587'
/>
<Form.Input
label='SMTP 账户'
name='SMTPAccount'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.SMTPAccount}
placeholder='通常是邮箱地址'
/>
<Form.Input
label='SMTP 访问凭证'
name='SMTPToken'
onChange={handleInputChange}
type='password'
autoComplete='new-password'
value={inputs.SMTPToken}
placeholder='敏感信息不会发送到前端显示'
/>
</Form.Group>
<Form.Button onClick={submitSMTP}>保存 SMTP 设置</Form.Button>
<Divider />
<Header as='h3'>
配置 GitHub OAuth App
<Header.Subheader>
用以支持通过 GitHub 进行登录注册,
<a href='https://github.com/settings/developers' target='_blank'>
点击此处
</a>
管理你的 GitHub OAuth App
</Header.Subheader>
</Header>
<Message>
Homepage URL 填 <code>{inputs.ServerAddress}</code>
,Authorization callback URL 填{' '}
<code>{`${inputs.ServerAddress}/oauth/github`}</code>
</Message>
<Form.Group widths={3}>
<Form.Input
label='GitHub Client ID'
name='GitHubClientId'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.GitHubClientId}
placeholder='输入你注册的 GitHub OAuth APP 的 ID'
/>
<Form.Input
label='GitHub Client Secret'
name='GitHubClientSecret'
onChange={handleInputChange}
type='password'
autoComplete='new-password'
value={inputs.GitHubClientSecret}
placeholder='敏感信息不会发送到前端显示'
/>
</Form.Group>
<Form.Button onClick={submitGitHubOAuth}>
保存 GitHub OAuth 设置
</Form.Button>
<Divider />
<Header as='h3'>
配置 WeChat Server
<Header.Subheader>
用以支持通过微信进行登录注册,
<a
href='https://github.com/songquanpeng/wechat-server'
target='_blank'
>
点击此处
</a>
了解 WeChat Server
</Header.Subheader>
</Header>
<Form.Group widths={3}>
<Form.Input
label='WeChat Server 服务器地址'
name='WeChatServerAddress'
placeholder='例如:https://yourdomain.com'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.WeChatServerAddress}
/>
<Form.Input
label='WeChat Server 访问凭证'
name='WeChatServerToken'
type='password'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.WeChatServerToken}
placeholder='敏感信息不会发送到前端显示'
/>
<Form.Input
label='微信公众号二维码图片链接'
name='WeChatAccountQRCodeImageURL'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.WeChatAccountQRCodeImageURL}
placeholder='输入一个图片链接'
/>
</Form.Group>
<Form.Button onClick={submitWeChat}>
保存 WeChat Server 设置
</Form.Button>
<Divider />
<Header as='h3'>
配置 Turnstile
<Header.Subheader>
用以支持用户校验,
<a href='https://dash.cloudflare.com/' target='_blank'>
点击此处
</a>
管理你的 Turnstile Sites,推荐选择 Invisible Widget Type
</Header.Subheader>
</Header>
<Form.Group widths={3}>
<Form.Input
label='Turnstile Site Key'
name='TurnstileSiteKey'
onChange={handleInputChange}
autoComplete='new-password'
value={inputs.TurnstileSiteKey}
placeholder='输入你注册的 Turnstile Site Key'
/>
<Form.Input
label='Turnstile Secret Key'
name='TurnstileSecretKey'
onChange={handleInputChange}
type='password'
autoComplete='new-password'
value={inputs.TurnstileSecretKey}
placeholder='敏感信息不会发送到前端显示'
/>
</Form.Group>
<Form.Button onClick={submitTurnstile}>
保存 Turnstile 设置
</Form.Button>
</Form>
</Grid.Column>
</Grid>
);
};
export default SystemSetting;
@@ -1,317 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
Button,
Form,
Label,
Pagination,
Popup,
Table,
} from 'semantic-ui-react';
import { Link } from 'react-router-dom';
import { API, showError, showSuccess } from '../helpers';
import { ITEMS_PER_PAGE } from '../constants';
function renderRole(role) {
switch (role) {
case 1:
return <Label>普通用户</Label>;
case 10:
return <Label color='yellow'>管理员</Label>;
case 100:
return <Label color='orange'>超级管理员</Label>;
default:
return <Label color='red'>未知身份</Label>;
}
}
const UsersTable = () => {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [activePage, setActivePage] = useState(1);
const [searchKeyword, setSearchKeyword] = useState('');
const [searching, setSearching] = useState(false);
const loadUsers = async (startIdx) => {
const res = await API.get(`/api/user/?p=${startIdx}`);
const { success, message, data } = res.data;
if (success) {
if (startIdx === 0) {
setUsers(data);
} else {
let newUsers = users;
newUsers.push(...data);
setUsers(newUsers);
}
} else {
showError(message);
}
setLoading(false);
};
const onPaginationChange = (e, { activePage }) => {
(async () => {
if (activePage === Math.ceil(users.length / ITEMS_PER_PAGE) + 1) {
// In this case we have to load more data and then append them.
await loadUsers(activePage - 1);
}
setActivePage(activePage);
})();
};
useEffect(() => {
loadUsers(0)
.then()
.catch((reason) => {
showError(reason);
});
}, []);
const manageUser = (username, action, idx) => {
(async () => {
const res = await API.post('/api/user/manage', {
username,
action,
});
const { success, message } = res.data;
if (success) {
showSuccess('操作成功完成!');
let user = res.data.data;
let newUsers = [...users];
let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
if (action === 'delete') {
newUsers[realIdx].deleted = true;
} else {
newUsers[realIdx].status = user.status;
newUsers[realIdx].role = user.role;
}
setUsers(newUsers);
} else {
showError(message);
}
})();
};
const renderStatus = (status) => {
switch (status) {
case 1:
return <Label basic>已激活</Label>;
case 2:
return (
<Label basic color='red'>
已封禁
</Label>
);
default:
return (
<Label basic color='grey'>
未知状态
</Label>
);
}
};
const searchUsers = async () => {
if (searchKeyword === '') {
// if keyword is blank, load files instead.
await loadUsers(0);
setActivePage(1);
return;
}
setSearching(true);
const res = await API.get(`/api/user/search?keyword=${searchKeyword}`);
const { success, message, data } = res.data;
if (success) {
setUsers(data);
setActivePage(1);
} else {
showError(message);
}
setSearching(false);
};
const handleKeywordChange = async (e, { value }) => {
setSearchKeyword(value.trim());
};
const sortUser = (key) => {
if (users.length === 0) return;
setLoading(true);
let sortedUsers = [...users];
sortedUsers.sort((a, b) => {
return ('' + a[key]).localeCompare(b[key]);
});
if (sortedUsers[0].id === users[0].id) {
sortedUsers.reverse();
}
setUsers(sortedUsers);
setLoading(false);
};
return (
<>
<Form onSubmit={searchUsers}>
<Form.Input
icon='search'
fluid
iconPosition='left'
placeholder='搜索用户的 ID,用户名,显示名称,以及邮箱地址 ...'
value={searchKeyword}
loading={searching}
onChange={handleKeywordChange}
/>
</Form>
<Table basic>
<Table.Header>
<Table.Row>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortUser('username');
}}
>
用户名
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortUser('display_name');
}}
>
显示名称
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortUser('email');
}}
>
邮箱地址
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortUser('role');
}}
>
用户角色
</Table.HeaderCell>
<Table.HeaderCell
style={{ cursor: 'pointer' }}
onClick={() => {
sortUser('status');
}}
>
状态
</Table.HeaderCell>
<Table.HeaderCell>操作</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{users
.slice(
(activePage - 1) * ITEMS_PER_PAGE,
activePage * ITEMS_PER_PAGE
)
.map((user, idx) => {
if (user.deleted) return <></>;
return (
<Table.Row key={user.id}>
<Table.Cell>{user.username}</Table.Cell>
<Table.Cell>{user.display_name}</Table.Cell>
<Table.Cell>{user.email ? user.email : '无'}</Table.Cell>
<Table.Cell>{renderRole(user.role)}</Table.Cell>
<Table.Cell>{renderStatus(user.status)}</Table.Cell>
<Table.Cell>
<div>
<Button
size={'small'}
positive
onClick={() => {
manageUser(user.username, 'promote', idx);
}}
>
提升
</Button>
<Button
size={'small'}
color={'yellow'}
onClick={() => {
manageUser(user.username, 'demote', idx);
}}
>
降级
</Button>
<Popup
trigger={
<Button size='small' negative>
删除
</Button>
}
on='click'
flowing
hoverable
>
<Button
negative
onClick={() => {
manageUser(user.username, 'delete', idx);
}}
>
删除账户 {user.username}
</Button>
</Popup>
<Button
size={'small'}
onClick={() => {
manageUser(
user.username,
user.status === 1 ? 'disable' : 'enable',
idx
);
}}
>
{user.status === 1 ? '禁用' : '启用'}
</Button>
<Button
size={'small'}
as={Link}
to={'/user/edit/' + user.id}
>
编辑
</Button>
</div>
</Table.Cell>
</Table.Row>
);
})}
</Table.Body>
<Table.Footer>
<Table.Row>
<Table.HeaderCell colSpan='6'>
<Button size='small' as={Link} to='/user/add' loading={loading}>
添加新的用户
</Button>
<Pagination
floated='right'
activePage={activePage}
onPageChange={onPaginationChange}
size='small'
siblingRange={1}
totalPages={
Math.ceil(users.length / ITEMS_PER_PAGE) +
(users.length % ITEMS_PER_PAGE === 0 ? 1 : 0)
}
/>
</Table.HeaderCell>
</Table.Row>
</Table.Footer>
</Table>
</>
);
};
export default UsersTable;
@@ -1 +0,0 @@
export const ITEMS_PER_PAGE = 10; // this value must keep same as the one defined in backend!
-3
View File
@@ -1,3 +0,0 @@
export * from './toast.constants';
export * from './user.constants';
export * from './common.constant';
@@ -1,7 +0,0 @@
export const toastConstants = {
SUCCESS_TIMEOUT: 500,
INFO_TIMEOUT: 3000,
ERROR_TIMEOUT: 5000,
WARNING_TIMEOUT: 10000,
NOTICE_TIMEOUT: 20000
};
@@ -1,19 +0,0 @@
export const userConstants = {
REGISTER_REQUEST: 'USERS_REGISTER_REQUEST',
REGISTER_SUCCESS: 'USERS_REGISTER_SUCCESS',
REGISTER_FAILURE: 'USERS_REGISTER_FAILURE',
LOGIN_REQUEST: 'USERS_LOGIN_REQUEST',
LOGIN_SUCCESS: 'USERS_LOGIN_SUCCESS',
LOGIN_FAILURE: 'USERS_LOGIN_FAILURE',
LOGOUT: 'USERS_LOGOUT',
GETALL_REQUEST: 'USERS_GETALL_REQUEST',
GETALL_SUCCESS: 'USERS_GETALL_SUCCESS',
GETALL_FAILURE: 'USERS_GETALL_FAILURE',
DELETE_REQUEST: 'USERS_DELETE_REQUEST',
DELETE_SUCCESS: 'USERS_DELETE_SUCCESS',
DELETE_FAILURE: 'USERS_DELETE_FAILURE'
};
@@ -1,19 +0,0 @@
// contexts/User/index.jsx
import React from 'react';
import { initialState, reducer } from './reducer';
export const StatusContext = React.createContext({
state: initialState,
dispatch: () => null,
});
export const StatusProvider = ({ children }) => {
const [state, dispatch] = React.useReducer(reducer, initialState);
return (
<StatusContext.Provider value={[state, dispatch]}>
{children}
</StatusContext.Provider>
);
};
@@ -1,20 +0,0 @@
export const reducer = (state, action) => {
switch (action.type) {
case 'set':
return {
...state,
status: action.payload,
};
case 'unset':
return {
...state,
status: undefined,
};
default:
return state;
}
};
export const initialState = {
status: undefined,
};
-19
View File
@@ -1,19 +0,0 @@
// contexts/User/index.jsx
import React from "react"
import { reducer, initialState } from "./reducer"
export const UserContext = React.createContext({
state: initialState,
dispatch: () => null
})
export const UserProvider = ({ children }) => {
const [state, dispatch] = React.useReducer(reducer, initialState)
return (
<UserContext.Provider value={[ state, dispatch ]}>
{ children }
</UserContext.Provider>
)
}
@@ -1,21 +0,0 @@
export const reducer = (state, action) => {
switch (action.type) {
case 'login':
return {
...state,
user: action.payload
};
case 'logout':
return {
...state,
user: undefined
};
default:
return state;
}
};
export const initialState = {
user: undefined
};
-13
View File
@@ -1,13 +0,0 @@
import { showError } from './utils';
import axios from 'axios';
export const API = axios.create({
baseURL: process.env.REACT_APP_SERVER ? process.env.REACT_APP_SERVER : '',
});
API.interceptors.response.use(
(response) => response,
(error) => {
showError(error);
}
);
@@ -1,10 +0,0 @@
export function authHeader() {
// return authorization header with jwt token
let user = JSON.parse(localStorage.getItem('user'));
if (user && user.token) {
return { 'Authorization': 'Bearer ' + user.token };
} else {
return {};
}
}
-3
View File
@@ -1,3 +0,0 @@
import { createBrowserHistory } from 'history';
export const history = createBrowserHistory();
-4
View File
@@ -1,4 +0,0 @@
export * from './history';
export * from './auth-header';
export * from './utils';
export * from './api';
-181
View File
@@ -1,181 +0,0 @@
import { toast } from 'react-toastify';
import { toastConstants } from '../constants';
export function isAdmin() {
let user = localStorage.getItem('user');
if (!user) return false;
user = JSON.parse(user);
return user.role >= 10;
}
export function isRoot() {
let user = localStorage.getItem('user');
if (!user) return false;
user = JSON.parse(user);
return user.role >= 100;
}
export function getSystemName() {
let system_name = localStorage.getItem('system_name');
if (!system_name) return 'ATSFlare';
return system_name;
}
export function getFooterHTML() {
return localStorage.getItem('footer_html');
}
export function normalizeVersion(value) {
if (!value) return '';
return String(value).trim().replace(/^v/i, '');
}
export function getBuildVersion() {
return normalizeVersion(process.env.REACT_APP_VERSION);
}
export function hasBuildVersion() {
return getBuildVersion() !== '';
}
export async function copy(text, label) {
let okay = true;
try {
await navigator.clipboard.writeText(text);
} catch (e) {
okay = false;
console.error(e);
}
if (okay && label) {
showSuccess(`${label} 已复制`);
}
return okay;
}
export function isMobile() {
return window.innerWidth <= 600;
}
let showErrorOptions = { autoClose: toastConstants.ERROR_TIMEOUT };
let showWarningOptions = { autoClose: toastConstants.WARNING_TIMEOUT };
let showSuccessOptions = { autoClose: toastConstants.SUCCESS_TIMEOUT };
let showInfoOptions = { autoClose: toastConstants.INFO_TIMEOUT };
let showNoticeOptions = { autoClose: false };
if (isMobile()) {
showErrorOptions.position = 'top-center';
// showErrorOptions.transition = 'flip';
showSuccessOptions.position = 'top-center';
// showSuccessOptions.transition = 'flip';
showInfoOptions.position = 'top-center';
// showInfoOptions.transition = 'flip';
showNoticeOptions.position = 'top-center';
// showNoticeOptions.transition = 'flip';
}
export function showError(error) {
console.error(error);
if (error.message) {
if (error.name === 'AxiosError') {
switch (error.response.status) {
case 401:
// toast.error('错误:未登录或登录已过期,请重新登录!', showErrorOptions);
window.location.href = '/login?expired=true';
break;
case 429:
toast.error('错误:请求次数过多,请稍后再试!', showErrorOptions);
break;
case 500:
toast.error('错误:服务器内部错误,请联系管理员!', showErrorOptions);
break;
case 405:
toast.info('本站仅作演示之用,无服务端!');
break;
default:
toast.error('错误:' + error.message, showErrorOptions);
}
return;
}
toast.error('错误:' + error.message, showErrorOptions);
} else {
toast.error('错误:' + error, showErrorOptions);
}
}
export function showWarning(message) {
toast.warn(message, showWarningOptions);
}
export function showSuccess(message) {
toast.success(message, showSuccessOptions);
}
export function showInfo(message) {
toast.info(message, showInfoOptions);
}
export function showNotice(message) {
toast.info(message, showNoticeOptions);
}
export function openPage(url) {
window.open(url);
}
export function removeTrailingSlash(url) {
if (url.endsWith('/')) {
return url.slice(0, -1);
} else {
return url;
}
}
export function timestamp2string(timestamp) {
let date = new Date(timestamp * 1000);
let year = date.getFullYear().toString();
let month = (date.getMonth() + 1).toString();
let day = date.getDate().toString();
let hour = date.getHours().toString();
let minute = date.getMinutes().toString();
let second = date.getSeconds().toString();
if (month.length === 1) {
month = '0' + month;
}
if (day.length === 1) {
day = '0' + day;
}
if (hour.length === 1) {
hour = '0' + hour;
}
if (minute.length === 1) {
minute = '0' + minute;
}
if (second.length === 1) {
second = '0' + second;
}
return (
year + '-' + month + '-' + day + ' ' + hour + ':' + minute + ':' + second
);
}
export function formatDateTime(value) {
if (!value) return '无';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString('zh-CN', { hour12: false });
}
export function timeAgo(value) {
if (!value) return '暂无';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const diff = Math.floor((Date.now() - date.getTime()) / 1000);
if (diff < 0) return '刚刚';
if (diff < 60) return `${diff} 秒前`;
if (diff < 3600) return `${Math.floor(diff / 60)} 分钟前`;
if (diff < 86400) return `${Math.floor(diff / 3600)} 小时前`;
return `${Math.floor(diff / 86400)} 天前`;
}
-74
View File
@@ -1,74 +0,0 @@
body {
margin: 0;
padding-top: 55px;
overflow-y: scroll;
font-family: Lato, 'Helvetica Neue', Arial, Helvetica, "Microsoft YaHei", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
scrollbar-width: none;
}
body::-webkit-scrollbar {
display: none;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
}
.main-content {
padding: 4px;
}
.small-icon .icon {
font-size: 1em !important;
}
.custom-footer {
font-size: 1.1em;
}
.page-toolbar {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 20px;
}
.page-subtitle {
margin-top: 6px;
color: rgba(0, 0, 0, 0.65);
}
.atsf-table td {
vertical-align: top !important;
}
.atsf-pre {
white-space: pre-wrap;
word-break: break-word;
background: #f7f8fa;
border: 1px solid #e3e6ea;
border-radius: 8px;
padding: 12px;
font-size: 12px;
line-height: 1.5;
}
.table-meta {
margin-top: 6px;
color: rgba(0, 0, 0, 0.58);
font-size: 12px;
}
@media only screen and (max-width: 600px) {
.hide-on-mobile {
display: none !important;
}
.page-toolbar {
flex-direction: column;
align-items: stretch;
}
}
-31
View File
@@ -1,31 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { Container } from 'semantic-ui-react';
import App from './App';
import Header from './components/Header';
import Footer from './components/Footer';
import 'semantic-ui-css/semantic.min.css';
import './index.css';
import { UserProvider } from './context/User';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import { StatusProvider } from './context/Status';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<StatusProvider>
<UserProvider>
<BrowserRouter>
<Header />
<Container className={'main-content'}>
<App />
</Container>
<ToastContainer />
<Footer />
</BrowserRouter>
</UserProvider>
</StatusProvider>
</React.StrictMode>
);
-51
View File
@@ -1,51 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Header, Segment } from 'semantic-ui-react';
import { API, showError } from '../../helpers';
import { marked } from 'marked';
const About = () => {
const [about, setAbout] = useState('');
const [aboutLoaded, setAboutLoaded] = useState(false);
const displayAbout = async () => {
setAbout(localStorage.getItem('about') || '');
const res = await API.get('/api/about');
const { success, message, data } = res.data;
if (success) {
let HTMLAbout = marked.parse(data);
setAbout(HTMLAbout);
localStorage.setItem('about', HTMLAbout);
} else {
showError(message);
setAbout('加载关于内容失败...');
}
setAboutLoaded(true);
};
useEffect(() => {
displayAbout().then();
}, []);
return (
<>
<Segment>
{aboutLoaded && about === '' ? (
<>
<Header as='h3'>关于</Header>
<p>可在设置页面设置关于内容,支持 HTML & Markdown</p>
项目仓库地址:
<a href='https://github.com/Rain-kl/ATSFlare'>
https://github.com/Rain-kl/ATSFlare
</a>
</>
) : (
<>
<div dangerouslySetInnerHTML={{ __html: about }}></div>
</>
)}
</Segment>
</>
);
};
export default About;
@@ -1,74 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Form, Header, Label, Segment, Table } from 'semantic-ui-react';
import { API, formatDateTime, showError } from '../../helpers';
const ApplyLog = () => {
const [logs, setLogs] = useState([]);
const [keyword, setKeyword] = useState('');
const [loading, setLoading] = useState(false);
const loadLogs = async (nodeId = '') => {
setLoading(true);
const path = nodeId ? `/api/apply-logs/?node_id=${encodeURIComponent(nodeId)}` : '/api/apply-logs/';
const res = await API.get(path);
const { success, message, data } = res.data;
if (success) {
setLogs(data || []);
} else {
showError(message);
}
setLoading(false);
};
useEffect(() => {
loadLogs().then();
}, []);
const submit = async () => {
await loadLogs(keyword.trim());
};
return (
<Segment loading={loading}>
<Header as='h3'>应用记录</Header>
<p className='page-subtitle'>按节点查看配置应用成功或失败记录。</p>
<Form onSubmit={submit}>
<Form.Input
icon='search'
iconPosition='left'
placeholder='输入 node_id 过滤应用记录'
value={keyword}
onChange={(e, { value }) => setKeyword(value)}
/>
</Form>
<Table celled stackable className='atsf-table'>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Node ID</Table.HeaderCell>
<Table.HeaderCell>版本</Table.HeaderCell>
<Table.HeaderCell>结果</Table.HeaderCell>
<Table.HeaderCell>信息</Table.HeaderCell>
<Table.HeaderCell>时间</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{logs.map((log) => (
<Table.Row key={log.id}>
<Table.Cell>{log.node_id}</Table.Cell>
<Table.Cell>{log.version}</Table.Cell>
<Table.Cell>
{log.result === 'success' ? <Label color='green'>成功</Label> : <Label color='red'>失败</Label>}
</Table.Cell>
<Table.Cell>{log.message || '无'}</Table.Cell>
<Table.Cell>{formatDateTime(log.created_at)}</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
</Segment>
);
};
export default ApplyLog;
@@ -1,213 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
Button,
Divider,
Header,
Icon,
Label,
Modal,
Segment,
Table,
} from 'semantic-ui-react';
import { API, formatDateTime, showError, showSuccess } from '../../helpers';
const renderDomainList = (items) => {
if (!items || items.length === 0) {
return <p className='page-subtitle'>无</p>;
}
return (
<ul>
{items.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
);
};
const ConfigVersion = () => {
const [versions, setVersions] = useState([]);
const [loading, setLoading] = useState(false);
const [publishing, setPublishing] = useState(false);
const [preview, setPreview] = useState(null);
const [publishPreviewOpen, setPublishPreviewOpen] = useState(false);
const [previewLoading, setPreviewLoading] = useState(false);
const [pendingPreview, setPendingPreview] = useState(null);
const [pendingDiff, setPendingDiff] = useState(null);
const loadVersions = async () => {
setLoading(true);
const res = await API.get('/api/config-versions/');
const { success, message, data } = res.data;
if (success) {
setVersions(data || []);
} else {
showError(message);
}
setLoading(false);
};
useEffect(() => {
loadVersions().then();
}, []);
const publishConfig = async () => {
setPublishing(true);
const res = await API.post('/api/config-versions/publish');
const { success, message, data } = res.data;
if (success) {
showSuccess(`发布成功,版本 ${data.version}`);
await loadVersions();
} else {
showError(message);
}
setPublishing(false);
return success;
};
const openPublishPreview = async () => {
setPreviewLoading(true);
const [previewRes, diffRes] = await Promise.all([
API.get('/api/config-versions/preview'),
API.get('/api/config-versions/diff'),
]);
const previewPayload = previewRes.data;
const diffPayload = diffRes.data;
if (!previewPayload.success) {
showError(previewPayload.message);
setPreviewLoading(false);
return;
}
if (!diffPayload.success) {
showError(diffPayload.message);
setPreviewLoading(false);
return;
}
setPendingPreview(previewPayload.data || null);
setPendingDiff(diffPayload.data || null);
setPublishPreviewOpen(true);
setPreviewLoading(false);
};
const confirmPublish = async () => {
const success = await publishConfig();
if (success) {
setPublishPreviewOpen(false);
setPendingPreview(null);
setPendingDiff(null);
}
};
const activateVersion = async (id) => {
const res = await API.put(`/api/config-versions/${id}/activate`);
const { success, message, data } = res.data;
if (success) {
showSuccess(`已激活版本 ${data.version}`);
await loadVersions();
} else {
showError(message);
}
};
return (
<Segment loading={loading}>
<div className='page-toolbar'>
<div>
<Header as='h3'>版本发布</Header>
<p className='page-subtitle'>查看历史快照,预览即将发布的配置与变更摘要,或重新激活旧版本。</p>
</div>
<Button primary icon labelPosition='left' loading={previewLoading} onClick={openPublishPreview}>
<Icon name='eye' />
预览并发布
</Button>
</div>
<Table celled stackable className='atsf-table'>
<Table.Header>
<Table.Row>
<Table.HeaderCell>版本号</Table.HeaderCell>
<Table.HeaderCell>状态</Table.HeaderCell>
<Table.HeaderCell>创建人</Table.HeaderCell>
<Table.HeaderCell>Checksum</Table.HeaderCell>
<Table.HeaderCell>创建时间</Table.HeaderCell>
<Table.HeaderCell>操作</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{versions.map((version) => (
<Table.Row key={version.id}>
<Table.Cell>{version.version}</Table.Cell>
<Table.Cell>
{version.is_active ? <Label color='green'>当前激活</Label> : <Label>历史版本</Label>}
</Table.Cell>
<Table.Cell>{version.created_by}</Table.Cell>
<Table.Cell title={version.checksum}>{(version.checksum || '').slice(0, 16)}...</Table.Cell>
<Table.Cell>{formatDateTime(version.created_at)}</Table.Cell>
<Table.Cell>
<Button size='small' onClick={() => setPreview(version)}>
查看快照
</Button>
{!version.is_active ? (
<Button size='small' positive onClick={() => activateVersion(version.id)}>
激活
</Button>
) : null}
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
<Modal open={!!preview} onClose={() => setPreview(null)} closeIcon>
<Modal.Header>版本预览</Modal.Header>
<Modal.Content scrolling>
{preview ? (
<>
<Header as='h4'>快照 JSON</Header>
<pre className='atsf-pre'>{preview.snapshot_json}</pre>
<Header as='h4'>渲染结果</Header>
<pre className='atsf-pre'>{preview.rendered_config}</pre>
</>
) : null}
</Modal.Content>
</Modal>
<Modal open={publishPreviewOpen} onClose={() => setPublishPreviewOpen(false)} closeIcon>
<Modal.Header>发布前预览</Modal.Header>
<Modal.Content scrolling>
{pendingDiff ? (
<>
<Header as='h4'>变更摘要</Header>
<p className='page-subtitle'>当前激活版本:{pendingDiff.active_version || '无'}</p>
<Label color='green'>新增 {pendingDiff.added_domains?.length || 0}</Label>
<Label color='orange'>删除 {pendingDiff.removed_domains?.length || 0}</Label>
<Label color='blue'>修改 {pendingDiff.modified_domains?.length || 0}</Label>
<Divider />
<Header as='h5'>新增域名</Header>
{renderDomainList(pendingDiff.added_domains)}
<Header as='h5'>删除域名</Header>
{renderDomainList(pendingDiff.removed_domains)}
<Header as='h5'>修改域名</Header>
{renderDomainList(pendingDiff.modified_domains)}
</>
) : null}
{pendingPreview ? (
<>
<Divider />
<Header as='h4'>渲染结果</Header>
<p className='page-subtitle'>启用规则数:{pendingPreview.route_count},Checksum:{pendingPreview.checksum}</p>
<pre className='atsf-pre'>{pendingPreview.rendered_config}</pre>
</>
) : null}
</Modal.Content>
<Modal.Actions>
<Button onClick={() => setPublishPreviewOpen(false)}>取消</Button>
<Button primary loading={publishing} onClick={confirmPublish}>
确认发布
</Button>
</Modal.Actions>
</Modal>
</Segment>
);
};
export default ConfigVersion;
-14
View File
@@ -1,14 +0,0 @@
import React from 'react';
import { Header, Segment } from 'semantic-ui-react';
import FilesTable from '../../components/FilesTable';
const File = () => (
<>
<Segment>
<Header as='h3'>管理文件</Header>
<FilesTable />
</Segment>
</>
);
export default File;
-110
View File
@@ -1,110 +0,0 @@
import React, { useContext, useEffect } from 'react';
import { Card, Grid, Header, Segment } from 'semantic-ui-react';
import { API, showError, showNotice, timestamp2string } from '../../helpers';
import { StatusContext } from '../../context/Status';
const Home = () => {
const [statusState, statusDispatch] = useContext(StatusContext);
const homePageLink = localStorage.getItem('home_page_link') || '';
const displayNotice = async () => {
const res = await API.get('/api/notice');
const { success, message, data } = res.data;
if (success) {
let oldNotice = localStorage.getItem('notice');
if (data !== oldNotice && data !== '') {
showNotice(data);
localStorage.setItem('notice', data);
}
} else {
showError(message);
}
};
const getStartTimeString = () => {
const timestamp = statusState?.status?.start_time;
return timestamp2string(timestamp);
};
useEffect(() => {
displayNotice().then();
}, []);
return (
<>
{homePageLink !== '' ? (
<>
<iframe
src={homePageLink}
style={{ width: '100%', height: '100vh', border: 'none' }}
/>
</>
) : (
<>
<Segment>
<Header as='h3'>系统状况</Header>
<Grid columns={2} stackable>
<Grid.Column>
<Card fluid>
<Card.Content>
<Card.Header>系统信息</Card.Header>
<Card.Meta>系统信息总览</Card.Meta>
<Card.Description>
<p>名称:{statusState?.status?.system_name}</p>
<p>版本:{statusState?.status?.version}</p>
<p>
源码:
<a
href='https://github.com/Rain-kl/ATSFlare'
target='_blank'
>
https://github.com/Rain-kl/ATSFlare
</a>
</p>
<p>启动时间:{getStartTimeString()}</p>
</Card.Description>
</Card.Content>
</Card>
</Grid.Column>
<Grid.Column>
<Card fluid>
<Card.Content>
<Card.Header>系统配置</Card.Header>
<Card.Meta>系统配置总览</Card.Meta>
<Card.Description>
<p>
邮箱验证:
{statusState?.status?.email_verification === true
? '已启用'
: '未启用'}
</p>
<p>
GitHub 身份验证:
{statusState?.status?.github_oauth === true
? '已启用'
: '未启用'}
</p>
<p>
微信身份验证:
{statusState?.status?.wechat_login === true
? '已启用'
: '未启用'}
</p>
<p>
Turnstile 用户校验:
{statusState?.status?.turnstile_check === true
? '已启用'
: '未启用'}
</p>
</Card.Description>
</Card.Content>
</Card>
</Grid.Column>
</Grid>
</Segment>
</>
)}
</>
);
};
export default Home;
@@ -1,198 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Button, Dropdown, Form, Header, Label, Segment, Table } from 'semantic-ui-react';
import { API, formatDateTime, showError, showSuccess } from '../../helpers';
const initialForm = {
domain: '',
cert_id: '',
enabled: true,
remark: '',
};
const ManagedDomain = () => {
const [domains, setDomains] = useState([]);
const [certificates, setCertificates] = useState([]);
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState(initialForm);
const [editingId, setEditingId] = useState(null);
const loadCertificates = async () => {
const res = await API.get('/api/tls-certificates/');
const { success, message, data } = res.data;
if (success) {
setCertificates(data || []);
} else {
showError(message);
}
};
const loadDomains = async () => {
setLoading(true);
const res = await API.get('/api/managed-domains/');
const { success, message, data } = res.data;
if (success) {
setDomains(data || []);
} else {
showError(message);
}
setLoading(false);
};
useEffect(() => {
loadCertificates().then();
loadDomains().then();
}, []);
const certificateMap = useMemo(() => {
const map = new Map();
certificates.forEach((certificate) => {
map.set(certificate.id, certificate);
});
return map;
}, [certificates]);
const certificateOptions = certificates.map((certificate) => ({
key: certificate.id,
value: certificate.id,
text: certificate.name,
}));
const resetForm = () => {
setForm(initialForm);
setEditingId(null);
};
const submitManagedDomain = async () => {
setSubmitting(true);
const payload = {
domain: form.domain.trim(),
cert_id: form.cert_id ? Number(form.cert_id) : null,
enabled: form.enabled,
remark: form.remark.trim(),
};
const res = editingId
? await API.put(`/api/managed-domains/${editingId}`, payload)
: await API.post('/api/managed-domains/', payload);
const { success, message } = res.data;
if (success) {
showSuccess(editingId ? '域名规则已更新' : '域名规则已创建');
resetForm();
await loadDomains();
} else {
showError(message);
}
setSubmitting(false);
};
const deleteManagedDomain = async (id) => {
const res = await API.delete(`/api/managed-domains/${id}`);
const { success, message } = res.data;
if (success) {
showSuccess('域名规则已删除');
await loadDomains();
} else {
showError(message);
}
};
const beginEdit = (domain) => {
setEditingId(domain.id);
setForm({
domain: domain.domain,
cert_id: domain.cert_id || '',
enabled: domain.enabled,
remark: domain.remark || '',
});
};
return (
<Segment loading={loading}>
<Header as='h3'>域名管理</Header>
<p className='page-subtitle'>维护精确域名与通配符域名,并为其绑定默认 TLS 证书。</p>
<Form onSubmit={submitManagedDomain}>
<Form.Group widths='equal'>
<Form.Input
label='域名'
placeholder='example.com 或 *.example.com'
value={form.domain}
onChange={(e, { value }) => setForm({ ...form, domain: value })}
/>
<Form.Field
control={Dropdown}
selection
clearable
label='默认证书'
placeholder='可选,绑定默认 TLS 证书'
options={certificateOptions}
value={form.cert_id}
onChange={(e, { value }) => setForm({ ...form, cert_id: value || '' })}
/>
</Form.Group>
<Form.Group widths='equal'>
<Form.Input
label='备注'
placeholder='可选备注'
value={form.remark}
onChange={(e, { value }) => setForm({ ...form, remark: value })}
/>
<Form.Checkbox
toggle
label='启用规则'
checked={form.enabled}
onChange={(e, { checked }) => setForm({ ...form, enabled: checked })}
style={{ alignSelf: 'flex-end', marginBottom: '1rem' }}
/>
</Form.Group>
<Button primary type='submit' loading={submitting}>
{editingId ? '保存修改' : '新增域名'}
</Button>
{editingId ? (
<Button type='button' onClick={resetForm}>
取消编辑
</Button>
) : null}
</Form>
<Table celled stackable className='atsf-table'>
<Table.Header>
<Table.Row>
<Table.HeaderCell>域名</Table.HeaderCell>
<Table.HeaderCell>绑定证书</Table.HeaderCell>
<Table.HeaderCell>状态</Table.HeaderCell>
<Table.HeaderCell>备注</Table.HeaderCell>
<Table.HeaderCell>更新时间</Table.HeaderCell>
<Table.HeaderCell>操作</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{domains.map((domain) => {
const certificate = domain.cert_id ? certificateMap.get(domain.cert_id) : null;
return (
<Table.Row key={domain.id}>
<Table.Cell>{domain.domain}</Table.Cell>
<Table.Cell>{certificate ? certificate.name : '未绑定'}</Table.Cell>
<Table.Cell>
{domain.enabled ? <Label color='green'>启用</Label> : <Label>停用</Label>}
</Table.Cell>
<Table.Cell>{domain.remark || '无'}</Table.Cell>
<Table.Cell>{formatDateTime(domain.updated_at)}</Table.Cell>
<Table.Cell>
<Button size='small' onClick={() => beginEdit(domain)}>
编辑
</Button>
<Button size='small' negative onClick={() => deleteManagedDomain(domain.id)}>
删除
</Button>
</Table.Cell>
</Table.Row>
);
})}
</Table.Body>
</Table>
</Segment>
);
};
export default ManagedDomain;
-291
View File
@@ -1,291 +0,0 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Button, Form, Header, Label, Segment, Table } from 'semantic-ui-react';
import { API, showError, showSuccess, timeAgo } from '../../helpers';
const initialForm = {
name: '',
auto_update_enabled: false,
};
const renderStatus = (status) => {
if (status === 'online') {
return <Label color='green'>在线</Label>;
}
if (status === 'pending') {
return <Label color='orange'>待接入</Label>;
}
return <Label color='grey'>离线</Label>;
};
const renderApply = (result) => {
if (result === 'success') {
return <Label color='green'>成功</Label>;
}
if (result === 'failed') {
return <Label color='red'>失败</Label>;
}
return <Label>暂无</Label>;
};
const renderUpdateMode = (node) => {
if (node.update_requested) {
return <Label color='orange'>等待更新</Label>;
}
if (node.auto_update_enabled) {
return <Label color='green'>自动更新</Label>;
}
return <Label>手动更新</Label>;
};
const Node = () => {
const [nodes, setNodes] = useState([]);
const [bootstrap, setBootstrap] = useState({ discovery_token: '' });
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState(initialForm);
const [editingId, setEditingId] = useState(null);
const [, setTick] = useState(0);
const refreshTimer = useRef(null);
const loadNodes = useCallback(async (silent) => {
if (!silent) setLoading(true);
const res = await API.get('/api/nodes/');
const { success, message, data } = res.data;
if (success) {
setNodes(data || []);
} else {
showError(message);
}
if (!silent) setLoading(false);
}, []);
const loadBootstrapToken = async () => {
const res = await API.get('/api/nodes/bootstrap-token');
const { success, message, data } = res.data;
if (success) {
setBootstrap(data || { discovery_token: '' });
} else {
showError(message);
}
};
const rotateBootstrapToken = async () => {
setLoading(true);
const res = await API.post('/api/nodes/bootstrap-token/rotate');
const { success, message, data } = res.data;
if (success) {
setBootstrap(data || { discovery_token: '' });
showSuccess('全局 discovery token 已重新生成');
} else {
showError(message);
}
setLoading(false);
};
useEffect(() => {
loadBootstrapToken().then();
loadNodes(false).then();
// Refresh node list and relative time every 30s
refreshTimer.current = setInterval(() => {
loadNodes(true);
setTick((t) => t + 1);
}, 30000);
return () => clearInterval(refreshTimer.current);
}, [loadNodes]);
const resetForm = () => {
setForm(initialForm);
setEditingId(null);
};
const submitNode = async () => {
setSubmitting(true);
const payload = {
name: form.name.trim(),
auto_update_enabled: form.auto_update_enabled,
};
const res = editingId
? await API.put(`/api/nodes/${editingId}`, payload)
: await API.post('/api/nodes/', payload);
const { success, message } = res.data;
if (success) {
showSuccess(editingId ? '节点已更新' : '节点已创建');
resetForm();
await loadNodes(false);
} else {
showError(message);
}
setSubmitting(false);
};
const beginEdit = (node) => {
setEditingId(node.id);
setForm({
name: node.name || '',
auto_update_enabled: !!node.auto_update_enabled,
});
};
const requestAgentUpdate = async (node) => {
const res = await API.post(`/api/nodes/${node.id}/agent-update`);
const { success, message } = res.data;
if (success) {
showSuccess(`已向节点 ${node.name} 下发更新指令`);
await loadNodes(false);
} else {
showError(message);
}
};
const deleteNode = async (node) => {
if (
!window.confirm(
`确认删除节点“${node.name}”吗?删除后该节点需要重新创建并重新接入。`
)
) {
return;
}
const res = await API.delete(`/api/nodes/${node.id}`);
const { success, message } = res.data;
if (success) {
showSuccess('节点已删除');
if (editingId === node.id) {
resetForm();
}
await loadNodes(false);
} else {
showError(message);
}
};
return (
<Segment loading={loading}>
<Header as='h3'>节点管理</Header>
<p className='page-subtitle'>
创建节点后会直接生成节点专属 auth token;批量部署时可复用全局 discovery
token 自动注册。
</p>
<Form>
<Form.Group widths='equal'>
<Form.Input
label='全局 Discovery Token'
readOnly
value={bootstrap.discovery_token || ''}
/>
</Form.Group>
<Button type='button' onClick={rotateBootstrapToken}>
重新生成 Discovery Token
</Button>
</Form>
<Form onSubmit={submitNode}>
<Form.Group widths='equal'>
<Form.Input
label='节点名'
placeholder='例如 shanghai-edge-1'
value={form.name}
onChange={(e, { value }) => setForm({ ...form, name: value })}
/>
<Form.Checkbox
label='启用该节点自动更新'
checked={form.auto_update_enabled}
onChange={(e, { checked }) =>
setForm({ ...form, auto_update_enabled: !!checked })
}
style={{ alignSelf: 'flex-end', marginBottom: '0.5rem' }}
/>
</Form.Group>
<Button primary type='submit' loading={submitting}>
{editingId ? '保存修改' : '新增节点'}
</Button>
{editingId ? (
<Button type='button' onClick={resetForm}>
取消编辑
</Button>
) : null}
</Form>
<Table celled stackable className='atsf-table'>
<Table.Header>
<Table.Row>
<Table.HeaderCell>节点名</Table.HeaderCell>
<Table.HeaderCell>Node ID</Table.HeaderCell>
<Table.HeaderCell>Auth Token</Table.HeaderCell>
<Table.HeaderCell>IP</Table.HeaderCell>
<Table.HeaderCell>状态</Table.HeaderCell>
<Table.HeaderCell>Agent 更新</Table.HeaderCell>
<Table.HeaderCell>Agent / Nginx</Table.HeaderCell>
<Table.HeaderCell>当前版本</Table.HeaderCell>
<Table.HeaderCell>最近应用</Table.HeaderCell>
<Table.HeaderCell>最近心跳</Table.HeaderCell>
<Table.HeaderCell>错误</Table.HeaderCell>
<Table.HeaderCell>操作</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{nodes.map((node) => (
<Table.Row key={node.id}>
<Table.Cell>{node.name}</Table.Cell>
<Table.Cell>{node.node_id}</Table.Cell>
<Table.Cell>
{node.agent_token ? (
<>
{node.pending ? (
<Label color='orange'>未占用</Label>
) : (
<Label color='green'>已绑定</Label>
)}
<div
className='table-meta'
style={{ wordBreak: 'break-all' }}
>
{node.agent_token}
</div>
</>
) : (
'暂无'
)}
</Table.Cell>
<Table.Cell>{node.ip}</Table.Cell>
<Table.Cell>{renderStatus(node.status)}</Table.Cell>
<Table.Cell>{renderUpdateMode(node)}</Table.Cell>
<Table.Cell>
{node.agent_version} / {node.nginx_version || 'unknown'}
</Table.Cell>
<Table.Cell>{node.current_version || '未应用'}</Table.Cell>
<Table.Cell>
{renderApply(node.latest_apply_result)}
<div className='table-meta'>
{node.latest_apply_message || '暂无记录'}
</div>
</Table.Cell>
<Table.Cell title={node.last_seen_at}>
{node.last_seen_at ? timeAgo(node.last_seen_at) : '暂无'}
</Table.Cell>
<Table.Cell>{node.last_error || '无'}</Table.Cell>
<Table.Cell>
<Button
size='small'
primary
disabled={node.update_requested}
onClick={() => requestAgentUpdate(node)}
>
立即更新
</Button>
<Button size='small' onClick={() => beginEdit(node)}>
编辑
</Button>
<Button size='small' negative onClick={() => deleteNode(node)}>
删除
</Button>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
</Segment>
);
};
export default Node;
@@ -1,20 +0,0 @@
import React from 'react';
import { Segment, Header } from 'semantic-ui-react';
const NotFound = () => (
<>
<Header
block
as="h4"
content="404"
attached="top"
icon="info"
className="small-icon"
/>
<Segment attached="bottom">
未找到所请求的页面
</Segment>
</>
);
export default NotFound;
@@ -1,398 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
Button,
Dropdown,
Form,
Header,
Icon,
Input,
Label,
Segment,
Table,
TextArea,
} from 'semantic-ui-react';
import { API, showError, showSuccess, formatDateTime } from '../../helpers';
const emptyHeader = () => ({ key: '', value: '' });
const parseCustomHeaders = (rawValue) => {
if (Array.isArray(rawValue)) {
return rawValue;
}
if (!rawValue) {
return [];
}
try {
const parsed = JSON.parse(rawValue);
return Array.isArray(parsed) ? parsed : [];
} catch (error) {
return [];
}
};
const initialForm = {
domain: '',
origin_url: '',
enabled: true,
enable_https: false,
cert_id: '',
redirect_http: false,
custom_headers: [emptyHeader()],
remark: '',
};
const ProxyRoute = () => {
const [routes, setRoutes] = useState([]);
const [certificates, setCertificates] = useState([]);
const [loading, setLoading] = useState(false);
const [publishing, setPublishing] = useState(false);
const [matching, setMatching] = useState(false);
const [matchResult, setMatchResult] = useState(null);
const [form, setForm] = useState(initialForm);
const [editingId, setEditingId] = useState(null);
const loadRoutes = async () => {
setLoading(true);
const res = await API.get('/api/proxy-routes/');
const { success, message, data } = res.data;
if (success) {
setRoutes(data || []);
} else {
showError(message);
}
setLoading(false);
};
const loadCertificates = async () => {
const res = await API.get('/api/tls-certificates/');
const { success, message, data } = res.data;
if (success) {
setCertificates(data || []);
} else {
showError(message);
}
};
useEffect(() => {
loadRoutes().then();
loadCertificates().then();
}, []);
useEffect(() => {
if (!form.enable_https) {
setMatchResult(null);
return undefined;
}
const domain = form.domain.trim().toLowerCase();
if (!domain) {
setMatchResult(null);
return undefined;
}
const timer = setTimeout(async () => {
setMatching(true);
const res = await API.get('/api/managed-domains/match', {
params: { domain },
});
const { success, message, data } = res.data;
if (success) {
setMatchResult(data || null);
if (data?.matched && data?.candidate?.certificate_id) {
setForm((current) => {
if (!current.enable_https || current.domain.trim().toLowerCase() !== domain || current.cert_id) {
return current;
}
return { ...current, cert_id: data.candidate.certificate_id };
});
}
} else {
setMatchResult(null);
showError(message);
}
setMatching(false);
}, 400);
return () => clearTimeout(timer);
}, [form.domain, form.enable_https]);
const resetForm = () => {
setForm(initialForm);
setEditingId(null);
setMatchResult(null);
};
const submitRoute = async (event) => {
event?.preventDefault?.();
const payload = {
...form,
domain: form.domain.trim(),
origin_url: form.origin_url.trim(),
cert_id: form.enable_https && form.cert_id ? Number(form.cert_id) : null,
custom_headers: (form.custom_headers || []).filter((item) => item.key.trim() || item.value.trim()),
remark: form.remark.trim(),
};
const res = editingId
? await API.put(`/api/proxy-routes/${editingId}`, payload)
: await API.post('/api/proxy-routes/', payload);
const { success, message } = res.data;
if (success) {
showSuccess(editingId ? '规则已更新' : '规则已创建');
resetForm();
await loadRoutes();
} else {
showError(message);
}
};
const deleteRoute = async (id) => {
const res = await API.delete(`/api/proxy-routes/${id}`);
const { success, message } = res.data;
if (success) {
showSuccess('规则已删除');
await loadRoutes();
} else {
showError(message);
}
};
const publishConfig = async () => {
setPublishing(true);
const res = await API.post('/api/config-versions/publish');
const { success, message, data } = res.data;
if (success) {
showSuccess(`发布成功,版本 ${data.version}`);
} else {
showError(message);
}
setPublishing(false);
};
const beginEdit = (route) => {
const customHeaders = parseCustomHeaders(route.custom_headers);
setEditingId(route.id);
setForm({
domain: route.domain,
origin_url: route.origin_url,
enabled: route.enabled,
enable_https: route.enable_https || false,
cert_id: route.cert_id || '',
redirect_http: route.redirect_http || false,
custom_headers: customHeaders.length > 0 ? customHeaders : [emptyHeader()],
remark: route.remark || '',
});
setMatchResult(null);
};
const updateCustomHeader = (index, field, value) => {
setForm((current) => ({
...current,
custom_headers: current.custom_headers.map((item, itemIndex) => (
itemIndex === index ? { ...item, [field]: value } : item
)),
}));
};
const addCustomHeader = () => {
setForm((current) => ({
...current,
custom_headers: [...(current.custom_headers || []), emptyHeader()],
}));
};
const removeCustomHeader = (index) => {
setForm((current) => {
const nextHeaders = (current.custom_headers || []).filter((item, itemIndex) => itemIndex !== index);
return {
...current,
custom_headers: nextHeaders.length > 0 ? nextHeaders : [emptyHeader()],
};
});
};
const certificateOptions = certificates.map((certificate) => ({
key: certificate.id,
text: `${certificate.name} (${certificate.not_after ? formatDateTime(certificate.not_after) : 'unknown'})`,
value: certificate.id,
}));
return (
<Segment loading={loading}>
<div className='page-toolbar'>
<div>
<Header as='h3'>反代规则</Header>
<p className='page-subtitle'>维护 Host 到 Origin 的映射,并可直接触发发布。</p>
</div>
<Button primary icon labelPosition='left' loading={publishing} onClick={publishConfig}>
<Icon name='cloud upload' />
发布当前规则
</Button>
</div>
<Form onSubmit={submitRoute}>
<Form.Group widths='equal'>
<Form.Input
label='域名'
placeholder='example.com'
value={form.domain}
onChange={(e, { value }) => setForm({ ...form, domain: value })}
/>
<Form.Input
label='源站地址'
placeholder='https://origin.internal'
value={form.origin_url}
onChange={(e, { value }) => setForm({ ...form, origin_url: value })}
/>
</Form.Group>
<Form.Group widths='equal'>
<Form.Checkbox
toggle
label='启用 HTTPS'
checked={form.enable_https}
onChange={(e, { checked }) =>
setForm({
...form,
enable_https: checked,
cert_id: checked ? form.cert_id : '',
redirect_http: checked ? form.redirect_http : false,
})
}
style={{ alignSelf: 'flex-end', marginBottom: '1rem' }}
/>
<Form.Field
control={Dropdown}
selection
clearable
disabled={!form.enable_https}
label='TLS 证书'
placeholder='选择已导入证书'
options={certificateOptions}
value={form.cert_id}
onChange={(e, { value }) => setForm({ ...form, cert_id: value || '' })}
/>
</Form.Group>
{form.enable_https ? (
<p className='page-subtitle' style={{ marginTop: '-0.5rem' }}>
{matching
? '正在按域名自动匹配证书...'
: matchResult?.matched
? `已匹配${matchResult.candidate?.match_type === 'exact' ? '精确' : '通配符'}规则 ${matchResult.candidate?.domain},推荐证书:${matchResult.candidate?.certificate_name}`
: form.domain.trim()
? '未找到匹配证书,可继续手动选择。'
: '输入域名后会自动匹配托管证书。'}
</p>
) : null}
<Form.Group widths='equal'>
<Form.Field
control={TextArea}
label='备注'
placeholder='可选备注'
value={form.remark}
onChange={(e, { value }) => setForm({ ...form, remark: value })}
/>
<Form.Checkbox
toggle
label='启用规则'
checked={form.enabled}
onChange={(e, { checked }) => setForm({ ...form, enabled: checked })}
style={{ alignSelf: 'flex-end', marginBottom: '1rem' }}
/>
<Form.Checkbox
toggle
label='HTTP 跳转 HTTPS'
checked={form.redirect_http}
disabled={!form.enable_https}
onChange={(e, { checked }) => setForm({ ...form, redirect_http: checked })}
style={{ alignSelf: 'flex-end', marginBottom: '1rem' }}
/>
</Form.Group>
<Form.Field>
<label>自定义请求头</label>
{(form.custom_headers || []).map((header, index) => (
<Form.Group widths='equal' key={`header-${index}`}>
<Form.Field
control={Input}
label={index === 0 ? 'Header 名称' : undefined}
placeholder='X-Trace-Id'
value={header.key}
onChange={(e, { value }) => updateCustomHeader(index, 'key', value)}
/>
<Form.Field
control={Input}
label={index === 0 ? 'Header 值' : undefined}
placeholder='$request_id'
value={header.value}
onChange={(e, { value }) => updateCustomHeader(index, 'value', value)}
/>
<Form.Field style={{ display: 'flex', alignItems: index === 0 ? 'flex-end' : 'center' }}>
<Button type='button' icon='trash' negative basic onClick={() => removeCustomHeader(index)} />
</Form.Field>
</Form.Group>
))}
<Button type='button' basic icon labelPosition='left' onClick={addCustomHeader}>
<Icon name='plus' />
添加请求头
</Button>
</Form.Field>
<Button primary type='submit'>
{editingId ? '保存修改' : '新增规则'}
</Button>
{editingId ? (
<Button type='button' onClick={resetForm}>
取消编辑
</Button>
) : null}
</Form>
<Table celled stackable className='atsf-table'>
<Table.Header>
<Table.Row>
<Table.HeaderCell>域名</Table.HeaderCell>
<Table.HeaderCell>源站地址</Table.HeaderCell>
<Table.HeaderCell>HTTPS</Table.HeaderCell>
<Table.HeaderCell>自定义头</Table.HeaderCell>
<Table.HeaderCell>状态</Table.HeaderCell>
<Table.HeaderCell>备注</Table.HeaderCell>
<Table.HeaderCell>更新时间</Table.HeaderCell>
<Table.HeaderCell>操作</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{routes.map((route) => (
<Table.Row key={route.id}>
<Table.Cell>{route.domain}</Table.Cell>
<Table.Cell>{route.origin_url}</Table.Cell>
<Table.Cell>
{route.enable_https ? (
<Label color='blue'>{route.redirect_http ? 'HTTPS + 重定向' : 'HTTPS'}</Label>
) : (
<Label>HTTP</Label>
)}
</Table.Cell>
<Table.Cell>
{parseCustomHeaders(route.custom_headers).length > 0 ? (
<Label color='teal'>{parseCustomHeaders(route.custom_headers).length} 条</Label>
) : (
<Label>无</Label>
)}
</Table.Cell>
<Table.Cell>
{route.enabled ? <Label color='green'>启用</Label> : <Label>停用</Label>}
</Table.Cell>
<Table.Cell>{route.remark || '无'}</Table.Cell>
<Table.Cell>{formatDateTime(route.updated_at)}</Table.Cell>
<Table.Cell>
<Button size='small' onClick={() => beginEdit(route)}>
编辑
</Button>
<Button size='small' negative onClick={() => deleteRoute(route.id)}>
删除
</Button>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
</Segment>
);
};
export default ProxyRoute;
@@ -1,55 +0,0 @@
import React from 'react';
import { Segment, Tab } from 'semantic-ui-react';
import SystemSetting from '../../components/SystemSetting';
import { isRoot } from '../../helpers';
import OtherSetting from '../../components/OtherSetting';
import PersonalSetting from '../../components/PersonalSetting';
import OperationSetting from '../../components/OperationSetting';
const Setting = () => {
let panes = [
{
menuItem: '个人设置',
render: () => (
<Tab.Pane attached={false}>
<PersonalSetting />
</Tab.Pane>
),
},
];
if (isRoot()) {
panes.push({
menuItem: '运维设置',
render: () => (
<Tab.Pane attached={false}>
<OperationSetting />
</Tab.Pane>
),
});
panes.push({
menuItem: '系统设置',
render: () => (
<Tab.Pane attached={false}>
<SystemSetting />
</Tab.Pane>
),
});
panes.push({
menuItem: '其他设置',
render: () => (
<Tab.Pane attached={false}>
<OtherSetting />
</Tab.Pane>
),
});
}
return (
<Segment>
<Tab menu={{ secondary: true, pointing: true }} panes={panes} />
</Segment>
);
};
export default Setting;
@@ -1,221 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
Button,
Form,
Header,
Icon,
Segment,
Table,
TextArea,
} from 'semantic-ui-react';
import { API, formatDateTime, showError, showSuccess } from '../../helpers';
const initialManualForm = {
name: '',
cert_pem: '',
key_pem: '',
remark: '',
};
const initialFileForm = {
name: '',
remark: '',
};
const TLSCertificate = () => {
const [certificates, setCertificates] = useState([]);
const [loading, setLoading] = useState(false);
const [manualForm, setManualForm] = useState(initialManualForm);
const [fileForm, setFileForm] = useState(initialFileForm);
const [certFile, setCertFile] = useState(null);
const [keyFile, setKeyFile] = useState(null);
const [submittingManual, setSubmittingManual] = useState(false);
const [submittingFiles, setSubmittingFiles] = useState(false);
const loadCertificates = async () => {
setLoading(true);
const res = await API.get('/api/tls-certificates/');
const { success, message, data } = res.data;
if (success) {
setCertificates(data || []);
} else {
showError(message);
}
setLoading(false);
};
useEffect(() => {
loadCertificates().then();
}, []);
const submitManual = async () => {
setSubmittingManual(true);
const payload = {
name: manualForm.name.trim(),
cert_pem: manualForm.cert_pem.trim(),
key_pem: manualForm.key_pem.trim(),
remark: manualForm.remark.trim(),
};
const res = await API.post('/api/tls-certificates/', payload);
const { success, message } = res.data;
if (success) {
showSuccess('证书已导入');
setManualForm(initialManualForm);
await loadCertificates();
} else {
showError(message);
}
setSubmittingManual(false);
};
const submitFiles = async () => {
if (!certFile || !keyFile) {
showError('请选择证书文件和私钥文件');
return;
}
setSubmittingFiles(true);
const formData = new FormData();
formData.append('name', fileForm.name.trim());
formData.append('remark', fileForm.remark.trim());
formData.append('cert_file', certFile);
formData.append('key_file', keyFile);
const res = await API.post('/api/tls-certificates/import-file', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
const { success, message } = res.data;
if (success) {
showSuccess('证书文件已导入');
setFileForm(initialFileForm);
setCertFile(null);
setKeyFile(null);
await loadCertificates();
} else {
showError(message);
}
setSubmittingFiles(false);
};
const deleteCertificate = async (id) => {
const res = await API.delete(`/api/tls-certificates/${id}`);
const { success, message } = res.data;
if (success) {
showSuccess('证书已删除');
await loadCertificates();
} else {
showError(message);
}
};
return (
<Segment loading={loading}>
<Header as='h3'>证书管理</Header>
<p className='page-subtitle'>支持手动粘贴 PEM 导入或直接上传证书文件。</p>
<Form onSubmit={submitManual}>
<Header as='h4'>手动导入</Header>
<Form.Group widths='equal'>
<Form.Input
label='证书名称'
placeholder='example-com'
value={manualForm.name}
onChange={(e, { value }) => setManualForm({ ...manualForm, name: value })}
/>
<Form.Input
label='备注'
placeholder='可选备注'
value={manualForm.remark}
onChange={(e, { value }) => setManualForm({ ...manualForm, remark: value })}
/>
</Form.Group>
<Form.Field
control={TextArea}
label='证书 PEM'
placeholder='-----BEGIN CERTIFICATE-----'
value={manualForm.cert_pem}
onChange={(e, { value }) => setManualForm({ ...manualForm, cert_pem: value })}
style={{ minHeight: 140 }}
/>
<Form.Field
control={TextArea}
label='私钥 PEM'
placeholder='-----BEGIN PRIVATE KEY-----'
value={manualForm.key_pem}
onChange={(e, { value }) => setManualForm({ ...manualForm, key_pem: value })}
style={{ minHeight: 140 }}
/>
<Button primary type='submit' loading={submittingManual}>
导入证书
</Button>
</Form>
<Segment secondary>
<Form onSubmit={submitFiles}>
<Header as='h4'>文件导入</Header>
<Form.Group widths='equal'>
<Form.Input
label='证书名称'
placeholder='wildcard-example'
value={fileForm.name}
onChange={(e, { value }) => setFileForm({ ...fileForm, name: value })}
/>
<Form.Input
label='备注'
placeholder='可选备注'
value={fileForm.remark}
onChange={(e, { value }) => setFileForm({ ...fileForm, remark: value })}
/>
</Form.Group>
<Form.Group widths='equal'>
<Form.Input
type='file'
label='证书文件'
onChange={(e) => setCertFile(e.target.files?.[0] || null)}
/>
<Form.Input
type='file'
label='私钥文件'
onChange={(e) => setKeyFile(e.target.files?.[0] || null)}
/>
</Form.Group>
<Button primary type='submit' loading={submittingFiles}>
上传文件
</Button>
</Form>
</Segment>
<Table celled stackable className='atsf-table'>
<Table.Header>
<Table.Row>
<Table.HeaderCell>名称</Table.HeaderCell>
<Table.HeaderCell>有效期</Table.HeaderCell>
<Table.HeaderCell>备注</Table.HeaderCell>
<Table.HeaderCell>更新时间</Table.HeaderCell>
<Table.HeaderCell>操作</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{certificates.map((certificate) => (
<Table.Row key={certificate.id}>
<Table.Cell>
<Icon name='lock' />
{certificate.name}
</Table.Cell>
<Table.Cell>
{formatDateTime(certificate.not_before)} ~ {formatDateTime(certificate.not_after)}
</Table.Cell>
<Table.Cell>{certificate.remark || '无'}</Table.Cell>
<Table.Cell>{formatDateTime(certificate.updated_at)}</Table.Cell>
<Table.Cell>
<Button size='small' negative onClick={() => deleteCertificate(certificate.id)}>
删除
</Button>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
</Segment>
);
};
export default TLSCertificate;
-77
View File
@@ -1,77 +0,0 @@
import React, { useState } from 'react';
import { Button, Form, Header, Segment } from 'semantic-ui-react';
import { API, showError, showSuccess } from '../../helpers';
const AddUser = () => {
const originInputs = {
username: '',
display_name: '',
password: '',
};
const [inputs, setInputs] = useState(originInputs);
const { username, display_name, password } = inputs;
const handleInputChange = (e, { name, value }) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
const submit = async () => {
if (inputs.username === '' || inputs.password === '') return;
const res = await API.post(`/api/user/`, inputs);
const { success, message } = res.data;
if (success) {
showSuccess('用户账户创建成功!');
setInputs(originInputs);
} else {
showError(message);
}
};
return (
<>
<Segment>
<Header as="h3">创建新用户账户</Header>
<Form autoComplete="off">
<Form.Field>
<Form.Input
label="用户名"
name="username"
placeholder={'请输入用户名'}
onChange={handleInputChange}
value={username}
autoComplete="off"
required
/>
</Form.Field>
<Form.Field>
<Form.Input
label="显示名称"
name="display_name"
placeholder={'请输入显示名称'}
onChange={handleInputChange}
value={display_name}
autoComplete="off"
/>
</Form.Field>
<Form.Field>
<Form.Input
label="密码"
name="password"
type={'password'}
placeholder={'请输入密码'}
onChange={handleInputChange}
value={password}
autoComplete="off"
required
/>
</Form.Field>
<Button type={'submit'} onClick={submit}>
提交
</Button>
</Form>
</Segment>
</>
);
};
export default AddUser;
-132
View File
@@ -1,132 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Button, Form, Header, Segment } from 'semantic-ui-react';
import { useParams } from 'react-router-dom';
import { API, showError, showSuccess } from '../../helpers';
const EditUser = () => {
const params = useParams();
const userId = params.id;
const [loading, setLoading] = useState(true);
const [inputs, setInputs] = useState({
username: '',
display_name: '',
password: '',
github_id: '',
wechat_id: '',
email: '',
});
const { username, display_name, password, github_id, wechat_id, email } =
inputs;
const handleInputChange = (e, { name, value }) => {
setInputs((inputs) => ({ ...inputs, [name]: value }));
};
const loadUser = async () => {
let res = undefined;
if (userId) {
res = await API.get(`/api/user/${userId}`);
} else {
res = await API.get(`/api/user/self`);
}
const { success, message, data } = res.data;
if (success) {
data.password = '';
setInputs(data);
} else {
showError(message);
}
setLoading(false);
};
useEffect(() => {
loadUser().then();
}, []);
const submit = async () => {
let res = undefined;
if (userId) {
res = await API.put(`/api/user/`, { ...inputs, id: parseInt(userId) });
} else {
res = await API.put(`/api/user/self`, inputs);
}
const { success, message } = res.data;
if (success) {
showSuccess('用户信息更新成功!');
} else {
showError(message);
}
};
return (
<>
<Segment loading={loading}>
<Header as='h3'>更新用户信息</Header>
<Form autoComplete='new-password'>
<Form.Field>
<Form.Input
label='用户名'
name='username'
placeholder={'请输入新的用户名'}
onChange={handleInputChange}
value={username}
autoComplete='new-password'
/>
</Form.Field>
<Form.Field>
<Form.Input
label='密码'
name='password'
type={'password'}
placeholder={'请输入新的密码'}
onChange={handleInputChange}
value={password}
autoComplete='new-password'
/>
</Form.Field>
<Form.Field>
<Form.Input
label='显示名称'
name='display_name'
placeholder={'请输入新的显示名称'}
onChange={handleInputChange}
value={display_name}
autoComplete='new-password'
/>
</Form.Field>
<Form.Field>
<Form.Input
label='已绑定的 GitHub 账户'
name='github_id'
value={github_id}
autoComplete='new-password'
placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
readOnly
/>
</Form.Field>
<Form.Field>
<Form.Input
label='已绑定的微信账户'
name='wechat_id'
value={wechat_id}
autoComplete='new-password'
placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
readOnly
/>
</Form.Field>
<Form.Field>
<Form.Input
label='已绑定的邮箱账户'
name='email'
value={email}
autoComplete='new-password'
placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
readOnly
/>
</Form.Field>
<Button onClick={submit}>提交</Button>
</Form>
</Segment>
</>
);
};
export default EditUser;
-14
View File
@@ -1,14 +0,0 @@
import React from 'react';
import { Segment, Header } from 'semantic-ui-react';
import UsersTable from '../../components/UsersTable';
const User = () => (
<>
<Segment>
<Header as='h3'>管理用户</Header>
<UsersTable/>
</Segment>
</>
);
export default User;
+2 -2
View File
@@ -25,5 +25,5 @@
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules", "build", "src"]
}
"exclude": ["node_modules", "build"]
}
-17
View File
@@ -329,23 +329,6 @@ atsf_server/web/
* 日常管理操作均可在新前端完成
* 旧前端仅剩兼容兜底价值
### 阶段 5:联调、回归与切换
目标:完成替换上线准备。
任务:
1. 对照现有页面与接口完成功能回归
2. 补齐前端测试与关键页面 E2E
3. 验证静态资源构建、嵌入、发布流程
4. 切换默认前端入口并保留回滚预案
验收:
* 所有核心页面通过冒烟测试
* 构建与部署文档可复现
* 可在必要时快速回退到旧前端版本
---
## 9. 页面与交互设计原则