From 615e85fdf726076c268d1c5530a679478e5ff5d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=9F=83=E6=8B=89?= Date: Tue, 14 Jan 2025 18:02:38 +0800 Subject: [PATCH] wip: update --- src/App.tsx | 13 + src/api/challenge.ts | 11 + src/api/config.ts | 7 + .../core/InputBase/InputBase.module.scss | 6 + src/components/core/InputBase/InputBase.tsx | 3 + .../core/NumberInput/NumberInput.module.scss | 46 +++ .../core/NumberInput/NumberInput.tsx | 62 ++++ src/components/core/NumberInput/index.ts | 1 + .../core/Popover/Popover.module.scss | 13 +- src/components/core/Popover/Popover.tsx | 143 ++++---- src/components/core/Select/Select.module.scss | 11 + src/components/core/Select/Select.tsx | 82 +++++ src/components/core/Select/index.ts | 1 + src/components/core/TableCell/TableCell.tsx | 32 +- .../core/TextInput/TextInput.module.scss | 2 - src/components/core/TextInput/TextInput.tsx | 4 + src/components/core/Toast/Toast.module.scss | 1 + src/components/core/index.ts | 6 + .../ErrorFallback/ErrorFallback.module.scss | 1 + .../widgets/Navbar/Dropdown/Dropdown.tsx | 5 +- src/components/widgets/Navbar/Navbar.tsx | 6 +- src/models/challenge.ts | 12 +- src/pages/challenges/index.tsx | 6 +- src/pages/games/index.tsx | 8 + src/pages/index.tsx | 34 ++ src/pages/login/index.tsx | 6 + .../challenges/Default/Default.module.scss | 0 .../settings/challenges/Default/Default.tsx | 0 .../settings/challenges/Default/index.ts | 0 .../ChallengeCreateModal.module.scss | 12 + .../ChallengeCreateModal.tsx | 95 ++++++ .../_blocks/ChallengeCreateModal/index.ts | 1 + .../settings/challenges/index.module.scss | 11 + src/pages/settings/challenges/index.ts | 0 src/pages/settings/challenges/index.tsx | 311 ++++++++++++++++++ .../games/Default/Default.module.scss | 0 src/pages/settings/games/Default/Default.tsx | 0 src/pages/settings/games/Default/index.ts | 0 src/routers.tsx | 9 + src/stores/shared.ts | 7 + 40 files changed, 861 insertions(+), 107 deletions(-) create mode 100644 src/api/config.ts create mode 100644 src/components/core/Select/Select.module.scss create mode 100644 src/components/core/Select/Select.tsx create mode 100644 src/components/core/Select/index.ts delete mode 100644 src/pages/settings/challenges/Default/Default.module.scss delete mode 100644 src/pages/settings/challenges/Default/Default.tsx delete mode 100644 src/pages/settings/challenges/Default/index.ts create mode 100644 src/pages/settings/challenges/_blocks/ChallengeCreateModal/ChallengeCreateModal.module.scss create mode 100644 src/pages/settings/challenges/_blocks/ChallengeCreateModal/ChallengeCreateModal.tsx create mode 100644 src/pages/settings/challenges/_blocks/ChallengeCreateModal/index.ts create mode 100644 src/pages/settings/challenges/index.module.scss delete mode 100644 src/pages/settings/challenges/index.ts create mode 100644 src/pages/settings/challenges/index.tsx delete mode 100644 src/pages/settings/games/Default/Default.module.scss delete mode 100644 src/pages/settings/games/Default/Default.tsx delete mode 100644 src/pages/settings/games/Default/index.ts diff --git a/src/App.tsx b/src/App.tsx index 26d2fa0..4e2bf39 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,9 +2,18 @@ import { useThemeStore } from "@/stores/theme"; import { RouterProvider } from "react-router"; import { useEffect } from "react"; import { router } from "@/routers"; +import { useSharedStore } from "./stores/shared"; +import { get } from "./api/config"; export default function App() { const themeStore = useThemeStore(); + const sharedStore = useSharedStore(); + + function fetchConfigs() { + get().then((res) => { + sharedStore.setConfig(res.data); + }); + } useEffect(() => { document.documentElement.setAttribute( @@ -13,5 +22,9 @@ export default function App() { ); }, [themeStore.darkMode]); + useEffect(() => { + fetchConfigs(); + }, []); + return ; } diff --git a/src/api/challenge.ts b/src/api/challenge.ts index 0ef2ced..b067bde 100644 --- a/src/api/challenge.ts +++ b/src/api/challenge.ts @@ -3,6 +3,7 @@ import { ChallengeGetRequest, ChallengeStatus, ChallengeStatusRequest, + ChallengeUpdateRequest, } from "@/models/challenge"; import { Response } from "@/types"; import { alovaInstance } from "@/utils/alova"; @@ -22,3 +23,13 @@ export async function getStatus(request: ChallengeStatusRequest) { } ); } + +export async function update(request: ChallengeUpdateRequest) { + return alovaInstance.Put>( + `/challenges/${request?.id}`, + request, + { + cacheFor: 0, + } + ); +} diff --git a/src/api/config.ts b/src/api/config.ts new file mode 100644 index 0000000..e477305 --- /dev/null +++ b/src/api/config.ts @@ -0,0 +1,7 @@ +import { alovaInstance } from "@/utils/alova"; +import { Response } from "@/types"; +import { Config } from "@/models/config"; + +export async function get() { + return alovaInstance.Get>("/configs"); +} diff --git a/src/components/core/InputBase/InputBase.module.scss b/src/components/core/InputBase/InputBase.module.scss index 8b6fcc3..0d854f6 100644 --- a/src/components/core/InputBase/InputBase.module.scss +++ b/src/components/core/InputBase/InputBase.module.scss @@ -70,4 +70,10 @@ $border-color: var(--input-border-color); &:hover { filter: brightness(1.2); } + + &:disabled, + &[data-disabled="true"] { + cursor: not-allowed; + opacity: 0.5; + } } diff --git a/src/components/core/InputBase/InputBase.tsx b/src/components/core/InputBase/InputBase.tsx index c0122ee..308aaf4 100644 --- a/src/components/core/InputBase/InputBase.tsx +++ b/src/components/core/InputBase/InputBase.tsx @@ -10,6 +10,7 @@ export interface InputBaseProps extends ComponentProps<"div"> { color?: string; variant?: "outlined" | "solid"; invalid?: boolean; + disabled?: boolean; label?: string; helperText?: string; errorText?: string; @@ -24,6 +25,7 @@ export function InputBase(props: InputBaseProps) { height = "fit-content", color = "primary", invalid = false, + disabled = false, variant = "outlined", label = "", helperText = "", @@ -68,6 +70,7 @@ export function InputBase(props: InputBaseProps) { {children} diff --git a/src/components/core/NumberInput/NumberInput.module.scss b/src/components/core/NumberInput/NumberInput.module.scss index e69de29..3b754c5 100644 --- a/src/components/core/NumberInput/NumberInput.module.scss +++ b/src/components/core/NumberInput/NumberInput.module.scss @@ -0,0 +1,46 @@ +.root { + gap: 8px; + + &[data-variant="solid"] { + background-color: var(--input-bg-color); + + .icon { + color: #ffffff; + } + + .input { + color: #ffffff; + caret-color: #ffffff; + + &::placeholder { + color: #ffffff7d; + } + } + } + + &[data-variant="outlined"] { + .icon { + color: light-dark(var(--input-border-color), #ffffff); + } + } + + &:focus, + &:hover { + filter: brightness(1.2); + } +} + +.input { + flex: 1; + width: 100%; + background: transparent; + border: none; + outline: none; + caret-color: var(--input-border-color); + font-size: 16px; + line-height: 1.5; + + &:disabled { + cursor: not-allowed; + } +} diff --git a/src/components/core/NumberInput/NumberInput.tsx b/src/components/core/NumberInput/NumberInput.tsx index e69de29..e72bf1f 100644 --- a/src/components/core/NumberInput/NumberInput.tsx +++ b/src/components/core/NumberInput/NumberInput.tsx @@ -0,0 +1,62 @@ +import clsx from "clsx"; +import { InputBase, InputBaseProps } from "../InputBase"; +import styles from "./NumberInput.module.scss"; + +export interface NumberInputProps extends Omit { + invalid?: boolean; + value?: number; + label?: string; + icon?: React.ReactNode; + placeholder?: string; + onChange?: (value: number) => void; + min?: number; + max?: number; + style?: React.CSSProperties; +} + +export function NumberInput(props: NumberInputProps) { + const { + width, + color = "primary", + invalid = false, + variant = "outlined", + icon, + value = "", + onChange, + label = "", + placeholder = "", + helperText = "", + errorText = "", + min, + max, + style, + className, + ...rest + } = props; + + return ( + + {icon &&
{icon}
} + onChange?.(e.target.valueAsNumber)} + min={min} + max={max} + /> +
+ ); +} diff --git a/src/components/core/NumberInput/index.ts b/src/components/core/NumberInput/index.ts index e69de29..3f941e0 100644 --- a/src/components/core/NumberInput/index.ts +++ b/src/components/core/NumberInput/index.ts @@ -0,0 +1 @@ +export { NumberInput, type NumberInputProps } from "./NumberInput"; diff --git a/src/components/core/Popover/Popover.module.scss b/src/components/core/Popover/Popover.module.scss index 556c2fe..951de63 100644 --- a/src/components/core/Popover/Popover.module.scss +++ b/src/components/core/Popover/Popover.module.scss @@ -1,15 +1,8 @@ .root { - position: relative; - width: fit-content; -} - -.trigger { - display: inline-block; -} - -.content { position: absolute; - z-index: 1; + z-index: 9999; + inset: 0px auto auto 0px; + margin: 0; &.enter { opacity: 0; diff --git a/src/components/core/Popover/Popover.tsx b/src/components/core/Popover/Popover.tsx index 5332bb1..f6d7c25 100644 --- a/src/components/core/Popover/Popover.tsx +++ b/src/components/core/Popover/Popover.tsx @@ -1,36 +1,26 @@ -import { cloneElement, useEffect, useMemo, useRef, useState } from "react"; +import { cloneElement, useEffect, useRef, useState } from "react"; import styles from "./Popover.module.scss"; import { CSSTransition } from "react-transition-group"; import { Box } from "../Box"; +import clsx from "clsx"; +import { useSharedStore } from "@/stores/shared"; +import { createPortal } from "react-dom"; export interface PopoverProps { - /** - * The content of the popover. - */ children: React.ReactElement; - /** - * The offset of the popover. - */ offsetY?: number; - /** - * The offset of the popover. - */ offsetX?: number; - /** - * The trigger of the popover. - */ content: React.ReactElement; - /** - * Whether the popover is opened or not. - */ opened: boolean; - /** - * The callback function when the popover is opened. - */ onChange: (opened: boolean) => void; + className?: string; + style?: React.CSSProperties; + portal?: HTMLDivElement | null; } export function Popover(props: PopoverProps) { + const sharedStore = useSharedStore(); + const { children, offsetY = 10, @@ -38,47 +28,56 @@ export function Popover(props: PopoverProps) { content, opened, onChange, + className, + style, + portal = sharedStore?.portal, } = props; const contentRef = useRef(null); const triggerRef = useRef(null); - const [position, setPosition] = useState<"top" | "bottom">("bottom"); + const [popoverStyle, setPopoverStyle] = useState({}); useEffect(() => { - if (triggerRef.current && opened) { - const triggerRect = triggerRef.current.getBoundingClientRect(); - const viewportHeight = window.innerHeight; + if ( + !sharedStore?.portal || + !triggerRef.current || + !contentRef.current || + !opened + ) + return; - const spaceBelow = viewportHeight - triggerRect.bottom - offsetY; - const spaceAbove = triggerRect.top - offsetY; + const portalRect = portal?.getBoundingClientRect()!; + const triggerRect = triggerRef.current.getBoundingClientRect(); + const contentRect = contentRef.current.getBoundingClientRect(); + const viewportHeight = window.innerHeight; + const viewportWidth = window.innerWidth; - if ( - spaceBelow < (contentRef?.current?.offsetHeight || 0) && - spaceAbove > (contentRef?.current?.offsetHeight || 0) - ) { - setPosition("top"); - } else { - setPosition("bottom"); - } + let newStyle: React.CSSProperties = {}; + + // Calculate vertical position + if ( + viewportHeight - triggerRect.bottom >= + contentRect.height + offsetY + ) { + newStyle.top = `${triggerRect.bottom - portalRect.top + offsetY}px`; + } else if (triggerRect.top >= contentRect.height + offsetY) { + newStyle.top = `${triggerRect.top - portalRect.top - contentRect.height - offsetY}px`; + } else { + newStyle.top = `${Math.max(triggerRect.top - portalRect.top, 0)}px`; } - }, [opened, offsetY]); - const positionStyle = useMemo(() => { - switch (position) { - case "top": - return { - bottom: `calc(100% + ${offsetY}px)`, - right: `${offsetX}px`, - }; - case "bottom": - default: - return { - top: `calc(100% + ${offsetY}px)`, - right: `${offsetX}px`, - }; + // Calculate horizontal position + if (viewportWidth - triggerRect.left >= contentRect.width + offsetX) { + newStyle.left = `${triggerRect.left - portalRect.left + offsetX}px`; + } else if (triggerRect.right >= contentRect.width + offsetX) { + newStyle.left = `${triggerRect.right - portalRect.left - contentRect.width - offsetX}px`; + } else { + newStyle.left = `${Math.max(triggerRect.left - portalRect.left, 0)}px`; } - }, [position, offsetY, offsetX]); + + setPopoverStyle(newStyle); + }, [opened, offsetY, offsetX]); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -98,28 +97,32 @@ export function Popover(props: PopoverProps) { }, []); return ( - + <> {cloneElement(children, { ref: triggerRef })} - - - {content} - - - + {sharedStore?.portal && + createPortal( + + + {content} + + , + sharedStore?.portal + )} + ); } diff --git a/src/components/core/Select/Select.module.scss b/src/components/core/Select/Select.module.scss new file mode 100644 index 0000000..bd4d644 --- /dev/null +++ b/src/components/core/Select/Select.module.scss @@ -0,0 +1,11 @@ +.dropdown { + background-color: var(--bg-color); + border-radius: 12px; + min-width: 200px; + max-height: 15rem; + + padding: 15px; + box-shadow: var(--shadow-lg); + + overflow: auto; +} diff --git a/src/components/core/Select/Select.tsx b/src/components/core/Select/Select.tsx new file mode 100644 index 0000000..a0e5a5e --- /dev/null +++ b/src/components/core/Select/Select.tsx @@ -0,0 +1,82 @@ +import React, { ComponentProps, useRef, useState } from "react"; +import { InputBase, InputBaseProps } from "../InputBase"; +import clsx from "clsx"; +import styles from "./Select.module.scss"; +import { Popover } from "../Popover"; +import { Box } from "../Box"; +import { Stack } from "../Stack"; + +export interface SelectProps extends Omit { + value: string; + onChange: (value: string) => void; + options: { value: string; label: React.ReactElement }[]; + icon?: React.ReactNode; +} + +export function Select(props: SelectProps) { + const { + width, + color = "primary", + invalid = false, + variant = "outlined", + icon, + value = "", + onChange, + options, + label = "", + helperText = "", + errorText = "", + style, + className, + ...rest + } = props; + + const [open, setOpen] = useState(false); + const dropdownRef = useRef(null); + + return ( + + {icon &&
{icon}
} + + {options.map((option) => ( + { + onChange(option.value); + setOpen(false); + }} + > + {option.label} + + ))} + + } + opened={open} + onChange={setOpen} + offsetX={Number(dropdownRef.current?.clientWidth) * -1} + offsetY={20} + > + setOpen(true)}> + {options.find((option) => option.value === value)?.label} + + +
+ ); +} diff --git a/src/components/core/Select/index.ts b/src/components/core/Select/index.ts new file mode 100644 index 0000000..3215efa --- /dev/null +++ b/src/components/core/Select/index.ts @@ -0,0 +1 @@ +export { Select, type SelectProps } from "./Select"; diff --git a/src/components/core/TableCell/TableCell.tsx b/src/components/core/TableCell/TableCell.tsx index db533d3..71cbdcd 100644 --- a/src/components/core/TableCell/TableCell.tsx +++ b/src/components/core/TableCell/TableCell.tsx @@ -9,14 +9,20 @@ import { Box } from "../Box"; export interface TableCellProps extends Omit, "align"> { - align?: "left" | "right" | "center" | "justify" | "start" | "end"; + justify?: + | "flex-start" + | "flex-end" + | "center" + | "space-between" + | "space-around" + | "space-evenly"; sortDirection?: "asc" | "desc"; onClick?: () => void; } export function TableCell(props: TableCellProps) { const { - align, + justify, sortDirection, style, children, @@ -29,30 +35,24 @@ export function TableCell(props: TableCellProps) { const Element = tablelvl2Context === "head" ? "th" : "td"; - const variables = { - textAlign: align, - } as CSSProperties; - return ( - + {children} - - {sortDirection !== undefined && ( - <> - {sortDirection === "asc" && } - {sortDirection === "desc" && } - - )} - + {sortDirection !== undefined && ( + + {sortDirection === "asc" && } + {sortDirection === "desc" && } + + )} ); diff --git a/src/components/core/TextInput/TextInput.module.scss b/src/components/core/TextInput/TextInput.module.scss index b3866f9..7636685 100644 --- a/src/components/core/TextInput/TextInput.module.scss +++ b/src/components/core/TextInput/TextInput.module.scss @@ -1,5 +1,3 @@ -@use "@/styles/mixins"; - .root { gap: 8px; diff --git a/src/components/core/TextInput/TextInput.tsx b/src/components/core/TextInput/TextInput.tsx index 499620b..8a219d3 100644 --- a/src/components/core/TextInput/TextInput.tsx +++ b/src/components/core/TextInput/TextInput.tsx @@ -15,6 +15,7 @@ export interface TextInputProps extends Omit { label?: string; icon?: React.ReactNode; placeholder?: string; + disabled?: boolean; onChange?: (value: string) => void; style?: React.CSSProperties; } @@ -26,6 +27,7 @@ export function TextInput(props: TextInputProps) { clearable = false, password = false, invalid = false, + disabled = false, variant = "outlined", icon, value = "", @@ -60,6 +62,7 @@ export function TextInput(props: TextInputProps) { helperText={helperText} errorText={errorText} label={label} + disabled={disabled} className={clsx(styles["root"], className)} style={style} {...rest} @@ -71,6 +74,7 @@ export function TextInput(props: TextInputProps) { type={password && !isPasswordVisible ? "password" : "text"} placeholder={placeholder} onChange={(e) => onChange?.(e.target.value)} + disabled={disabled} /> {clearable && ( diff --git a/src/components/core/Toast/Toast.module.scss b/src/components/core/Toast/Toast.module.scss index 6275a0c..ca10018 100644 --- a/src/components/core/Toast/Toast.module.scss +++ b/src/components/core/Toast/Toast.module.scss @@ -10,6 +10,7 @@ border-radius: 16px; padding: 10px 20px; min-width: 20vw; + max-width: 20vw; position: relative; } diff --git a/src/components/core/index.ts b/src/components/core/index.ts index d0e6a01..f241b7b 100644 --- a/src/components/core/index.ts +++ b/src/components/core/index.ts @@ -15,8 +15,14 @@ export * from "./LoadingOverlay"; export * from "./NumberInput"; export * from "./Pagination"; export * from "./Popover"; +export * from "./Select"; export * from "./Stack"; export * from "./Switch"; +export * from "./Table"; +export * from "./TableBody"; +export * from "./TableCell"; +export * from "./TableHead"; +export * from "./TableRow"; export * from "./Textarea"; export * from "./TextInput"; export * from "./Toast"; diff --git a/src/components/utils/ErrorFallback/ErrorFallback.module.scss b/src/components/utils/ErrorFallback/ErrorFallback.module.scss index 0d75fc1..38570f7 100644 --- a/src/components/utils/ErrorFallback/ErrorFallback.module.scss +++ b/src/components/utils/ErrorFallback/ErrorFallback.module.scss @@ -13,6 +13,7 @@ align-items: center; justify-content: center; gap: 15px; + color: light-dark(var(--color-primary), #ffffff); } .icon { diff --git a/src/components/widgets/Navbar/Dropdown/Dropdown.tsx b/src/components/widgets/Navbar/Dropdown/Dropdown.tsx index 6888721..e8688f7 100644 --- a/src/components/widgets/Navbar/Dropdown/Dropdown.tsx +++ b/src/components/widgets/Navbar/Dropdown/Dropdown.tsx @@ -100,7 +100,10 @@ export function Dropdown() { variant={"solid"} color={"error"} icon={} - onClick={() => authStore?.clear()} + onClick={() => { + authStore?.clear(); + navigate("/login"); + }} > 退出登录 diff --git a/src/components/widgets/Navbar/Navbar.tsx b/src/components/widgets/Navbar/Navbar.tsx index 71f5a9f..2b8a5e3 100644 --- a/src/components/widgets/Navbar/Navbar.tsx +++ b/src/components/widgets/Navbar/Navbar.tsx @@ -25,11 +25,13 @@ import React from "react"; import { get } from "@/api/game"; import { Game } from "@/models/game"; import { useAuthStore } from "@/stores/auth"; +import { useSharedStore } from "@/stores/shared"; export function Navbar() { const location = useLocation(); const { id } = useParams(); const authStore = useAuthStore(); + const sharedStore = useSharedStore(); const links = { default: [ @@ -148,7 +150,9 @@ export function Navbar() { radius={9999} >

- {mode === "game" ? game?.title : "CdsCTF"} + {mode === "game" + ? game?.title + : sharedStore?.config?.site?.title}

diff --git a/src/models/challenge.ts b/src/models/challenge.ts index 95563fd..cde7987 100644 --- a/src/models/challenge.ts +++ b/src/models/challenge.ts @@ -9,7 +9,7 @@ export interface Challenge { description?: string; category?: number; has_attachment?: boolean; - is_practicable?: boolean; + is_public?: boolean; is_dynamic?: boolean; duration?: number; image_name?: string; @@ -19,6 +19,7 @@ export interface Challenge { envs?: Array; flags?: Array; hints?: Array; + updated_at?: string; } export interface ChallengeGetRequest { @@ -26,14 +27,13 @@ export interface ChallengeGetRequest { title?: string; description?: string; category?: number; - is_practicable?: boolean; + is_public?: boolean; is_dynamic?: boolean; is_detailed?: boolean; difficulty?: number; page?: number; size?: number; - sort_key?: string; - sort_order?: string; + sorts?: string; } export interface ChallengeUpdateRequest { @@ -42,7 +42,7 @@ export interface ChallengeUpdateRequest { description?: string; category?: number; attachment_url?: string; - is_practicable?: boolean; + is_public?: boolean; is_dynamic?: boolean; duration?: number; image_name?: string; @@ -57,7 +57,7 @@ export interface ChallengeCreateRequest { title?: string; description?: string; category?: number; - is_practicable?: boolean; + is_public?: boolean; is_dynamic?: boolean; duration?: number; image_name?: string; diff --git a/src/pages/challenges/index.tsx b/src/pages/challenges/index.tsx index 69c5737..dd2c5b5 100644 --- a/src/pages/challenges/index.tsx +++ b/src/pages/challenges/index.tsx @@ -59,7 +59,7 @@ export function Index() { setChallenges([]); get({ id: id ? id : undefined, - is_practicable: true, + is_public: true, page: page, size: size, title: search, @@ -98,6 +98,10 @@ export function Index() { setLoading(false); }, [challenges, sharedStore.refresh]); + useEffect(() => { + document.title = `题库 - ${sharedStore?.config?.site?.title}`; + }, [sharedStore?.config?.site?.title]); + return ( <> { + document.title = `比赛 - ${sharedStore?.config?.site?.title}`; + }, [sharedStore?.config?.site?.title]); + return ( diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 803c4d1..04f0362 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -23,8 +23,14 @@ import { TableHead } from "@/components/core/TableHead"; import { TableRow } from "@/components/core/TableRow"; import { TableCell } from "@/components/core/TableCell/TableCell"; import { TableBody } from "@/components/core/TableBody/TableBody"; +import { useSharedStore } from "@/stores/shared"; +import { Box, Flex, Select } from "@/components/core"; +import { useCategoryStore } from "@/stores/category"; export function Index() { + const sharedStore = useSharedStore(); + const categoryStore = useCategoryStore(); + const [color, setColor] = useState("#0d47a1"); const handleChange = (e: any) => { setColor(e.target.value); @@ -44,6 +50,8 @@ export function Index() { const [open, setOpen] = useState(false); const [popoverOpen, setPopoverOpen] = useState(false); + const [category, setCategory] = useState(1); + const [loading, setLoading] = useState(false); const [datetime, setDatetime] = useState(DateTime.now()); @@ -101,6 +109,10 @@ ReactDOM.render( - list `; + useEffect(() => { + document.title = sharedStore?.config?.site?.title || ""; + }, [sharedStore?.config?.site?.title]); + return (
+ + setCategory(Number(value))} + options={categoryStore?.categories.map((category) => ({ + label: ( + + {category?.icon} + {category.name} + + ), + value: String(category.id), + }))} + /> + + + + + + +

+ 或者使用分享链接以导入题目 +

+
+ + + + + + + + + ); +} diff --git a/src/pages/settings/challenges/_blocks/ChallengeCreateModal/index.ts b/src/pages/settings/challenges/_blocks/ChallengeCreateModal/index.ts new file mode 100644 index 0000000..bd1894d --- /dev/null +++ b/src/pages/settings/challenges/_blocks/ChallengeCreateModal/index.ts @@ -0,0 +1 @@ +export { ChallengeCreateModal } from "./ChallengeCreateModal"; diff --git a/src/pages/settings/challenges/index.module.scss b/src/pages/settings/challenges/index.module.scss new file mode 100644 index 0000000..d5bfa54 --- /dev/null +++ b/src/pages/settings/challenges/index.module.scss @@ -0,0 +1,11 @@ +.root { + padding: 20px; + min-height: calc(100vh - 64px); +} + +.table { + flex: 1; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); + border-radius: 12px; + overflow: hidden; +} diff --git a/src/pages/settings/challenges/index.ts b/src/pages/settings/challenges/index.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/pages/settings/challenges/index.tsx b/src/pages/settings/challenges/index.tsx new file mode 100644 index 0000000..e1fdcec --- /dev/null +++ b/src/pages/settings/challenges/index.tsx @@ -0,0 +1,311 @@ +import { get, update } from "@/api/challenge"; +import { + Box, + Button, + Dialog, + Flex, + IconButton, + NumberInput, + Pagination, + Stack, + Switch, + TextInput, +} from "@/components/core"; +import { Table } from "@/components/core/Table"; +import { TableBody } from "@/components/core/TableBody/TableBody"; +import { TableCell } from "@/components/core/TableCell/TableCell"; +import { TableHead } from "@/components/core/TableHead"; +import { TableRow } from "@/components/core/TableRow"; +import { Challenge } from "@/models/challenge"; +import { useSharedStore } from "@/stores/shared"; +import { useToastStore } from "@/stores/toast"; +import { useEffect, useState } from "react"; +import MinimalisticMagniferBoldDuotone from "~icons/solar/minimalistic-magnifer-bold-duotone"; +import PenNewSquareBold from "~icons/solar/pen-new-square-bold"; +import GalleryEditBold from "~icons/solar/gallery-edit-bold"; +import TrashBinTrashBold from "~icons/solar/trash-bin-trash-bold"; +import styles from "./index.module.scss"; +import { useCategoryStore } from "@/stores/category"; +import { ChallengeCreateModal } from "./_blocks/ChallengeCreateModal"; + +export function Index() { + const toastStore = useToastStore(); + const sharedStore = useSharedStore(); + const categoryStore = useCategoryStore(); + + const [challenges, setChallenges] = useState>(); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [size, setSize] = useState(7); + const [sorts, setSorts] = useState("id"); + const [search, setSearch] = useState(""); + + const [challengeCreateModalOpen, setChallengeCreateModalOpen] = + useState(false); + + function fetchChallenges() { + get({ + page: page, + size: size, + sorts: sorts, + title: search, + }).then((res) => { + setChallenges(res.data); + setTotal(res.total || 0); + }); + } + + function handleVisibilityChange(challenge: Challenge) { + update({ + id: challenge.id, + is_public: !challenge.is_public, + }) + .then((res) => { + toastStore?.add({ + type: "success", + title: "成功", + description: `题目 ${challenge?.title} 可见性已设置为 ${res.data?.is_public ? "公开" : "隐藏"}`, + }); + }) + .finally(() => { + sharedStore?.setRefresh(); + }); + } + + useEffect(() => { + fetchChallenges(); + }, [page, size, sorts, search, sharedStore?.refresh]); + + useEffect(() => { + document.title = `题库管理 - ${sharedStore?.config?.site?.title}`; + }, [sharedStore?.config?.site?.title]); + + return ( + <> + + + } + value={search} + onChange={(value) => setSearch(value)} + clearable + style={{ + flex: 1, + }} + /> + + + + + + + 公开 + { + if (sorts === "id") { + setSorts("-id"); + } else { + setSorts("id"); + } + }} + > + # + + { + if (sorts === "title") { + setSorts("-title"); + } else { + setSorts("title"); + } + }} + style={{ + width: "200px", + }} + > + 标题 + + + 分类 + + + 描述 + + { + if (sorts === "updated_at") { + setSorts("-updated_at"); + } else { + setSorts("updated_at"); + } + }} + > + 最后更新于 + + + 操作 + + + + + {challenges?.map((challenge) => ( + + + { + handleVisibilityChange( + challenge + ); + }} + /> + + {challenge.id} + +

+ {challenge.title} +

+
+ + + { + categoryStore?.getCategory( + challenge?.category + )?.icon + } + {categoryStore + ?.getCategory( + challenge?.category + ) + ?.name?.toUpperCase()} + + + +

+ {challenge.description} +

+
+ + {new Date( + Number(challenge.updated_at) * 1000 + ).toLocaleString()} + + + + + + + + + + + +
+ ))} +
+
+
+ + setPage(value)} + /> + + 每页显示 + + + +
+ setChallengeCreateModalOpen(false)} + > + + + + ); +} diff --git a/src/pages/settings/games/Default/Default.module.scss b/src/pages/settings/games/Default/Default.module.scss deleted file mode 100644 index e69de29..0000000 diff --git a/src/pages/settings/games/Default/Default.tsx b/src/pages/settings/games/Default/Default.tsx deleted file mode 100644 index e69de29..0000000 diff --git a/src/pages/settings/games/Default/index.ts b/src/pages/settings/games/Default/index.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/routers.tsx b/src/routers.tsx index 707826b..aff1ac7 100644 --- a/src/routers.tsx +++ b/src/routers.tsx @@ -73,6 +73,15 @@ export const router = createBrowserRouter([ return { Component: Index }; }, }, + { + path: "challenges", + lazy: async () => { + let { Index } = await import( + "@/pages/settings/challenges" + ); + return { Component: Index }; + }, + }, ], }, ], diff --git a/src/stores/shared.ts b/src/stores/shared.ts index 64709ef..b348006 100644 --- a/src/stores/shared.ts +++ b/src/stores/shared.ts @@ -1,3 +1,4 @@ +import { Config } from "@/models/config"; import { create } from "zustand"; interface SharedState { @@ -6,6 +7,9 @@ interface SharedState { portal?: HTMLDivElement; setPortal: (el: HTMLDivElement) => void; + + config?: Config; + setConfig: (config?: Config) => void; } export const useSharedStore = create()((set, get) => ({ @@ -14,4 +18,7 @@ export const useSharedStore = create()((set, get) => ({ portal: undefined, setPortal: (el) => set({ portal: el }), + + config: undefined, + setConfig: (config) => set({ config }), }));