feat: 优化项目目录结构

This commit is contained in:
2025-12-12 17:25:26 +08:00
parent ae627d0496
commit b89f83291e
235 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,71 @@
'use client';
import { AppSidebar } from "@/components/app-sidebar"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
} from "@/components/ui/breadcrumb"
import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar"
import { useUserMe } from "@/hooks/user/use-user-me";
import { UserApi } from "@/lib/api";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
export default function ConsoleMenuLayout({
children,
}: {
children: React.ReactNode
}) {
const router = useRouter();
const { user, isLoading, error } = useUserMe({
onError: (e) => {
if (e.statusCode === 401) {
toast.info('登录凭证已失效,请重新登录');
router.replace('/console/login');
}
}
});
if (!isLoading && !error && !user) {
router.replace('/console/login');
localStorage.removeItem('token');
localStorage.removeItem(UserApi.USER_ME_CACHE_KEY);
toast.error('账户状态异常,请重新登录');
}
return (
<SidebarProvider>
<AppSidebar user={user} isUserLoading={isLoading} />
<SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12">
<div className="flex items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" />
<div className="w-[1px] h-4 mr-2 bg-zinc-300"></div>
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbPage>
{new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long'
}).format(new Date())}
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
</header>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
{children}
</div>
</SidebarInset>
</SidebarProvider>
)
}

View File

@@ -0,0 +1,7 @@
export default function MailInbox() {
return (
<div>
...
</div>
)
}

View File

@@ -0,0 +1,7 @@
export default function MailManage() {
return (
<div>
...
</div>
)
}

View File

@@ -0,0 +1,7 @@
export default function MailSend() {
return (
<div>
...
</div>
)
}

View File

@@ -0,0 +1,7 @@
export default function MailSent() {
return (
<div>
...
</div>
)
}

View File

@@ -0,0 +1,7 @@
export default function Page() {
return (
<div>
page
</div>
)
}

View File

@@ -0,0 +1,210 @@
'use client';
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Check, CloudUpload, File, Files, X } from "lucide-react";
import React, { useRef, useState } from "react";
import { Progress } from '@/components/ui/progress';
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { OssStore } from "@/lib/oss/OssStore";
import OSS, { Checkpoint } from "ali-oss";
interface UploadManagerProps {
children: React.ReactNode;
ossStore?: OssStore;
handleRefreshFileList?: () => void;
}
interface UploadFileItem {
id: string;
file: File;
status: 'ready' | 'uploading' | 'finish' | 'failed';
progress: number;// 0 ~ 100
}
export function UploadManager({ children, ossStore, handleRefreshFileList }: UploadManagerProps) {
const [filesList, setFileList] = useState<UploadFileItem[]>([]);
const handleFileSelect = (fileList: FileList) => {
setFileList(currentFileList => {
const newFiles: UploadFileItem[] = [];
for (const file of fileList) {
const repeatFile = currentFileList.find(f =>
f.file.name === file.name &&
f.file.size === file.size &&
f.file.lastModified === file.lastModified
);
if (!repeatFile) {
newFiles.push({
id: `${Math.random()}${Date.now()}`,
file: file,
progress: 0,
status: 'ready',
})
}
}
return [...currentFileList, ...newFiles];
})
}
const handleFileDelete = (fileItemId: string) => {
if (isUploading) return toast.warning('上传过程暂时无法删除');
setFileList(currentFileList => currentFileList.filter(i => i.id !== fileItemId))
}
const inputFileRef = useRef<HTMLInputElement>(null);
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files) return;
handleFileSelect(e.target.files);
}
const [fileDroping, setFileDroping] = useState(false);
const handleFileOnDropOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setFileDroping(true);
}
const handleFileOnDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setFileDroping(false);
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
handleFileSelect(e.dataTransfer.files);
}
}
const [isUploading, setIsUploading] = useState(false);
/** 开始上传文件 */
const handleUpload = async () => {
if (!ossStore) return;
let store: OSS;
try { store = ossStore.getStore(); } catch { return toast.error(`初始化失败`) };
const needUploadFiles = filesList.filter(f => f.status !== 'finish');
if (needUploadFiles.length === 0) return toast.info('请选择需要上传的文件');
let failCount = 0;
setIsUploading(true);
for (const fileItem of needUploadFiles) {
fileItem.status = 'uploading';
await startUploadFile(store, fileItem, ossStore.getWorkDir()).catch(() => { fileItem.status = 'failed'; failCount++; });
fileItem.status = 'finish';
}
setIsUploading(false);
if (failCount > 0) {
toast.warning(`上传完成,本次共有${failCount}个文件上传失败`);
} else {
toast.success(`上传完成,共上传了${needUploadFiles.length}个文件`)
}
// 清空上传成功的文件
setFileList(current => current.filter(f => f.status !== 'finish'));
handleRefreshFileList?.();
}
// 上传单个文件
const startUploadFile = async (store: OSS, fileItem: UploadFileItem, workDir?: string) => {
let checkpoint: Checkpoint | undefined;
await store.multipartUpload(`${workDir ? `${workDir}/` : ''}${fileItem.file.name}`, fileItem.file, {
checkpoint: checkpoint,
progress: (p, cpt) => {
setFileList(currentFileList => {
return currentFileList.map(f => {
if (f.id == fileItem.id) {
f.progress = p * 100;
}
return f;
})
});
checkpoint = cpt;
}
})
}
return (
<Dialog>
<DialogTrigger asChild>
{children}
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div>
<div
onDrop={handleFileOnDrop}
onDragOver={handleFileOnDropOver}
onClick={() => inputFileRef.current?.click()}
className="flex items-center justify-center border shadow rounded select-none cursor-pointer relative">
<div className={cn('flex flex-col items-center py-6 text-zinc-500 text-sm text-center', fileDroping && 'invisible')}>
<div className="flex items-center gap-3">
<CloudUpload className="size-10 text-zinc-400" />
<span> </span>
</div>
<div className="w-full px-5 mt-3 text-xs">
<p>5GB的文件</p>
</div>
</div>
<div className={cn('absolute flex items-center text-zinc-600 gap-1 invisible', fileDroping && 'visible')}>
<Files className="size-6 text-zinc-500" />
<span className="text-sm"></span>
</div>
</div>
<input
type="file"
multiple
ref={inputFileRef}
className="hidden"
onChange={handleFileInputChange} />
</div>
<div>
{
filesList.map(f => (
<div
key={f.id}
className="group hover:bg-zinc-100 duration-300 rounded px-1 py-1">
<div className="flex justify-between items-center">
<div className="flex items-center gap-1">
<File className="size-4 text-zinc-500" />
{/* 存在一个bug文本省略无法自动适配容器宽度 */}
<div className="text-sm text-zinc-800 overflow-hidden text-nowrap text-ellipsis max-w-60">
{f.file.name}
</div>
</div>
<div>
{f.status === 'finish' && <Check className="size-4 text-green-600 group-hover:hidden" />}
<X
onClick={() => handleFileDelete(f.id)}
className="size-4 text-zinc-500 cursor-pointer hover:text-zinc-600 hidden group-hover:block" />
</div>
</div>
<div className="h-0 w-full relative">
{f.status === 'uploading' && <Progress value={f.progress} className="h-1" />}
</div>
</div>
))
}
</div>
<DialogFooter>
<Button
type="button"
className="cursor-pointer"
onClick={handleUpload}
disabled={isUploading}></Button>
</DialogFooter>
</DialogContent>
</Dialog >
)
}

