[重构] 前端改造v1

This commit is contained in:
ryan
2026-03-11 12:47:08 +08:00
parent 052e3e98f8
commit 6eea676f8f
71 changed files with 3619 additions and 8791 deletions
+4 -3
View File
@@ -42,7 +42,7 @@ jobs:
if: steps.version.outputs.should_run == 'true'
uses: https://github.com/actions/setup-node@v4
with:
node-version: 18
node-version: 20
- name: Build Frontend
if: steps.version.outputs.should_run == 'true'
@@ -51,8 +51,9 @@ jobs:
VERSION: ${{ steps.version.outputs.version }}
run: |
cd atsf_server/web
npm install
REACT_APP_VERSION="$VERSION" npm run build
corepack enable
pnpm install --frozen-lockfile
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build
- name: Set up Go
if: steps.version.outputs.should_run == 'true'
+4 -3
View File
@@ -18,12 +18,13 @@ jobs:
CI: ""
run: |
cd atsf_server/web
npm install
npm run build
corepack enable
pnpm install --frozen-lockfile
pnpm build
- name: Deploy 🚀
uses: JamesIves/github-pages-deploy-action@releases/v3
with:
ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }}
BRANCH: gh-pages # The branch the action should deploy to.
FOLDER: web/build # The folder the action should deploy.
FOLDER: atsf_server/web/build # The folder the action should deploy.
+4 -3
View File
@@ -56,7 +56,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 18
node-version: 20
- name: Build Frontend
env:
@@ -64,8 +64,9 @@ jobs:
VERSION: ${{ needs.prepare.outputs.version }}
run: |
cd atsf_server/web
npm install
REACT_APP_VERSION="$VERSION" npm run build
corepack enable
pnpm install --frozen-lockfile
NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build
- name: Upload Frontend Artifact
uses: actions/upload-artifact@v4
+7 -1
View File
@@ -134,4 +134,10 @@ See:
1. [docs/deployment.md](./docs/deployment.md)
2. [docs/design.md](./docs/design.md)
3. [docs/development-guidelines.md](./docs/development-guidelines.md)
4. [docs/development-plan.md](./docs/development-plan.md)
4. [docs/development-plan.md](./docs/development-plan.md)
Frontend notes:
* The new admin frontend lives in `atsf_server/web`
* `pnpm` is the default package manager
* `pnpm build` exports static assets into `atsf_server/web/build`
+6
View File
@@ -137,3 +137,9 @@ curl -fsSL https://raw.githubusercontent.com/Rain-kl/ATSFlare/main/scripts/insta
2. [docs/development-guidelines.md](./docs/development-guidelines.md)
3. [docs/development-plan.md](./docs/development-plan.md)
前端开发补充:
* 新版管理端位于 `atsf_server/web`
* 前端包管理器统一使用 `pnpm`
* 构建命令为 `pnpm build`,产物输出到 `atsf_server/web/build`
+5 -4
View File
@@ -1,14 +1,15 @@
ARG VERSION=dev
FROM node:18 AS builder
FROM node:20 AS builder
ARG VERSION
WORKDIR /build
COPY ./web/package*.json ./
RUN npm install
COPY ./web/package.json ./
COPY ./web/pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY ./web ./
RUN REACT_APP_VERSION="$VERSION" npm run build
RUN NEXT_PUBLIC_APP_VERSION="$VERSION" pnpm build
FROM golang:1.22 AS builder2
+11
View File
@@ -0,0 +1,11 @@
{
"extends": ["next/core-web-vitals", "next/typescript"],
"ignorePatterns": [
".next",
"build",
"coverage",
"playwright-report",
"test-results",
"src"
]
}
+29 -27
View File
@@ -1,28 +1,30 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.idea
package-lock.json
yarn.lock
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
# build outputs
/.next
/out
/build
/coverage
/playwright-report
/test-results
# local env
.env.local
.env.development.local
.env.test.local
.env.production.local
# misc
.DS_Store
.idea
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
package-lock.json
yarn.lock
*.db
+40 -21
View File
@@ -1,21 +1,40 @@
# React Template
## Basic Usages
```shell
# Runs the app in the development mode
npm start
# Builds the app for production to the `build` folder
npm run build
```
If you want to change the default server, please set `REACT_APP_SERVER` environment variables before build,
for example: `REACT_APP_SERVER=http://your.domain.com`.
Before you start editing, make sure your `Actions on Save` options have `Optimize imports` & `Run Prettier` enabled.
## Reference
1. https://github.com/OIerDb-ng/OIerDb
2. https://github.com/cornflourblue/react-hooks-redux-registration-login-example
# ATSFlare Web
ATSFlare 管理端新版前端已切换为 Next.js App Router + TypeScript + Tailwind CSS 工程。
## 常用命令
```shell
corepack enable
pnpm install
# 本地开发
pnpm dev
# 类型检查
pnpm typecheck
# 代码检查
pnpm lint
# 单元测试
pnpm test
# 生成静态构建产物到 build/
pnpm build
```
## 构建说明
* 构建采用 Next.js 静态导出模式。
* `pnpm build` 会先生成 `out/`,随后自动复制为 Go Server 兼容的 `build/` 目录。
* 默认 API Base URL 为 `/api`,如需覆盖可在构建时设置 `NEXT_PUBLIC_API_BASE_URL`。
* 构建版本号可通过 `NEXT_PUBLIC_APP_VERSION` 注入,例如 `NEXT_PUBLIC_APP_VERSION=v0.4.0 pnpm build`。
## 目录约定
* `app/`:路由与布局
* `features/`:业务模块
* `components/`:复用组件
* `lib/`:请求、环境变量、工具与常量
* `tests/`:测试代码
@@ -0,0 +1,10 @@
import { ModulePageSkeleton } from '@/features/shared/components/module-page-skeleton';
export default function ApplyLogsPage() {
return (
<ModulePageSkeleton
title='应用记录'
description='阶段 3 将在此接入过滤器、结果状态可视化与分页详情。'
/>
);
}
@@ -0,0 +1,10 @@
import { ModulePageSkeleton } from '@/features/shared/components/module-page-skeleton';
export default function ConfigVersionsPage() {
return (
<ModulePageSkeleton
title='配置版本'
description='阶段 3 将在此接入版本列表、diff 预览、激活与回滚流程。'
/>
);
}
@@ -0,0 +1,10 @@
import { ModulePageSkeleton } from '@/features/shared/components/module-page-skeleton';
export default function FilesPage() {
return (
<ModulePageSkeleton
title='文件管理'
description='阶段 4 将在此接入文件列表、下载与管理交互。'
/>
);
}
@@ -0,0 +1,11 @@
import type { ReactNode } from 'react';
import { DashboardShell } from '@/components/layout/dashboard-shell';
interface DashboardLayoutProps {
children: ReactNode;
}
export default function DashboardLayout({ children }: DashboardLayoutProps) {
return <DashboardShell>{children}</DashboardShell>;
}
@@ -0,0 +1,10 @@
import { ModulePageSkeleton } from '@/features/shared/components/module-page-skeleton';
export default function ManagedDomainsPage() {
return (
<ModulePageSkeleton
title='域名管理'
description='阶段 3 将在此接入域名列表、证书绑定状态与启用切换。'
/>
);
}
@@ -0,0 +1,10 @@
import { ModulePageSkeleton } from '@/features/shared/components/module-page-skeleton';
export default function NodesPage() {
return (
<ModulePageSkeleton
title='节点管理'
description='阶段 3 将在此接入节点状态标签、部署命令、心跳与更新动作。'
/>
);
}
+14
View File
@@ -0,0 +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='新版前端已切换到 Next.js 工程骨架,当前页面用于展示阶段 1 交付结果与模块入口。'
/>
<DashboardOverview />
</div>
);
}
@@ -0,0 +1,10 @@
import { ModulePageSkeleton } from '@/features/shared/components/module-page-skeleton';
export default function ProxyRoutesPage() {
return (
<ModulePageSkeleton
title='反代规则'
description='阶段 3 将在此接入规则列表、创建编辑抽屉与发布动作。'
/>
);
}
@@ -0,0 +1,10 @@
import { ModulePageSkeleton } from '@/features/shared/components/module-page-skeleton';
export default function SettingsPage() {
return (
<ModulePageSkeleton
title='设置'
description='阶段 4 将在此接入系统设置、运维设置与个人设置重组。'
/>
);
}
@@ -0,0 +1,10 @@
import { ModulePageSkeleton } from '@/features/shared/components/module-page-skeleton';
export default function TlsCertificatesPage() {
return (
<ModulePageSkeleton
title='TLS 证书'
description='阶段 3 将在此接入证书上传、导入与有效期展示。'
/>
);
}
@@ -0,0 +1,10 @@
import { ModulePageSkeleton } from '@/features/shared/components/module-page-skeleton';
export default function UsersPage() {
return (
<ModulePageSkeleton
title='用户管理'
description='阶段 4 将在此接入用户列表、角色管理与搜索体验。'
/>
);
}
+11
View File
@@ -0,0 +1,11 @@
import type { ReactNode } from 'react';
import { PublicShell } from '@/components/layout/public-shell';
interface PublicLayoutProps {
children: ReactNode;
}
export default function PublicLayout({ children }: PublicLayoutProps) {
return <PublicShell>{children}</PublicShell>;
}
@@ -0,0 +1,10 @@
import { PublicRoutePlaceholder } from '@/features/auth/components/public-route-placeholder';
export default function LoginPage() {
return (
<PublicRoutePlaceholder
title='登录页占位'
description='阶段 2 将在此接入真实登录表单、Session 兼容与登录后跳转。'
/>
);
}
@@ -0,0 +1,10 @@
import { PublicRoutePlaceholder } from '@/features/auth/components/public-route-placeholder';
export default function GithubOAuthPage() {
return (
<PublicRoutePlaceholder
title='GitHub OAuth 回调占位'
description='阶段 2 将在此接入授权回调处理、状态提示与跳转逻辑。'
/>
);
}
@@ -0,0 +1,10 @@
import { PublicRoutePlaceholder } from '@/features/auth/components/public-route-placeholder';
export default function RegisterPage() {
return (
<PublicRoutePlaceholder
title='注册页占位'
description='阶段 2 将在此接入注册表单、字段校验与提交流程。'
/>
);
}
@@ -0,0 +1,10 @@
import { PublicRoutePlaceholder } from '@/features/auth/components/public-route-placeholder';
export default function ResetPasswordPage() {
return (
<PublicRoutePlaceholder
title='重置密码页占位'
description='阶段 2 将在此接入找回密码与重置流程。'
/>
);
}
+35
View File
@@ -0,0 +1,35 @@
@import '../styles/tokens.css';
@import 'tailwindcss';
* {
box-sizing: border-box;
}
html {
color-scheme: dark;
}
body {
min-height: 100vh;
background:
radial-gradient(circle at top, rgba(56, 189, 248, 0.08), transparent 30%),
linear-gradient(180deg, rgba(8, 17, 31, 0.98), rgba(8, 17, 31, 1));
color: var(--foreground-primary);
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
sans-serif;
}
a {
color: inherit;
text-decoration: none;
}
button {
cursor: pointer;
}
::selection {
background: rgba(56, 189, 248, 0.32);
color: #f8fafc;
}
+29
View File
@@ -0,0 +1,29 @@
import type { Metadata } from 'next';
import type { ReactNode } from 'react';
import { AppProviders } from '@/components/providers/app-providers';
import './globals.css';
export const metadata: Metadata = {
title: {
default: 'ATSFlare 控制台',
template: '%s | ATSFlare',
},
description: 'ATSFlare 管理端新版工程骨架',
applicationName: 'ATSFlare',
};
interface RootLayoutProps {
children: ReactNode;
}
export default function RootLayout({ children }: RootLayoutProps) {
return (
<html lang='zh-CN' suppressHydrationWarning>
<body>
<AppProviders>{children}</AppProviders>
</body>
</html>
);
}
+18
View File
@@ -0,0 +1,18 @@
import Link from 'next/link';
import { AppCard } from '@/components/ui/app-card';
export default function NotFound() {
return (
<main className='flex min-h-screen items-center justify-center px-4'>
<AppCard title='页面不存在' description='当前请求的页面尚未接入新版管理端路由。'>
<div className='space-y-4 text-sm leading-6 text-[var(--foreground-secondary)]'>
<p>请返回总览页,或继续通过侧边导航访问已初始化的模块入口。</p>
<Link href='/' className='inline-flex rounded-full bg-sky-400 px-4 py-2 font-medium text-slate-950 transition hover:bg-sky-300'>
返回总览
</Link>
</div>
</AppCard>
</main>
);
}
@@ -0,0 +1,13 @@
interface EmptyStateProps {
title: string;
description: string;
}
export function EmptyState({ title, description }: EmptyStateProps) {
return (
<div className='rounded-2xl border border-dashed border-white/10 bg-white/5 px-5 py-6 text-sm'>
<p className='text-base font-semibold text-white'>{title}</p>
<p className='mt-2 leading-6 text-[var(--foreground-secondary)]'>{description}</p>
</div>
);
}
@@ -0,0 +1,13 @@
interface ErrorStateProps {
title: string;
description: string;
}
export function ErrorState({ title, description }: ErrorStateProps) {
return (
<div className='rounded-2xl border border-rose-400/20 bg-rose-500/10 px-5 py-6 text-sm'>
<p className='text-base font-semibold text-rose-100'>{title}</p>
<p className='mt-2 leading-6 text-rose-100/80'>{description}</p>
</div>
);
}
@@ -0,0 +1,31 @@
import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
interface FeaturePlaceholderProps {
title: string;
description: string;
milestones: string[];
}
export function FeaturePlaceholder({
title,
description,
milestones,
}: FeaturePlaceholderProps) {
return (
<AppCard
title={title}
description={description}
action={<StatusBadge label='阶段 1 骨架完成' variant='success' />}
>
<ul className='space-y-3 text-sm leading-6 text-[var(--foreground-secondary)]'>
{milestones.map((item) => (
<li key={item} className='flex gap-3'>
<span className='mt-2 h-2 w-2 shrink-0 rounded-full bg-sky-400' />
<span>{item}</span>
</li>
))}
</ul>
</AppCard>
);
}
@@ -0,0 +1,15 @@
import { cn } from '@/lib/utils/cn';
interface LoadingStateProps {
className?: string;
}
export function LoadingState({ className }: LoadingStateProps) {
return (
<div className={cn('space-y-3', className)}>
<div className='h-4 w-32 animate-pulse rounded bg-white/10' />
<div className='h-24 animate-pulse rounded-2xl bg-white/5' />
<div className='h-24 animate-pulse rounded-2xl bg-white/5' />
</div>
);
}
@@ -0,0 +1,20 @@
import type { ReactNode } from 'react';
import { DashboardSidebar } from '@/components/layout/dashboard-sidebar';
import { DashboardTopbar } from '@/components/layout/dashboard-topbar';
interface DashboardShellProps {
children: ReactNode;
}
export function DashboardShell({ children }: DashboardShellProps) {
return (
<div className='flex min-h-screen bg-transparent text-[var(--foreground-primary)]'>
<DashboardSidebar />
<div className='flex min-w-0 flex-1 flex-col'>
<DashboardTopbar />
<main className='flex-1 px-4 py-6 md:px-8 md:py-8'>{children}</main>
</div>
</div>
);
}
@@ -0,0 +1,75 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { dashboardNavigation } from '@/lib/constants/navigation';
import { cn } from '@/lib/utils/cn';
import { isPathActive } from '@/lib/utils/navigation';
import { useAppShellStore } from '@/store/app-shell';
export function DashboardSidebar() {
const pathname = usePathname();
const currentPath = pathname ?? '/';
const isSidebarCollapsed = useAppShellStore((state) => state.isSidebarCollapsed);
return (
<aside
className={cn(
'sticky top-0 hidden h-screen shrink-0 border-r border-[var(--border-default)] bg-[var(--surface-panel)]/95 px-3 py-6 backdrop-blur lg:block',
isSidebarCollapsed ? 'w-24' : 'w-72',
)}
>
<div className='flex h-full flex-col gap-6'>
<div className='flex items-center gap-3 rounded-2xl border border-[var(--border-default)] bg-white/5 px-3 py-3'>
<div className='flex h-11 w-11 items-center justify-center rounded-2xl bg-sky-400/15 text-lg font-semibold text-sky-200'>
AF
</div>
{!isSidebarCollapsed ? (
<div>
<p className='text-sm font-semibold text-white'>ATSFlare</p>
<p className='text-xs text-[var(--foreground-secondary)]'>控制面新版工程</p>
</div>
) : null}
</div>
<nav className='flex-1 space-y-2'>
{dashboardNavigation.map((item) => {
const active = isPathActive(currentPath, item.href);
return (
<Link
key={item.href}
href={item.href}
className={cn(
'flex items-start gap-3 rounded-2xl border px-3 py-3 transition-colors',
active
? 'border-sky-300/30 bg-sky-400/10 text-white'
: 'border-transparent text-[var(--foreground-secondary)] hover:border-[var(--border-default)] hover:bg-white/5 hover:text-white',
)}
>
<span className='mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-white/5 text-xs font-semibold'>
{item.shortLabel.slice(0, 1)}
</span>
{!isSidebarCollapsed ? (
<span className='min-w-0'>
<span className='block text-sm font-medium'>{item.label}</span>
<span className='mt-1 block text-xs leading-5 text-inherit/80'>
{item.description}
</span>
</span>
) : null}
</Link>
);
})}
</nav>
{!isSidebarCollapsed ? (
<div className='rounded-2xl border border-emerald-400/15 bg-emerald-500/10 px-4 py-4 text-xs leading-6 text-emerald-100/90'>
阶段 1 已完成目录骨架、静态导出与基础布局初始化。
</div>
) : null}
</div>
</aside>
);
}
@@ -0,0 +1,45 @@
'use client';
import { getCurrentNavigationItem } from '@/lib/utils/navigation';
import { publicEnv } from '@/lib/env/public-env';
import { useAppShellStore } from '@/store/app-shell';
import { usePathname } from 'next/navigation';
export function DashboardTopbar() {
const pathname = usePathname();
const currentPath = pathname ?? '/';
const toggleSidebar = useAppShellStore((state) => state.toggleSidebar);
const currentItem = getCurrentNavigationItem(currentPath);
return (
<header className='sticky top-0 z-10 border-b border-[var(--border-default)] bg-[var(--surface-panel)]/75 px-4 py-4 backdrop-blur md:px-8'>
<div className='flex flex-col gap-4 md:flex-row md:items-center md:justify-between'>
<div className='flex items-center gap-3'>
<button
type='button'
onClick={toggleSidebar}
className='inline-flex h-11 w-11 items-center justify-center rounded-2xl border border-[var(--border-default)] bg-white/5 text-lg text-white transition hover:bg-white/10'
aria-label='切换侧边栏'
>
≡
</button>
<div>
<p className='text-xs uppercase tracking-[0.24em] text-[var(--foreground-secondary)]'>当前模块</p>
<h2 className='text-lg font-semibold text-white'>
{currentItem?.label ?? 'ATSFlare 控制台'}
</h2>
</div>
</div>
<div className='flex items-center gap-3 text-sm text-[var(--foreground-secondary)]'>
<span className='rounded-full border border-[var(--border-default)] px-3 py-1.5'>
静态导出模式
</span>
<span className='rounded-full border border-[var(--border-default)] px-3 py-1.5'>
版本 {publicEnv.appVersion}
</span>
</div>
</div>
</header>
);
}
@@ -0,0 +1,18 @@
interface PageHeaderProps {
title: string;
description: string;
}
export function PageHeader({ title, description }: PageHeaderProps) {
return (
<div className='space-y-3'>
<p className='text-sm font-medium uppercase tracking-[0.24em] text-sky-300'>ATSFlare</p>
<div className='space-y-2'>
<h1 className='text-3xl font-semibold tracking-tight text-white'>{title}</h1>
<p className='max-w-3xl text-sm leading-7 text-[var(--foreground-secondary)]'>
{description}
</p>
</div>
</div>
);
}
@@ -0,0 +1,39 @@
import Link from 'next/link';
import type { ReactNode } from 'react';
import { publicEnv } from '@/lib/env/public-env';
interface PublicShellProps {
children: ReactNode;
}
export function PublicShell({ children }: PublicShellProps) {
return (
<div className='flex min-h-screen items-center justify-center px-4 py-12'>
<div className='w-full max-w-3xl rounded-[var(--radius-xl)] border border-[var(--border-default)] bg-[var(--surface-panel)]/85 p-8 shadow-[var(--shadow-soft)] backdrop-blur'>
<div className='mb-8 flex flex-col gap-4 border-b border-[var(--border-default)] pb-6 md:flex-row md:items-end md:justify-between'>
<div>
<p className='text-sm font-medium uppercase tracking-[0.24em] text-sky-300'>ATSFlare</p>
<h1 className='mt-2 text-3xl font-semibold text-white'>管理端改造进行中</h1>
<p className='mt-2 text-sm leading-6 text-[var(--foreground-secondary)]'>
认证流程将在阶段 2 接入。当前页面用于承载入口骨架与路由占位。
</p>
</div>
<div className='text-sm text-[var(--foreground-secondary)]'>
<span className='rounded-full border border-[var(--border-default)] px-3 py-1.5'>
{publicEnv.appVersion}
</span>
</div>
</div>
<div className='space-y-6'>{children}</div>
<div className='mt-8 border-t border-[var(--border-default)] pt-6 text-sm text-[var(--foreground-secondary)]'>
<Link href='/' className='text-sky-300 transition hover:text-sky-200'>
返回新版总览
</Link>
</div>
</div>
</div>
);
}
@@ -0,0 +1,28 @@
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { type ReactNode, useState } from 'react';
interface AppProvidersProps {
children: ReactNode;
}
export function AppProviders({ children }: AppProvidersProps) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
staleTime: 30_000,
},
mutations: {
retry: 0,
},
},
}),
);
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
@@ -0,0 +1,41 @@
import type { HTMLAttributes, ReactNode } from 'react';
import { cn } from '@/lib/utils/cn';
interface AppCardProps extends HTMLAttributes<HTMLDivElement> {
title?: string;
description?: string;
action?: ReactNode;
}
export function AppCard({
title,
description,
action,
className,
children,
...props
}: AppCardProps) {
return (
<section
className={cn(
'rounded-[var(--radius-xl)] border border-[var(--border-default)] bg-[var(--surface-card)]/90 shadow-[var(--shadow-soft)] backdrop-blur',
className,
)}
{...props}
>
{(title || description || action) && (
<header className='flex flex-col gap-3 border-b border-[var(--border-default)] px-6 py-5 md:flex-row md:items-start md:justify-between'>
<div className='space-y-1'>
{title ? <h2 className='text-lg font-semibold text-white'>{title}</h2> : null}
{description ? (
<p className='text-sm leading-6 text-[var(--foreground-secondary)]'>{description}</p>
) : null}
</div>
{action ? <div className='shrink-0'>{action}</div> : null}
</header>
)}
<div className='px-6 py-5'>{children}</div>
</section>
);
}
@@ -0,0 +1,32 @@
import { cn } from '@/lib/utils/cn';
const variantClasses = {
success: 'border-emerald-400/25 bg-emerald-500/15 text-emerald-200',
warning: 'border-amber-400/25 bg-amber-500/15 text-amber-200',
danger: 'border-rose-400/25 bg-rose-500/15 text-rose-200',
info: 'border-sky-400/25 bg-sky-500/15 text-sky-200',
} as const;
interface StatusBadgeProps {
label: string;
variant?: keyof typeof variantClasses;
className?: string;
}
export function StatusBadge({
label,
variant = 'info',
className,
}: StatusBadgeProps) {
return (
<span
className={cn(
'inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-medium tracking-wide',
variantClasses[variant],
className,
)}
>
{label}
</span>
);
}
@@ -0,0 +1,23 @@
import { FeaturePlaceholder } from '@/components/feedback/feature-placeholder';
interface PublicRoutePlaceholderProps {
title: string;
description: string;
}
export function PublicRoutePlaceholder({
title,
description,
}: PublicRoutePlaceholderProps) {
return (
<FeaturePlaceholder
title={title}
description={description}
milestones={[
'认证页路由入口已建立,便于阶段 2 接入真实表单。',
'公共布局已统一,可复用品牌区、说明区与回跳入口。',
'后续将在此处补齐表单校验、Session 兼容与 OAuth 回调处理。',
]}
/>
);
}
@@ -0,0 +1,73 @@
import Link from 'next/link';
import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import { dashboardNavigation } from '@/lib/constants/navigation';
const readinessItems = [
{
title: '工程底座',
description: 'Next.js App Router、TypeScript strict、Tailwind CSS 与静态导出链路已建立。',
},
{
title: '质量工具',
description: 'ESLint、Prettier、Vitest、Playwright 配置已就位,可继续补充模块测试。',
},
{
title: '目录分层',
description: '已拆分 app、components、features、lib、store、tests 等基础结构。',
},
];
export function DashboardOverview() {
return (
<div className='space-y-6'>
<AppCard
title='阶段 1 已启动'
description='当前已完成新版管理端基础工程初始化,可继续推进认证迁移与框架层骨架。'
action={<StatusBadge label='可继续阶段 2' variant='success' />}
>
<div className='grid gap-4 lg:grid-cols-3'>
{readinessItems.map((item) => (
<div
key={item.title}
className='rounded-2xl border border-[var(--border-default)] bg-white/5 p-4'
>
<p className='text-base font-semibold text-white'>{item.title}</p>
<p className='mt-2 text-sm leading-6 text-[var(--foreground-secondary)]'>
{item.description}
</p>
</div>
))}
</div>
</AppCard>
<div className='grid gap-6 xl:grid-cols-[1.3fr_0.9fr]'>
<AppCard title='模块入口' description='各业务页面已建立路由占位,后续按阶段逐步接入真实数据与交互。'>
<div className='grid gap-3 md:grid-cols-2'>
{dashboardNavigation.slice(1).map((item) => (
<Link
key={item.href}
href={item.href}
className='rounded-2xl border border-[var(--border-default)] bg-white/5 p-4 transition hover:border-sky-300/30 hover:bg-sky-400/10'
>
<p className='text-sm font-semibold text-white'>{item.label}</p>
<p className='mt-2 text-sm leading-6 text-[var(--foreground-secondary)]'>
{item.description}
</p>
</Link>
))}
</div>
</AppCard>
<AppCard title='下一步建议' description='按前端改造计划,后续优先进入认证与框架层迁移。'>
<ol className='space-y-3 text-sm leading-6 text-[var(--foreground-secondary)]'>
<li>1. 接入登录、注册、重置密码与 OAuth 回调页面。</li>
<li>2. 增加统一鉴权守卫、未登录跳转与消息反馈容器。</li>
<li>3. 在业务模块中逐步接入 Query 与 API 资源层。</li>
</ol>
</AppCard>
</div>
</div>
);
}
@@ -0,0 +1,37 @@
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { FeaturePlaceholder } from '@/components/feedback/feature-placeholder';
import { LoadingState } from '@/components/feedback/loading-state';
import { PageHeader } from '@/components/layout/page-header';
interface ModulePageSkeletonProps {
title: string;
description: string;
}
export function ModulePageSkeleton({
title,
description,
}: ModulePageSkeletonProps) {
return (
<div className='space-y-6'>
<PageHeader title={title} description={description} />
<FeaturePlaceholder
title={`${title} 页面骨架`}
description='当前阶段先完成页面入口、统一头部和反馈组件接线,真实列表、表单与联动能力将在后续业务迁移阶段接入。'
milestones={[
'路由入口已创建,可纳入统一导航。',
'页面标题区、说明区与内容容器已统一。',
'加载态、空态、错误态组件已建立,可在真实查询接入后直接复用。',
]}
/>
<div className='grid gap-4 xl:grid-cols-3'>
<LoadingState />
<EmptyState title='空态骨架' description='后续接入查询后,可将资源为空时的提示统一沉淀为此组件。' />
<ErrorState title='错误态骨架' description='后续请求失败、鉴权失效与重试提示可在此基础上扩展。' />
</div>
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { publicEnv } from '@/lib/env/public-env';
import type { ApiEnvelope } from '@/types/api';
export class ApiError extends Error {
constructor(
message: string,
public readonly status: number,
) {
super(message);
this.name = 'ApiError';
}
}
export function getApiUrl(path: string) {
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return `${publicEnv.apiBaseUrl}${normalizedPath}`;
}
export async function apiRequest<T>(path: string, init?: RequestInit) {
const response = await fetch(getApiUrl(path), {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(init?.headers ?? {}),
},
...init,
});
if (!response.ok) {
throw new ApiError(`请求失败(${response.status})`, response.status);
}
const payload = (await response.json()) as ApiEnvelope<T>;
if (!payload.success) {
throw new ApiError(payload.message || '请求失败', response.status);
}
return payload.data;
}
@@ -0,0 +1,64 @@
import type { NavigationItem } from '@/types/navigation';
export const dashboardNavigation: NavigationItem[] = [
{
href: '/',
label: '总览',
shortLabel: '总览',
description: '查看阶段推进情况、系统入口与模块骨架状态。',
},
{
href: '/proxy-routes',
label: '反代规则',
shortLabel: '规则',
description: '阶段 3 接入列表、表单与发布动作。',
},
{
href: '/config-versions',
label: '配置版本',
shortLabel: '版本',
description: '阶段 3 接入版本预览、激活与回滚体验。',
},
{
href: '/nodes',
label: '节点管理',
shortLabel: '节点',
description: '阶段 3 接入状态标签、部署命令与更新动作。',
},
{
href: '/apply-logs',
label: '应用记录',
shortLabel: '记录',
description: '阶段 3 接入筛选、分页与详情展示。',
},
{
href: '/managed-domains',
label: '域名管理',
shortLabel: '域名',
description: '阶段 3 接入证书绑定与启用状态切换。',
},
{
href: '/tls-certificates',
label: 'TLS 证书',
shortLabel: '证书',
description: '阶段 3 接入导入、上传与有效期展示。',
},
{
href: '/files',
label: '文件管理',
shortLabel: '文件',
description: '阶段 4 接入列表、下载与管理动作。',
},
{
href: '/users',
label: '用户管理',
shortLabel: '用户',
description: '阶段 4 接入用户列表、角色与搜索体验。',
},
{
href: '/settings',
label: '设置',
shortLabel: '设置',
description: '阶段 4 接入系统设置、运维设置与个人设置。',
},
];
+26
View File
@@ -0,0 +1,26 @@
import { z } from 'zod';
const publicEnvSchema = z.object({
NEXT_PUBLIC_API_BASE_URL: z.string().default('/api'),
NEXT_PUBLIC_APP_VERSION: z.string().default('dev'),
});
const rawEnv = {
NEXT_PUBLIC_API_BASE_URL: process.env.NEXT_PUBLIC_API_BASE_URL,
NEXT_PUBLIC_APP_VERSION: process.env.NEXT_PUBLIC_APP_VERSION,
};
const parsedEnv = publicEnvSchema.parse(rawEnv);
function normalizeBaseUrl(value: string) {
if (value === '/') {
return '';
}
return value.replace(/\/+$/, '');
}
export const publicEnv = {
apiBaseUrl: normalizeBaseUrl(parsedEnv.NEXT_PUBLIC_API_BASE_URL),
appVersion: parsedEnv.NEXT_PUBLIC_APP_VERSION,
};
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+14
View File
@@ -0,0 +1,14 @@
import { dashboardNavigation } from '@/lib/constants/navigation';
import type { NavigationItem } from '@/types/navigation';
export function isPathActive(pathname: string, href: string) {
if (href === '/') {
return pathname === '/';
}
return pathname === href || pathname.startsWith(`${href}/`);
}
export function getCurrentNavigationItem(pathname: string): NavigationItem | undefined {
return dashboardNavigation.find((item) => isPathActive(pathname, item.href));
}
+6
View File
@@ -0,0 +1,6 @@
/// <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.
+13
View File
@@ -0,0 +1,13 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
pageExtensions: ['ts', 'tsx'],
images: {
unoptimized: true,
},
eslint: {
dirs: ['app', 'components', 'features', 'hooks', 'lib', 'store', 'tests', 'types'],
},
};
export default nextConfig;
+51 -51
View File
@@ -1,51 +1,51 @@
{
"name": "react-template",
"version": "0.1.0",
"private": true,
"dependencies": {
"axios": "^0.27.2",
"history": "^5.3.0",
"marked": "^4.1.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-router-dom": "^6.3.0",
"react-scripts": "5.0.1",
"react-toastify": "^9.0.8",
"react-turnstile": "^1.0.5",
"semantic-ui-css": "^2.5.0",
"semantic-ui-react": "^2.1.3"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"prettier": "^2.7.1"
},
"prettier": {
"singleQuote": true,
"jsxSingleQuote": true
},
"proxy": "http://localhost:3000"
}
{
"name": "atsflare-web",
"version": "0.1.0",
"private": true,
"packageManager": "pnpm@10.6.2",
"scripts": {
"dev": "next dev",
"build": "next build",
"postbuild": "node scripts/prepare-static-export.mjs",
"start": "next start",
"lint": "eslint . --max-warnings=0",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"dependencies": {
"@hookform/resolvers": "^4.1.3",
"@tanstack/react-query": "^5.66.8",
"clsx": "^2.1.1",
"next": "15.2.2",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-hook-form": "^7.54.2",
"tailwind-merge": "^3.0.2",
"zod": "^3.24.2",
"zustand": "^5.0.3"
},
"devDependencies": {
"@playwright/test": "^1.50.1",
"@tailwindcss/postcss": "^4.0.9",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^22.13.4",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^8.57.1",
"eslint-config-next": "15.2.2",
"jsdom": "^26.0.0",
"prettier": "^3.5.2",
"prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^4.0.9",
"typescript": "^5.8.2",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.0.5"
}
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
use: {
baseURL: 'http://127.0.0.1:3000',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
+2324 -8646
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
};
+8
View File
@@ -0,0 +1,8 @@
const config = {
singleQuote: true,
semi: true,
trailingComma: 'all',
plugins: ['prettier-plugin-tailwindcss'],
};
export default config;
-18
View File
@@ -1,18 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#ffffff" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<title>ATSFlare</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>
@@ -0,0 +1,14 @@
import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs';
import { resolve } from 'node:path';
const root = process.cwd();
const outDir = resolve(root, 'out');
const buildDir = resolve(root, 'build');
if (!existsSync(outDir)) {
throw new Error('Missing Next.js export output: out/');
}
rmSync(buildDir, { recursive: true, force: true });
mkdirSync(buildDir, { recursive: true });
cpSync(outDir, buildDir, { recursive: true });
+16
View File
@@ -0,0 +1,16 @@
import { create } from 'zustand';
interface AppShellState {
isSidebarCollapsed: boolean;
toggleSidebar: () => void;
setSidebarCollapsed: (value: boolean) => void;
}
export const useAppShellStore = create<AppShellState>((set) => ({
isSidebarCollapsed: false,
toggleSidebar: () =>
set((state) => ({
isSidebarCollapsed: !state.isSidebarCollapsed,
})),
setSidebarCollapsed: (value) => set({ isSidebarCollapsed: value }),
}));
+19
View File
@@ -0,0 +1,19 @@
:root {
--surface-canvas: #08111f;
--surface-panel: #0f172a;
--surface-card: #111c33;
--surface-muted: #16243d;
--border-default: rgba(148, 163, 184, 0.18);
--border-strong: rgba(125, 211, 252, 0.28);
--foreground-primary: #e2e8f0;
--foreground-secondary: #94a3b8;
--foreground-muted: #64748b;
--brand-primary: #38bdf8;
--brand-secondary: #818cf8;
--status-success: #22c55e;
--status-warning: #f59e0b;
--status-danger: #ef4444;
--status-info: #38bdf8;
--shadow-soft: 0 16px 40px rgba(8, 15, 31, 0.35);
--radius-xl: 1.25rem;
}
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest';
@@ -0,0 +1,14 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { DashboardOverview } from '@/features/dashboard/components/dashboard-overview';
describe('DashboardOverview', () => {
it('renders stage one heading and readiness items', () => {
render(<DashboardOverview />);
expect(screen.getByText('阶段 1 已启动')).toBeInTheDocument();
expect(screen.getByText('工程底座')).toBeInTheDocument();
expect(screen.getByText('质量工具')).toBeInTheDocument();
});
});
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import { getCurrentNavigationItem, isPathActive } from '@/lib/utils/navigation';
describe('navigation utils', () => {
it('marks root path as active only for home', () => {
expect(isPathActive('/', '/')).toBe(true);
expect(isPathActive('/nodes', '/')).toBe(false);
});
it('resolves current navigation item for nested paths', () => {
expect(getCurrentNavigationItem('/nodes/abc')?.label).toBe('节点管理');
expect(getCurrentNavigationItem('/settings')?.label).toBe('设置');
});
});
+29
View File
@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"types": ["node", "vitest/globals"],
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules", "build", "src"]
}
+5
View File
@@ -0,0 +1,5 @@
export interface ApiEnvelope<T> {
success: boolean;
message: string;
data: T;
}
+6
View File
@@ -0,0 +1,6 @@
export interface NavigationItem {
href: string;
label: string;
shortLabel: string;
description: string;
}
+14
View File
@@ -0,0 +1,14 @@
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vitest/config';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [react(), tsconfigPaths()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./tests/setup/vitest.setup.ts'],
include: ['tests/**/*.test.ts', 'tests/**/*.test.tsx'],
css: true,
},
});
+14
View File
@@ -94,6 +94,20 @@ volumes:
* `REDIS_CONN_STRING` 未配置时,相关能力将回退为进程内实现
* `UPLOAD_PATH` 目录在启动时若不存在会自动创建
### 1.3 前端构建环境变量
新版管理端位于 `atsf_server/web`,构建时支持以下公开环境变量:
| 环境变量 | 作用 | 默认值 | 示例 |
| --- | --- | --- | --- |
| `NEXT_PUBLIC_API_BASE_URL` | 前端请求后端 API 的基础路径;默认走同源 `/api` | `/api` | `NEXT_PUBLIC_API_BASE_URL=https://demo.example.com/api` |
| `NEXT_PUBLIC_APP_VERSION` | 构建时注入前端展示版本号 | `dev` | `NEXT_PUBLIC_APP_VERSION=v0.4.0` |
说明:
* 以上变量在前端构建阶段读取,并会被打包进静态资源
* 推荐生产环境继续使用同源部署,优先保持 `NEXT_PUBLIC_API_BASE_URL=/api`
---
## 2. Agent 配置
+10 -3
View File
@@ -27,10 +27,17 @@
```bash
cd atsf_server/web
npm install
npm run build
corepack enable
pnpm install
pnpm build
```
说明:
* 前端使用 Next.js 静态导出模式构建
* `pnpm build` 会生成供 Go Server 托管的 `atsf_server/web/build` 目录
* 如需覆盖默认接口地址,可在构建前设置 `NEXT_PUBLIC_API_BASE_URL`
### 2.2 启动服务
```bash
@@ -226,7 +233,7 @@ GOCACHE=/tmp/atsflare-go-cache go test ./...
```bash
cd atsf_server/web
npm run build
pnpm build
```
### 6.4 发布工作流
+10 -10
View File
@@ -43,11 +43,11 @@
### 2.3 前端
前端继续基于现有 React 管理端:
前端改造专项以 `atsf_server/web` 新版工程为基线:
* 保持现有目录结构
* 继续复用现有 UI 与 helper 组织方式
* 不为第三版提前引入新的大型框架或状态管理方案
* 使用 Next.js App Router + TypeScript + Tailwind CSS
* 按 [docs/frontend-development-guidelines.md](./frontend-development-guidelines.md) 执行目录分层与组件规范
* 首期仍以静态导出产物交由 Go Server 托管为前提
---
@@ -216,15 +216,15 @@ V3 新增行为:
要求:
* 只为当前版本主链路增加页面与交互
* API 请求统一放在已有 helper 体系
* 页面状态优先保持简单
* 沿用现有组件与样式体系,不大规模重构后台 UI
* 新前端页面、组件与请求层统一遵循 [docs/frontend-development-guidelines.md](./frontend-development-guidelines.md)
* API 请求统一收敛到 `atsf_server/web/lib/api/`
* 页面路由与布局放在 `app/`,业务逻辑放在 `features/`
* 构建产物必须保持可被 Go Server 静态托管
如果第三版要新增页面,优先原则:
* 能复用现有页面结构就不新增一套页面框架
* 能复用已有表单模式就不自造 DSL 编辑器
* 能复用现有 feature 结构就不平行再造一套页面逻辑
* 能复用统一表单、反馈与布局组件就不在页面中重复实现
---
+18 -1
View File
@@ -145,10 +145,27 @@
* 节点状态有颜色区分:在线(绿色)、离线(红色)、待接入(黄色)
* curl 部署命令支持一键复制
### 3.6 并行专项:管理端前端工程改造
目标:按 [docs/frontend-revamp-plan.md](./frontend-revamp-plan.md) 推进新版管理端重建,不阻塞 Server/Agent 主链路。
实施约束:
1. 前端专项按独立阶段推进,当前从“阶段 1:工程初始化”开始执行
2. 首期产物必须保持静态导出,并继续由 `atsf_server` 托管
3. 认证迁移、业务模块迁移与最终切换按 `docs/frontend-revamp-plan.md` 的阶段顺序执行
验收标准:
* 新前端可本地开发、可静态构建
* Go Server 可继续托管新版构建产物
* 前端专项与现有第三版主链路互不破坏
---
## 4. 阶段执行原则
* 每个阶段完成后验证验收标准,再进入下一阶段
* 阶段间的代码不相互依赖时可并行
* 每个阶段完成后运行全量测试
* 每个阶段完成后运行全量测试
* 管理端前端改造按 [docs/frontend-revamp-plan.md](./frontend-revamp-plan.md) 单独跟踪阶段状态