From 0aa2a649f59ba9059e10141056fc8ca2d0a3e2f0 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Mon, 5 Jan 2026 01:40:13 +0800 Subject: [PATCH 1/3] feat: implement cheat records page --- .../[id]/_components/details.tsx | 130 +++++++++ .../cheat-records/[id]/_components/header.tsx | 59 ++++ .../[id]/_components/resolve-button.tsx | 39 +++ .../cheat-records/[id]/page.tsx | 48 ++++ .../cheat-records/_components/create-form.tsx | 135 +++++++++ .../cheat-records/_components/create.tsx | 145 ++++++++++ .../_components/data-table-columns.tsx | 142 ++++++++++ .../cheat-records/_components/data-table.tsx | 83 ++++++ .../_components/filterable-data-table.tsx | 29 ++ .../cheat-records/_components/mutation.ts | 22 ++ .../cheat-records/_components/query.ts | 60 ++++ .../_components/resolve-form.tsx | 76 ++++++ .../cheat-records/_components/resolve.tsx | 216 +++++++++++++++ .../cheat-records/page.tsx | 33 +++ .../users/[id]/_components/cheat-records.tsx | 119 ++++++++ .../users/[id]/_components/user-cards.tsx | 3 + components/app-sidebar.tsx | 8 +- gql/gql.ts | 78 +++++- gql/graphql.ts | 256 +++++++++++++++++- schema.graphql | 149 ++++++++++ 20 files changed, 1824 insertions(+), 6 deletions(-) create mode 100644 app/(admin)/(activity-management)/cheat-records/[id]/_components/details.tsx create mode 100644 app/(admin)/(activity-management)/cheat-records/[id]/_components/header.tsx create mode 100644 app/(admin)/(activity-management)/cheat-records/[id]/_components/resolve-button.tsx create mode 100644 app/(admin)/(activity-management)/cheat-records/[id]/page.tsx create mode 100644 app/(admin)/(activity-management)/cheat-records/_components/create-form.tsx create mode 100644 app/(admin)/(activity-management)/cheat-records/_components/create.tsx create mode 100644 app/(admin)/(activity-management)/cheat-records/_components/data-table-columns.tsx create mode 100644 app/(admin)/(activity-management)/cheat-records/_components/data-table.tsx create mode 100644 app/(admin)/(activity-management)/cheat-records/_components/filterable-data-table.tsx create mode 100644 app/(admin)/(activity-management)/cheat-records/_components/mutation.ts create mode 100644 app/(admin)/(activity-management)/cheat-records/_components/query.ts create mode 100644 app/(admin)/(activity-management)/cheat-records/_components/resolve-form.tsx create mode 100644 app/(admin)/(activity-management)/cheat-records/_components/resolve.tsx create mode 100644 app/(admin)/(activity-management)/cheat-records/page.tsx create mode 100644 app/(admin)/(user-management)/users/[id]/_components/cheat-records.tsx diff --git a/app/(admin)/(activity-management)/cheat-records/[id]/_components/details.tsx b/app/(admin)/(activity-management)/cheat-records/[id]/_components/details.tsx new file mode 100644 index 0000000..48db7c8 --- /dev/null +++ b/app/(admin)/(activity-management)/cheat-records/[id]/_components/details.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { CardLayout } from "@/components/card-layout"; +import { Badge } from "@/components/ui/badge"; +import { StyledLink } from "@/components/ui/link"; +import { graphql, useFragment, type FragmentType } from "@/gql"; +import { useSuspenseQuery } from "@apollo/client/react"; +import AppAvatar from "@/components/avatar"; + +const CHEAT_RECORD_DETAILS_QUERY = graphql(` + query CheatRecordDetails($id: ID!) { + cheatRecord(id: $id) { + ...CheatRecordDetailsCard + id + } + } +`); + +const CHEAT_RECORD_DETAILS_FRAGMENT = graphql(` + fragment CheatRecordDetailsCard on CheatRecord { + id + reason + cheatedAt + resolvedAt + resolvedReason + user { + id + name + email + avatar + } + } +`); + +export function CheatRecordDetails({ id }: { id: string }) { + const { data } = useSuspenseQuery(CHEAT_RECORD_DETAILS_QUERY, { + variables: { id }, + }); + + const fragment = data.cheatRecord; + + return ( +
+ +
+ ); +} + +function DetailsCard({ + fragment, +}: { + fragment: FragmentType; +}) { + const { + reason, + cheatedAt, + resolvedAt, + resolvedReason, + user, + } = useFragment(CHEAT_RECORD_DETAILS_FRAGMENT, fragment); + + const isResolved = !!resolvedAt; + + return ( + +
+
+

狀態

+ {isResolved ? ( + + 已解決 + + ) : ( + 未解決 + )} +
+ +
+

使用者

+
+ +
+ + {user.name} + +

{user.email}

+
+
+
+ +
+

原因

+

{reason}

+
+ +
+

作弊時間

+

+ {new Date(cheatedAt).toLocaleString("zh-TW", { + timeZone: "Asia/Taipei", + })} +

+
+ + {isResolved && ( + <> +
+

解決時間

+

+ {new Date(resolvedAt).toLocaleString("zh-TW", { + timeZone: "Asia/Taipei", + })} +

+
+ + {resolvedReason && ( +
+

解決原因

+

{resolvedReason}

+
+ )} + + )} +
+
+ ); +} diff --git a/app/(admin)/(activity-management)/cheat-records/[id]/_components/header.tsx b/app/(admin)/(activity-management)/cheat-records/[id]/_components/header.tsx new file mode 100644 index 0000000..34c949e --- /dev/null +++ b/app/(admin)/(activity-management)/cheat-records/[id]/_components/header.tsx @@ -0,0 +1,59 @@ +"use client"; + +import AppAvatar from "@/components/avatar"; +import PageHeader, { PageHeaderSkeleton } from "@/components/page-header"; +import { graphql } from "@/gql"; +import { useSuspenseQuery } from "@apollo/client/react"; +import { Suspense } from "react"; + +const CHEAT_RECORD_HEADER_QUERY = graphql(` + query CheatRecordHeader($id: ID!) { + cheatRecord(id: $id) { + id + reason + user { + id + name + email + avatar + } + } + } +`); + +export function Header({ id }: { id: string }) { + return ( + }> + + + ); +} + +function HeaderMain({ id }: { id: string }) { + const { data } = useSuspenseQuery(CHEAT_RECORD_HEADER_QUERY, { + variables: { id }, + }); + + return ( +
+ + +
+ ); +} + +function HeaderSkeleton() { + return ( +
+ + +
+ ); +} diff --git a/app/(admin)/(activity-management)/cheat-records/[id]/_components/resolve-button.tsx b/app/(admin)/(activity-management)/cheat-records/[id]/_components/resolve-button.tsx new file mode 100644 index 0000000..b144cf1 --- /dev/null +++ b/app/(admin)/(activity-management)/cheat-records/[id]/_components/resolve-button.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { graphql, useFragment, type FragmentType } from "@/gql"; +import { useSuspenseQuery } from "@apollo/client/react"; +import { ResolveCheatRecordButtonTrigger } from "../../_components/resolve"; + +const CHEAT_RECORD_RESOLVE_BUTTON_QUERY = graphql(` + query CheatRecordResolveButton($id: ID!) { + cheatRecord(id: $id) { + ...CheatRecordResolveButtonFragment + id + } + } +`); + +const CHEAT_RECORD_RESOLVE_BUTTON_FRAGMENT = graphql(` + fragment CheatRecordResolveButtonFragment on CheatRecord { + id + resolvedAt + } +`); + +export function ResolveButton({ id }: { id: string }) { + const { data } = useSuspenseQuery(CHEAT_RECORD_RESOLVE_BUTTON_QUERY, { + variables: { id }, + }); + + const fragment = data.cheatRecord; + const { resolvedAt } = useFragment( + CHEAT_RECORD_RESOLVE_BUTTON_FRAGMENT, + fragment, + ); + + if (resolvedAt) { + return null; + } + + return ; +} diff --git a/app/(admin)/(activity-management)/cheat-records/[id]/page.tsx b/app/(admin)/(activity-management)/cheat-records/[id]/page.tsx new file mode 100644 index 0000000..4ec531e --- /dev/null +++ b/app/(admin)/(activity-management)/cheat-records/[id]/page.tsx @@ -0,0 +1,48 @@ +import { SiteHeader } from "@/components/site-header"; +import type { Metadata } from "next"; +import { Suspense } from "react"; +import { Header } from "./_components/header"; +import { CheatRecordDetails } from "./_components/details"; +import { ResolveButton } from "./_components/resolve-button"; + +export const metadata: Metadata = { + title: "作弊記錄詳情", +}; + +export default async function CheatRecordPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + + return ( + <> + +
+
+
+ +
+ + + +
+
+ + + +
+ + ); +} diff --git a/app/(admin)/(activity-management)/cheat-records/_components/create-form.tsx b/app/(admin)/(activity-management)/cheat-records/_components/create-form.tsx new file mode 100644 index 0000000..0d8a721 --- /dev/null +++ b/app/(admin)/(activity-management)/cheat-records/_components/create-form.tsx @@ -0,0 +1,135 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/spinner"; +import { Textarea } from "@/components/ui/textarea"; +import { graphql } from "@/gql"; +import { skipToken, useQuery } from "@apollo/client/react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useDebouncedValue } from "foxact/use-debounced-value"; +import { useEffect } from "react"; +import { useForm, useWatch } from "react-hook-form"; +import { z } from "zod"; + +const createCheatRecordFormSchema = z.object({ + reason: z.string().min(1, "請輸入原因"), + userID: z.string().min(1, "請輸入使用者 ID"), +}); + +const CREATE_CHEAT_RECORD_FORM_USER_INFO_QUERY = graphql(` + query CreateCheatRecordFormUserInfo($id: ID!) { + user(id: $id) { + id + email + name + } + } +`); + +export type CreateCheatRecordFormData = z.infer< + typeof createCheatRecordFormSchema +>; + +export function CreateCheatRecordForm({ + defaultValues, + onSubmit, + onFormStateChange, +}: { + defaultValues: CreateCheatRecordFormData; + onSubmit: (data: CreateCheatRecordFormData) => void; + onFormStateChange?: (isDirty: boolean) => void; +}) { + const form = useForm({ + resolver: zodResolver(createCheatRecordFormSchema), + defaultValues, + }); + + const isDirty = form.formState.isDirty; + + useEffect(() => { + onFormStateChange?.(isDirty); + }, [isDirty, onFormStateChange]); + + const userID = useWatch({ control: form.control, name: "userID" }); + const userIDDebounced = useDebouncedValue(userID, 200); + + const { data: userInfoData, loading } = useQuery( + CREATE_CHEAT_RECORD_FORM_USER_INFO_QUERY, + userIDDebounced + ? { + variables: { + id: userIDDebounced, + }, + errorPolicy: "ignore", + } + : skipToken, + ); + + return ( +
+ + ( + + 使用者 ID + + + + +
+ 選擇要新增作弊記錄的使用者。可以到使用者管理頁面確認對應代號。 +
+ +
+ {loading ? : null} + {userInfoData?.user + ? `您正要為:${userInfoData.user.name} (${userInfoData.user.email}) 新增作弊記錄` + : userIDDebounced + ? "您輸入的使用者 ID 不存在。" + : ""} +
+
+ +
+ )} + /> + + ( + + 原因 + +