View File

@@ -0,0 +1,164 @@
'use client';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Delete, Download, RefreshCcw, Upload } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { toast } from 'sonner';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { UploadManager } from './components/UploadManager';
import { OssObjectItem, OssObjectList, OssStore } from '@/lib/oss/OssStore';
import { useOssStore } from '@/hooks/admin/web/blog/use-oss-store';
import OSS from 'ali-oss';
const formatSizeNumber = (n: number) => {
const unit = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'];
for (const [i] of unit.entries()) {
if (n < 1024 ** (i + 1)) {
if (i <= 0) {
return `${(n)}${unit[i]}`;
} else {
return `${(n / 1024 ** i).toFixed(2)}${unit[i]}`;
}
}
}
}
const ossStore = new OssStore();
export default function Page() {
const [objectList, setObjectList] = useState<OssObjectList>(null)
ossStore.setSetObjectList(setObjectList);
const storeMeta = useOssStore();
useEffect(() => {
const data = storeMeta.stsTokenData;
if (!data) return;
const store = new OSS({
region: 'oss-cn-chengdu',
bucket: 'tone-personal',
accessKeyId: data.AccessKeyId,
accessKeySecret: data.AccessKeySecret,
stsToken: data.SecurityToken,
refreshSTSToken: async () => {
await storeMeta.refresh();
if (!storeMeta.stsTokenData) throw new Error();
const { AccessKeyId, AccessKeySecret, SecurityToken } = storeMeta.stsTokenData;
return {
accessKeyId: AccessKeyId,
accessKeySecret: AccessKeySecret,
stsToken: SecurityToken,
};
},
})
ossStore.setStore(store);
ossStore.setWorkDir(`tone-page/${data.userId}`)
ossStore.loadObjectList();
// eslint-disable-next-line react-hooks/exhaustive-deps -- storeMeta引用会导致无限循环依赖stsTokenData即可
}, [storeMeta.stsTokenData]);
const handleRefreshFileList = async () => ossStore.loadObjectList().catch(e => toast.error(e.message));
const handleCheckboxChange = ossStore.handleObjectCheckedStateChanged.bind(ossStore);
const checkedFileIds = useMemo(() => {
return (objectList || []).filter(i => i.isChecked).map(i => i.id);
}, [objectList])
const handleDeleteObject = async (objectItem: OssObjectItem) => {
await ossStore.deleteObject(objectItem)
.then(() => ossStore.loadObjectList())
.catch(e => toast.error(`${e.message}`))
}
const handleDeleteCheckedFiles = async () => {
if (!objectList) return;
const checkedObjects = objectList.filter(o => o.isChecked);
const res = await ossStore.deleteCheckedObjects(checkedObjects);
if (res.failed > 0) {
toast.warning(`删除完成,共有${res.failed}个文件删除失败`)
} else {
toast.success(`${res.all}个文件删除完成`)
}
handleRefreshFileList();
}
const handleDownloadObject = async (objectItem: OssObjectItem) => {
ossStore.downloadObject(objectItem).catch(e => toast.error(`${e.message}`));
}
return (
<div>
<div className='mt-1 flex gap-2'>
<UploadManager
ossStore={ossStore}
handleRefreshFileList={handleRefreshFileList}
>
<Button variant='default' className='cursor-pointer'><Upload /></Button>
</UploadManager>
<Button
variant='destructive'
className='cursor-pointer'
disabled={(checkedFileIds.length) <= 0}
onClick={() => handleDeleteCheckedFiles()}
><Delete /></Button>
<div className='flex items-center'>
<Button variant='secondary' size='icon' className='cursor-pointer' onClick={() => handleRefreshFileList()}><RefreshCcw /></Button>
{objectList && <div className='text-sm ml-2'> {objectList.length} 100</div>}
</div>
</div>
<Table>
<TableCaption>
{objectList === null && <div>...</div>}
{/* {error && <div>{`${error}`}</div>} */}
{objectList && objectList.length === 0 && <div></div>}
</TableCaption>
<TableHeader>
<TableRow>
<TableHead className='w-10'></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{
objectList && objectList.map(d => (
<TableRow key={d.name} >
<TableCell>
<Checkbox checked={d.isChecked} onCheckedChange={v => handleCheckboxChange(d.id, Boolean(v))} />
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger className='whitespace-normal break-all cursor-pointer text-left'>
{d.name}
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => handleDownloadObject(d)}><Download /></DropdownMenuItem>
{/* <DropdownMenuItem><Edit />编辑</DropdownMenuItem> */}
<DropdownMenuItem onClick={() => handleDeleteObject(d)}><Delete /></DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
<TableCell>{formatSizeNumber(d.size)}</TableCell>
<TableCell className='whitespace-normal break-words'>{d.lastModified.toLocaleString()}</TableCell>
</TableRow>
))
}
</TableBody>
</Table>
</div >
)
}

View File

