mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-07 16:16:37 +08:00
feat: add user management features including user creation, editing, and role management
- Implemented user API functions for fetching, creating, updating, and managing users. - Created a UsersPage component for displaying and managing users with search and pagination. - Added types for user data and mutation payloads. - Introduced a LegacyRouteRedirect component for handling legacy routes. - Defined settings-related types for better type safety in settings management.
This commit is contained in:
@@ -1,10 +1,5 @@
|
|||||||
import { ModulePageSkeleton } from '@/features/shared/components/module-page-skeleton';
|
import { FilesPage } from '@/features/files/components/files-page';
|
||||||
|
|
||||||
export default function FilesPage() {
|
export default function FilesRoute() {
|
||||||
return (
|
return <FilesPage />;
|
||||||
<ModulePageSkeleton
|
|
||||||
title='文件管理'
|
|
||||||
description='阶段 4 将在此接入文件列表、下载与管理交互。'
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export default function DashboardPage() {
|
|||||||
<div className='space-y-6'>
|
<div className='space-y-6'>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title='ATSFlare 管理端迁移进度'
|
title='ATSFlare 管理端迁移进度'
|
||||||
description='新版前端已完成认证与框架层迁移,当前进入阶段 3,优先接入核心业务模块。'
|
description='新版前端已完成认证、核心链路与阶段 4 边缘模块迁移,当前可继续推进联调与回归。'
|
||||||
/>
|
/>
|
||||||
<DashboardOverview />
|
<DashboardOverview />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
import { ModulePageSkeleton } from '@/features/shared/components/module-page-skeleton';
|
import { SettingsPage } from '@/features/settings/components/settings-page';
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsRoute() {
|
||||||
return (
|
return <SettingsPage />;
|
||||||
<ModulePageSkeleton
|
|
||||||
title='设置'
|
|
||||||
description='阶段 4 将在此接入系统设置、运维设置与个人设置重组。'
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
import { ModulePageSkeleton } from '@/features/shared/components/module-page-skeleton';
|
import { UsersPage } from '@/features/users/components/users-page';
|
||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersRoute() {
|
||||||
return (
|
return <UsersPage />;
|
||||||
<ModulePageSkeleton
|
|
||||||
title='用户管理'
|
|
||||||
description='阶段 4 将在此接入用户列表、角色管理与搜索体验。'
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { AboutPage } from '@/features/about/components/about-page';
|
||||||
|
|
||||||
|
export default function AboutRoute() {
|
||||||
|
return <AboutPage />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { LegacyRouteRedirect } from '@/features/shared/components/legacy-route-redirect';
|
||||||
|
|
||||||
|
export default function LegacyFileRoute() {
|
||||||
|
return <LegacyRouteRedirect href='/files' />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { LegacyRouteRedirect } from '@/features/shared/components/legacy-route-redirect';
|
||||||
|
|
||||||
|
export default function LegacySettingRoute() {
|
||||||
|
return <LegacyRouteRedirect href='/settings' />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { LegacyRouteRedirect } from '@/features/shared/components/legacy-route-redirect';
|
||||||
|
|
||||||
|
export default function LegacyAddUserRoute() {
|
||||||
|
return <LegacyRouteRedirect href='/users' />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { LegacyRouteRedirect } from '@/features/shared/components/legacy-route-redirect';
|
||||||
|
|
||||||
|
export default function LegacyEditUserRoute() {
|
||||||
|
return <LegacyRouteRedirect href='/users' />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { LegacyRouteRedirect } from '@/features/shared/components/legacy-route-redirect';
|
||||||
|
|
||||||
|
export default function LegacyEditCurrentUserRoute() {
|
||||||
|
return <LegacyRouteRedirect href='/users' />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { LegacyRouteRedirect } from '@/features/shared/components/legacy-route-redirect';
|
||||||
|
|
||||||
|
export default function LegacyUserRoute() {
|
||||||
|
return <LegacyRouteRedirect href='/users' />;
|
||||||
|
}
|
||||||
@@ -66,7 +66,7 @@ export function DashboardSidebar() {
|
|||||||
|
|
||||||
{!isSidebarCollapsed ? (
|
{!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 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)]'>
|
||||||
阶段 1 已完成目录骨架、静态导出与基础布局初始化。
|
阶段 4 已接入设置、用户、文件等边缘模块,旧前端仅保留少量兼容兜底价值。
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function PublicShell({ children }: PublicShellProps) {
|
|||||||
<p className='text-sm font-medium uppercase tracking-[0.24em] text-[var(--brand-primary)]'>ATSFlare</p>
|
<p className='text-sm font-medium uppercase tracking-[0.24em] text-[var(--brand-primary)]'>ATSFlare</p>
|
||||||
<h1 className='mt-2 text-3xl font-semibold text-[var(--foreground-primary)]'>欢迎使用 ATSFlare 管理端</h1>
|
<h1 className='mt-2 text-3xl font-semibold text-[var(--foreground-primary)]'>欢迎使用 ATSFlare 管理端</h1>
|
||||||
<p className='mt-2 text-sm leading-6 text-[var(--foreground-secondary)]'>
|
<p className='mt-2 text-sm leading-6 text-[var(--foreground-secondary)]'>
|
||||||
当前已接入登录、注册、密码重置与 GitHub OAuth 基础流程,后续将继续完善鉴权与业务模块体验。
|
当前已接入认证入口、核心业务模块以及阶段 4 的设置、用户、文件与关于页迁移。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex flex-col items-start gap-3 text-sm text-[var(--foreground-secondary)] md:items-end'>
|
<div className='flex flex-col items-start gap-3 text-sm text-[var(--foreground-secondary)] md:items-end'>
|
||||||
@@ -31,9 +31,14 @@ export function PublicShell({ children }: PublicShellProps) {
|
|||||||
<div className='space-y-6'>{children}</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)]'>
|
<div className='mt-8 border-t border-[var(--border-default)] pt-6 text-sm text-[var(--foreground-secondary)]'>
|
||||||
<Link href='/' className='text-[var(--brand-primary)] transition hover:opacity-80'>
|
<div className='flex flex-wrap gap-4'>
|
||||||
返回新版总览
|
<Link href='/' className='text-[var(--brand-primary)] transition hover:opacity-80'>
|
||||||
</Link>
|
返回新版总览
|
||||||
|
</Link>
|
||||||
|
<Link href='/about' className='text-[var(--brand-primary)] transition hover:opacity-80'>
|
||||||
|
查看关于页
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { marked } from 'marked';
|
||||||
|
|
||||||
|
import { EmptyState } from '@/components/feedback/empty-state';
|
||||||
|
import { ErrorState } from '@/components/feedback/error-state';
|
||||||
|
import { LoadingState } from '@/components/feedback/loading-state';
|
||||||
|
import { AppCard } from '@/components/ui/app-card';
|
||||||
|
import { StatusBadge } from '@/components/ui/status-badge';
|
||||||
|
import { getPublicStatus } from '@/features/auth/api/public';
|
||||||
|
import { getAboutContent } from '@/features/settings/api/settings';
|
||||||
|
import { formatDateTime } from '@/lib/utils/date';
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown) {
|
||||||
|
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AboutPage() {
|
||||||
|
const aboutQuery = useQuery({
|
||||||
|
queryKey: ['about'],
|
||||||
|
queryFn: getAboutContent,
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusQuery = useQuery({
|
||||||
|
queryKey: ['public-status'],
|
||||||
|
queryFn: getPublicStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (aboutQuery.isLoading || statusQuery.isLoading) {
|
||||||
|
return <LoadingState />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aboutQuery.isError) {
|
||||||
|
return <ErrorState title='关于页加载失败' description={getErrorMessage(aboutQuery.error)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusQuery.isError) {
|
||||||
|
return <ErrorState title='系统状态加载失败' description={getErrorMessage(statusQuery.error)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aboutContent = aboutQuery.data?.trim() ?? '';
|
||||||
|
const status = statusQuery.data;
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return <EmptyState title='暂无系统信息' description='未能获取 ATSFlare 的公开状态信息。' />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-6'>
|
||||||
|
<AppCard
|
||||||
|
title='关于 ATSFlare'
|
||||||
|
description='公开展示当前系统简介、版本信息与项目入口。'
|
||||||
|
action={<StatusBadge label={status.version || 'dev'} variant='info' />}
|
||||||
|
>
|
||||||
|
<div className='grid gap-4 md:grid-cols-3'>
|
||||||
|
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-4'>
|
||||||
|
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>系统名称</p>
|
||||||
|
<p className='mt-2 text-sm font-medium text-[var(--foreground-primary)]'>{status.system_name || 'ATSFlare'}</p>
|
||||||
|
</div>
|
||||||
|
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-4'>
|
||||||
|
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>Server 启动时间</p>
|
||||||
|
<p className='mt-2 text-sm font-medium text-[var(--foreground-primary)]'>
|
||||||
|
{formatDateTime(new Date(status.start_time * 1000))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-4'>
|
||||||
|
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>项目仓库</p>
|
||||||
|
<a
|
||||||
|
href='https://github.com/Rain-kl/ATSFlare'
|
||||||
|
target='_blank'
|
||||||
|
rel='noreferrer'
|
||||||
|
className='mt-2 block text-sm font-medium text-[var(--brand-primary)] transition hover:opacity-80'
|
||||||
|
>
|
||||||
|
github.com/Rain-kl/ATSFlare
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppCard>
|
||||||
|
|
||||||
|
{aboutContent ? (
|
||||||
|
<AppCard title='项目介绍' description='以下内容由系统设置中的“关于内容”维护。'>
|
||||||
|
<div
|
||||||
|
className='prose prose-sm max-w-none text-[var(--foreground-primary)] [&_a]:text-[var(--brand-primary)]'
|
||||||
|
dangerouslySetInnerHTML={{ __html: marked.parse(aboutContent) as string }}
|
||||||
|
/>
|
||||||
|
</AppCard>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
title='尚未配置关于内容'
|
||||||
|
description='可在设置页的“其他设置”标签中编写 Markdown / HTML 内容,这里会自动同步展示。'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='flex flex-wrap gap-3 text-sm'>
|
||||||
|
<Link href='/' className='text-[var(--brand-primary)] transition hover:opacity-80'>
|
||||||
|
返回新版总览
|
||||||
|
</Link>
|
||||||
|
<Link href='/login' className='text-[var(--brand-primary)] transition hover:opacity-80'>
|
||||||
|
前往登录
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,8 +14,8 @@ const readinessItems = [
|
|||||||
description: '登录、注册、重置密码、鉴权守卫与后台布局已迁移到新前端。',
|
description: '登录、注册、重置密码、鉴权守卫与后台布局已迁移到新前端。',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '核心模块',
|
title: '业务模块',
|
||||||
description: '阶段 3 已接入反代规则、配置版本与节点页面,其余核心模块可继续按优先级接入。',
|
description: '核心链路与阶段 4 的设置、用户、文件模块都已接入真实数据与交互。',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -23,9 +23,9 @@ export function DashboardOverview() {
|
|||||||
return (
|
return (
|
||||||
<div className='space-y-6'>
|
<div className='space-y-6'>
|
||||||
<AppCard
|
<AppCard
|
||||||
title='阶段 3 进行中'
|
title='阶段 4 进行中'
|
||||||
description='当前已完成新版管理端基础工程与认证骨架,可继续推进核心业务模块迁移。'
|
description='当前已完成新版管理端基础工程、核心模块和大部分边缘模块迁移,可继续推进联调与回归。'
|
||||||
action={<StatusBadge label='继续迁移主链路' variant='success' />}
|
action={<StatusBadge label='准备联调回归' variant='success' />}
|
||||||
>
|
>
|
||||||
<div className='grid gap-4 lg:grid-cols-3'>
|
<div className='grid gap-4 lg:grid-cols-3'>
|
||||||
{readinessItems.map((item) => (
|
{readinessItems.map((item) => (
|
||||||
@@ -43,7 +43,7 @@ export function DashboardOverview() {
|
|||||||
</AppCard>
|
</AppCard>
|
||||||
|
|
||||||
<div className='grid gap-6 xl:grid-cols-[1.3fr_0.9fr]'>
|
<div className='grid gap-6 xl:grid-cols-[1.3fr_0.9fr]'>
|
||||||
<AppCard title='模块入口' description='核心页面已开始接入真实数据与交互,其余模块按阶段逐步替换占位页面。'>
|
<AppCard title='模块入口' description='新版导航中的业务页面均已接入真实数据,设置与边缘模块也已可直接使用。'>
|
||||||
<div className='grid gap-3 md:grid-cols-2'>
|
<div className='grid gap-3 md:grid-cols-2'>
|
||||||
{dashboardNavigation.slice(1).map((item) => (
|
{dashboardNavigation.slice(1).map((item) => (
|
||||||
<Link
|
<Link
|
||||||
@@ -60,11 +60,11 @@ export function DashboardOverview() {
|
|||||||
</div>
|
</div>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
|
|
||||||
<AppCard title='下一步建议' description='按前端改造计划,阶段 3 后续优先补齐其余核心主链路页面。'>
|
<AppCard title='下一步建议' description='按前端改造计划,当前可以开始阶段 5 的联调、回归与默认入口切换准备。'>
|
||||||
<ol className='space-y-3 text-sm leading-6 text-[var(--foreground-secondary)]'>
|
<ol className='space-y-3 text-sm leading-6 text-[var(--foreground-secondary)]'>
|
||||||
<li>1. 继续迁移节点、域名、证书与应用记录页面。</li>
|
<li>1. 对照旧前端完成设置、用户、文件与关于页的联调回归。</li>
|
||||||
<li>2. 为核心动作补齐确认、反馈与错误提示的一致体验。</li>
|
<li>2. 补齐关键页面的测试覆盖与构建验收。</li>
|
||||||
<li>3. 补充模块测试与静态构建回归验证。</li>
|
<li>3. 评估默认入口切换与旧前端回滚预案。</li>
|
||||||
</ol>
|
</ol>
|
||||||
</AppCard>
|
</AppCard>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { apiRequest, getApiUrl } from '@/lib/api/client';
|
||||||
|
|
||||||
|
import type { FileItem } from '@/features/files/types';
|
||||||
|
|
||||||
|
export function getFiles(page: number) {
|
||||||
|
return apiRequest<FileItem[]>(`/file/?p=${page}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchFiles(keyword: string) {
|
||||||
|
return apiRequest<FileItem[]>(`/file/search?keyword=${encodeURIComponent(keyword)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteFile(id: number) {
|
||||||
|
return apiRequest<void>(`/file/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFileDownloadUrl(link: string) {
|
||||||
|
return `/upload/${link}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFileAbsoluteUrl(link: string) {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return buildFileDownloadUrl(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new URL(buildFileDownloadUrl(link), window.location.origin).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadFiles(files: File[], description?: string) {
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
files.forEach((file) => {
|
||||||
|
formData.append('file', file);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (description) {
|
||||||
|
formData.append('description', description);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(getApiUrl('/file/'), {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = (await response.json()) as {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!response.ok || !payload.success) {
|
||||||
|
throw new Error(payload.message || `上传失败(${response.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { 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 { PageHeader } from '@/components/layout/page-header';
|
||||||
|
import { AppCard } from '@/components/ui/app-card';
|
||||||
|
import {
|
||||||
|
buildFileAbsoluteUrl,
|
||||||
|
buildFileDownloadUrl,
|
||||||
|
deleteFile,
|
||||||
|
getFiles,
|
||||||
|
searchFiles,
|
||||||
|
uploadFiles,
|
||||||
|
} from '@/features/files/api/files';
|
||||||
|
import type { FileItem } from '@/features/files/types';
|
||||||
|
import {
|
||||||
|
DangerButton,
|
||||||
|
PrimaryButton,
|
||||||
|
ResourceField,
|
||||||
|
ResourceInput,
|
||||||
|
SecondaryButton,
|
||||||
|
} from '@/features/shared/components/resource-primitives';
|
||||||
|
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 10;
|
||||||
|
const filesQueryKey = ['files', 'list'] as const;
|
||||||
|
|
||||||
|
type FeedbackState = {
|
||||||
|
tone: 'info' | 'success' | 'danger';
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown) {
|
||||||
|
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyToClipboard(value: string) {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilesPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [searchInput, setSearchInput] = useState('');
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||||
|
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||||
|
|
||||||
|
const isAdmin = (user?.role ?? 0) >= 10;
|
||||||
|
|
||||||
|
const filesPageQuery = useQuery({
|
||||||
|
queryKey: [...filesQueryKey, page],
|
||||||
|
queryFn: () => getFiles(page),
|
||||||
|
enabled: isAdmin && searchKeyword.length === 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filesSearchQuery = useQuery({
|
||||||
|
queryKey: [...filesQueryKey, 'search', searchKeyword],
|
||||||
|
queryFn: () => searchFiles(searchKeyword),
|
||||||
|
enabled: isAdmin && searchKeyword.length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadMutation = useMutation({
|
||||||
|
mutationFn: async () => uploadFiles(selectedFiles, description.trim()),
|
||||||
|
onSuccess: async () => {
|
||||||
|
setFeedback({ tone: 'success', message: `已上传 ${selectedFiles.length} 个文件。` });
|
||||||
|
setSelectedFiles([]);
|
||||||
|
setDescription('');
|
||||||
|
await queryClient.invalidateQueries({ queryKey: filesQueryKey });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: deleteFile,
|
||||||
|
onSuccess: async () => {
|
||||||
|
setFeedback({ tone: 'success', message: '文件已删除。' });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: filesQueryKey });
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeQuery = searchKeyword.length > 0 ? filesSearchQuery : filesPageQuery;
|
||||||
|
const files = activeQuery.data ?? [];
|
||||||
|
|
||||||
|
const summary = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{ label: searchKeyword ? '搜索结果' : '当前页文件', value: files.length },
|
||||||
|
{ label: '当前上传批次', value: selectedFiles.length },
|
||||||
|
{
|
||||||
|
label: '下载总次数',
|
||||||
|
value: files.reduce((total, item) => total + (item.download_counter || 0), 0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '上传者数',
|
||||||
|
value: new Set(files.map((item) => item.uploader_id)).size,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [files, searchKeyword, selectedFiles.length]);
|
||||||
|
|
||||||
|
const handleUpload = () => {
|
||||||
|
if (selectedFiles.length === 0) {
|
||||||
|
setFeedback({ tone: 'info', message: '请先选择要上传的文件。' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFeedback(null);
|
||||||
|
uploadMutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (file: FileItem) => {
|
||||||
|
if (!window.confirm(`确认删除文件“${file.filename}”吗?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFeedback(null);
|
||||||
|
deleteMutation.mutate(file.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = async (link: string) => {
|
||||||
|
try {
|
||||||
|
await copyToClipboard(buildFileAbsoluteUrl(link));
|
||||||
|
setFeedback({ tone: 'success', message: '文件链接已复制到剪贴板。' });
|
||||||
|
} catch (error) {
|
||||||
|
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearchSubmit = () => {
|
||||||
|
setFeedback(null);
|
||||||
|
setPage(0);
|
||||||
|
setSearchKeyword(searchInput.trim());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetSearch = () => {
|
||||||
|
setSearchInput('');
|
||||||
|
setSearchKeyword('');
|
||||||
|
setPage(0);
|
||||||
|
setFeedback(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
return (
|
||||||
|
<div className='space-y-6'>
|
||||||
|
<PageHeader
|
||||||
|
title='文件管理'
|
||||||
|
description='阶段 4 已迁移文件管理页面,但当前账户没有管理员权限。'
|
||||||
|
/>
|
||||||
|
<EmptyState
|
||||||
|
title='权限不足'
|
||||||
|
description='文件上传、删除与下载链接管理仅对管理员开放,请使用管理员账号登录后继续。'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-6'>
|
||||||
|
<PageHeader
|
||||||
|
title='文件管理'
|
||||||
|
description='支持上传、搜索、分页、下载、复制外链和删除文件,覆盖常见日常文件分发场景。'
|
||||||
|
/>
|
||||||
|
|
||||||
|
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null}
|
||||||
|
|
||||||
|
<div className='grid gap-6 xl:grid-cols-[1.1fr_0.9fr]'>
|
||||||
|
<AppCard title='文件概览' description='统计信息会随当前视图和待上传队列同步变化。'>
|
||||||
|
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||||
|
{summary.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.label}
|
||||||
|
className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'
|
||||||
|
>
|
||||||
|
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>
|
||||||
|
{item.label}
|
||||||
|
</p>
|
||||||
|
<p className='mt-2 text-lg font-semibold text-[var(--foreground-primary)]'>{item.value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</AppCard>
|
||||||
|
|
||||||
|
<AppCard
|
||||||
|
title='上传文件'
|
||||||
|
description='支持一次选择多个文件并附加统一描述信息。文件上传后将立即可下载。'
|
||||||
|
action={
|
||||||
|
<PrimaryButton type='button' onClick={handleUpload} disabled={uploadMutation.isPending}>
|
||||||
|
{uploadMutation.isPending ? '上传中...' : '开始上传'}
|
||||||
|
</PrimaryButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className='space-y-5'>
|
||||||
|
<ResourceField label='上传描述' hint='可选,适用于同批次文件的统一说明。'>
|
||||||
|
<ResourceInput
|
||||||
|
value={description}
|
||||||
|
onChange={(event) => setDescription(event.target.value)}
|
||||||
|
placeholder='例如:节点安装包 / 测试证书 / 宣传素材'
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
|
||||||
|
<ResourceField label='文件选择' hint='按住 Ctrl 或 Shift 可批量选择多个文件。'>
|
||||||
|
<input
|
||||||
|
type='file'
|
||||||
|
multiple
|
||||||
|
onChange={(event) => setSelectedFiles(Array.from(event.target.files ?? []))}
|
||||||
|
className='block w-full rounded-2xl border border-dashed border-[var(--border-default)] bg-[var(--surface-muted)] px-4 py-6 text-sm text-[var(--foreground-secondary)] file:mr-4 file:rounded-xl file:border-0 file:bg-[var(--brand-primary)] file:px-4 file:py-2 file:text-sm file:font-medium file:text-[var(--foreground-inverse)]'
|
||||||
|
/>
|
||||||
|
</ResourceField>
|
||||||
|
|
||||||
|
{selectedFiles.length > 0 ? (
|
||||||
|
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
||||||
|
<p className='text-sm font-medium text-[var(--foreground-primary)]'>待上传文件</p>
|
||||||
|
<ul className='mt-3 space-y-2 text-sm text-[var(--foreground-secondary)]'>
|
||||||
|
{selectedFiles.map((file) => (
|
||||||
|
<li key={`${file.name}-${file.size}`} className='break-all'>
|
||||||
|
{file.name} · {(file.size / 1024).toFixed(1)} KB
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState title='尚未选择文件' description='选择文件后即可直接上传,无需离开当前页面。' />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AppCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AppCard
|
||||||
|
title='文件列表'
|
||||||
|
description='分页模式下按服务器每页 10 条加载;搜索模式会返回全部匹配结果。'
|
||||||
|
action={
|
||||||
|
<SecondaryButton
|
||||||
|
type='button'
|
||||||
|
onClick={() => void queryClient.invalidateQueries({ queryKey: filesQueryKey })}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</SecondaryButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className='space-y-5'>
|
||||||
|
<div className='flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between'>
|
||||||
|
<div className='flex w-full flex-col gap-3 md:flex-row'>
|
||||||
|
<ResourceInput
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(event) => setSearchInput(event.target.value)}
|
||||||
|
placeholder='搜索文件名、上传者或上传者 ID'
|
||||||
|
className='md:max-w-xl'
|
||||||
|
/>
|
||||||
|
<div className='flex flex-wrap gap-2'>
|
||||||
|
<PrimaryButton type='button' onClick={handleSearchSubmit}>
|
||||||
|
搜索
|
||||||
|
</PrimaryButton>
|
||||||
|
<SecondaryButton type='button' onClick={handleResetSearch}>
|
||||||
|
清空
|
||||||
|
</SecondaryButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{searchKeyword.length === 0 ? (
|
||||||
|
<div className='flex flex-wrap gap-2'>
|
||||||
|
<SecondaryButton type='button' onClick={() => setPage((value) => Math.max(value - 1, 0))} disabled={page === 0 || activeQuery.isLoading}>
|
||||||
|
上一页
|
||||||
|
</SecondaryButton>
|
||||||
|
<SecondaryButton
|
||||||
|
type='button'
|
||||||
|
onClick={() => setPage((value) => value + 1)}
|
||||||
|
disabled={activeQuery.isLoading || files.length < ITEMS_PER_PAGE}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</SecondaryButton>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeQuery.isLoading ? (
|
||||||
|
<LoadingState />
|
||||||
|
) : activeQuery.isError ? (
|
||||||
|
<ErrorState title='文件列表加载失败' description={getErrorMessage(activeQuery.error)} />
|
||||||
|
) : files.length === 0 ? (
|
||||||
|
<EmptyState title='暂无文件' description='当前条件下没有可展示的文件记录。' />
|
||||||
|
) : (
|
||||||
|
<div className='overflow-x-auto'>
|
||||||
|
<table className='min-w-full divide-y divide-[var(--border-default)] text-left text-sm'>
|
||||||
|
<thead>
|
||||||
|
<tr className='text-[var(--foreground-secondary)]'>
|
||||||
|
<th className='px-3 py-3 font-medium'>文件</th>
|
||||||
|
<th className='px-3 py-3 font-medium'>上传者</th>
|
||||||
|
<th className='px-3 py-3 font-medium'>上传时间</th>
|
||||||
|
<th className='px-3 py-3 font-medium'>下载量</th>
|
||||||
|
<th className='px-3 py-3 font-medium'>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className='divide-y divide-[var(--border-default)]'>
|
||||||
|
{files.map((file) => (
|
||||||
|
<tr key={file.id} className='align-top'>
|
||||||
|
<td className='px-3 py-4'>
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<a
|
||||||
|
href={buildFileDownloadUrl(file.link)}
|
||||||
|
target='_blank'
|
||||||
|
rel='noreferrer'
|
||||||
|
className='font-medium text-[var(--brand-primary)] transition hover:opacity-80'
|
||||||
|
>
|
||||||
|
{file.filename}
|
||||||
|
</a>
|
||||||
|
<p className='max-w-80 text-xs leading-5 text-[var(--foreground-secondary)]'>
|
||||||
|
{file.description || '无描述信息'}
|
||||||
|
</p>
|
||||||
|
<p className='text-xs text-[var(--foreground-secondary)]'>
|
||||||
|
存储链接:{file.link}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className='px-3 py-4 text-[var(--foreground-secondary)]'>
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<p>{file.uploader}</p>
|
||||||
|
<p className='text-xs'>上传者 ID:{file.uploader_id}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className='px-3 py-4 text-[var(--foreground-secondary)]'>
|
||||||
|
{formatRelativeTime(file.upload_time)} · {formatDateTime(file.upload_time)}
|
||||||
|
</td>
|
||||||
|
<td className='px-3 py-4 text-[var(--foreground-secondary)]'>
|
||||||
|
{file.download_counter || 0}
|
||||||
|
</td>
|
||||||
|
<td className='px-3 py-4'>
|
||||||
|
<div className='flex flex-wrap gap-2'>
|
||||||
|
<PrimaryButton
|
||||||
|
type='button'
|
||||||
|
onClick={() => window.open(buildFileDownloadUrl(file.link), '_blank', 'noopener,noreferrer')}
|
||||||
|
className='px-3 py-2 text-xs'
|
||||||
|
>
|
||||||
|
下载
|
||||||
|
</PrimaryButton>
|
||||||
|
<SecondaryButton
|
||||||
|
type='button'
|
||||||
|
onClick={() => void handleCopy(file.link)}
|
||||||
|
className='px-3 py-2 text-xs'
|
||||||
|
>
|
||||||
|
复制链接
|
||||||
|
</SecondaryButton>
|
||||||
|
<DangerButton
|
||||||
|
type='button'
|
||||||
|
onClick={() => handleDelete(file)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
className='px-3 py-2 text-xs'
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</DangerButton>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AppCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export interface FileItem {
|
||||||
|
id: number;
|
||||||
|
filename: string;
|
||||||
|
description: string;
|
||||||
|
uploader: string;
|
||||||
|
uploader_id: number;
|
||||||
|
link: string;
|
||||||
|
upload_time: string;
|
||||||
|
download_counter: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { apiRequest } from '@/lib/api/client';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
BootstrapTokenPayload,
|
||||||
|
LatestReleaseInfo,
|
||||||
|
OptionItem,
|
||||||
|
SettingsProfile,
|
||||||
|
UpdateSelfPayload,
|
||||||
|
} from '@/features/settings/types';
|
||||||
|
|
||||||
|
export function getOptions() {
|
||||||
|
return apiRequest<OptionItem[]>('/option/');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateOption(key: string, value: string) {
|
||||||
|
return apiRequest<void>('/option/', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ key, value }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBootstrapToken() {
|
||||||
|
return apiRequest<BootstrapTokenPayload>('/nodes/bootstrap-token');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rotateBootstrapToken() {
|
||||||
|
return apiRequest<BootstrapTokenPayload>('/nodes/bootstrap-token/rotate', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLatestRelease() {
|
||||||
|
return apiRequest<LatestReleaseInfo>('/update/latest-release');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSettingsProfile() {
|
||||||
|
return apiRequest<SettingsProfile>('/user/self');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSelf(payload: UpdateSelfPayload) {
|
||||||
|
return apiRequest<void>('/user/self', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateAccessToken() {
|
||||||
|
return apiRequest<string>('/user/token');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindWeChat(code: string) {
|
||||||
|
return apiRequest<void>(`/oauth/wechat/bind?code=${encodeURIComponent(code)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindEmail(email: string, code: string) {
|
||||||
|
const searchParams = new URLSearchParams({ email, code });
|
||||||
|
return apiRequest<void>(`/oauth/email/bind?${searchParams.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAboutContent() {
|
||||||
|
return apiRequest<string>('/about');
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
|||||||
|
import type { AuthUser } from '@/types/auth';
|
||||||
|
|
||||||
|
export interface OptionItem {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LatestReleaseInfo {
|
||||||
|
tag_name: string;
|
||||||
|
body: string;
|
||||||
|
html_url: string;
|
||||||
|
published_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BootstrapTokenPayload {
|
||||||
|
discovery_token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateSelfPayload {
|
||||||
|
username: string;
|
||||||
|
display_name: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingsProfile extends AuthUser {}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { LoadingState } from '@/components/feedback/loading-state';
|
||||||
|
|
||||||
|
interface LegacyRouteRedirectProps {
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LegacyRouteRedirect({ href }: LegacyRouteRedirectProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
router.replace(href);
|
||||||
|
}, [href, router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className='flex min-h-screen items-center justify-center px-4 py-8'>
|
||||||
|
<LoadingState className='w-full max-w-md' />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { apiRequest } from '@/lib/api/client';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ManageUserAction,
|
||||||
|
ManageUserResult,
|
||||||
|
UserItem,
|
||||||
|
UserMutationPayload,
|
||||||
|
} from '@/features/users/types';
|
||||||
|
|
||||||
|
export function getUsers(page: number) {
|
||||||
|
return apiRequest<UserItem[]>(`/user/?p=${page}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchUsers(keyword: string) {
|
||||||
|
return apiRequest<UserItem[]>(`/user/search?keyword=${encodeURIComponent(keyword)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUser(id: number) {
|
||||||
|
return apiRequest<UserItem>(`/user/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUser(payload: UserMutationPayload) {
|
||||||
|
return apiRequest<void>('/user/', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateUser(payload: UserMutationPayload & { id: number }) {
|
||||||
|
return apiRequest<void>('/user/', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function manageUser(username: string, action: ManageUserAction) {
|
||||||
|
return apiRequest<ManageUserResult>('/user/manage', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ username, action }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,492 @@
|
|||||||
|
'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';
|
||||||
|
|
||||||
|
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 { PageHeader } from '@/components/layout/page-header';
|
||||||
|
import { AppCard } from '@/components/ui/app-card';
|
||||||
|
import { StatusBadge } from '@/components/ui/status-badge';
|
||||||
|
import {
|
||||||
|
createUser,
|
||||||
|
getUser,
|
||||||
|
getUsers,
|
||||||
|
manageUser,
|
||||||
|
searchUsers,
|
||||||
|
updateUser,
|
||||||
|
} from '@/features/users/api/users';
|
||||||
|
import type {
|
||||||
|
ManageUserAction,
|
||||||
|
UserItem,
|
||||||
|
UserMutationPayload,
|
||||||
|
} from '@/features/users/types';
|
||||||
|
import {
|
||||||
|
DangerButton,
|
||||||
|
PrimaryButton,
|
||||||
|
ResourceField,
|
||||||
|
ResourceInput,
|
||||||
|
SecondaryButton,
|
||||||
|
} from '@/features/shared/components/resource-primitives';
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 10;
|
||||||
|
const usersListQueryKey = ['users', 'list'] as const;
|
||||||
|
|
||||||
|
const userSchema = z.object({
|
||||||
|
username: z.string().trim().min(1, '请输入用户名').max(12, '用户名不能超过 12 个字符'),
|
||||||
|
display_name: z.string().trim().max(20, '显示名称不能超过 20 个字符'),
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.max(20, '密码不能超过 20 个字符')
|
||||||
|
.refine((value) => value.length === 0 || value.length >= 8, '密码至少需要 8 个字符'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type UserFormValues = z.infer<typeof userSchema>;
|
||||||
|
|
||||||
|
type FeedbackState = {
|
||||||
|
tone: 'info' | 'success' | 'danger';
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultValues: UserFormValues = {
|
||||||
|
username: '',
|
||||||
|
display_name: '',
|
||||||
|
password: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown) {
|
||||||
|
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRoleMeta(role: number) {
|
||||||
|
if (role >= 100) {
|
||||||
|
return { label: '超级管理员', variant: 'warning' as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role >= 10) {
|
||||||
|
return { label: '管理员', variant: 'info' as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { label: '普通用户', variant: 'success' as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusMeta(status: number) {
|
||||||
|
if (status === 1) {
|
||||||
|
return { label: '已激活', variant: 'success' as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { label: '已封禁', variant: 'danger' as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPayload(values: UserFormValues): UserMutationPayload {
|
||||||
|
return {
|
||||||
|
username: values.username.trim(),
|
||||||
|
display_name: values.display_name.trim(),
|
||||||
|
password: values.password,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UsersPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { user: currentUser } = useAuth();
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [searchInput, setSearchInput] = useState('');
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState('');
|
||||||
|
const [editingUserId, setEditingUserId] = useState<number | null>(null);
|
||||||
|
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
|
||||||
|
|
||||||
|
const form = useForm<UserFormValues>({
|
||||||
|
resolver: zodResolver(userSchema),
|
||||||
|
defaultValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isAdmin = (currentUser?.role ?? 0) >= 10;
|
||||||
|
const isRoot = (currentUser?.role ?? 0) >= 100;
|
||||||
|
|
||||||
|
const usersQuery = useQuery({
|
||||||
|
queryKey: [...usersListQueryKey, page],
|
||||||
|
queryFn: () => getUsers(page),
|
||||||
|
enabled: isAdmin && searchKeyword.length === 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const searchQuery = useQuery({
|
||||||
|
queryKey: [...usersListQueryKey, 'search', searchKeyword],
|
||||||
|
queryFn: () => searchUsers(searchKeyword),
|
||||||
|
enabled: isAdmin && searchKeyword.length > 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const editingUserQuery = useQuery({
|
||||||
|
queryKey: ['users', 'detail', editingUserId],
|
||||||
|
queryFn: () => getUser(editingUserId as number),
|
||||||
|
enabled: isAdmin && editingUserId !== null,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingUserQuery.data) {
|
||||||
|
form.reset({
|
||||||
|
username: editingUserQuery.data.username,
|
||||||
|
display_name: editingUserQuery.data.display_name || '',
|
||||||
|
password: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [editingUserQuery.data, form]);
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: async (values: UserFormValues) => {
|
||||||
|
if (!editingUserId && values.password.length === 0) {
|
||||||
|
throw new Error('创建用户时必须填写密码。');
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = toPayload(values);
|
||||||
|
|
||||||
|
if (editingUserId) {
|
||||||
|
await updateUser({ id: editingUserId, ...payload });
|
||||||
|
return '用户信息已更新。';
|
||||||
|
}
|
||||||
|
|
||||||
|
await createUser(payload);
|
||||||
|
return '用户已创建。';
|
||||||
|
},
|
||||||
|
onSuccess: async (message) => {
|
||||||
|
setFeedback({ tone: 'success', message });
|
||||||
|
setEditingUserId(null);
|
||||||
|
form.reset(defaultValues);
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: usersListQueryKey }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['users', 'detail'] }),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const manageMutation = useMutation({
|
||||||
|
mutationFn: async ({ username, action }: { username: string; action: ManageUserAction }) => {
|
||||||
|
await manageUser(username, action);
|
||||||
|
return action;
|
||||||
|
},
|
||||||
|
onSuccess: async (action) => {
|
||||||
|
const actionLabel = {
|
||||||
|
promote: '提升权限',
|
||||||
|
demote: '降级权限',
|
||||||
|
delete: '删除用户',
|
||||||
|
disable: '禁用用户',
|
||||||
|
enable: '启用用户',
|
||||||
|
}[action];
|
||||||
|
|
||||||
|
setFeedback({ tone: 'success', message: `${actionLabel}操作已完成。` });
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: usersListQueryKey }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['users', 'detail'] }),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeQuery = searchKeyword.length > 0 ? searchQuery : usersQuery;
|
||||||
|
const users = activeQuery.data ?? [];
|
||||||
|
|
||||||
|
const summary = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{ label: searchKeyword ? '搜索结果' : '当前页用户', value: users.length },
|
||||||
|
{ label: '管理员', value: users.filter((item) => item.role >= 10).length },
|
||||||
|
{ label: '已激活', value: users.filter((item) => item.status === 1).length },
|
||||||
|
{ label: '已封禁', value: users.filter((item) => item.status !== 1).length },
|
||||||
|
];
|
||||||
|
}, [searchKeyword, users]);
|
||||||
|
|
||||||
|
const handleSearchSubmit = () => {
|
||||||
|
setFeedback(null);
|
||||||
|
setPage(0);
|
||||||
|
setSearchKeyword(searchInput.trim());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetSearch = () => {
|
||||||
|
setSearchInput('');
|
||||||
|
setSearchKeyword('');
|
||||||
|
setPage(0);
|
||||||
|
setFeedback(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetForm = () => {
|
||||||
|
setEditingUserId(null);
|
||||||
|
setFeedback(null);
|
||||||
|
form.reset(defaultValues);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (targetUser: UserItem) => {
|
||||||
|
setFeedback(null);
|
||||||
|
setEditingUserId(targetUser.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleManage = (targetUser: UserItem, action: ManageUserAction) => {
|
||||||
|
const actionText = {
|
||||||
|
promote: '提升',
|
||||||
|
demote: '降级',
|
||||||
|
delete: '删除',
|
||||||
|
disable: '禁用',
|
||||||
|
enable: '启用',
|
||||||
|
}[action];
|
||||||
|
|
||||||
|
if (action === 'delete' && !window.confirm(`确认删除用户“${targetUser.username}”吗?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((action === 'disable' || action === 'enable') && !window.confirm(`确认${actionText}用户“${targetUser.username}”吗?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFeedback(null);
|
||||||
|
manageMutation.mutate({ username: targetUser.username, action });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = form.handleSubmit((values) => {
|
||||||
|
setFeedback(null);
|
||||||
|
saveMutation.mutate(values);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
return (
|
||||||
|
<div className='space-y-6'>
|
||||||
|
<PageHeader
|
||||||
|
title='用户管理'
|
||||||
|
description='阶段 4 已迁移用户管理页面,但当前账户没有管理员权限。'
|
||||||
|
/>
|
||||||
|
<EmptyState
|
||||||
|
title='权限不足'
|
||||||
|
description='用户列表、编辑与账户管理动作仅对管理员开放,请使用管理员账号登录后继续。'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-6'>
|
||||||
|
<PageHeader
|
||||||
|
title='用户管理'
|
||||||
|
description='支持搜索、分页、创建、编辑以及启用、封禁、权限调整等常见账户管理动作。'
|
||||||
|
/>
|
||||||
|
|
||||||
|
{feedback ? <InlineMessage tone={feedback.tone} message={feedback.message} /> : null}
|
||||||
|
|
||||||
|
<div className='grid gap-6 xl:grid-cols-[1.2fr_0.8fr]'>
|
||||||
|
<AppCard title='用户概览' description='当前视图会根据分页或搜索结果实时刷新。'>
|
||||||
|
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
|
||||||
|
{summary.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.label}
|
||||||
|
className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'
|
||||||
|
>
|
||||||
|
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>
|
||||||
|
{item.label}
|
||||||
|
</p>
|
||||||
|
<p className='mt-2 text-lg font-semibold text-[var(--foreground-primary)]'>{item.value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</AppCard>
|
||||||
|
|
||||||
|
<AppCard
|
||||||
|
title={editingUserId ? '编辑用户' : '创建新用户'}
|
||||||
|
description={editingUserId ? '可更新用户名、显示名称与密码。留空密码表示不修改。' : '新建用户默认创建为普通用户。'}
|
||||||
|
>
|
||||||
|
{editingUserId && editingUserQuery.isLoading ? (
|
||||||
|
<LoadingState />
|
||||||
|
) : editingUserId && editingUserQuery.isError ? (
|
||||||
|
<ErrorState title='用户详情加载失败' description={getErrorMessage(editingUserQuery.error)} />
|
||||||
|
) : (
|
||||||
|
<form className='space-y-5' onSubmit={handleSubmit}>
|
||||||
|
<ResourceField label='用户名' error={form.formState.errors.username?.message}>
|
||||||
|
<ResourceInput placeholder='请输入用户名' {...form.register('username')} />
|
||||||
|
</ResourceField>
|
||||||
|
|
||||||
|
<ResourceField label='显示名称' error={form.formState.errors.display_name?.message}>
|
||||||
|
<ResourceInput placeholder='请输入显示名称' {...form.register('display_name')} />
|
||||||
|
</ResourceField>
|
||||||
|
|
||||||
|
<ResourceField
|
||||||
|
label={editingUserId ? '新密码' : '密码'}
|
||||||
|
hint={editingUserId ? '留空表示保持原密码不变。' : '密码长度需为 8 到 20 个字符。'}
|
||||||
|
error={form.formState.errors.password?.message}
|
||||||
|
>
|
||||||
|
<ResourceInput type='password' placeholder='请输入密码' {...form.register('password')} />
|
||||||
|
</ResourceField>
|
||||||
|
|
||||||
|
{editingUserId && editingUserQuery.data ? (
|
||||||
|
<div className='grid gap-4 md:grid-cols-2'>
|
||||||
|
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
||||||
|
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>绑定邮箱</p>
|
||||||
|
<p className='mt-2 break-all text-sm text-[var(--foreground-primary)]'>
|
||||||
|
{editingUserQuery.data.email || '未绑定'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
|
||||||
|
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>第三方账号</p>
|
||||||
|
<p className='mt-2 text-sm text-[var(--foreground-primary)]'>
|
||||||
|
GitHub:{editingUserQuery.data.github_id || '未绑定'}
|
||||||
|
</p>
|
||||||
|
<p className='mt-1 text-sm text-[var(--foreground-primary)]'>
|
||||||
|
微信:{editingUserQuery.data.wechat_id || '未绑定'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className='flex flex-wrap gap-3'>
|
||||||
|
<PrimaryButton type='submit' disabled={saveMutation.isPending}>
|
||||||
|
{saveMutation.isPending ? '保存中...' : editingUserId ? '保存修改' : '创建用户'}
|
||||||
|
</PrimaryButton>
|
||||||
|
<SecondaryButton type='button' onClick={handleResetForm} disabled={saveMutation.isPending}>
|
||||||
|
{editingUserId ? '取消编辑' : '重置表单'}
|
||||||
|
</SecondaryButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</AppCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AppCard
|
||||||
|
title='用户列表'
|
||||||
|
description='搜索会切换到全量匹配结果;分页模式下按服务器默认每页 10 条加载。'
|
||||||
|
action={
|
||||||
|
<div className='flex flex-wrap gap-2'>
|
||||||
|
<SecondaryButton
|
||||||
|
type='button'
|
||||||
|
onClick={() => void queryClient.invalidateQueries({ queryKey: usersListQueryKey })}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</SecondaryButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className='space-y-5'>
|
||||||
|
<div className='flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between'>
|
||||||
|
<div className='flex w-full flex-col gap-3 md:flex-row'>
|
||||||
|
<ResourceInput
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(event) => setSearchInput(event.target.value)}
|
||||||
|
placeholder='搜索用户 ID、用户名、显示名称或邮箱地址'
|
||||||
|
className='md:max-w-xl'
|
||||||
|
/>
|
||||||
|
<div className='flex flex-wrap gap-2'>
|
||||||
|
<PrimaryButton type='button' onClick={handleSearchSubmit}>
|
||||||
|
搜索
|
||||||
|
</PrimaryButton>
|
||||||
|
<SecondaryButton type='button' onClick={handleResetSearch}>
|
||||||
|
清空
|
||||||
|
</SecondaryButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{searchKeyword.length === 0 ? (
|
||||||
|
<div className='flex flex-wrap gap-2'>
|
||||||
|
<SecondaryButton type='button' onClick={() => setPage((value) => Math.max(value - 1, 0))} disabled={page === 0 || activeQuery.isLoading}>
|
||||||
|
上一页
|
||||||
|
</SecondaryButton>
|
||||||
|
<SecondaryButton
|
||||||
|
type='button'
|
||||||
|
onClick={() => setPage((value) => value + 1)}
|
||||||
|
disabled={activeQuery.isLoading || users.length < ITEMS_PER_PAGE}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</SecondaryButton>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeQuery.isLoading ? (
|
||||||
|
<LoadingState />
|
||||||
|
) : activeQuery.isError ? (
|
||||||
|
<ErrorState title='用户列表加载失败' description={getErrorMessage(activeQuery.error)} />
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
<EmptyState title='暂无用户' description='当前条件下没有可展示的用户记录。' />
|
||||||
|
) : (
|
||||||
|
<div className='overflow-x-auto'>
|
||||||
|
<table className='min-w-full divide-y divide-[var(--border-default)] text-left text-sm'>
|
||||||
|
<thead>
|
||||||
|
<tr className='text-[var(--foreground-secondary)]'>
|
||||||
|
<th className='px-3 py-3 font-medium'>用户</th>
|
||||||
|
<th className='px-3 py-3 font-medium'>邮箱</th>
|
||||||
|
<th className='px-3 py-3 font-medium'>角色</th>
|
||||||
|
<th className='px-3 py-3 font-medium'>状态</th>
|
||||||
|
<th className='px-3 py-3 font-medium'>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className='divide-y divide-[var(--border-default)]'>
|
||||||
|
{users.map((targetUser) => {
|
||||||
|
const roleMeta = getRoleMeta(targetUser.role);
|
||||||
|
const statusMeta = getStatusMeta(targetUser.status);
|
||||||
|
const canManage = (currentUser?.role ?? 0) > targetUser.role;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={targetUser.id} className='align-top'>
|
||||||
|
<td className='px-3 py-4'>
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<p className='font-medium text-[var(--foreground-primary)]'>{targetUser.username}</p>
|
||||||
|
<p className='text-xs text-[var(--foreground-secondary)]'>
|
||||||
|
显示名称:{targetUser.display_name || '未设置'}
|
||||||
|
</p>
|
||||||
|
<p className='text-xs text-[var(--foreground-secondary)]'>ID:{targetUser.id}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className='px-3 py-4 text-[var(--foreground-secondary)]'>
|
||||||
|
{targetUser.email || '未绑定'}
|
||||||
|
</td>
|
||||||
|
<td className='px-3 py-4'>
|
||||||
|
<StatusBadge label={roleMeta.label} variant={roleMeta.variant} />
|
||||||
|
</td>
|
||||||
|
<td className='px-3 py-4'>
|
||||||
|
<StatusBadge label={statusMeta.label} variant={statusMeta.variant} />
|
||||||
|
</td>
|
||||||
|
<td className='px-3 py-4'>
|
||||||
|
<div className='flex flex-wrap gap-2'>
|
||||||
|
<SecondaryButton type='button' onClick={() => handleEdit(targetUser)} className='px-3 py-2 text-xs' disabled={!canManage}>
|
||||||
|
编辑
|
||||||
|
</SecondaryButton>
|
||||||
|
<SecondaryButton
|
||||||
|
type='button'
|
||||||
|
onClick={() => handleManage(targetUser, targetUser.status === 1 ? 'disable' : 'enable')}
|
||||||
|
className='px-3 py-2 text-xs'
|
||||||
|
disabled={!canManage || manageMutation.isPending}
|
||||||
|
>
|
||||||
|
{targetUser.status === 1 ? '禁用' : '启用'}
|
||||||
|
</SecondaryButton>
|
||||||
|
<SecondaryButton
|
||||||
|
type='button'
|
||||||
|
onClick={() => handleManage(targetUser, targetUser.role >= 10 ? 'demote' : 'promote')}
|
||||||
|
className='px-3 py-2 text-xs'
|
||||||
|
disabled={!canManage || manageMutation.isPending || (!isRoot && targetUser.role < 10)}
|
||||||
|
>
|
||||||
|
{targetUser.role >= 10 ? '降级' : '提升'}
|
||||||
|
</SecondaryButton>
|
||||||
|
<DangerButton
|
||||||
|
type='button'
|
||||||
|
onClick={() => handleManage(targetUser, 'delete')}
|
||||||
|
className='px-3 py-2 text-xs'
|
||||||
|
disabled={!canManage || manageMutation.isPending}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</DangerButton>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AppCard>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export interface UserItem {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
display_name: string;
|
||||||
|
role: number;
|
||||||
|
status: number;
|
||||||
|
email?: string;
|
||||||
|
github_id?: string;
|
||||||
|
wechat_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserMutationPayload {
|
||||||
|
id?: number;
|
||||||
|
username: string;
|
||||||
|
display_name: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ManageUserAction = 'promote' | 'demote' | 'delete' | 'disable' | 'enable';
|
||||||
|
|
||||||
|
export interface ManageUserResult {
|
||||||
|
role: number;
|
||||||
|
status: number;
|
||||||
|
}
|
||||||
@@ -17,12 +17,15 @@ export function getApiUrl(path: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function apiRequest<T>(path: string, init?: RequestInit) {
|
export async function apiRequest<T>(path: string, init?: RequestInit) {
|
||||||
|
const headers = new Headers(init?.headers ?? {});
|
||||||
|
|
||||||
|
if (!(init?.body instanceof FormData) && !headers.has('Content-Type')) {
|
||||||
|
headers.set('Content-Type', 'application/json');
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(getApiUrl(path), {
|
const response = await fetch(getApiUrl(path), {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: {
|
headers,
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...(init?.headers ?? {}),
|
|
||||||
},
|
|
||||||
...init,
|
...init,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -47,18 +47,18 @@ export const dashboardNavigation: NavigationItem[] = [
|
|||||||
href: '/files',
|
href: '/files',
|
||||||
label: '文件管理',
|
label: '文件管理',
|
||||||
shortLabel: '文件',
|
shortLabel: '文件',
|
||||||
description: '阶段 4 接入列表、下载与管理动作。',
|
description: '已接入上传、下载、复制链接、搜索与删除动作。',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/users',
|
href: '/users',
|
||||||
label: '用户管理',
|
label: '用户管理',
|
||||||
shortLabel: '用户',
|
shortLabel: '用户',
|
||||||
description: '阶段 4 接入用户列表、角色与搜索体验。',
|
description: '已接入搜索、创建、编辑、封禁与权限调整。',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/settings',
|
href: '/settings',
|
||||||
label: '设置',
|
label: '设置',
|
||||||
shortLabel: '设置',
|
shortLabel: '设置',
|
||||||
description: '阶段 4 接入系统设置、运维设置与个人设置。',
|
description: '已接入个人、运维、系统与关于内容维护。',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ function toDate(value: string | Date | null | undefined) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const date = value instanceof Date ? value : new Date(value);
|
const normalizedValue = typeof value === 'string' ? value.replace(' ', 'T') : value;
|
||||||
|
const date = normalizedValue instanceof Date ? normalizedValue : new Date(normalizedValue);
|
||||||
return Number.isNaN(date.getTime()) ? null : date;
|
return Number.isNaN(date.getTime()) ? null : date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"@hookform/resolvers": "^4.1.3",
|
"@hookform/resolvers": "^4.1.3",
|
||||||
"@tanstack/react-query": "^5.66.8",
|
"@tanstack/react-query": "^5.66.8",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"marked": "^17.0.4",
|
||||||
"next": "15.2.2",
|
"next": "15.2.2",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-dom": "19.0.0",
|
"react-dom": "19.0.0",
|
||||||
|
|||||||
Generated
+10
@@ -17,6 +17,9 @@ importers:
|
|||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
|
marked:
|
||||||
|
specifier: ^17.0.4
|
||||||
|
version: 17.0.4
|
||||||
next:
|
next:
|
||||||
specifier: 15.2.2
|
specifier: 15.2.2
|
||||||
version: 15.2.2(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
version: 15.2.2(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||||
@@ -2095,6 +2098,11 @@ packages:
|
|||||||
magic-string@0.30.21:
|
magic-string@0.30.21:
|
||||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||||
|
|
||||||
|
marked@17.0.4:
|
||||||
|
resolution: {integrity: sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ==}
|
||||||
|
engines: {node: '>= 20'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
math-intrinsics@1.1.0:
|
math-intrinsics@1.1.0:
|
||||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -4979,6 +4987,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
|
|
||||||
|
marked@17.0.4: {}
|
||||||
|
|
||||||
math-intrinsics@1.1.0: {}
|
math-intrinsics@1.1.0: {}
|
||||||
|
|
||||||
merge2@1.4.1: {}
|
merge2@1.4.1: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user