mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-01 06:36:38 +08:00
[重构] 前端改造v5
This commit is contained in:
@@ -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, "/")
|
||||
|
||||
|
||||
@@ -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' />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LegacyRouteRedirect } from '@/features/shared/components/legacy-route-redirect';
|
||||
|
||||
export default function LegacyFilePage() {
|
||||
return <LegacyRouteRedirect href='/files' />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LegacyRouteRedirect } from '@/features/shared/components/legacy-route-redirect';
|
||||
|
||||
export default function LegacyNodePage() {
|
||||
return <LegacyRouteRedirect href='/nodes' />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LegacyRouteRedirect } from '@/features/shared/components/legacy-route-redirect';
|
||||
|
||||
export default function LegacyProxyRoutePage() {
|
||||
return <LegacyRouteRedirect href='/proxy-routes' />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LegacyRouteRedirect } from '@/features/shared/components/legacy-route-redirect';
|
||||
|
||||
export default function LegacySettingPage() {
|
||||
return <LegacyRouteRedirect href='/settings' />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LegacyRouteRedirect } from '@/features/shared/components/legacy-route-redirect';
|
||||
|
||||
export default function LegacyUserAddPage() {
|
||||
return <LegacyRouteRedirect href='/users?mode=create' />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LegacyRouteRedirect } from '@/features/shared/components/legacy-route-redirect';
|
||||
|
||||
export default function LegacyUserEditPage() {
|
||||
return <LegacyRouteRedirect href='/users' />;
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
Vendored
-1
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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!
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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 {};
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import { createBrowserHistory } from 'history';
|
||||
|
||||
export const history = createBrowserHistory();
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from './history';
|
||||
export * from './auth-header';
|
||||
export * from './utils';
|
||||
export * from './api';
|
||||
@@ -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)} 天前`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -25,5 +25,5 @@
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules", "build", "src"]
|
||||
}
|
||||
"exclude": ["node_modules", "build"]
|
||||
}
|
||||
|
||||
@@ -329,23 +329,6 @@ atsf_server/web/
|
||||
* 日常管理操作均可在新前端完成
|
||||
* 旧前端仅剩兼容兜底价值
|
||||
|
||||
### 阶段 5:联调、回归与切换
|
||||
|
||||
目标:完成替换上线准备。
|
||||
|
||||
任务:
|
||||
|
||||
1. 对照现有页面与接口完成功能回归
|
||||
2. 补齐前端测试与关键页面 E2E
|
||||
3. 验证静态资源构建、嵌入、发布流程
|
||||
4. 切换默认前端入口并保留回滚预案
|
||||
|
||||
验收:
|
||||
|
||||
* 所有核心页面通过冒烟测试
|
||||
* 构建与部署文档可复现
|
||||
* 可在必要时快速回退到旧前端版本
|
||||
|
||||
---
|
||||
|
||||
## 9. 页面与交互设计原则
|
||||
|
||||
Reference in New Issue
Block a user