@@ -0,0 +1,89 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer"
import { useState } from "react";
import { AdminApi } from "@/lib/api";
import { toast } from "sonner";
import { ApiError } from "next/dist/server/api-utils";
interface CreateUserEditorProps {
children: React.ReactNode;
onRefresh: () => void;
}
export function CreateUserEditor({ children, onRefresh }: CreateUserEditorProps) {
const [open, setOpen] = useState(false);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
try {
await AdminApi.user.create({
username: formData.get("username")?.toString() || null,
nickname: formData.get("nickname")?.toString() || null,
email: formData.get("email")?.toString() || null,
phone: formData.get("phone")?.toString() || null,
password: formData.get("password")?.toString() || null,
});
setOpen(false);
toast.success('创建成功')
onRefresh();
} catch (error) {
toast.error((error as ApiError).message || '创建失败')
}
}
return (
<>
<div onClick={() => setOpen(true)} className="cursor-pointer">
{children}
</div>
<Drawer open={open} onClose={() => setOpen(false)}>
<DrawerContent>
<DrawerHeader className="text-left">
<DrawerTitle></DrawerTitle>
<DrawerDescription></DrawerDescription>
</DrawerHeader>
<form className="grid items-start gap-4 px-4" onSubmit={handleSubmit}>
<div className="grid gap-2">
<Label htmlFor="username"></Label>
<Input id="username" name="username" />
</div>
<div className="grid gap-2">
<Label htmlFor="nickname"></Label>
<Input id="nickname" name="nickname" />
</div>
<div className="grid gap-2">
<Label htmlFor="email"></Label>
<Input id="email" name="email" />
</div>
<div className="grid gap-2">
<Label htmlFor="phone"></Label>
<Input id="phone" name="phone" />
</div>
<div className="grid gap-2">
<Label htmlFor="password"></Label>
<Input id="password" name="password" />
</div>
<Button type="submit"></Button>
</form>
<DrawerFooter className="pt-2">
<DrawerClose asChild>
<Button variant="outline"></Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
</>
)
}

View File

@@ -0,0 +1,234 @@
'use client';
import * as React from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { useUser } from "@/hooks/admin/user/use-user";
import { User } from "@/lib/types/user";
import { Skeleton } from "@/components/ui/skeleton";
import { AdminApi } from "@/lib/api";
import { toast } from "sonner";
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/ui/alert"
import { AlertCircle } from "lucide-react";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
export function UserInfoEditor({
onClose,
onUserUpdate,
onUserSoftDelete,
userId,
}: {
onClose: () => void,
onUserUpdate: (user: User) => void,
onUserSoftDelete: (userId: string) => void,
userId: string
}) {
const { user, isLoading, error } = useUser(userId);
// const [saveLoading, setSaveLoading] = React.useState(false);
const handleSave = async (user: {
username: string;
nickname: string;
email: string | null;
phone: string | null;
}) => {
try {
// setSaveLoading(true);
const res = await AdminApi.user.update(userId, user);
if (res) {
toast.success("保存成功");
onUserUpdate(res);
onClose();
} else {
throw new Error();
}
} catch (error) {
toast.error((error as Error).message || "保存失败");
} finally {
// setSaveLoading(false);
}
}
// const [removeLoading, setRemoveLoading] = React.useState(false);
const handleRemove = async (userId: string) => {
try {
// setRemoveLoading(true);
await AdminApi.user.remove(userId, true);
toast.success("注销成功");
onUserSoftDelete(userId);
onClose();
} catch (error) {
toast.error((error as Error).message || "注销失败");
} finally {
// setRemoveLoading(false);
}
}
const [passwordDialogOpen, setPasswordDialogOpen] = React.useState(false);
// const [setPasswordLoading, setSetPasswordLoading] = React.useState(false);
const handleSetPassword = async (userId: string, password: string) => {
try {
// setSetPasswordLoading(true);
await AdminApi.user.setPassword(userId, password);
toast.success("密码修改成功");
setPasswordDialogOpen(false);
} catch (error) {
toast.error((error as Error).message || "密码修改失败");
} finally {
// setSetPasswordLoading(false);
}
}
return (
<Drawer open={!!userId} onClose={onClose} >
<DrawerContent>
<DrawerHeader className="text-left">
<DrawerTitle></DrawerTitle>
<DrawerDescription></DrawerDescription>
</DrawerHeader>
{user && <ProfileForm className="px-4"
user={user}
onSetPassword={handleSetPassword}
onRemove={handleRemove}
passwordDialogOpen={passwordDialogOpen}
setPasswordDialogOpen={setPasswordDialogOpen}
onSubmit={(e) => {
e.preventDefault()
const formData = new FormData(e.currentTarget);
handleSave({
username: formData.get("username")?.toString() as unknown as string,
nickname: formData.get("nickname")?.toString() as unknown as string,
email: formData.get("email")?.toString() || null,
phone: formData.get("phone")?.toString() || null,
})
}} />}
{isLoading &&
[...Array(5)].map((_, i) => (
<Skeleton className="h-20 mx-4 my-1" key={i} />
))
}
{
error && (
<Alert variant="destructive" className="mx-4">
<AlertCircle className="h-6 w-6" />
<AlertTitle>!</AlertTitle>
<AlertDescription>{error.message}</AlertDescription>
</Alert>
)
}
<DrawerFooter className="pt-2">
<DrawerClose asChild>
<Button variant="outline"></Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}
function ProfileForm({ className, user, onSetPassword, onRemove, passwordDialogOpen, setPasswordDialogOpen, ...props }:
React.ComponentProps<"form"> & {
user: User;
onSetPassword: (userId: string, password: string) => Promise<void>;
onRemove: (userId: string) => Promise<void>;
passwordDialogOpen: boolean;
setPasswordDialogOpen: (s: boolean) => void;
}) {
const [newPassword, setNewPassword] = React.useState<string>("");
return (
<form className={cn("grid items-start gap-4", className)} {...props}>
<div className="grid gap-2">
<Label htmlFor="userId">UserId</Label>
<Input id="userId" name="userId" defaultValue={user.userId} disabled />
</div>
<div className="grid gap-2">
<Label htmlFor="username"></Label>
<Input id="username" name="username" defaultValue={user.username} />
</div>
<div className="grid gap-2">
<Label htmlFor="nickname"></Label>
<Input id="nickname" name="nickname" defaultValue={user.nickname} />
</div>
<div className="grid gap-2">
<Label htmlFor="email"></Label>
<Input id="email" name="email" defaultValue={user.email} />
</div>
<div className="grid gap-2">
<Label htmlFor="phone"></Label>
<Input id="phone" name="phone" defaultValue={user.phone} />
</div>
<div className="w-full flex gap-5">
<Dialog open={passwordDialogOpen} onOpenChange={setPasswordDialogOpen}>
<DialogTrigger asChild>
<Button type="button" variant="secondary" className="flex-1" onClick={() => setNewPassword('')}></Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]" >
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
6-32
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="password" className="text-right">
</Label>
<Input
id="password"
name="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="col-span-3"
/>
</div>
</div>
<DialogFooter>
<Button type="button" onClick={() => onSetPassword(user.userId, newPassword)}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button type="button" variant="destructive" className="flex-1"></Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>?</AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={() => onRemove(user.userId)}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<Button type="submit"></Button>
</form>
)
}

View File

