From bcc72dafe1ce2412e8a2bc8ea37f3f4d2105f223 Mon Sep 17 00:00:00 2001 From: JiPai Date: Wed, 11 Mar 2026 04:11:49 +0800 Subject: [PATCH 1/4] feat(auth): integrate captcha verification --- package.json | 1 + src/api/auth/index.ts | 12 ++- src/components/CapWidget/index.tsx | 68 ++++++++++++++++ src/pages/auth/index.tsx | 124 ++++++++++++++++++++++++++--- yarn.lock | 8 ++ 5 files changed, 199 insertions(+), 14 deletions(-) create mode 100644 src/components/CapWidget/index.tsx diff --git a/package.json b/package.json index 36919ea..4ddd682 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "preview": "rsbuild preview" }, "dependencies": { + "@cap.js/widget": "^0.1.41", "@hookform/resolvers": "^5.2.2", "@tabler/icons-react": "^3.36.1", "@tanstack/react-query": "^5.90.17", diff --git a/src/api/auth/index.ts b/src/api/auth/index.ts index 425cd7d..eaecc5d 100644 --- a/src/api/auth/index.ts +++ b/src/api/auth/index.ts @@ -8,11 +8,21 @@ export const UpdatePasswordApiBody = z.object({ newPassword: z.string().min(8, "密码长度至少为8位"), }); export class AuthAPI { - static async login(data: { email: string; password: string }) { + static async login(data: { email: string; password: string; captchaToken: string }) { const res = await Axios.post<{ token: string; user: User }>("/auth/login", data); return res.data; } + static async register(data: { + name: string; + password: string; + email: string; + captchaToken: string; + }) { + const res = await Axios.post<{ token: string; user: User }>("/auth/register", data); + return res.data; + } + static async getCurrentUser() { const res = await Axios.get("/auth/me"); return res.data; diff --git a/src/components/CapWidget/index.tsx b/src/components/CapWidget/index.tsx new file mode 100644 index 0000000..8b780fd --- /dev/null +++ b/src/components/CapWidget/index.tsx @@ -0,0 +1,68 @@ +import "@cap.js/widget"; +import type { CapWidget as CapWidgetType } from "@cap.js/widget"; +import { forwardRef, useImperativeHandle, useRef } from "react"; + +export interface CapWidgetProps { + apiEndpoint: string; + onSolve?: (token: string) => void; + onProgress?: (progress: number) => void; + onCapError?: (message: string) => void; + className?: string; + style?: React.CSSProperties; +} + +export interface CapWidgetRef { + reset: () => void; +} + +declare module "react" { + namespace JSX { + interface IntrinsicElements { + "cap-widget": React.DetailedHTMLProps< + React.HTMLAttributes & { + "data-cap-api-endpoint"?: string; + onsolve?: (e: CustomEvent<{ token: string }>) => void; + onprogress?: (e: CustomEvent<{ progress: number }>) => void; + onerror?: (e: CustomEvent<{ message: string }>) => void; + }, + CapWidgetType + >; + } + } +} + +export const CapWidget = forwardRef( + ({ apiEndpoint, onSolve, onProgress, onCapError, className, style }, ref) => { + const widgetRef = useRef(null); + + useImperativeHandle(ref, () => ({ + reset: () => { + widgetRef.current?.reset(); + }, + })); + + return ( + onSolve?.(e.detail.token)} + onprogress={(e) => onProgress?.(e.detail.progress)} + onerror={(e) => onCapError?.(e.detail.message)} + data-cap-i18n-initial-state="验证您是人类" + data-cap-i18n-verifying-label="验证中..." + data-cap-i18n-solved-label="验证成功" + data-cap-i18n-error-label="验证失败" + data-cap-i18n-troubleshooting-label="故障排除" + data-cap-i18n-wasm-disabled="启用 WASM 以显著加快验证速度" + data-cap-i18n-verify-aria-label="点击验证您是人类" + data-cap-i18n-verifying-aria-label="验证中,请稍候" + data-cap-i18n-verified-aria-label="验证成功" + data-cap-i18n-error-aria-label="验证失败,请重试" + /> + ); + } +); + +CapWidget.displayName = "CapWidget"; diff --git a/src/pages/auth/index.tsx b/src/pages/auth/index.tsx index ab20058..81da81d 100644 --- a/src/pages/auth/index.tsx +++ b/src/pages/auth/index.tsx @@ -2,9 +2,11 @@ import useAuthStore from "@/stores/auth"; import { useMutation } from "@tanstack/react-query"; import { AuthAPI } from "@/api/auth"; import { Button, Card, Flex, App, Typography, Form, Input, Segmented } from "antd"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { IconFileDescription } from "@tabler/icons-react"; +import { CapWidget, CapWidgetRef } from "@/components/CapWidget"; +import { AxiosError } from "axios"; const { Link } = Typography; @@ -13,10 +15,11 @@ export default function Auth() { const navigate = useNavigate(); const { message } = App.useApp(); const [form] = Form.useForm(); + const capWidgetRef = useRef(null); const [currentSegment, setCurrentSegment] = useState<"login" | "register" | "reset">("login"); - const { mutate, isPending } = useMutation({ + const loginMutation = useMutation({ mutationFn: AuthAPI.login, onSuccess: (data) => { login(data.user); @@ -25,12 +28,54 @@ export default function Auth() { message.success("登录成功"); }, onError: (error) => { - message.error(`登录失败: ${error instanceof Error ? error.message : "未知错误"}`); + capWidgetRef.current?.reset(); + form.setFieldValue("captchaToken", undefined); + if (error instanceof AxiosError) { + if (error.response?.status === 404) { + message.error("邮箱或密码错误"); + return; + } + message.error(error.response?.data?.message || "登录失败"); + } else { + message.error("登录失败: 未知错误"); + } }, }); - const handleFinish = (values: { email: string; password: string }) => { - mutate(values); + const registerMutation = useMutation({ + mutationFn: AuthAPI.register, + onSuccess: (data) => { + login(data.user); + refreshToken(data.token); + navigate("/dashboard"); + message.success("注册成功"); + }, + onError: (error) => { + capWidgetRef.current?.reset(); + form.setFieldValue("captchaToken", undefined); + if (error instanceof AxiosError) { + if (error.response?.status === 409) { + message.error("该邮箱已被注册"); + return; + } + message.error(error.response?.data?.message || "注册失败"); + } else { + message.error("注册失败: 未知错误"); + } + }, + }); + + const handleFinish = (values: { name: string; email: string; password: string; captchaToken: string }) => { + if (currentSegment === "login") { + loginMutation.mutate(values); + } else if (currentSegment === "register") { + registerMutation.mutate({ + name: values.name, + email: values.email, + password: values.password, + captchaToken: values.captchaToken, + }); + } }; return ( @@ -54,7 +99,10 @@ export default function Auth() { block size="middle" style={{ marginBottom: 8 }} - onChange={setCurrentSegment} + onChange={(value) => { + setCurrentSegment(value as "login" | "register" | "reset"); + form.resetFields(); + }} options={[ { label: "登录", value: "login" }, { label: "注册", value: "register" }, @@ -62,6 +110,20 @@ export default function Auth() { />
+ {currentSegment === "register" && ( + + + + )} - + - + - - 忘记密码了吗? - -
diff --git a/yarn.lock b/yarn.lock index 1fad872..f8707eb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -304,6 +304,13 @@ __metadata: languageName: node linkType: hard +"@cap.js/widget@npm:^0.1.41": + version: 0.1.41 + resolution: "@cap.js/widget@npm:0.1.41" + checksum: 10c0/bfaa90339c31199b3aafc46280b7879996d7c649f210ae30aa36b04d5330cd071e20e7cc7220278431fd5d839d01a7aae43ddec121d67f8c8106a532e95885a2 + languageName: node + linkType: hard + "@emnapi/core@npm:^1.5.0, @emnapi/core@npm:^1.7.1": version: 1.8.1 resolution: "@emnapi/core@npm:1.8.1" @@ -2261,6 +2268,7 @@ __metadata: version: 0.0.0-use.local resolution: "fcc-dashboard@workspace:." dependencies: + "@cap.js/widget": "npm:^0.1.41" "@hookform/resolvers": "npm:^5.2.2" "@rsbuild/core": "npm:1.7.2" "@rsbuild/plugin-react": "npm:1.4.3" From af9f2f9fa8330ab32cd82d2787a9ee79b8ccd5f7 Mon Sep 17 00:00:00 2001 From: JiPai Date: Wed, 11 Mar 2026 04:12:01 +0800 Subject: [PATCH 2/4] feat(dashboard): refactor menu items based on user role --- src/pages/dashboard/layout.tsx | 101 +++++++++++++++++++-------------- 1 file changed, 57 insertions(+), 44 deletions(-) diff --git a/src/pages/dashboard/layout.tsx b/src/pages/dashboard/layout.tsx index 74f8675..c17a333 100644 --- a/src/pages/dashboard/layout.tsx +++ b/src/pages/dashboard/layout.tsx @@ -1,4 +1,5 @@ import useAuthStore from "@/stores/auth"; +import { UserRole } from "@/types/User"; import { Layout, Menu, Button, Flex } from "antd"; import type { MenuProps } from "antd"; import { @@ -14,55 +15,65 @@ import { IconUserCode, } from "@tabler/icons-react"; import { Outlet, useLocation, useNavigate } from "react-router-dom"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import clsx from "clsx"; const { Sider, Content, Header } = Layout; type MenuItem = Required["items"][number]; -const menuItems: MenuItem[] = [ - { - key: "/dashboard", - icon: , - label: "首页", - }, - { - key: "/dashboard/event", - icon: , - label: "展会", - }, - { - key: "/dashboard/organization", - icon: , - label: "展商", - }, - { - key: "/dashboard/feature", - icon: , - label: "展会标签", - }, - { - key: "/dashboard/region", - icon: , - label: "地区管理", - }, - { - type: "divider", - }, - { - key: "/developer", - icon: , - label: "开发者", - children: [ - { - key: "/developer/application", - icon: , - label: "应用管理", - }, - ], - }, -]; +const getMenuItems = (role?: string): MenuItem[] => { + const developerMenu: MenuItem[] = [ + { + key: "/developer", + icon: , + label: "开发者", + children: [ + { + key: "/developer/application", + icon: , + label: "应用管理", + }, + ], + }, + ]; + + if (role === UserRole.Developer) { + return developerMenu; + } + + return [ + { + key: "/dashboard", + icon: , + label: "首页", + }, + { + key: "/dashboard/event", + icon: , + label: "展会", + }, + { + key: "/dashboard/organization", + icon: , + label: "展商", + }, + { + key: "/dashboard/feature", + icon: , + label: "展会标签", + }, + { + key: "/dashboard/region", + icon: , + label: "地区管理", + }, + { + type: "divider", + }, + ...developerMenu, + ]; +}; const bottomMenuItems: MenuItem[] = [ { @@ -93,7 +104,9 @@ export default function DashboardLayout() { const [collapsed, setCollapsed] = useState(false); - const { logout } = useAuthStore(); + const { logout, user } = useAuthStore(); + + const menuItems = useMemo(() => getMenuItems(user?.role), [user?.role]); const getSelectedKeys = () => { const currentPath = location.pathname; From 12a6e2521e52781adf24209ddc923a651793e7d8 Mon Sep 17 00:00:00 2001 From: JiPai Date: Wed, 11 Mar 2026 17:33:24 +0800 Subject: [PATCH 3/4] feat(application): implement CRUD operations for applications --- src/api/developer/application.ts | 39 +++++++- .../Application/ApplicationEditor.tsx | 95 +++++++++++++++++++ .../dashboard/developer/application/list.tsx | 64 ++++++++++--- src/pages/dashboard/index.tsx | 4 +- src/types/application.ts | 1 + 5 files changed, 186 insertions(+), 17 deletions(-) create mode 100644 src/components/Application/ApplicationEditor.tsx diff --git a/src/api/developer/application.ts b/src/api/developer/application.ts index 26acb1d..d92f5dc 100644 --- a/src/api/developer/application.ts +++ b/src/api/developer/application.ts @@ -1,8 +1,39 @@ import Axios from "@/api"; -import { Application } from "@/types/application"; +import { InferZodType } from "@/types/common"; import { List } from "@/types/Request"; +import { Application } from "@/types/application"; +import { z } from "zod"; + +export const EditApplicationApiBody = z.object({ + name: z.string().min(1), + description: z.string().nullish(), +}); + +export class ApplicationApi { + static async getApplicationList(params?: { pageSize?: number; current?: number; search?: string; orgSearch?: string }) { + const res = await Axios.get>("/developer/application", { + params, + }); + return res.data; + } + + static async getApplicationDetail(id: string) { + const res = await Axios.get(`/developer/application/${id}`); + return res.data; + } + + static async createApplication(application: InferZodType) { + const res = await Axios.post("/developer/application", application); + return res.data; + } + + static async updateApplication(id: string, application: InferZodType) { + const res = await Axios.post(`/developer/application/${id}`, application); + return res.data; + } -export async function getApplicationList() { - const res = await Axios.get>("/developer/application"); - return res.data; + static async deleteApplication(id: string) { + const res = await Axios.delete<{ success: boolean }>(`/developer/application/${id}`); + return res.data; + } } diff --git a/src/components/Application/ApplicationEditor.tsx b/src/components/Application/ApplicationEditor.tsx new file mode 100644 index 0000000..56f2cee --- /dev/null +++ b/src/components/Application/ApplicationEditor.tsx @@ -0,0 +1,95 @@ +import { ApplicationApi, EditApplicationApiBody } from "@/api/developer/application"; +import { InferZodType } from "@/types/common"; +import { Application } from "@/types/application"; +import { useZodValidateData } from "@/utils/form"; +import { pickBy } from "es-toolkit"; +import { App, Button, Flex, Form, Input, Modal } from "antd"; + +const { TextArea } = Input; + +export default function ApplicationEditor({ + opened, + onClose, + editingApplication, +}: { + opened: boolean; + onClose: () => void; + editingApplication: Application | null; +}) { + return ( + + + + ); +} + +function EditorContent({ + editingApplication, + onClose, +}: { + editingApplication: Application | null; + onClose: () => void; +}) { + const { message, modal } = App.useApp(); + const cleanedApplication = editingApplication ? pickBy(editingApplication, (v) => v !== "" && v != null) : {}; + const [form] = Form.useForm(); + + const initialValues = { + name: cleanedApplication.name, + description: cleanedApplication.description, + }; + + const onSubmit = async (value: InferZodType) => { + try { + if (editingApplication?.id) { + await ApplicationApi.updateApplication(editingApplication.id, value); + message.success("更新应用成功"); + return onClose(); + } + await ApplicationApi.createApplication(value); + message.success("创建应用成功"); + return onClose(); + } catch (error) { + message.error(`有错误发生: ${JSON.stringify(error)}`); + } + }; + + const handleFinish = (value: typeof initialValues) => { + const processedValues = useZodValidateData(value, EditApplicationApiBody); + if (processedValues.errors.length > 0) { + return modal.warning({ + title: "接口数据校验失败☹️", + content: processedValues.prettyErrors, + }); + } + if (processedValues.values) { + return onSubmit(processedValues.values); + } + return; + }; + + return ( +
+ + + + + +