(),
20 | {
21 | header: "Name",
22 | accessorKey: "name",
23 | },
24 | {
25 | header: "Description",
26 | cell: ({ row }) => {
27 | const _package = JSON.parse(row.original.package);
28 | return {_package.description};
29 | },
30 | },
31 | {
32 | header: "ID",
33 | accessorKey: "id",
34 | },
35 | {
36 | header: "Version",
37 | accessorKey: "version",
38 | },
39 | {
40 | header: "Author",
41 | cell: ({ row }) => {
42 | const _package = JSON.parse(row.original.package);
43 | return @{_package.author};
44 | },
45 | },
46 | {
47 | header: "Actions",
48 | cell: ({ row }) => {
49 | return (
50 |
51 |
76 |
77 |
88 |
89 |
113 |
114 | );
115 | },
116 | },
117 | ];
118 |
--------------------------------------------------------------------------------
/src/components/ui/data-table-pagination.tsx:
--------------------------------------------------------------------------------
1 | import type { Table } from "@tanstack/react-table";
2 | import {
3 | ChevronLeft,
4 | ChevronRight,
5 | ChevronsLeft,
6 | ChevronsRight,
7 | } from "lucide-react";
8 |
9 | import { Button } from "@components/ui/button";
10 | import {
11 | Select,
12 | SelectContent,
13 | SelectItem,
14 | SelectTrigger,
15 | SelectValue,
16 | } from "@components/ui/select";
17 |
18 | interface DataTablePaginationProps {
19 | table: Table;
20 | }
21 |
22 | export function DataTablePagination({
23 | table,
24 | }: DataTablePaginationProps) {
25 | return (
26 |
27 |
28 | {table.getFilteredSelectedRowModel().rows.length} of{" "}
29 | {table.getFilteredRowModel().rows.length} row(s) selected.
30 |
31 |
32 |
33 |
Rows per page
34 |
51 |
52 |
53 | Page {table.getState().pagination.pageIndex + 1} of{" "}
54 | {table.getPageCount()}
55 |
56 |
57 |
66 |
75 |
84 |
93 |
94 |
95 |
96 | );
97 | }
98 |
--------------------------------------------------------------------------------
/src/pages/Login/index.tsx:
--------------------------------------------------------------------------------
1 | import clsx from "clsx";
2 | import { useEffect, useRef, useState } from "react";
3 | import type { BasicPage } from "@type/basic";
4 | import styles from "./index.module.css";
5 | import { motion } from "framer-motion";
6 | import { useNavigate } from "react-router-dom";
7 | import { apiClient } from "@utils/request";
8 | import { app } from "@states/app";
9 | import { setCookie } from "@utils/cookie";
10 | import { jump } from "@utils/path";
11 | import { useSeo } from "@hooks/useSeo";
12 | import { toast } from "sonner";
13 | import useSWR from "swr";
14 | import { useSnapshot } from "valtio";
15 |
16 | export const Login: BasicPage = () => {
17 | useSeo("登录");
18 | const [loading, setLoading] = useState(false);
19 | const formRef = useRef(null);
20 | const navigate = useNavigate();
21 |
22 | const appSnapshot = useSnapshot(app);
23 | const { error } = useSWR("/user/master/info");
24 | if (error) {
25 | toast.error("无用户信息,请注册");
26 | navigate(jump("/register"));
27 | }
28 | useEffect(() => {
29 | if (appSnapshot.authenticated) {
30 | toast.success("已登录,正在前往仪表盘");
31 | navigate(jump("/dashboard"));
32 | return;
33 | }
34 | app.showSidebar = false;
35 | }, []);
36 |
37 | function handleSubmit(e: React.FormEvent) {
38 | e.preventDefault();
39 | setLoading(true);
40 | const form = formRef.current;
41 | if (form) {
42 | const username = (form.elements.namedItem("username") as RadioNodeList)!
43 | .value;
44 | const password = (form.elements.namedItem("password") as RadioNodeList)!
45 | .value;
46 | apiClient("/user/login", {
47 | method: "POST",
48 | body: {
49 | username,
50 | password,
51 | },
52 | })
53 | .then((res) => {
54 | setCookie("token", res.token);
55 | toast.success("登录成功, 欢迎回来");
56 | app.authenticated = true;
57 | app.showSidebar = true;
58 | navigate(jump("/dashboard"));
59 | // window.location.reload();
60 | })
61 | .catch((res) => {
62 | toast.error(`登录失败 - ${res.data.message}`);
63 | });
64 | }
65 | setLoading(false);
66 | }
67 |
68 | return (
69 | <>
70 |
71 |
登录
72 |
73 |
115 |
116 |
117 | >
118 | );
119 | };
120 |
--------------------------------------------------------------------------------
/src/pages/Schedule/Table/column.tsx:
--------------------------------------------------------------------------------
1 | import { DataTableColumnHeader } from "@components/ui/data-table-column-header";
2 | import {
3 | generateAnyActionsColumn,
4 | generateSelectColumn,
5 | generateTitleColumn,
6 | } from "@components/widgets/AnyListDataTable/anyColumn";
7 | import { _private } from "@states/private";
8 | import type { ColumnDef } from "@tanstack/react-table";
9 | import { apiClient } from "@utils/request";
10 | import { Asterisk, Delete, Edit, PlayCircle } from "lucide-react";
11 | import { toast } from "sonner";
12 |
13 | export const ScheduleType = {
14 | url: "访问 URL",
15 | event: "广播事件",
16 | };
17 |
18 | export const ScheduleAfter = {
19 | none: "无",
20 | store: "存储",
21 | url: "访问 URL",
22 | };
23 |
24 | export interface ScheduleItemProps {
25 | token: string;
26 | name: string;
27 | description: string;
28 | cron: string;
29 | next: string;
30 | type: keyof typeof ScheduleType;
31 | after: keyof typeof ScheduleAfter;
32 | error: object[];
33 | running: boolean;
34 | }
35 |
36 | export type ScheduleColumns = ScheduleItemProps;
37 |
38 | export const ScheduleColumns: ColumnDef[] = [
39 | generateSelectColumn(),
40 | generateTitleColumn({
41 | key: "name",
42 | idKey: "token",
43 | clickHref: (id) => `/schedule/${id}`,
44 | }),
45 | {
46 | header: ({ column }) => (
47 |
48 | ),
49 | accessorKey: "description",
50 | },
51 | {
52 | header: ({ column }) => (
53 |
54 | ),
55 | accessorKey: "cron",
56 | },
57 | {
58 | header: ({ column }) => (
59 |
60 | ),
61 | accessorKey: "next",
62 | cell: ({ row }) => {
63 | const { next } = row.original;
64 | const date = new Date(next);
65 | return date.toLocaleString();
66 | },
67 | },
68 | {
69 | header: ({ column }) => (
70 |
71 | ),
72 | accessorKey: "running",
73 | cell: ({ row }) => {
74 | const { running } = row.original;
75 | return running ? "激活" : "停止";
76 | },
77 | },
78 | generateAnyActionsColumn({
79 | menus: [
80 | {
81 | title: "编辑",
82 | icon: ,
83 | onClick: (row) => {
84 | _private.showModal = true;
85 | _private.modalDataId = row.token;
86 | },
87 | },
88 | {
89 | title: "删除",
90 | icon: ,
91 | onClick: (row) => {
92 | toast.promise(
93 | apiClient(`/schedule/${row.token}`, {
94 | method: "DELETE",
95 | }),
96 | {
97 | loading: "正在删除...",
98 | success: "删除成功",
99 | error: "删除失败",
100 | }
101 | );
102 | },
103 | },
104 | {
105 | title: "运行",
106 | icon: ,
107 | onClick: (row) => {
108 | toast.promise(
109 | apiClient(`/schedule/${row.token}/run`, {
110 | method: "POST",
111 | }),
112 | {
113 | loading: "正在运行...",
114 | success: "运行成功",
115 | error: "运行失败",
116 | }
117 | );
118 | },
119 | },
120 | {
121 | title: "切换状态",
122 | icon: ,
123 | onClick: (row) => {
124 | toast.promise(
125 | apiClient(`/schedule/${row.token}/`, {
126 | method: "PATCH",
127 | }),
128 | {
129 | loading: "正在切换...",
130 | success: "切换成功",
131 | error: "切换失败",
132 | }
133 | );
134 | },
135 | },
136 | ],
137 | }),
138 | ];
139 |
--------------------------------------------------------------------------------
/src/components/universal/FileContextMenu/index.tsx:
--------------------------------------------------------------------------------
1 | import { dialog } from "@libs/dialogs";
2 | import styles from "./index.module.css";
3 | import clsx from "clsx";
4 | import { useEffect, useRef } from "react";
5 | import {
6 | Delete,
7 | Download,
8 | LinkOne,
9 | MoveOne,
10 | Pencil,
11 | Share,
12 | } from "@icon-park/react";
13 | import { API, apiClient } from "@utils/request";
14 | import path from "path";
15 | import { toast } from "sonner";
16 | import { ofetch } from "ofetch";
17 | import { useNavigate } from "react-router-dom";
18 |
19 | export const FileContextMenu = () => {
20 | const navgative = useNavigate();
21 | const { isOpen, handleClose, props } =
22 | dialog.useDialogController("fileContextMenu");
23 | const ref = useRef(null);
24 |
25 | useEffect(() => {
26 | const handleClick = (e: MouseEvent) => {
27 | if (!ref.current?.contains(e.target as Node)) {
28 | handleClose();
29 | }
30 | };
31 | window.addEventListener("click", handleClick);
32 | return () => {
33 | window.removeEventListener("click", handleClick);
34 | };
35 | }, []);
36 |
37 | const handleOpen = () => {
38 | if (props.isFile) {
39 | window.open(`${API}/store/raw${props.path}/${props.name}`);
40 | } else {
41 | navgative(`/files?path=${props.path}/${props.name}`);
42 | }
43 | };
44 |
45 | const handleDownload = async () => {
46 | const url = `${API}/store/raw${props.path}`;
47 | // blob
48 | const blob = await ofetch(`${url}/${props.name}`, {
49 | method: "GET",
50 | responseType: "blob",
51 | });
52 | const a = document.createElement("a");
53 | a.href = window.URL.createObjectURL(blob);
54 | a.download = props.name;
55 | a.click();
56 | };
57 |
58 | const handleRename = () => {
59 | props.onRename?.();
60 | };
61 |
62 | const handleDelete = () => {
63 | toast.promise(
64 | apiClient(`/store/delete?path=${props.path}/${props.name}`, {
65 | method: "POST",
66 | body: JSON.stringify({
67 | path: `${path}/${props.name}`,
68 | }),
69 | }),
70 | {
71 | loading: "正在删除",
72 | success: "删除成功",
73 | error: "删除失败",
74 | }
75 | );
76 | };
77 |
78 | const handleMove = () => {
79 | toast.error("Not implemented");
80 | };
81 |
82 | const handleCopy = () => {
83 | toast.promise(
84 | Promise.all([
85 | navigator.clipboard.writeText(
86 | `${API}/store/raw${props.path}/${props.name}`
87 | ),
88 | ]),
89 | {
90 | loading: "正在复制",
91 | success: "复制成功",
92 | error: "复制失败",
93 | }
94 | );
95 | };
96 |
97 | return (
98 |
108 |
111 | {props.isFile && (
112 |
115 | )}
116 | {props.isFile && (
117 |
120 | )}
121 |
124 |
127 |
131 |
132 | );
133 | };
134 |
--------------------------------------------------------------------------------
/src/pages/Files/index.tsx:
--------------------------------------------------------------------------------
1 | import type { BasicPage } from "@type/basic";
2 | import styles from "./index.module.css";
3 | import { Title } from "@components/universal/Title";
4 | import { useSeo } from "@hooks/useSeo";
5 | import useSWR from "swr";
6 | import type { FileItemProps } from "./components";
7 | import { FilesBreadcrumb, Item } from "./components";
8 | import { getQueryVariable } from "@utils/url";
9 | import { useEffect, useState } from "react";
10 | import clsx from "clsx";
11 | import { ActionButton, ActionButtons } from "@components/widgets/ActionButtons";
12 | import { apiClient } from "@utils/request";
13 | import { toast } from "sonner";
14 | import { Upload } from "@icon-park/react";
15 |
16 | export const FilesPage: BasicPage = () => {
17 | useSeo("文件 · 管理");
18 | const path = getQueryVariable("path") || "";
19 | const { data, mutate } = useSWR(`/store/list${path}`);
20 | const [select, setSelect] = useState([]);
21 |
22 | useEffect(() => {
23 | mutate();
24 | setSelect([]);
25 | }, [path]);
26 |
27 | return (
28 | <>
29 |
30 |
31 |
32 | 文件 · 管理
33 |
34 |
35 |
36 |
}
39 | action={() => {
40 | const input = document.createElement("input");
41 | input.type = "file";
42 | input.onchange = async (e) => {
43 | const file = (e.target as HTMLInputElement).files?.[0];
44 | if (!file) return;
45 | const formData = new FormData();
46 | formData.append("file", file);
47 | formData.append("path", path);
48 | toast.promise(
49 | apiClient(`/store/upload?path=${path}`, {
50 | method: "POST",
51 | body: formData,
52 | }).then(() => {
53 | mutate();
54 | }),
55 | {
56 | loading: "正在上传",
57 | success: "上传成功",
58 | error: "上传失败",
59 | }
60 | );
61 | };
62 | input.click();
63 | }}
64 | />
65 |
{
70 | toast.promise(
71 | Promise.all(
72 | select.map(async (item) => {
73 | await apiClient(`/store/delete?path=${item}`, {
74 | method: "POST",
75 | body: JSON.stringify({
76 | path: `${path}/${item}`,
77 | }),
78 | });
79 | })
80 | ),
81 | {
82 | loading: "正在删除",
83 | success: "删除成功",
84 | error: "删除失败",
85 | }
86 | );
87 | }}
88 | />
89 |
90 |
91 |
92 |
93 | {data?.data.map((item: FileItemProps) => {
94 | return (
95 | - {
97 | if (select.includes(item.name)) {
98 | setSelect(select.filter((i) => i !== item.name));
99 | } else {
100 | setSelect([...select, item.name]);
101 | }
102 | }}
103 | className={clsx(select.includes(item.name) && styles.selected)}
104 | {...item}
105 | key={item.name}
106 | path={path}
107 | />
108 | );
109 | })}
110 |
111 | >
112 | );
113 | };
114 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | on:
2 | push:
3 | tags:
4 | - 'v*'
5 |
6 | name: Release
7 |
8 | jobs:
9 | build:
10 | name: Upload Release Asset
11 | strategy:
12 | matrix:
13 | os: [ubuntu-latest]
14 | runs-on: ${{ matrix.os }}
15 | outputs:
16 | release_url: ${{ steps.create_release.outputs.upload_url }}
17 | steps:
18 | - name: Checkout code
19 | uses: actions/checkout@v4
20 | with:
21 | fetch-depth: 0
22 | - uses: actions/setup-node@v4
23 | with:
24 | node-version: 20.x
25 |
26 | - name: Create Release
27 | id: create_release
28 | run: |
29 | npx changelogithub
30 | env:
31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
32 | - name: Cache pnpm modules
33 | uses: actions/cache@v4
34 | env:
35 | cache-name: cache-pnpm-modules
36 | with:
37 | path: ~/.pnpm-store
38 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.node-version }}-${{ hashFiles('**/pnpm-lock.yaml') }}
39 | restore-keys: |
40 | ${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.node-version }}-${{ hashFiles('**/pnpm-lock.yaml') }}
41 | - uses: pnpm/action-setup@v4
42 | with:
43 | version: latest
44 | run_install: true
45 |
46 | - name: Build project
47 | run: |
48 | pnpm run build:mog
49 | zip -r dist.zip dist/
50 | - name: Get CSS file name
51 | id: get_css_file_name
52 | run: |
53 | echo "::set-output name=css_file_name::$(ls dist/assets/ | grep css)"
54 | - name: Get JS file name (index-xxx.js)
55 | id: get_js_file_name
56 | run: |
57 | echo "::set-output name=js_file_name::$(ls dist/assets | grep js)"
58 |
59 | - name: Get release
60 | id: get_release
61 | uses: bruceadams/get-release@v1.3.2
62 | env:
63 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
64 |
65 | - name: Upload Release Asset
66 | id: upload-release-asset
67 | uses: actions/upload-release-asset@v1
68 | env:
69 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
70 | with:
71 | upload_url: ${{ steps.get_release.outputs.upload_url }}
72 | asset_path: ./dist.zip
73 | asset_name: dist.zip
74 | asset_content_type: application/zip
75 |
76 | - name: Upload Release Asset - index.html
77 | uses: actions/upload-release-asset@v1
78 | env:
79 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
80 | with:
81 | upload_url: ${{ steps.get_release.outputs.upload_url }}
82 | asset_path: ./dist/index.html
83 | asset_name: index.html
84 | asset_content_type: text/html
85 |
86 | - name: Upload Release Asset - index.css
87 | uses: actions/upload-release-asset@v1
88 | env:
89 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
90 | with:
91 | upload_url: ${{ steps.get_release.outputs.upload_url }}
92 | asset_path: ./dist/assets/${{ steps.get_css_file_name.outputs.css_file_name }}
93 | asset_name: ${{ steps.get_css_file_name.outputs.css_file_name }}
94 | asset_content_type: text/css
95 |
96 | - name: Upload Release Asset - index.js
97 | uses: actions/upload-release-asset@v1
98 | env:
99 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
100 | with:
101 | upload_url: ${{ steps.get_release.outputs.upload_url }}
102 | asset_path: ./dist/assets/${{ steps.get_js_file_name.outputs.js_file_name }}
103 | asset_name: ${{ steps.get_js_file_name.outputs.js_file_name }}
104 | asset_content_type: application/javascript
105 | - name: Upload Release Asset - background.avif
106 | uses: actions/upload-release-asset@v1
107 | env:
108 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
109 | with:
110 | upload_url: ${{ steps.get_release.outputs.upload_url }}
111 | asset_path: ./dist/background.avif
112 | asset_name: background.avif
113 | asset_content_type: image/avif
--------------------------------------------------------------------------------
/src/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as SelectPrimitive from "@radix-ui/react-select";
4 | import { Check, ChevronDown } from "lucide-react";
5 |
6 | import { cn } from "@libs/cn";
7 | import { forwardRef } from "react";
8 |
9 | const Select = SelectPrimitive.Root;
10 |
11 | const SelectGroup = SelectPrimitive.Group;
12 |
13 | const SelectValue = SelectPrimitive.Value;
14 |
15 | const SelectTrigger = forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, children, ...props }, ref) => (
19 |
27 | {children}
28 |
29 |
30 |
31 |
32 | ));
33 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
34 |
35 | const SelectContent = forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, children, position = "popper", ...props }, ref) => (
39 |
40 |
50 |
57 | {children}
58 |
59 |
60 |
61 | ));
62 | SelectContent.displayName = SelectPrimitive.Content.displayName;
63 |
64 | const SelectLabel = forwardRef<
65 | React.ElementRef,
66 | React.ComponentPropsWithoutRef
67 | >(({ className, ...props }, ref) => (
68 |
73 | ));
74 | SelectLabel.displayName = SelectPrimitive.Label.displayName;
75 |
76 | const SelectItem = forwardRef<
77 | React.ElementRef,
78 | React.ComponentPropsWithoutRef
79 | >(({ className, children, ...props }, ref) => (
80 |
88 |
89 |
90 |
91 |
92 |
93 |
94 | {children}
95 |
96 | ));
97 | SelectItem.displayName = SelectPrimitive.Item.displayName;
98 |
99 | const SelectSeparator = forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ));
109 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
110 |
111 | export {
112 | Select,
113 | SelectGroup,
114 | SelectValue,
115 | SelectTrigger,
116 | SelectContent,
117 | SelectLabel,
118 | SelectItem,
119 | SelectSeparator,
120 | };
121 |
--------------------------------------------------------------------------------
/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as DialogPrimitive from "@radix-ui/react-dialog"
3 | import { X } from "lucide-react"
4 |
5 | import { cn } from "@libs/cn"
6 |
7 | const Dialog = DialogPrimitive.Root
8 |
9 | const DialogTrigger = DialogPrimitive.Trigger
10 |
11 | const DialogPortal = ({
12 | className,
13 | ...props
14 | }: DialogPrimitive.DialogPortalProps) => (
15 |
16 | )
17 | DialogPortal.displayName = DialogPrimitive.Portal.displayName
18 |
19 | const DialogOverlay = React.forwardRef<
20 | React.ElementRef,
21 | React.ComponentPropsWithoutRef
22 | >(({ className, ...props }, ref) => (
23 |
31 | ))
32 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
33 |
34 | const DialogContent = React.forwardRef<
35 | React.ElementRef,
36 | React.ComponentPropsWithoutRef
37 | >(({ className, children, ...props }, ref) => (
38 |
39 |
40 |
48 | {children}
49 |
50 |
51 | Close
52 |
53 |
54 |
55 | ))
56 | DialogContent.displayName = DialogPrimitive.Content.displayName
57 |
58 | const DialogHeader = ({
59 | className,
60 | ...props
61 | }: React.HTMLAttributes) => (
62 |
69 | )
70 | DialogHeader.displayName = "DialogHeader"
71 |
72 | const DialogFooter = ({
73 | className,
74 | ...props
75 | }: React.HTMLAttributes) => (
76 |
83 | )
84 | DialogFooter.displayName = "DialogFooter"
85 |
86 | const DialogTitle = React.forwardRef<
87 | React.ElementRef,
88 | React.ComponentPropsWithoutRef
89 | >(({ className, ...props }, ref) => (
90 |
98 | ))
99 | DialogTitle.displayName = DialogPrimitive.Title.displayName
100 |
101 | const DialogDescription = React.forwardRef<
102 | React.ElementRef,
103 | React.ComponentPropsWithoutRef
104 | >(({ className, ...props }, ref) => (
105 |
110 | ))
111 | DialogDescription.displayName = DialogPrimitive.Description.displayName
112 |
113 | export {
114 | Dialog,
115 | DialogTrigger,
116 | DialogContent,
117 | DialogHeader,
118 | DialogFooter,
119 | DialogTitle,
120 | DialogDescription,
121 | }
122 |
--------------------------------------------------------------------------------
/src/pages/Comments/index.tsx:
--------------------------------------------------------------------------------
1 | import postStyles from "@pages/Posts/Index/index.module.css";
2 | import type { BasicPage } from "@type/basic";
3 | import clsx from "clsx";
4 | import { useEffect, useState } from "react";
5 | import tabs from "@components/universal/Tabs/index.module.css";
6 | import { Tab } from "@headlessui/react";
7 | import { getQueryVariable } from "@utils/url";
8 | import { useNavigate } from "react-router-dom";
9 | import { apiClient } from "@utils/request";
10 | import { Title } from "@components/universal/Title";
11 | import { jump } from "@utils/path";
12 | import { useSeo } from "@hooks/useSeo";
13 | import { EditModal } from "./component";
14 | import useSWR from "swr";
15 | import useSWRMutation from "swr/mutation";
16 | import { CommentsTable } from "./Table/data-table";
17 | import { commentsListColumns } from "./Table/column";
18 | import { Button } from "@components/ui/button";
19 | import { _private } from "@states/private";
20 | import { useSnapshot } from "valtio";
21 |
22 | const tabsList = [
23 | {
24 | name: "待审核",
25 | status: 0,
26 | },
27 | {
28 | name: "已通过",
29 | status: 1,
30 | },
31 | {
32 | name: "垃圾评论",
33 | status: 2,
34 | },
35 | {
36 | name: "回收站",
37 | status: 3,
38 | },
39 | ];
40 | export const CommentsPage: BasicPage = () => {
41 | useSeo("评论 · 列表");
42 | const status = getQueryVariable("status");
43 | const page = Number(getQueryVariable("page")) || 1;
44 | const navigate = useNavigate();
45 | const [tab, setTab] = useState(status ? Number(status) : 0);
46 |
47 | const { showModal } = useSnapshot(_private);
48 |
49 | const { data, mutate } = useSWR(`/comments?status=${tab}&page=${page}`);
50 |
51 | useEffect(() => {
52 | navigate(jump(`/comments?status=${tab}&page=${page}`));
53 | mutate();
54 | }, [page, tab]);
55 |
56 | const { trigger: updateStatus } = useSWRMutation(
57 | `/comments/`,
58 | (
59 | key: string,
60 | {
61 | arg,
62 | }: {
63 | arg: {
64 | item: string;
65 | status: number;
66 | };
67 | }
68 | ) => {
69 | return apiClient(`${key}/${arg.item}`, {
70 | method: "PATCH",
71 | query: {
72 | status: arg.status,
73 | },
74 | });
75 | }
76 | );
77 |
78 | return (
79 | <>
80 | {/* */}
81 |
84 |
85 |
86 |
评论 · 列表
87 |
88 |
95 |
96 |
97 |
98 |
{
101 | setTab(index);
102 | }}
103 | >
104 |
105 | {tabsList.map((tab, index) => (
106 |
109 | clsx(tabs.tab, selected && tabs.selected)
110 | }
111 | >
112 | {tab.name}
113 |
114 | ))}
115 |
116 |
117 | {tabsList.map((tab, index) => (
118 |
119 |
124 |
125 | ))}
126 |
127 |
128 |
129 | {showModal && (
130 | {
133 | mutate((prev) => ({
134 | data: comments.data,
135 | pagination: prev.pagination,
136 | }));
137 | }}
138 | // setInSideLoading={setInSideLoading}
139 | tab={tab}
140 | page={page}
141 | />
142 | )}
143 | >
144 | );
145 | };
146 |
--------------------------------------------------------------------------------
/src/components/universal/Modal/index.tsx:
--------------------------------------------------------------------------------
1 | import styles from "./index.module.css";
2 | import { Fragment, useState } from "react";
3 | import { Dialog, Transition } from "@headlessui/react";
4 | import clsx from "clsx";
5 | import { Button } from "../Button";
6 | export const Modal = ({
7 | children,
8 | onClose,
9 | title,
10 | type,
11 | options,
12 | onConfirm,
13 | size,
14 | doubleClick,
15 | onCancel,
16 | className,
17 | }: {
18 | children: React.ReactNode;
19 | title: string;
20 | type?: "confirm" | "info";
21 | options?: {
22 | confirmText?: string;
23 | cancelText?: string;
24 | };
25 | onConfirm?: (e: boolean) => void;
26 | onCancel?: (e: boolean) => void;
27 | onClose?: (e: boolean) => void;
28 | size?: "sm" | "md" | "lg";
29 | doubleClick?: {
30 | confirm?: boolean;
31 | cancel?: boolean;
32 | };
33 | className?: string;
34 | }) => {
35 | const [isOpen, setIsOpen] = useState(true);
36 | const [doubleClickState, setDoubleClickState] = useState({
37 | confirm: doubleClick?.confirm || false,
38 | cancel: doubleClick?.cancel || false,
39 | });
40 |
41 | const confirm = (e) => {
42 | if (doubleClickState.confirm) {
43 | e.preventDefault();
44 | e.currentTarget.classList.add(styles["double-click"]);
45 | setDoubleClickState({
46 | ...doubleClickState,
47 | confirm: false,
48 | });
49 | return;
50 | }
51 | onClose?.(true);
52 | onConfirm?.(true);
53 | };
54 |
55 | const cancel = (e) => {
56 | if (doubleClickState.cancel) {
57 | e.preventDefault();
58 | e.currentTarget.classList.add(styles["double-click"]);
59 | setDoubleClickState({
60 | ...doubleClickState,
61 | cancel: false,
62 | });
63 | return;
64 | }
65 | onClose?.(false);
66 | onCancel?.(false);
67 | };
68 |
69 | function closeModal(e: boolean) {
70 | setIsOpen(false);
71 | onClose?.(e);
72 | }
73 | return (
74 |
75 |
133 |
134 | );
135 | };
136 |
137 | export const ModalBody = ({ children }) => {
138 | return {children}
;
139 | };
140 |
--------------------------------------------------------------------------------
/src/components/universal/Calendar/index.module.css:
--------------------------------------------------------------------------------
1 | .calendar {
2 | width: 300px;
3 | height: 300px;
4 | background-color: var(--background-color);
5 | border-radius: 4px;
6 | box-shadow: 0 3px 6px -4px rgba(0, 0, 0, 0.16), 0 6px 16px 0 rgba(0, 0, 0, 0.08),
7 | 0 9px 28px 8px rgba(0, 0, 0, 0.10);
8 | display: flex;
9 | flex-direction: column;
10 | position: absolute;
11 | z-index: 100;
12 | animation: fadeIn 0.3s ease-in-out;
13 | }
14 |
15 | @keyframes fadeIn {
16 | 0% {
17 | opacity: 0;
18 | }
19 |
20 | 100% {
21 | opacity: 1;
22 | }
23 | }
24 |
25 | .previewDate {
26 | display: flex;
27 | /* justify-content: center; */
28 | /* align-items: center; */
29 | padding: 10px;
30 | padding-bottom: 0;
31 | font-size: 13px;
32 | color: var(--text-color);
33 | }
34 |
35 | .previewDate span {
36 | width: 50%;
37 | margin-left: 10px;
38 | margin-right: 10px;
39 | border: 1px solid var(--background-color-primary);
40 | padding: 5px;
41 | padding-left: 10px;
42 | border-radius: 4px;
43 | transition: border 0.2s ease-in-out, background-color 0.2s ease-in-out;
44 | cursor: pointer;
45 | }
46 |
47 | .previewDate span:hover {
48 | border: 1px solid var(--background-color-secondary);
49 | }
50 |
51 | .previewDate.active {
52 | border: 1px solid var(--background-color-secondary);
53 | }
54 |
55 | .header {
56 | display: flex;
57 | justify-content: center;
58 | align-items: center;
59 | padding: 10px;
60 | }
61 |
62 | .yearAndMonth {
63 | display: flex;
64 | align-items: center;
65 | font-weight: bold;
66 | user-select: none;
67 | }
68 |
69 | .yearAndMonth span {
70 | margin-left: 2px;
71 | margin-right: 2px;
72 | }
73 |
74 | .btn {
75 | width: 30px;
76 | height: 30px;
77 | border: none;
78 | background-color: var(--background-color);
79 | cursor: pointer;
80 | font-size: 18px;
81 | color: var(--text-color);
82 | transition: background-color 0.2s ease-in-out;
83 | border-radius: 4px;
84 | }
85 |
86 | .body {
87 | flex: 1;
88 | display: flex;
89 | flex-direction: column;
90 | }
91 |
92 | .bodyContainer {
93 | flex: 1;
94 | display: flex;
95 | flex-direction: column;
96 | justify-items: space-between;
97 | }
98 |
99 | .week {
100 | display: flex;
101 | justify-content: space-between;
102 | padding: 10px;
103 | padding-bottom: 0px;
104 | font-size: 13px;
105 | color: var(--text-color);
106 | user-select: none;
107 | border-bottom: 1px solid var(--background-color-secondary);
108 | }
109 |
110 | .week span {
111 | width: 30px;
112 | height: 30px;
113 | line-height: 30px;
114 | text-align: center;
115 | }
116 |
117 | .days {
118 | flex: 1;
119 | display: flex;
120 | flex-wrap: wrap;
121 | padding: 10px;
122 | padding-top: 8px;
123 | }
124 |
125 | .day {
126 | width: 30px;
127 | height: 30px;
128 | line-height: 30px;
129 | text-align: center;
130 | cursor: pointer;
131 | font-size: 13px;
132 | color: var(--text-color);
133 | transition: background-color 0.2s ease-in-out;
134 | border-radius: 4px;
135 | }
136 |
137 | .footer {
138 | display: flex;
139 | justify-content: flex-end;
140 | padding: 10px;
141 | }
142 |
143 | .day:hover,
144 | .btn:hover {
145 | background-color: var(--background-color-secondary);
146 | }
147 |
148 | .current {
149 | background-color: var(--background-color-tertiary) !important;
150 | }
151 |
152 | @media only screen and (max-width: 600px) {
153 | .calendar {
154 | width: 100%;
155 | }
156 |
157 | .week span,
158 | .day {
159 | width: 40px;
160 | height: 40px;
161 | line-height: 40px;
162 | }
163 | }
164 |
165 | .active {
166 | background-color: var(--background-color-primary);
167 | }
168 |
169 | .time{
170 | display: flex;
171 | justify-content: center;
172 | align-items: center;
173 | padding: 10px;
174 | padding-top: 8px;
175 | padding-bottom: 8px;
176 | font-size: 13px;
177 | color: var(--text-color);
178 | background-color: var(--background-color);
179 | position: absolute;
180 | top: -50px;
181 | right: -3.333px;
182 | box-shadow: 0 3px 6px -4px rgba(0, 0, 0, 0.358), 0 6px 16px 0 rgba(0, 0, 0, 0),
183 | 0 9px 28px 8px rgba(0, 0, 0, 0);
184 | }
185 |
186 | .time .btn {
187 | background-color: var(--background-color);
188 | }
189 |
190 | .time .btn:hover {
191 | background-color: var(--background-color-primary);
192 | }
193 |
194 | .timeItem {
195 | width: 100%;
196 | height: 100%;
197 | display: flex;
198 | justify-content: center;
199 | align-items: center;
200 | font-size: 13px;
201 | color: var(--text-color);
202 | transition: background-color 0.2s ease-in-out;
203 | border-radius: 4px;
204 | }
205 |
--------------------------------------------------------------------------------
/src/components/widgets/Sidebar/index.tsx:
--------------------------------------------------------------------------------
1 | import { Login, Logout, MenuFoldOne, MenuUnfoldOne } from "@icon-park/react";
2 | import clsx from "clsx";
3 | import { useEffect, useState } from "react";
4 | import { Space } from "@components/universal/Space";
5 | import styles from "./index.module.css";
6 | import { SidebarItem } from "./item";
7 | import { motion } from "framer-motion";
8 | import { getStorage, setStorage } from "@utils/storage";
9 | import { useWindowSize } from "react-use";
10 | import itemStyle from "./item/index.module.css";
11 | import { useSnapshot } from "valtio";
12 | import { app } from "@states/app";
13 | import { useNavigate } from "react-router-dom";
14 | import { apiClient } from "@utils/request";
15 | import { removeCookie } from "@utils/cookie";
16 | import { jump } from "@utils/path";
17 | import { mutate } from "swr";
18 | import { toast } from "sonner";
19 | import { SIDEBAR } from "../../../sidebar";
20 |
21 | const Links = () => {
22 | const authenticated = useSnapshot(app).authenticated;
23 | const [disabled, setDisabled] = useState(false);
24 | const navigate = useNavigate();
25 |
26 | useEffect(() => {
27 | if (authenticated) {
28 | setDisabled(false);
29 | } else {
30 | setDisabled(true);
31 | }
32 | }, [authenticated]);
33 | return (
34 |
39 | {SIDEBAR.map((item, index) => (
40 | <>
41 | {item.map((item) => (
42 | <>
43 |
49 | {item.subItems?.map((item) => (
50 |
56 | ))}
57 | >
58 | ))}
59 |
60 | >
61 | ))}
62 |
63 |
{
66 | apiClient("/user/logout", {
67 | method: "POST",
68 | })
69 | .then(() => {
70 | removeCookie("token");
71 | app.authenticated = false;
72 | navigate(jump("/login"));
73 | mutate("/user/check");
74 | toast("退出成功");
75 | })
76 | .catch((e) => {
77 | console.log(e);
78 | });
79 | }}
80 | >
81 |
82 | {authenticated ? Logout({}) : Login({})}
83 |
84 |
85 | {authenticated ? "退出" : "前往"}
86 | 登录
87 |
88 |
89 |
90 | );
91 | };
92 |
93 | export const Sidebar: React.FC = () => {
94 | const [float, setFloat] = useState(
95 | getStorage("sidebarFloat") === "true" || false
96 | );
97 | const [isMobile, setIsMobile] = useState(false);
98 | const [x, setX] = useState(0);
99 | const { width } = useWindowSize();
100 |
101 | function setXEnterByFloat() {
102 | if (float) setX(0);
103 | }
104 | function setXLeaveByFloat() {
105 | if (float) setX(-300);
106 | }
107 |
108 | useEffect(() => {
109 | setStorage("sidebarFloat", String(float));
110 | }, [float]);
111 |
112 | useEffect(() => {
113 | if (width < 768) {
114 | setFloat(true);
115 | setIsMobile(true);
116 | setX(-300);
117 | }
118 | }, [width]);
119 | return (
120 | <>
121 |
136 |
137 |
138 |
Mog
139 |
setFloat(!float)}>
140 | {/* {!isMobile && } */}
141 | {float ? : }
142 |
143 |
144 |
145 |
146 |
147 |
148 |
149 | >
150 | );
151 | };
152 |
--------------------------------------------------------------------------------
/src/pages/Comments/component.tsx:
--------------------------------------------------------------------------------
1 | import { Modal, ModalBody } from "@components/universal/Modal";
2 |
3 |
4 | import { Input } from "@components/ui/input";
5 | import { Textarea } from "@components/ui/textarea";
6 | import { apiClient } from "@utils/request";
7 | import { useEffect, useState } from "react";
8 | import useSWR from "swr";
9 | import useSWRMutation from "swr/mutation";
10 | import { Select } from "@components/widgets/ThemeComponent/ThemeSelect";
11 | import { Label } from "@components/ui/label";
12 | import { Dialog, DialogHeader } from "@components/ui/dialog";
13 | import { useSnapshot } from "valtio";
14 | import { _private } from "@states/private";
15 | import { DialogContent } from "@radix-ui/react-dialog";
16 |
17 | interface EditModalProps {
18 | status: {
19 | name: string;
20 | status: number;
21 | }[];
22 | }
23 |
24 | export const EditModal: React.FC = ({
25 | tabsList,
26 | select,
27 | setShowEditModal,
28 | setSelect,
29 | setComments,
30 | // setInSideLoading,
31 | tab,
32 | page,
33 | }) => {
34 | const { data } = useSWR(`/comments/${select[0]}`);
35 | const { trigger } = useSWRMutation(
36 | `/comments/${select[0]}`,
37 | (key: string, { arg }: { arg: string }) => {
38 | return apiClient(key, {
39 | method: "PUT",
40 | body: JSON.stringify(arg),
41 | });
42 | }
43 | );
44 | const [_data, setData] = useState(null);
45 |
46 | useEffect(() => {
47 | setData(data);
48 | }, [data]);
49 |
50 | const handleRequest = async (status: number, page: number) => {
51 | return apiClient(`/comments?status=${status}&page=${page}`).then(
52 | async (res) => {
53 | for (let i = 0; i < res.data.length; i++) {
54 | res.data[i].post = await apiClient(`/post/${res.data[i].pid}`);
55 | }
56 | return res;
57 | }
58 | );
59 | };
60 |
61 | const handleConfirm = async () => {
62 | // setInSideLoading(true);
63 | await trigger(_data);
64 | handleRequest(tab, page).then((res) => {
65 | setComments(res);
66 | // setInSideLoading(false);
67 | });
68 | };
69 |
70 | return (
71 | <>
72 | {
79 | setShowEditModal(false);
80 | setSelect([]);
81 | }}
82 | options={{
83 | confirmText: "提交",
84 | }}
85 | onConfirm={handleConfirm}
86 | >
87 | 状态
88 |
137 | >
138 | );
139 | };
140 |
141 | export const _EditModal: React.FC = (props) => {
142 | const { showModal, modalDataId } = useSnapshot(_private);
143 | const { data } = useSWR(`/comments/${modalDataId}`);
144 | const { trigger } = useSWRMutation(
145 | `/comments/${modalDataId}`,
146 | (key: string, { arg }: { arg: string }) => {
147 | return apiClient(key, {
148 | method: "PUT",
149 | body: JSON.stringify(arg),
150 | });
151 | }
152 | );
153 |
154 | const handleConfirm = async () => {
155 | await trigger(data);
156 | _private.showModal = false;
157 | _private.modalDataId = "";
158 | };
159 |
160 | return (
161 |
172 | );
173 | };
174 |
--------------------------------------------------------------------------------
/src/pages/Register/index.tsx:
--------------------------------------------------------------------------------
1 | import styles from "@pages/Login/index.module.css";
2 | import clsx from "clsx";
3 | import { useEffect, useRef, useState } from "react";
4 | import type { BasicPage } from "@type/basic";
5 | import { motion } from "framer-motion";
6 | import { useNavigate } from "react-router-dom";
7 | import { apiClient } from "@utils/request";
8 | import { app } from "@states/app";
9 | import { setCookie } from "@utils/cookie";
10 | import { jump } from "@utils/path";
11 | import { useSeo } from "@hooks/useSeo";
12 | import { toast } from "sonner";
13 | import useSWR from "swr";
14 | import { useSnapshot } from "valtio";
15 |
16 | export const RegisterPage: BasicPage = () => {
17 | useSeo("注册");
18 | const navigate = useNavigate();
19 | const [loading, setLoading] = useState(false);
20 | const formRef = useRef(null);
21 | const appSnapshot = useSnapshot(app);
22 |
23 | const { error } = useSWR("/user/master/info");
24 | if (!error) {
25 | toast.error("已注册,正在前往登录页面");
26 | navigate(jump("/login"));
27 | app.showSidebar = false;
28 | }
29 |
30 | useEffect(() => {
31 | if (appSnapshot.authenticated) {
32 | toast.success("已登录,正在前往仪表盘");
33 | navigate(jump("/dashboard"));
34 | return;
35 | }
36 | }, []);
37 |
38 | function handleSubmit(e: React.FormEvent) {
39 | e.preventDefault();
40 | setLoading(true);
41 | const form = formRef.current;
42 | if (form) {
43 | const username = (form.elements.namedItem("username") as RadioNodeList)!
44 | .value;
45 | const password = (form.elements.namedItem("password") as RadioNodeList)!
46 | .value;
47 | const nickname = (form.elements.namedItem("nickname") as RadioNodeList)!
48 | .value;
49 | const description = (form.elements.namedItem(
50 | "description"
51 | ) as RadioNodeList)!.value;
52 | const email = (form.elements.namedItem("email") as RadioNodeList)!.value;
53 | const avatar = (form.elements.namedItem("avatar") as RadioNodeList)!
54 | .value;
55 | apiClient("/user/register", {
56 | method: "POST",
57 | body: {
58 | username,
59 | password,
60 | nickname,
61 | description,
62 | email,
63 | avatar,
64 | },
65 | })
66 | .then((res) => {
67 | setCookie("token", res.token);
68 | toast.success("登录成功, 欢迎回来");
69 | app.authenticated = true;
70 | navigate(jump("/dashboard"));
71 | window.location.reload();
72 | })
73 | .catch((res) => {
74 | toast.error(`登录失败 - ${res.data.message}`);
75 | });
76 | }
77 | setLoading(false);
78 | }
79 |
80 | return (
81 | <>
82 |
83 |
注册
84 |
85 |
150 |
151 |
152 | >
153 | );
154 | };
155 |
--------------------------------------------------------------------------------