@@ -0,0 +1,156 @@
'use client';
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { TooltipContent, TooltipProvider, TooltipTrigger, Tooltip } from "@/components/ui/tooltip";
import { useUserList } from "@/hooks/admin/user/use-user-list";
import { useState } from "react";
import { UserInfoEditor } from "./components/user-info-editor";
import { User } from "@/lib/types/user";
import { CreateUserEditor } from "./components/create-user-editor";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { AdminApi } from "@/lib/api";
import { toast } from "sonner";
import { ApiError } from "next/dist/server/api-utils";
export default function Page() {
const { users, isLoading, error, mutate, refresh } = useUserList();
const [editorUserId, setEditorUserId] = useState("");
const handleUserUpdateLocal = async (newUser: User) => {
await mutate(
(data) => {
if (!data) return data;
return {
...data,
items: data.items.map((user) =>
user.userId === newUser.userId ? newUser : user
),
};
},
{
revalidate: false,
}
)
}
const handleUserDeleteLocal = async (userId: string, soft: boolean) => {
await mutate(
(data) => {
if (!data) return data;
return soft ? {
...data,
items: data.items.map(u => u.userId === userId ? { ...u, deletedAt: new Date().toLocaleString() } : u)
} : {
...data,
items: data.items.filter((user) => user.userId !== userId),
};
},
{
revalidate: false,
}
)
}
const [deletedUserId, setDeletedUserId] = useState('');
const handleUserDelete = async (userId: string) => {
try {
await AdminApi.user.remove(userId, false);
toast.success('删除成功');
handleUserDeleteLocal(userId, false);
setDeletedUserId('');
} catch (error) {
toast.error((error as ApiError).message || '删除失败');
}
}
return (
<>
<div>
<CreateUserEditor onRefresh={() => refresh()}>
<Button ></Button>
</CreateUserEditor>
</div>
<Table>
{error && <TableCaption>{error.message}</TableCaption>}
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">userId</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{
users.map((user) => (
<TableRow key={user.userId}>
<TableCell className="font-medium">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="max-w-[100px] overflow-hidden text-ellipsis">{user.userId}</div>
</TooltipTrigger>
<TooltipContent>
<p>{user.userId}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableCell>
<TableCell>{user.username}</TableCell>
<TableCell>{user.nickname}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>{user.phone}</TableCell>
<TableCell>
{user.deletedAt
? <Button className="cursor-pointer" variant='destructive' size='sm'
onClick={() => setDeletedUserId(user.userId)}></Button>
: <Button className="cursor-pointer" variant='outline' size='sm'
onClick={() => setEditorUserId(user.userId)}></Button>
}
</TableCell>
</TableRow>
))
}
{isLoading && (
<TableRow>
{
Array.from({ length: 6 }).map((_, index) => (
<TableCell key={index}>
<Skeleton className="h-10 w-full" />
</TableCell>
))
}
</TableRow>
)}
</TableBody>
</Table >
<UserInfoEditor
onClose={() => setEditorUserId('')}
userId={editorUserId}
onUserUpdate={handleUserUpdateLocal}
onUserSoftDelete={userId => handleUserDeleteLocal(userId, true)}
/>
<AlertDialog open={!!deletedUserId} onOpenChange={o => !o && setDeletedUserId('')}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>?</AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={() => handleUserDelete(deletedUserId)}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View File

@@ -0,0 +1,5 @@
export default function Page() {
return (
<div>role</div>
)
}

View File

@@ -0,0 +1,8 @@
export default function Page() {
return (
<div className="text-sm text-zinc-600">
<div>线</div>
<li></li>
</div>
)
}

View File

@@ -0,0 +1,10 @@
export default function Page() {
return (
<div className="text-sm text-zinc-600">
<div>线</div>
<li></li>
<li></li>
<li></li>
</div>
)
}

View File

@@ -0,0 +1,146 @@
'use client'
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { AdminApi } from "@/lib/api";
import { BlogPermission } from "@/lib/types/Blog.Permission.enum";
import { useState } from "react";
import { toast } from "sonner";
import { BlogPermissionCheckBoxs } from "./BlogPermissionCheckBoxs";
interface AddBlogProps {
children: React.ReactNode;
onRefresh: () => void;
}
export default function AddBlog({ children, onRefresh }: AddBlogProps) {
const [open, setOpen] = useState(false);
const [blog, setBlog] = useState({
title: "",
description: "",
contentUrl: "",
permissions: [] as BlogPermission[],
password: "",
});
const handleSubmit = async () => {
try {
const res = await AdminApi.web.blog.create({
...blog,
});
if (res) {
setOpen(false);
onRefresh();
toast.success("添加成功");
setBlog({
title: '',
description: '',
contentUrl: '',
permissions: [],
password: '',
})
} else {
throw new Error();
}
} catch (error) {
toast.error((error as Error).message || "添加失败");
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{children}
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="title" className="text-right">
</Label>
<Input
id="title"
className="col-span-3"
value={blog.title}
onChange={(e) => setBlog({ ...blog, title: e.target.value })}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="description" className="text-right">
</Label>
<Input
id="description"
className="col-span-3"
value={blog.description}
onChange={(e) => setBlog({ ...blog, description: e.target.value })}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="contentUrl" className="text-right">
URL
</Label>
<Input
id="contentUrl"
className="col-span-3"
value={blog.contentUrl}
onChange={(e) => setBlog({ ...blog, contentUrl: e.target.value })}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label className="text-right">
</Label>
<div className="col-span-3">
{
<BlogPermissionCheckBoxs
permissions={blog.permissions}
onCheckedChange={(p, n) => setBlog({
...blog,
permissions: n ?
[...blog.permissions, p] :
[...blog.permissions].filter(p => p !== p),
})}
/>
}
</div>
</div>
{
blog.permissions.includes(BlogPermission.ByPassword) &&
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="password" className="text-right">
</Label>
<Input
id="password"
className="col-span-3"
value={blog.password}
onChange={(e) => setBlog({ ...blog, password: e.target.value })}
/>
</div>
}
</div>
<DialogFooter>
<Button type="button" variant='secondary' onClick={() => setOpen(false)}></Button>
<Button type="button" onClick={handleSubmit}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,167 @@
"use client"
import React, { useState } from "react"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
import { toast } from "sonner"
import { AdminApi } from "@/lib/api"
import useSWR from "swr"
import { ApiError } from "next/dist/server/api-utils"
import { BlogPermissionCheckBoxs } from "./BlogPermissionCheckBoxs"
import { BlogPermission } from "@/lib/types/Blog.Permission.enum"
import { SetPasswordDialog } from "./SetPasswordDialog"
interface BlogEditProps {
id: string;
children?: React.ReactNode;
onRefresh: () => void;
}
export default function BlogEdit({ id, children, onRefresh }: BlogEditProps) {
const [open, setOpen] = useState(false)
const { data: blog, mutate } = useSWR(
open ? `/api/admin/web/blog/${id}` : null,
() => AdminApi.web.blog.get(id),
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
dedupingInterval: 5000,
}
)
const handleSubmit = async () => {
if (!blog) return;
try {
await AdminApi.web.blog.update(id, {
title: blog.title,
description: blog.description,
contentUrl: blog.contentUrl,
permissions: blog.permissions,
});
toast.success("更新成功")
setOpen(false);
onRefresh();
} catch (error) {
toast.error((error as ApiError).message || "更新失败")
}
}
const handleDelete = async () => {
try {
await AdminApi.web.blog.remove(id);
toast.success("删除成功")
setOpen(false);
onRefresh();
} catch (error) {
toast.error((error as ApiError).message || "删除失败")
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{children}
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
{
blog && (
<>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="title" className="text-right">
</Label>
<Input
id="title"
className="col-span-3"
value={blog.title}
onChange={(e) => mutate({ ...blog, title: e.target.value }, false)}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="description" className="text-right">
</Label>
<Input
id="description"
className="col-span-3"
value={blog.description}
onChange={(e) => mutate({ ...blog, description: e.target.value }, false)}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="contentUrl" className="text-right">
URL
</Label>
<Input
id="contentUrl"
className="col-span-3"
value={blog.contentUrl}
onChange={(e) => mutate({ ...blog, contentUrl: e.target.value }, false)}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="permissions" className="text-right">
</Label>
<div className="col-span-3">
<BlogPermissionCheckBoxs
permissions={blog.permissions}
onCheckedChange={(permission, newState) => {
mutate({
...blog,
permissions: newState ?
[...blog.permissions, permission] :
blog.permissions.filter(p => p !== permission)
}, false)
}}
/>
</div>
</div>
{
blog.permissions.includes(BlogPermission.ByPassword) &&
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="permissions" className="text-right">
</Label>
<SetPasswordDialog id={id}>
<Button variant='outline'></Button>
</SetPasswordDialog>
</div>
}
</div>
<DialogFooter>
<div className="w-full flex justify-between">
<div>
<Button variant='destructive' onClick={handleDelete}></Button>
</div>
<div>
<Button type="button" variant='secondary' onClick={() => setOpen(false)}></Button>
<Button type="button" onClick={handleSubmit} className="ml-5"></Button>
</div>
</div>
</DialogFooter>
</>
)
}
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,45 @@
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { BlogPermission } from "@/lib/types/Blog.Permission.enum";
const blogPermissions = [
{
permission: BlogPermission.Public,
localText: '公开可读',
},
{
permission: BlogPermission.ByPassword,
localText: '受密码保护',
},
{
permission: BlogPermission.List,
localText: '显示在列表中',
},
{
permission: BlogPermission.AllowComments,
localText: '允许评论',
}
] as const;
interface BlogPermissionCheckBoxsProps {
permissions: BlogPermission[];
onCheckedChange: (permission: BlogPermission, newState: boolean) => void;
}
export function BlogPermissionCheckBoxs({ permissions, onCheckedChange }: BlogPermissionCheckBoxsProps) {
return (
<div className="flex gap-3 gap-x-8 flex-wrap">
{
blogPermissions.map((v, i) => (
<div key={`blog-permission-option-${i}`} className="flex gap-2">
<Checkbox
id={`blog-permission-option-checkbox-${i}`}
checked={permissions.includes(v.permission)}
onCheckedChange={newChecked => onCheckedChange(v.permission, !!newChecked)} />
<Label htmlFor={`blog-permission-option-checkbox-${i}`} className="whitespace-nowrap">{v.localText}</Label>
</div>
))
}
</div>
)
}

View File

@@ -0,0 +1,66 @@
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { Blog } from "@/lib/types/blog"
import BlogEdit from "./BlogEdit"
import { Button } from "@/components/ui/button"
interface BlogTableProps {
blogs: Blog[],
error?: string,
onRefresh: () => void,
}
export default function BlogTable({ blogs, error, onRefresh }: BlogTableProps) {
return (
<Table className="w-full overflow-x-auto">
{
error && (
<TableCaption>{error}</TableCaption>
)
}
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">Id</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>URL</TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{blogs.map((blog) => (
<TableRow key={blog.id}>
<TableCell className="font-medium">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="max-w-[100px] overflow-hidden text-ellipsis">{blog.id}</div>
</TooltipTrigger>
<TooltipContent>
<p>{blog.id}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableCell>
<TableCell className="whitespace-normal break-all">{blog.title}</TableCell>
<TableCell className="whitespace-normal break-all">{blog.description}</TableCell>
<TableCell className="whitespace-normal break-all">{blog.contentUrl}</TableCell>
<TableCell className="text-right">
<BlogEdit id={blog.id} onRefresh={() => onRefresh()}>
<Button variant={'outline'} size={'sm'}></Button>
</BlogEdit>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}

View File

@@ -0,0 +1,99 @@
'use client';
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { AdminApi } from "@/lib/api";
import { base62 } from "@/lib/utils";
import React, { useEffect, useState } from "react";
import { toast } from "sonner";
interface SetPasswordDialogProps {
id: string;
children: React.ReactNode;
}
export function SetPasswordDialog({ id, children }: SetPasswordDialogProps) {
const [open, setOpen] = useState(false);
const [password, setPassword] = useState('');
const handleSubmit = async () => {
if (!password) {
return toast.error('请输入密码');
}
await AdminApi.web.blog.setPassword(id, password).then(() => {
toast.success('修改成功');
setOpen(false);
}).catch(e => {
toast.error(`${e.message || e || '请求失败'}`)
});
}
useEffect(() => {
if (open) {
setPassword('');
}
}, [open])
const handleCopyShareURL = () => {
if (!password) {
return toast.warning('请先填写新密码');
}
const url = `${window.location.origin}/blog/${base62.encode(Buffer.from(id.replace(/-/g, ''), 'hex'))}?p=${password}`;
navigator.clipboard.writeText(url);
toast.success('分享链接复制成功,请点击保存按钮以提交新密码');
}
return (
<Dialog open={open} onOpenChange={v => setOpen(v)}>
<form>
<DialogTrigger asChild>
{children}
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
访URL需要填写完新密码后再点击
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<div className="grid gap-3">
<Label htmlFor="new-password"></Label>
<Input
id="new-password"
name="password"
value={password}
onChange={e => setPassword(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<div className="w-full flex justify-between">
<div>
<Button variant='secondary' onClick={handleCopyShareURL}>URl</Button>
</div>
<div className="flex gap-5">
<DialogClose asChild>
<Button variant="outline"></Button>
</DialogClose>
<Button type="submit" onClick={handleSubmit}></Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</form>
</Dialog>
)
}

View File

@@ -0,0 +1,21 @@
"use client"
import { useBlogList } from "@/hooks/admin/web/blog/use-blog-list"
import BlogTable from "./components/BlogTable"
import AddBlog from "./components/AddBlog";
import { Button } from "@/components/ui/button";
export default function Page() {
const { blogs, refresh } = useBlogList();
return (
<>
<div>
<AddBlog onRefresh={refresh}>
<Button></Button>
</AddBlog>
</div>
<BlogTable blogs={blogs || []} onRefresh={refresh} />
</>
)
}

View File

@@ -0,0 +1,161 @@
"use client"
import React, { useState } from "react"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
import { AdminApi } from "@/lib/api"
import { toast } from "sonner"
import { ApiError } from "next/dist/server/api-utils"
import { ResourceBadge } from "@/components/resource"
import AddResourceTag from "./AddResourceTag"
import { Textarea } from "@/components/ui/textarea"
import { Plus } from "lucide-react"
interface AddResourceProps {
children: React.ReactNode;
refresh: () => void;
}
export default function AddResource({ children, refresh }: AddResourceProps) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
title: "",
description: "",
imageUrl: "",
link: "",
tags: [] as { type: string, name: string }[],
});
const handleSubmit = async () => {
try {
setLoading(true);
await AdminApi.web.resource.create({
...formData,
});
toast.success("添加成功");
setOpen(false);
refresh();
} catch (error) {
toast.error((error as ApiError).message || "添加失败");
}
}
const handleOpenChange = (open: boolean) => {
setOpen(open);
if (open) {
setFormData({
title: "",
description: "",
imageUrl: "",
link: "",
tags: [] as { type: string, name: string }[],
});
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
{children}
</DialogTrigger>
<DialogContent className="w-300">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="admin-web-resource-add-title" className="text-right">
</Label>
<Input
id="admin-web-resource-add-title"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="admin-web-resource-add-description" className="text-right">
</Label>
<Textarea
id="admin-web-resource-add-description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="col-span-3 max-h-15"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="admin-web-resource-add-imageUrl" className="text-right">
URL
</Label>
<Input
id="admin-web-resource-add-imageUrl"
value={formData.imageUrl}
onChange={(e) => setFormData({ ...formData, imageUrl: e.target.value })}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="admin-web-resource-add-link" className="text-right">
</Label>
<Input
id="admin-web-resource-add-link"
value={formData.link}
onChange={(e) => setFormData({ ...formData, link: e.target.value })}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="admin-web-resource-add-tags" className="text-right">
</Label>
<div className="flex items-center gap-2">
{
formData.tags.map((tag, index) => (
<ResourceBadge tag={tag} key={index} editMode={true} onClose={name => setFormData({ ...formData, tags: formData.tags.filter(tag => tag.name !== name) })} />
))
}
<AddResourceTag onTagAdd={(tag) => {
if (!tag.name) return;
if (formData.tags.find(t => t.name === tag.name)) {
toast.error("标签已存在");
return;
}
setFormData({
...formData,
tags: [...formData.tags, tag],
})
}}>
<Button size='sm' variant='outline'>
<Plus />
</Button>
</AddResourceTag>
</div>
</div>
</div>
<DialogFooter>
<Button type="submit" onClick={handleSubmit} disabled={loading}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,79 @@
"use client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useState } from "react";
interface AddResourceTagProps {
children: React.ReactNode;
onTagAdd: (tag: { name: string; type: string }) => void;
}
export default function AddResourceTag({ onTagAdd, children }: AddResourceTagProps) {
const [open, setOpen] = useState(false);
const [tag, setTag] = useState({ name: '', type: 'default' });
function handleSetOpen(open: boolean) {
setOpen(open);
if (open) {
setTag({ name: '', type: 'default' });
}
}
return (
<Popover open={open} onOpenChange={handleSetOpen}>
<PopoverTrigger asChild>
{children}
</PopoverTrigger>
<PopoverContent className="w-80">
<div className="grid gap-4">
<div className="space-y-2">
<h4 className="font-medium leading-none"></h4>
</div>
<div className="grid gap-2">
<div className="grid grid-cols-3 items-center gap-4">
<Label htmlFor="add-tag-name"></Label>
<Input
id="add-tag-name"
className="col-span-2 h-8"
value={tag.name}
onChange={(e) => setTag({ ...tag, name: e.target.value })}
/>
</div>
<div className="grid grid-cols-3 items-center gap-4">
<Label htmlFor="add-tag-type"></Label>
<Select onValueChange={v => setTag({ ...tag, type: v })} defaultValue="default">
<SelectTrigger className="w-[185px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default"></SelectItem>
<SelectItem value="os">OS</SelectItem>
</SelectContent>
</Select>
</div>
<Button onClick={() => {
onTagAdd({
...tag,
});
setOpen(false);
}}></Button>
</div>
</div>
</PopoverContent>
</Popover>
)
}

View File

@@ -0,0 +1,209 @@
"use client"
import React, { useState } from "react"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
import { toast } from "sonner"
import { ResourceBadge } from "@/components/resource"
import AddResourceTag from "./AddResourceTag"
import { Textarea } from "@/components/ui/textarea"
import { Plus } from "lucide-react"
import { Resource } from "@/lib/types/resource"
import { AdminApi } from "@/lib/api"
import useSWR from "swr"
import { ApiError } from "next/dist/server/api-utils"
import { Skeleton } from "@/components/ui/skeleton"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
interface ResourceEditProps {
children: React.ReactNode
id: string;
onRefresh: () => void;
}
export default function ResourceEdit({ children, id, onRefresh }: ResourceEditProps) {
const [open, setOpen] = useState(false);
const { data: resource, isLoading, mutate } = useSWR<Resource>(
open ? [`/api/admin/web/resource/${id}`] : null,
() => AdminApi.web.resource.get(id),
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
revalidateIfStale: false,
dedupingInterval: 5000,
}
)
const handleSubmit = async () => {
if (!resource) return;
try {
await AdminApi.web.resource.update(id, {
title: resource.title,
description: resource.description,
imageUrl: resource.imageUrl,
link: resource.link,
tags: resource.tags,
});
toast.success("资源更新成功");
onRefresh();
setOpen(false);
} catch (error) {
toast.error((error as ApiError).message || "资源更新失败");
}
}
const handleRemove = async (id: string) => {
try {
await AdminApi.web.resource.remove(id);
toast.success("资源删除成功");
onRefresh();
setOpen(false);
} catch (error) {
toast.error((error as ApiError).message || "资源删除失败");
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{children}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
{
isLoading && (
[...Array(5)].map((_, index) => (
<Skeleton className="w-full h-14 rounded" key={index} />
))
)
}
{resource && (
<>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="admin-web-resource-add-title" className="text-right">
</Label>
<Input
id="admin-web-resource-add-title"
value={resource.title}
onChange={(e) => mutate({ ...resource, title: e.target.value }, false)}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="admin-web-resource-add-description" className="text-right">
</Label>
<Textarea
id="admin-web-resource-add-description"
value={resource.description}
onChange={(e) => mutate({ ...resource, description: e.target.value }, false)}
className="col-span-3 max-h-15"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="admin-web-resource-add-imageUrl" className="text-right">
URL
</Label>
<Input
id="admin-web-resource-add-imageUrl"
value={resource.imageUrl}
onChange={(e) => mutate({ ...resource, imageUrl: e.target.value }, false)}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="admin-web-resource-add-link" className="text-right">
</Label>
<Input
id="admin-web-resource-add-link"
value={resource.link}
onChange={(e) => mutate({ ...resource, link: e.target.value }, false)}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="admin-web-resource-add-tags" className="text-right">
</Label>
<div className="col-span-3 w-full flex flex-wrap items-center gap-2 gap-y-1">
{
resource.tags.map((tag, index) => (
<ResourceBadge tag={tag} key={index} editMode={true} onClose={name => mutate({ ...resource, tags: resource.tags.filter(tag => tag.name !== name) }, false)} />
))
}
<AddResourceTag onTagAdd={(tag) => {
if (!tag.name) return;
if (resource.tags.find(t => t.name === tag.name)) {
toast.error("标签已存在");
return;
}
mutate({
...resource,
tags: [...resource.tags, tag],
}, false)
}}>
<Button size='sm' variant='outline'>
<Plus />
</Button>
</AddResourceTag>
</div>
</div>
</div>
<DialogFooter>
<div className="w-full flex justify-between">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant={'destructive'}></Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>?</AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={() => handleRemove(id)}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<div >
<Button variant={'secondary'} onClick={() => setOpen(false)}></Button>
<Button type="button" onClick={handleSubmit} className="ml-3"></Button>
</div>
</div>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,83 @@
"use client"
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Resource } from "@/lib/types/resource";
import { ResourceBadge } from "@/components/resource";
import { Button } from "@/components/ui/button";
import ResourceEdit from "./ResourceEdit";
interface ResourceTableProps {
resources: Resource[];
errorMessage?: string;
onRefresh: () => void;
}
export default function ResourceTable({ resources, errorMessage, onRefresh }: ResourceTableProps) {
return (
<>
<Table>
{errorMessage && <TableCaption>{errorMessage}</TableCaption>}
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">Id</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>URL</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right w-10"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{resources.length > 0 ? resources.map((resource) => (
<TableRow key={resource.id}>
<TableCell className="font-medium">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="max-w-[100px] overflow-hidden text-ellipsis">{resource.id}</div>
</TooltipTrigger>
<TooltipContent>
<p>{resource.id}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableCell>
<TableCell className="whitespace-normal break-all">{resource.title}</TableCell>
<TableCell className="whitespace-normal break-all">{resource.description}</TableCell>
<TableCell className="whitespace-normal break-all">{resource.imageUrl}</TableCell>
<TableCell className="whitespace-normal break-all">{resource.link}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{resource.tags.map((tag, index) => (
<ResourceBadge tag={tag} key={index} />
))}
</div>
</TableCell>
<TableCell className="text-right">
<ResourceEdit id={resource.id} onRefresh={() => onRefresh()}>
<Button variant={'outline'} size={'sm'}></Button>
</ResourceEdit>
</TableCell>
</TableRow>
)) : (
<TableRow>
<TableCell colSpan={7} className="text-center">
<div className="my-5 text-zinc-500"></div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</>
)
}

View File

@@ -0,0 +1,24 @@
"use client";
import { useResourceList } from "@/hooks/admin/web/resource/use-resource-list"
import ResourceTable from "./components/ResourceTable"
import { Button } from "@/components/ui/button";
import AddResource from "./components/AddResource";
export default function Page() {
const { resources, refresh } = useResourceList();
return (
<>
<div>
<AddResource refresh={refresh}>
<Button></Button>
</AddResource>
</div>
<ResourceTable
resources={resources || []}
onRefresh={refresh}
/>
</>
)
}

View File

@@ -0,0 +1,67 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
import { useState, useCallback } from "react";
import { toast } from "sonner";
import LoginHeader from "./LoginHeader";
import { SendCodeFormData } from "./types";
import { Label } from "@/components/ui/label";
export default function EmailLoginMode({ onSendCode }: { onSendCode: (data: SendCodeFormData) => Promise<boolean> }) {
const [email, setEmail] = useState("");
const handleSendCode = useCallback(() => {
if (!email.trim().match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
toast.error('请输入正确的邮箱地址');
return;
}
onSendCode({
type: 'email',
email,
})
}, [email, onSendCode]);
return (
<>
<LoginHeader />
<div className="grid gap-3">
<Label htmlFor="email"></Label>
<Input
id="email-login-mode-email"
name="email"
type="text"
placeholder="电子邮箱"
value={email}
onChange={(e) => setEmail(e.target.value)}
required />
</div>
<div className="grid gap-3">
<div className="flex items-center h-4">
<Label htmlFor="code"></Label>
</div>
<div className="flex gap-5">
<InputOTP
id="email-login-mode-code"
name="code"
maxLength={6}
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
required
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
<Button type="button" variant="secondary" onClick={handleSendCode}></Button>
</div>
</div>
<Button type="submit" className="w-full">
</Button>
</>
)
}

View File

@@ -0,0 +1,12 @@
export default function LoginHeader() {
return (
<>
<div className="flex flex-col items-center text-center">
<h1 className="text-2xl font-bold"></h1>
<p className="text-muted-foreground text-balance">
</p>
</div>
</>
)
}

View File

@@ -0,0 +1,41 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import LoginHeader from "./LoginHeader";
import { Label } from "@/components/ui/label"
export default function PasswordLoginMode({ forgetPassword }: { forgetPassword: () => void }) {
return (
<>
<LoginHeader />
<div className="grid gap-3">
<Label htmlFor="email">//</Label>
<Input
id="password-login-mode-account"
name="account"
type="text"
placeholder="电子邮箱/手机号/账号"
required
/>
</div>
<div className="grid gap-3">
<div className="flex items-center h-4">
<Label htmlFor="password"></Label>
<a
onClick={forgetPassword}
className="ml-auto text-sm underline-offset-2 hover:underline cursor-pointer"
>
</a>
</div>
<Input
id="password-login-mode-password"
name="password"
type="password"
required />
</div>
<Button type="submit" className="w-full">
</Button>
</>
)
}

View File

@@ -0,0 +1,67 @@
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
import { useState, useCallback } from "react";
import { toast } from "sonner";
import LoginHeader from "./LoginHeader";
import { SendCodeFormData } from "./types";
import { Label } from "@/components/ui/label"
export default function PhoneLoginMode({ onSendCode }: { onSendCode: (data: SendCodeFormData) => Promise<boolean> }) {
const [phone, setPhone] = useState("");
const handleSendCode = useCallback(() => {
if (phone.trim().length !== 11) {
toast.error('请输入正确的手机号');
return;
}
onSendCode({
type: 'phone',
phone,
})
}, [phone, onSendCode]);
return (
<>
<LoginHeader />
<div className="grid gap-3">
<Label htmlFor="phone"></Label>
<Input
id="phone-login-mode-phone"
name="phone"
type="text"
placeholder="+86 手机号"
value={phone}
onChange={(e) => setPhone(e.target.value)}
required />
</div>
<div className="grid gap-3">
<div className="flex items-center h-4">
<Label htmlFor="code"></Label>
</div>
<div className="flex gap-5">
<InputOTP
id="phone-login-mode-code"
name="code"
maxLength={6}
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
required
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
<Button type="button" variant="secondary" onClick={handleSendCode} disabled></Button>
</div>
</div>
<Button type="submit" className="w-full" disabled>
</Button>
</>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

View File

@@ -0,0 +1,16 @@
export type SubmitMode = 'password' | 'phone' | 'email';
export type LoginFormData = {
type: SubmitMode;
account?: string;
password?: string;
phone?: string;
email?: string;
code?: string;
}
export type SendCodeMode = 'phone' | 'email';
export type SendCodeFormData = {
type: SendCodeMode;
phone?: string;
email?: string;
}

View File

@@ -0,0 +1,149 @@
'use client';
import { authApi, verificationApi } from "@/lib/api";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { KeyRound, Phone, Mail } from "lucide-react";
import EmailLoginMode from "./components/EmailLoginMode";
import PasswordLoginMode from "./components/PasswordLoginMode";
import PhoneLoginMode from "./components/PhoneLoginMode";
import { LoginFormData, SendCodeFormData, SubmitMode } from "./components/types";
import { useCallback, useState } from "react";
import LoginBG from './components/login-bg.jpg';
import Image from "next/image";
import { ApiError } from "@/lib/api/fetcher";
export default function Login() {
const router = useRouter();
const [loginMode, setLoginMode] = useState<SubmitMode>('password');
const handleForgetPassword = useCallback(() => {
toast.warning('开发中,敬请期待!暂时可通过发送邮件至网站管理员进行密码重置。');
}, []);
const handleSendCode = async (data: SendCodeFormData) => {
try {
const res = await verificationApi.send({
type: 'login',
targetType: data.type,
phone: data.phone,
email: data.email,
})
if (res) {
toast.success('验证码已发送,请注意查收');
return true;
} else {
throw new Error();
}
} catch (error) {
toast.error((error as ApiError).message || '验证码发送失败,请稍后再试');
return false;
}
}
const handleSubmit = async (data: LoginFormData) => {
try {
const res = await authApi.login({
...data,
});
if (res.token) {
toast.success('登录成功');
localStorage.setItem('token', res.token);
router.replace('/console');
return true;
} else {
throw new Error();
}
} catch (error) {
toast.error((error as ApiError).message || '登录失败,请稍后再试');
return false;
}
}
return (
<>
<Header />
<div className="flex flex-1 flex-col items-center justify-center p-6 md:p-10">
<div className="w-full max-w-sm md:max-w-3xl">
<div className={cn("flex flex-col gap-6")}>
<Card className="overflow-hidden p-0">
<CardContent className="grid p-0 md:grid-cols-2">
<form className="p-6 md:p-8" onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
handleSubmit({
type: loginMode,
account: formData.get('account')?.toString(),
password: formData.get('password')?.toString(),
phone: formData.get('phone')?.toString(),
email: formData.get('email')?.toString(),
code: formData.get('code')?.toString(),
})
}}>
<div className="flex flex-col gap-6">
{loginMode === 'password' ? <PasswordLoginMode forgetPassword={handleForgetPassword} /> : null}
{loginMode === 'phone' ? <PhoneLoginMode onSendCode={handleSendCode} /> : null}
{loginMode === 'email' ? <EmailLoginMode onSendCode={handleSendCode} /> : null}
<div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
<span className="bg-card text-muted-foreground relative z-10 px-2">
使
</span>
</div>
<div className="grid grid-cols-3 gap-4">
<Button variant={loginMode === 'password' ? 'default' : 'outline'} type="button" className="w-full" onClick={() => setLoginMode('password')}>
<KeyRound />
</Button>
<Button variant={loginMode === 'phone' ? 'default' : 'outline'} type="button" className="w-full" onClick={() => setLoginMode('phone')}>
<Phone />
</Button>
<Button variant={loginMode === 'email' ? 'default' : 'outline'} type="button" className="w-full" onClick={() => setLoginMode('email')}>
<Mail />
</Button>
</div>
<div className="text-center text-sm">
{" "}
<a className="underline underline-offset-4 cursor-pointer" onClick={() => setLoginMode('phone')}>
</a>
</div>
</div>
</form>
<div className="bg-muted relative hidden md:block">
<Image
src={LoginBG.src}
alt="Image"
width={500}
height={500}
className="absolute inset-0 h-full w-full object-cover dark:brightness-[0.2] dark:grayscale"
priority
quality={100}
/>
</div>
</CardContent>
</Card>
<div className="text-muted-foreground *:[a]:hover:text-primary text-center text-xs text-balance *:[a]:underline *:[a]:underline-offset-4">
{" "}
<a href="#" className="underline underline-offset-4">
</a>{" "}
{" "}
<a href="#" className="underline underline-offset-4">
</a>
</div>
</div >
</div>
</div>
<Footer />
</>
)
}