Compare commits

...

3 Commits

Author SHA1 Message Date
shanshanzhong147 d1697bed75 新功能(#43): 提现前端优化 — 收款方式选择 + 收款码上传 + 取消按钮
PR Check / Lint, Check, Test and Build (push) Has been cancelled
Build and Release / Build (push) Has been cancelled
Issue Close Require / issue-close-require (push) Has been cancelled
2026-05-26 08:37:03 -07:00
shanshanzhong147 14d4c5f36f 配置(#54): 前端 PR 质量门禁与 auto-merge 安全加固
PR Check / Lint, Check, Test and Build (push) Has been cancelled
Build and Release / Build (push) Has been cancelled
2026-05-25 22:08:48 -07:00
shanshanzhong147 37ceb5adfc 修复(#55): 修复前端 lint/build 失败阻塞质量门禁
Build and Release / Build (push) Has been cancelled
2026-05-25 22:03:36 -07:00
19 changed files with 619 additions and 174 deletions
+20 -21
View File
@@ -1,30 +1,29 @@
name: Dependabot Auto Merge name: Dependabot Auto Merge
on: on:
pull_request_target: pull_request_target:
types: [labeled, edited] types: [opened, reopened, synchronize, ready_for_review, labeled]
permissions:
contents: write
pull-requests: write
concurrency:
group: dependabot-auto-merge-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs: jobs:
merge: merge:
if: contains(github.event.pull_request.labels.*.name, 'dependencies') if: >
github.actor == 'dependabot[bot]' &&
github.event.pull_request.user.login == 'dependabot[bot]' &&
contains(github.event.pull_request.labels.*.name, 'dependencies')
name: Dependabot Auto Merge name: Dependabot Auto Merge
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 5
steps: steps:
- uses: actions/checkout@v4 - name: Enable auto-merge
env:
- name: Install pnpm GH_TOKEN: ${{ github.token }}
uses: pnpm/action-setup@v4 PR_URL: ${{ github.event.pull_request.html_url }}
run: gh pr merge "$PR_URL" --auto --merge
- name: Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install deps
run: pnpm install
- name: Merge
uses: ahmadnassri/action-dependabot-auto-merge@v2
with:
command: merge
target: minor
github-token: ${{ secrets.GH_TOKEN }}
+48
View File
@@ -0,0 +1,48 @@
name: PR Check
on:
push:
branches:
- main
- internal
pull_request:
branches:
- main
- internal
permissions:
contents: read
concurrency:
group: pr-check-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
validate:
name: Lint, Check, Test and Build
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: '1.3.1'
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Lint
run: bun run lint
- name: Check
run: bun run check
- name: Test
run: bun run test
- name: Build
run: bun run build
+9 -4
View File
@@ -9,20 +9,25 @@ on:
permissions: permissions:
contents: write contents: write
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs: jobs:
release: release:
name: Build name: Build
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Setup Bun - name: Setup Bun
uses: oven-sh/setup-bun@v1 uses: oven-sh/setup-bun@v2
with: with:
bun-version: 'latest' bun-version: '1.3.1'
- name: Cache Bun dependencies - name: Cache Bun dependencies
uses: actions/cache@v3 uses: actions/cache@v4
with: with:
path: | path: |
~/.bun ~/.bun
@@ -32,7 +37,7 @@ jobs:
${{ runner.os }}-bun-cache- ${{ runner.os }}-bun-cache-
- name: Install deps - name: Install deps
run: bun install run: bun install --frozen-lockfile
- name: Build - name: Build
run: bun run build run: bun run build
+26 -16
View File
@@ -138,9 +138,9 @@ const DashboardNodesLazyRoute = DashboardNodesLazyRouteImport.update({
) )
const DashboardWithdrawalIndexLazyRoute = const DashboardWithdrawalIndexLazyRoute =
DashboardWithdrawalIndexLazyRouteImport.update({ DashboardWithdrawalIndexLazyRouteImport.update({
id: '/withdrawal/', id: '/',
path: '/withdrawal/', path: '/',
getParentRoute: () => DashboardRouteLazyRoute, getParentRoute: () => DashboardWithdrawalLazyRoute,
} as any).lazy(() => } as any).lazy(() =>
import('./routes/dashboard/withdrawal/index.lazy').then((d) => d.Route), import('./routes/dashboard/withdrawal/index.lazy').then((d) => d.Route),
) )
@@ -367,7 +367,7 @@ export interface FileRoutesByFullPath {
'/dashboard': typeof DashboardRouteLazyRouteWithChildren '/dashboard': typeof DashboardRouteLazyRouteWithChildren
'/dashboard/nodes': typeof DashboardNodesLazyRoute '/dashboard/nodes': typeof DashboardNodesLazyRoute
'/dashboard/servers': typeof DashboardServersLazyRoute '/dashboard/servers': typeof DashboardServersLazyRoute
'/dashboard/withdrawal': typeof DashboardWithdrawalLazyRoute '/dashboard/withdrawal': typeof DashboardWithdrawalLazyRouteWithChildren
'/dashboard/': typeof DashboardIndexLazyRoute '/dashboard/': typeof DashboardIndexLazyRoute
'/dashboard/log/balance': typeof DashboardLogBalanceLazyRoute '/dashboard/log/balance': typeof DashboardLogBalanceLazyRoute
'/dashboard/log/commission': typeof DashboardLogCommissionLazyRoute '/dashboard/log/commission': typeof DashboardLogCommissionLazyRoute
@@ -397,13 +397,12 @@ export interface FileRoutesByFullPath {
'/dashboard/system': typeof DashboardSystemIndexLazyRoute '/dashboard/system': typeof DashboardSystemIndexLazyRoute
'/dashboard/ticket': typeof DashboardTicketIndexLazyRoute '/dashboard/ticket': typeof DashboardTicketIndexLazyRoute
'/dashboard/user': typeof DashboardUserIndexLazyRoute '/dashboard/user': typeof DashboardUserIndexLazyRoute
'/dashboard/withdrawal': typeof DashboardWithdrawalIndexLazyRoute '/dashboard/withdrawal/': typeof DashboardWithdrawalIndexLazyRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexLazyRoute '/': typeof IndexLazyRoute
'/dashboard/nodes': typeof DashboardNodesLazyRoute '/dashboard/nodes': typeof DashboardNodesLazyRoute
'/dashboard/servers': typeof DashboardServersLazyRoute '/dashboard/servers': typeof DashboardServersLazyRoute
'/dashboard/withdrawal': typeof DashboardWithdrawalLazyRoute
'/dashboard': typeof DashboardIndexLazyRoute '/dashboard': typeof DashboardIndexLazyRoute
'/dashboard/log/balance': typeof DashboardLogBalanceLazyRoute '/dashboard/log/balance': typeof DashboardLogBalanceLazyRoute
'/dashboard/log/commission': typeof DashboardLogCommissionLazyRoute '/dashboard/log/commission': typeof DashboardLogCommissionLazyRoute
@@ -441,7 +440,7 @@ export interface FileRoutesById {
'/dashboard': typeof DashboardRouteLazyRouteWithChildren '/dashboard': typeof DashboardRouteLazyRouteWithChildren
'/dashboard/nodes': typeof DashboardNodesLazyRoute '/dashboard/nodes': typeof DashboardNodesLazyRoute
'/dashboard/servers': typeof DashboardServersLazyRoute '/dashboard/servers': typeof DashboardServersLazyRoute
'/dashboard/withdrawal': typeof DashboardWithdrawalLazyRoute '/dashboard/withdrawal': typeof DashboardWithdrawalLazyRouteWithChildren
'/dashboard/': typeof DashboardIndexLazyRoute '/dashboard/': typeof DashboardIndexLazyRoute
'/dashboard/log/balance': typeof DashboardLogBalanceLazyRoute '/dashboard/log/balance': typeof DashboardLogBalanceLazyRoute
'/dashboard/log/commission': typeof DashboardLogCommissionLazyRoute '/dashboard/log/commission': typeof DashboardLogCommissionLazyRoute
@@ -510,13 +509,12 @@ export interface FileRouteTypes {
| '/dashboard/system' | '/dashboard/system'
| '/dashboard/ticket' | '/dashboard/ticket'
| '/dashboard/user' | '/dashboard/user'
| '/dashboard/withdrawal' | '/dashboard/withdrawal/'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
| '/' | '/'
| '/dashboard/nodes' | '/dashboard/nodes'
| '/dashboard/servers' | '/dashboard/servers'
| '/dashboard/withdrawal'
| '/dashboard' | '/dashboard'
| '/dashboard/log/balance' | '/dashboard/log/balance'
| '/dashboard/log/commission' | '/dashboard/log/commission'
@@ -637,10 +635,10 @@ declare module '@tanstack/react-router' {
} }
'/dashboard/withdrawal/': { '/dashboard/withdrawal/': {
id: '/dashboard/withdrawal/' id: '/dashboard/withdrawal/'
path: '/withdrawal' path: '/'
fullPath: '/dashboard/withdrawal' fullPath: '/dashboard/withdrawal/'
preLoaderRoute: typeof DashboardWithdrawalIndexLazyRouteImport preLoaderRoute: typeof DashboardWithdrawalIndexLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute parentRoute: typeof DashboardWithdrawalLazyRoute
} }
'/dashboard/user/': { '/dashboard/user/': {
id: '/dashboard/user/' id: '/dashboard/user/'
@@ -841,10 +839,24 @@ declare module '@tanstack/react-router' {
} }
} }
interface DashboardWithdrawalLazyRouteChildren {
DashboardWithdrawalIndexLazyRoute: typeof DashboardWithdrawalIndexLazyRoute
}
const DashboardWithdrawalLazyRouteChildren: DashboardWithdrawalLazyRouteChildren =
{
DashboardWithdrawalIndexLazyRoute: DashboardWithdrawalIndexLazyRoute,
}
const DashboardWithdrawalLazyRouteWithChildren =
DashboardWithdrawalLazyRoute._addFileChildren(
DashboardWithdrawalLazyRouteChildren,
)
interface DashboardRouteLazyRouteChildren { interface DashboardRouteLazyRouteChildren {
DashboardNodesLazyRoute: typeof DashboardNodesLazyRoute DashboardNodesLazyRoute: typeof DashboardNodesLazyRoute
DashboardServersLazyRoute: typeof DashboardServersLazyRoute DashboardServersLazyRoute: typeof DashboardServersLazyRoute
DashboardWithdrawalLazyRoute: typeof DashboardWithdrawalLazyRoute DashboardWithdrawalLazyRoute: typeof DashboardWithdrawalLazyRouteWithChildren
DashboardIndexLazyRoute: typeof DashboardIndexLazyRoute DashboardIndexLazyRoute: typeof DashboardIndexLazyRoute
DashboardLogBalanceLazyRoute: typeof DashboardLogBalanceLazyRoute DashboardLogBalanceLazyRoute: typeof DashboardLogBalanceLazyRoute
DashboardLogCommissionLazyRoute: typeof DashboardLogCommissionLazyRoute DashboardLogCommissionLazyRoute: typeof DashboardLogCommissionLazyRoute
@@ -874,13 +886,12 @@ interface DashboardRouteLazyRouteChildren {
DashboardSystemIndexLazyRoute: typeof DashboardSystemIndexLazyRoute DashboardSystemIndexLazyRoute: typeof DashboardSystemIndexLazyRoute
DashboardTicketIndexLazyRoute: typeof DashboardTicketIndexLazyRoute DashboardTicketIndexLazyRoute: typeof DashboardTicketIndexLazyRoute
DashboardUserIndexLazyRoute: typeof DashboardUserIndexLazyRoute DashboardUserIndexLazyRoute: typeof DashboardUserIndexLazyRoute
DashboardWithdrawalIndexLazyRoute: typeof DashboardWithdrawalIndexLazyRoute
} }
const DashboardRouteLazyRouteChildren: DashboardRouteLazyRouteChildren = { const DashboardRouteLazyRouteChildren: DashboardRouteLazyRouteChildren = {
DashboardNodesLazyRoute: DashboardNodesLazyRoute, DashboardNodesLazyRoute: DashboardNodesLazyRoute,
DashboardServersLazyRoute: DashboardServersLazyRoute, DashboardServersLazyRoute: DashboardServersLazyRoute,
DashboardWithdrawalLazyRoute: DashboardWithdrawalLazyRoute, DashboardWithdrawalLazyRoute: DashboardWithdrawalLazyRouteWithChildren,
DashboardIndexLazyRoute: DashboardIndexLazyRoute, DashboardIndexLazyRoute: DashboardIndexLazyRoute,
DashboardLogBalanceLazyRoute: DashboardLogBalanceLazyRoute, DashboardLogBalanceLazyRoute: DashboardLogBalanceLazyRoute,
DashboardLogCommissionLazyRoute: DashboardLogCommissionLazyRoute, DashboardLogCommissionLazyRoute: DashboardLogCommissionLazyRoute,
@@ -910,7 +921,6 @@ const DashboardRouteLazyRouteChildren: DashboardRouteLazyRouteChildren = {
DashboardSystemIndexLazyRoute: DashboardSystemIndexLazyRoute, DashboardSystemIndexLazyRoute: DashboardSystemIndexLazyRoute,
DashboardTicketIndexLazyRoute: DashboardTicketIndexLazyRoute, DashboardTicketIndexLazyRoute: DashboardTicketIndexLazyRoute,
DashboardUserIndexLazyRoute: DashboardUserIndexLazyRoute, DashboardUserIndexLazyRoute: DashboardUserIndexLazyRoute,
DashboardWithdrawalIndexLazyRoute: DashboardWithdrawalIndexLazyRoute,
} }
const DashboardRouteLazyRouteWithChildren = const DashboardRouteLazyRouteWithChildren =
+84 -5
View File
@@ -40,6 +40,13 @@ import { Display } from "@/components/display";
import { UserDetail } from "@/sections/user/user-detail"; import { UserDetail } from "@/sections/user/user-detail";
import { formatDate } from "@/utils/common"; import { formatDate } from "@/utils/common";
const WITHDRAWAL_METHOD_MAP: Record<number, string> = {
0: "其他",
1: "支付宝",
2: "微信",
3: "银行卡",
};
const rejectSchema = z.object({ const rejectSchema = z.object({
reason: z reason: z
.string() .string()
@@ -48,11 +55,40 @@ const rejectSchema = z.object({
.max(500, "拒绝原因不能超过 500 个字符"), .max(500, "拒绝原因不能超过 500 个字符"),
}); });
function QrCodeCell({ url }: { url?: string }) {
const [zoomOpen, setZoomOpen] = useState(false);
if (!url) return <span>--</span>;
return (
<>
<button
className="block overflow-hidden rounded border"
onClick={() => setZoomOpen(true)}
type="button"
>
<img
alt="收款码"
className="h-12 w-12 cursor-zoom-in object-cover"
src={url}
/>
</button>
{zoomOpen && (
<Dialog onOpenChange={setZoomOpen} open={zoomOpen}>
<DialogContent className="max-w-sm">
<img alt="收款码" className="w-full" src={url} />
</DialogContent>
</Dialog>
)}
</>
);
}
export default function WithdrawalPage() { export default function WithdrawalPage() {
const { t } = useTranslation("user"); const { t } = useTranslation("user");
const ref = useRef<ProTableActions>(null); const ref = useRef<ProTableActions>(null);
const [rejectOpen, setRejectOpen] = useState(false); const [rejectOpen, setRejectOpen] = useState(false);
const [currentRow, setCurrentRow] = useState<API.WithdrawalLog | null>(null); const [currentRow, setCurrentRow] = useState<API.AdminWithdrawalLog | null>(
null
);
const form = useForm<z.infer<typeof rejectSchema>>({ const form = useForm<z.infer<typeof rejectSchema>>({
resolver: zodResolver(rejectSchema), resolver: zodResolver(rejectSchema),
defaultValues: { defaultValues: {
@@ -93,6 +129,14 @@ export default function WithdrawalPage() {
{ value: "0", label: "审核中" }, { value: "0", label: "审核中" },
{ value: "1", label: "已通过" }, { value: "1", label: "已通过" },
{ value: "2", label: "已拒绝" }, { value: "2", label: "已拒绝" },
{ value: "3", label: "已取消" },
];
const methodOptions = [
{ value: "1", label: "支付宝" },
{ value: "2", label: "微信" },
{ value: "3", label: "银行卡" },
{ value: "0", label: "其他" },
]; ];
const statusMap: Record<number, { label: string; className: string }> = { const statusMap: Record<number, { label: string; className: string }> = {
@@ -108,11 +152,18 @@ export default function WithdrawalPage() {
label: "已拒绝", label: "已拒绝",
className: "bg-rose-100 text-rose-700 border-rose-200", className: "bg-rose-100 text-rose-700 border-rose-200",
}, },
3: {
label: "已取消",
className: "bg-gray-100 text-gray-500 border-gray-200",
},
}; };
return ( return (
<> <>
<ProTable<API.WithdrawalLog, { status?: string; user_id?: string }> <ProTable<
API.AdminWithdrawalLog,
{ status?: string; user_id?: string; method?: string }
>
action={ref} action={ref}
actions={{ actions={{
render: (row) => { render: (row) => {
@@ -170,10 +221,32 @@ export default function WithdrawalPage() {
), ),
}, },
{ {
accessorKey: "content", accessorKey: "method",
header: "收款信息", header: "收款方式",
cell: ({ row }) =>
WITHDRAWAL_METHOD_MAP[row.original.method] ?? "--",
},
{
accessorKey: "account",
header: "收款账号",
cell: ({ row }) => ( cell: ({ row }) => (
<div className="max-w-[360px] break-all text-sm"> <div className="max-w-[200px] break-all text-sm">
{row.original.account || "--"}
</div>
),
},
{
accessorKey: "qr_code_url",
header: "收款码",
cell: ({ row }) => (
<QrCodeCell url={row.original.qr_code_url} />
),
},
{
accessorKey: "content",
header: "备注",
cell: ({ row }) => (
<div className="max-w-[200px] break-all text-sm">
{row.original.content || "--"} {row.original.content || "--"}
</div> </div>
), ),
@@ -216,6 +289,11 @@ export default function WithdrawalPage() {
options: statusOptions, options: statusOptions,
placeholder: "状态", placeholder: "状态",
}, },
{
key: "method",
options: methodOptions,
placeholder: "收款方式",
},
{ {
key: "user_id", key: "user_id",
placeholder: "用户 ID", placeholder: "用户 ID",
@@ -226,6 +304,7 @@ export default function WithdrawalPage() {
page: pagination.page, page: pagination.page,
size: pagination.size, size: pagination.size,
status: filter.status ? Number(filter.status) : undefined, status: filter.status ? Number(filter.status) : undefined,
method: filter.method ? Number(filter.method) : undefined,
user_id: filter.user_id ? Number(filter.user_id) : undefined, user_id: filter.user_id ? Number(filter.user_id) : undefined,
}); });
@@ -2,7 +2,12 @@ import { Button } from "@workspace/ui/components/button";
import { Input } from "@workspace/ui/components/input"; import { Input } from "@workspace/ui/components/input";
import { Icon } from "@workspace/ui/composed/icon"; import { Icon } from "@workspace/ui/composed/icon";
import { generateCaptcha } from "@workspace/ui/services/common/auth"; import { generateCaptcha } from "@workspace/ui/services/common/auth";
import { useEffect, useImperativeHandle, useState } from "react"; import {
type RefObject,
useEffect,
useImperativeHandle,
useState,
} from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export interface LocalCaptchaRef { export interface LocalCaptchaRef {
@@ -10,7 +10,13 @@ import {
generateCaptcha, generateCaptcha,
verifyCaptchaSlider, verifyCaptchaSlider,
} from "@workspace/ui/services/common/auth"; } from "@workspace/ui/services/common/auth";
import { useCallback, useImperativeHandle, useRef, useState } from "react"; import {
type RefObject,
useCallback,
useImperativeHandle,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
export interface SliderCaptchaRef { export interface SliderCaptchaRef {
+333 -60
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button"; import { Button } from "@workspace/ui/components/button";
import { import {
Card, Card,
@@ -28,18 +28,29 @@ import {
FormMessage, FormMessage,
} from "@workspace/ui/components/form"; } from "@workspace/ui/components/form";
import { Input } from "@workspace/ui/components/input"; import { Input } from "@workspace/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
import { Textarea } from "@workspace/ui/components/textarea"; import { Textarea } from "@workspace/ui/components/textarea";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import type { ProListActions } from "@workspace/ui/composed/pro-list/pro-list"; import type { ProListActions } from "@workspace/ui/composed/pro-list/pro-list";
import { ProList } from "@workspace/ui/composed/pro-list/pro-list"; import { ProList } from "@workspace/ui/composed/pro-list/pro-list";
import { UploadImage } from "@workspace/ui/composed/upload-image";
import { import {
commissionWithdraw, commissionWithdraw,
queryUserAffiliate, queryUserAffiliate,
queryUserAffiliateList, queryUserAffiliateList,
queryWithdrawalLog, queryWithdrawalLog,
uploadFile,
withdrawalCancel,
} from "@workspace/ui/services/user/user"; } from "@workspace/ui/services/user/user";
import { formatDate } from "@workspace/ui/utils/formatting"; import { formatDate } from "@workspace/ui/utils/formatting";
import { unitConversion } from "@workspace/ui/utils/unit-conversions"; import { unitConversion } from "@workspace/ui/utils/unit-conversions";
import { Copy } from "lucide-react"; import { Copy, X } from "lucide-react";
import { useRef, useState, useTransition } from "react"; import { useRef, useState, useTransition } from "react";
import { CopyToClipboard } from "react-copy-to-clipboard"; import { CopyToClipboard } from "react-copy-to-clipboard";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -49,6 +60,17 @@ import { z } from "zod";
import { Display } from "@/components/display"; import { Display } from "@/components/display";
import { useGlobalStore } from "@/stores/global"; import { useGlobalStore } from "@/stores/global";
const WITHDRAWAL_METHODS = [
{ value: 1, label: "支付宝" },
{ value: 2, label: "微信" },
{ value: 3, label: "银行卡" },
{ value: 0, label: "其他" },
] as const;
function getWithdrawalMethodLabel(method: number): string {
return WITHDRAWAL_METHODS.find((m) => m.value === method)?.label ?? "未知";
}
function getWithdrawalStatusMeta( function getWithdrawalStatusMeta(
status: number, status: number,
t: ReturnType<typeof useTranslation<"affiliate">>["t"] t: ReturnType<typeof useTranslation<"affiliate">>["t"]
@@ -64,6 +86,11 @@ function getWithdrawalStatusMeta(
label: t("withdrawal.status.rejected", "Rejected"), label: t("withdrawal.status.rejected", "Rejected"),
className: "bg-destructive/10 text-destructive", className: "bg-destructive/10 text-destructive",
}; };
case 3:
return {
label: t("withdrawal.status.cancelled", "Cancelled"),
className: "bg-muted text-muted-foreground",
};
default: default:
return { return {
label: t("withdrawal.status.pending", "Pending"), label: t("withdrawal.status.pending", "Pending"),
@@ -72,11 +99,83 @@ function getWithdrawalStatusMeta(
} }
} }
function QrCodeView({ url }: { url: string }) {
const [zoomOpen, setZoomOpen] = useState(false);
return (
<>
<button
className="block overflow-hidden rounded border"
onClick={() => setZoomOpen(true)}
type="button"
>
<img
alt="收款码"
className="h-16 w-16 cursor-zoom-in object-cover"
src={url}
/>
</button>
{zoomOpen && (
<Dialog onOpenChange={setZoomOpen} open={zoomOpen}>
<DialogContent className="max-w-sm">
<img alt="收款码" className="w-full" src={url} />
</DialogContent>
</Dialog>
)}
</>
);
}
const withdrawalFormSchema = z
.object({
amount: z.number(),
method: z.number(),
account: z.string().trim().max(200).optional(),
qr_code_url: z.string().optional(),
content: z.string().trim().max(1000).optional(),
})
.superRefine((data, ctx) => {
if (!data.amount || data.amount <= 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "提现金额必须大于 0",
path: ["amount"],
});
}
if (data.method === 1 || data.method === 2) {
if (!data.qr_code_url) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "请上传收款码",
path: ["qr_code_url"],
});
}
} else if (data.method === 3) {
if (!data.account) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "银行卡收款账号为必填",
path: ["account"],
});
}
} else if (data.method === 0) {
if (!data.account && !data.content) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "收款账号和备注至少填一项",
path: ["account"],
});
}
}
});
type WithdrawalFormValues = z.infer<typeof withdrawalFormSchema>;
export default function Affiliate() { export default function Affiliate() {
const { t } = useTranslation("affiliate"); const { t } = useTranslation("affiliate");
const { user, common, getUserInfo } = useGlobalStore(); const { user, common, getUserInfo } = useGlobalStore();
const [withdrawalOpen, setWithdrawalOpen] = useState(false); const [withdrawalOpen, setWithdrawalOpen] = useState(false);
const [submitting, startTransition] = useTransition(); const [submitting, startTransition] = useTransition();
const [qrUploading, setQrUploading] = useState(false);
const withdrawalListActionRef = useRef<ProListActions | undefined>(undefined); const withdrawalListActionRef = useRef<ProListActions | undefined>(undefined);
const affiliateQuery = useQuery({ const affiliateQuery = useQuery({
@@ -89,51 +188,69 @@ export default function Affiliate() {
const availableCommission = Number( const availableCommission = Number(
user?.commission ?? affiliateQuery.data?.total_commission ?? 0 user?.commission ?? affiliateQuery.data?.total_commission ?? 0
); );
const withdrawalFormSchema = z.object({
amount: z const withdrawalForm = useForm<WithdrawalFormValues>({
.number()
.int(t("withdrawal.validation.amountInvalid", "Amount is invalid"))
.positive(
t(
"withdrawal.validation.amountPositive",
"Amount must be greater than 0"
)
)
.max(
availableCommission,
t(
"withdrawal.validation.amountExceeded",
"Amount cannot exceed available commission"
)
),
content: z
.string()
.trim()
.min(
1,
t(
"withdrawal.validation.contentRequired",
"Withdrawal info is required"
)
)
.max(
1000,
t("withdrawal.validation.contentTooLong", "Withdrawal info is too long")
),
});
const withdrawalForm = useForm<z.infer<typeof withdrawalFormSchema>>({
resolver: zodResolver(withdrawalFormSchema), resolver: zodResolver(withdrawalFormSchema),
defaultValues: { defaultValues: {
amount: 0, amount: 0,
method: 1,
account: "",
qr_code_url: "",
content: "", content: "",
}, },
}); });
const submitWithdrawal = (values: z.infer<typeof withdrawalFormSchema>) => { const watchedMethod = withdrawalForm.watch("method");
const watchedQrCodeUrl = withdrawalForm.watch("qr_code_url");
const handleQrUpload = async (file: string | File) => {
if (!(file instanceof File)) return;
setQrUploading(true);
try {
const formData = new FormData();
formData.append("file", file);
formData.append("biz_type", "withdrawal_qrcode");
const response = await uploadFile(formData);
const uploadedUrl =
response.data.data?.url || response.data.data?.key || "";
withdrawalForm.setValue("qr_code_url", uploadedUrl);
withdrawalForm.clearErrors("qr_code_url");
} catch {
toast.error("收款码上传失败,请重试");
} finally {
setQrUploading(false);
}
};
const cancelMutation = useMutation({
mutationFn: async (withdrawalId: number) =>
withdrawalCancel({ withdrawal_id: withdrawalId }),
onSuccess: () => {
toast.success("提现申请已取消");
withdrawalListActionRef.current?.refresh();
},
onError: () => {
toast.error("取消失败,请重试");
},
});
const submitWithdrawal = (values: WithdrawalFormValues) => {
if (values.amount > availableCommission) {
withdrawalForm.setError("amount", {
message: t(
"withdrawal.validation.amountExceeded",
"Amount cannot exceed available commission"
),
});
return;
}
startTransition(async () => { startTransition(async () => {
await commissionWithdraw({ await commissionWithdraw({
amount: values.amount, amount: values.amount,
content: values.content.trim(), method: values.method,
account: values.account || undefined,
qr_code_url: values.qr_code_url || undefined,
content: values.content || undefined,
}); });
toast.success( toast.success(
t("withdrawal.submitSuccess", "Withdrawal request submitted") t("withdrawal.submitSuccess", "Withdrawal request submitted")
@@ -146,6 +263,9 @@ export default function Affiliate() {
setWithdrawalOpen(false); setWithdrawalOpen(false);
withdrawalForm.reset({ withdrawalForm.reset({
amount: 0, amount: 0,
method: 1,
account: "",
qr_code_url: "",
content: "", content: "",
}); });
}); });
@@ -192,7 +312,7 @@ export default function Affiliate() {
{t("withdrawal.apply", "Apply for Withdrawal")} {t("withdrawal.apply", "Apply for Withdrawal")}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{t("withdrawal.applyTitle", "Apply for Withdrawal")} {t("withdrawal.applyTitle", "Apply for Withdrawal")}
@@ -256,21 +376,130 @@ export default function Affiliate() {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={withdrawalForm.control}
name="method"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<Select
onValueChange={(val) => {
field.onChange(Number(val));
withdrawalForm.setValue("qr_code_url", "");
withdrawalForm.setValue("account", "");
withdrawalForm.clearErrors();
}}
value={String(field.value)}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="请选择收款方式" />
</SelectTrigger>
</FormControl>
<SelectContent>
{WITHDRAWAL_METHODS.map((m) => (
<SelectItem
key={m.value}
value={String(m.value)}
>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{(watchedMethod === 1 || watchedMethod === 2) && (
<FormField
control={withdrawalForm.control}
name="qr_code_url"
render={() => (
<FormItem>
<FormLabel>
<span className="ml-1 text-destructive">
*
</span>
</FormLabel>
{watchedQrCodeUrl ? (
<div className="relative inline-block">
<QrCodeView url={watchedQrCodeUrl} />
<button
className="-top-2 -right-2 absolute flex h-5 w-5 items-center justify-center rounded-full bg-destructive text-destructive-foreground"
onClick={() =>
withdrawalForm.setValue("qr_code_url", "")
}
type="button"
>
<X className="h-3 w-3" />
</button>
</div>
) : (
<UploadImage
className="h-24 w-24"
id="qr-code-upload"
onChange={handleQrUpload}
returnType="file"
>
<div className="flex h-24 w-24 flex-col items-center justify-center rounded border-2 border-dashed text-muted-foreground text-xs">
{qrUploading ? "上传中..." : "点击上传\n收款码"}
</div>
</UploadImage>
)}
<FormMessage />
</FormItem>
)}
/>
)}
{(watchedMethod === 3 || watchedMethod === 0) && (
<FormField
control={withdrawalForm.control}
name="account"
render={({ field }) => (
<FormItem>
<FormLabel>
{watchedMethod === 3 && (
<span className="ml-1 text-destructive">
*
</span>
)}
</FormLabel>
<FormControl>
<Input
placeholder={
watchedMethod === 3
? "请输入银行卡号或账户信息"
: "请输入收款账号(与备注至少填一项)"
}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField <FormField
control={withdrawalForm.control} control={withdrawalForm.control}
name="content" name="content"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel></FormLabel>
{t("withdrawal.content", "Payout Information")}
</FormLabel>
<FormControl> <FormControl>
<Textarea <Textarea
placeholder={t( placeholder={
"withdrawal.contentPlaceholder", watchedMethod === 0
"Bank account, wallet address, recipient name, or other payout details" ? "备注(与账号至少填一项)"
)} : "备注(可选)"
rows={4} }
rows={3}
{...field} {...field}
/> />
</FormControl> </FormControl>
@@ -289,7 +518,11 @@ export default function Affiliate() {
{t("withdrawal.cancel", "Cancel")} {t("withdrawal.cancel", "Cancel")}
</Button> </Button>
<Button <Button
disabled={submitting || availableCommission <= 0} disabled={
submitting ||
qrUploading ||
availableCommission <= 0
}
form="withdrawal-form" form="withdrawal-form"
type="submit" type="submit"
> >
@@ -345,28 +578,68 @@ export default function Affiliate() {
<div className="font-semibold text-lg"> <div className="font-semibold text-lg">
<Display type="currency" value={item.amount} /> <Display type="currency" value={item.amount} />
</div> </div>
<span <div className="flex items-center gap-2">
className={`inline-flex rounded-full px-3 py-1 font-medium text-xs ${statusMeta.className}`} <span
> className={`inline-flex rounded-full px-3 py-1 font-medium text-xs ${statusMeta.className}`}
{statusMeta.label} >
</span> {statusMeta.label}
</span>
{item.status === 0 && (
<ConfirmButton
cancelText="取消"
confirmText="确认取消"
description="取消后该提现申请将关闭,佣金不会退还。确定取消吗?"
onConfirm={async () => {
await cancelMutation.mutateAsync(item.id);
}}
title="取消提现申请"
trigger={
<Button
disabled={cancelMutation.isPending}
size="sm"
variant="outline"
>
</Button>
}
/>
)}
</div>
</div> </div>
<ul className="grid gap-3 md:grid-cols-2"> <ul className="grid gap-3 md:grid-cols-2">
<li className="flex flex-col gap-1"> <li className="flex flex-col gap-1">
<span className="text-muted-foreground"> <span className="text-muted-foreground"></span>
{t("withdrawal.record.content", "Payout Information")} <span>{getWithdrawalMethodLabel(item.method)}</span>
</span>
<span className="whitespace-pre-wrap break-words">
{item.content}
</span>
</li> </li>
{item.account && (
<li className="flex flex-col gap-1">
<span className="text-muted-foreground"></span>
<span className="break-all">{item.account}</span>
</li>
)}
{item.qr_code_url && (
<li className="flex flex-col gap-1">
<span className="text-muted-foreground"></span>
<QrCodeView url={item.qr_code_url} />
</li>
)}
{item.content && (
<li className="flex flex-col gap-1">
<span className="text-muted-foreground">
{t("withdrawal.record.content", "Payout Information")}
</span>
<span className="whitespace-pre-wrap break-words">
{item.content}
</span>
</li>
)}
<li className="flex flex-col gap-1"> <li className="flex flex-col gap-1">
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{t("withdrawal.record.createdAt", "Created At")} {t("withdrawal.record.createdAt", "Created At")}
</span> </span>
<time>{formatDate(item.created_at)}</time> <time>{formatDate(item.created_at)}</time>
</li> </li>
{item.reason ? ( {item.reason && (
<li className="flex flex-col gap-1 md:col-span-2"> <li className="flex flex-col gap-1 md:col-span-2">
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{t("withdrawal.record.reason", "Reject Reason")} {t("withdrawal.record.reason", "Reject Reason")}
@@ -375,7 +648,7 @@ export default function Affiliate() {
{item.reason} {item.reason}
</span> </span>
</li> </li>
) : null} )}
</ul> </ul>
</CardContent> </CardContent>
</Card> </Card>
@@ -27,23 +27,52 @@ const FormSchema = z.object({
enable_balance_notify: z.boolean(), enable_balance_notify: z.boolean(),
enable_login_notify: z.boolean(), enable_login_notify: z.boolean(),
enable_subscribe_notify: z.boolean(), enable_subscribe_notify: z.boolean(),
// enable_trade_notify: z.boolean(), enable_trade_notify: z.boolean(),
}); });
type NotifyFormValues = z.infer<typeof FormSchema>;
const notificationFields = [
{
name: "enable_balance_notify",
labelKey: "notify.balanceChange",
fallbackLabel: "Balance Change",
},
{
name: "enable_login_notify",
labelKey: "notify.login",
fallbackLabel: "Login",
},
{
name: "enable_subscribe_notify",
labelKey: "notify.subscribe",
fallbackLabel: "Subscribe",
},
{
name: "enable_trade_notify",
labelKey: "notify.finance",
fallbackLabel: "Finance",
},
] satisfies Array<{
name: keyof NotifyFormValues;
labelKey: string;
fallbackLabel: string;
}>;
export default function NotifySettings() { export default function NotifySettings() {
const { t } = useTranslation("profile"); const { t } = useTranslation("profile");
const { user, getUserInfo } = useGlobalStore(); const { user, getUserInfo } = useGlobalStore();
const form = useForm<z.infer<typeof FormSchema>>({ const form = useForm<NotifyFormValues>({
resolver: zodResolver(FormSchema), resolver: zodResolver(FormSchema),
defaultValues: { defaultValues: {
enable_balance_notify: user?.enable_balance_notify ?? false, enable_balance_notify: user?.enable_balance_notify ?? false,
enable_login_notify: user?.enable_login_notify ?? false, enable_login_notify: user?.enable_login_notify ?? false,
enable_subscribe_notify: user?.enable_subscribe_notify ?? false, enable_subscribe_notify: user?.enable_subscribe_notify ?? false,
// enable_trade_notify: user?.enable_trade_notify ?? false, enable_trade_notify: user?.enable_trade_notify ?? false,
}, },
}); });
async function onSubmit(data: z.infer<typeof FormSchema>) { async function onSubmit(data: NotifyFormValues) {
await updateUserNotify(data); await updateUserNotify(data);
toast.success(t("notify.updateSuccess", "Update Successful")); toast.success(t("notify.updateSuccess", "Update Successful"));
await getUserInfo(); await getUserInfo();
@@ -67,32 +96,15 @@ export default function NotifySettings() {
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
> >
<div className="space-y-4"> <div className="space-y-4">
{[ {notificationFields.map(({ fallbackLabel, labelKey, name }) => (
{
name: "enable_balance_notify",
label: t("notify.balanceChange", "Balance Change"),
},
{
name: "enable_login_notify",
label: t("notify.login", "Login"),
},
{
name: "enable_subscribe_notify",
label: t("notify.subscribe", "Subscribe"),
},
// {
// name: "enable_trade_notify",
// label: t("notify.finance", "Finance"),
// },
].map(({ name, label }) => (
<FormField <FormField
control={form.control} control={form.control}
key={name} key={name}
name={name as any} name={name}
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex items-center justify-between space-x-4"> <FormItem className="flex items-center justify-between space-x-4">
<FormLabel className="text-muted-foreground"> <FormLabel className="text-muted-foreground">
{label} {t(labelKey, fallbackLabel)}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Switch <Switch
+5 -33
View File
@@ -28,21 +28,6 @@ declare namespace API {
updated_at: number; updated_at: number;
}; };
type AdminWithdrawalLog = {
id: number;
user_id: number;
amount: number;
content: string;
status: number;
reason?: string;
created_at: number;
updated_at: number;
};
type ApproveWithdrawalRequest = {
withdrawal_id: number;
};
type AnyTLS = { type AnyTLS = {
port: number; port: number;
security_config: SecurityConfig; security_config: SecurityConfig;
@@ -86,18 +71,6 @@ declare namespace API {
is_default: boolean; is_default: boolean;
}; };
type GetWithdrawalListParams = {
page: number;
size: number;
user_id?: number;
status?: number;
};
type GetWithdrawalListResponse = {
list: AdminWithdrawalLog[];
total: number;
};
type AppUserSubcbribe = { type AppUserSubcbribe = {
id: number; id: number;
name: string; name: string;
@@ -1190,6 +1163,7 @@ declare namespace API {
size: number; size: number;
user_id?: number; user_id?: number;
status?: number; status?: number;
method?: number;
}; };
type GetWithdrawalListResponse = { type GetWithdrawalListResponse = {
@@ -1801,11 +1775,6 @@ declare namespace API {
total: number; total: number;
}; };
type RejectWithdrawalRequest = {
withdrawal_id: number;
reason: string;
};
type QuotaTask = { type QuotaTask = {
id: number; id: number;
subscribers: number[]; subscribers: number[];
@@ -3129,7 +3098,10 @@ declare namespace API {
id: number; id: number;
user_id: number; user_id: number;
amount: number; amount: number;
content: string; method: number;
account?: string;
qr_code_url?: string;
content?: string;
status: number; status: number;
reason?: string; reason?: string;
created_at: number; created_at: number;
-1
View File
@@ -1,4 +1,3 @@
// @ts-expect-error
/* eslint-disable */ /* eslint-disable */
import request from "@workspace/ui/lib/request"; import request from "@workspace/ui/lib/request";
@@ -1,4 +1,3 @@
// @ts-expect-error
/* eslint-disable */ /* eslint-disable */
import request from "@workspace/ui/lib/request"; import request from "@workspace/ui/lib/request";
@@ -1,4 +1,3 @@
// @ts-expect-error
/* eslint-disable */ /* eslint-disable */
import request from "@workspace/ui/lib/request"; import request from "@workspace/ui/lib/request";
-1
View File
@@ -1,4 +1,3 @@
// @ts-expect-error
/* eslint-disable */ /* eslint-disable */
import request from "@workspace/ui/lib/request"; import request from "@workspace/ui/lib/request";
-1
View File
@@ -1,4 +1,3 @@
// @ts-expect-error
/* eslint-disable */ /* eslint-disable */
import request from "@workspace/ui/lib/request"; import request from "@workspace/ui/lib/request";
@@ -1,4 +1,3 @@
// @ts-expect-error
/* eslint-disable */ /* eslint-disable */
import request from "@workspace/ui/lib/request"; import request from "@workspace/ui/lib/request";
-1
View File
@@ -1,4 +1,3 @@
// @ts-expect-error
/* eslint-disable */ /* eslint-disable */
import request from "@workspace/ui/lib/request"; import request from "@workspace/ui/lib/request";
+12 -2
View File
@@ -172,7 +172,14 @@ declare namespace API {
type CommissionWithdrawRequest = { type CommissionWithdrawRequest = {
amount: number; amount: number;
content: string; method: number;
account?: string;
qr_code_url?: string;
content?: string;
};
type WithdrawalCancelRequest = {
withdrawal_id: number;
}; };
type Coupon = { type Coupon = {
@@ -1296,7 +1303,10 @@ declare namespace API {
id: number; id: number;
user_id: number; user_id: number;
amount: number; amount: number;
content: string; method: number;
account?: string;
qr_code_url?: string;
content?: string;
status: number; status: number;
reason?: string; reason?: string;
created_at: number; created_at: number;
+33
View File
@@ -470,3 +470,36 @@ export async function queryWithdrawalLog(
} }
); );
} }
/** Cancel Withdrawal POST /v1/public/user/withdrawal_cancel */
export async function withdrawalCancel(
body: API.WithdrawalCancelRequest,
options?: { [key: string]: any }
) {
return request<API.Response & { data?: any }>(
`${import.meta.env.VITE_API_PREFIX || ""}/v1/public/user/withdrawal_cancel`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
data: body,
...(options || {}),
}
);
}
/** Upload File POST /v1/public/file/upload */
export async function uploadFile(
formData: FormData,
options?: { [key: string]: any }
) {
return request<API.Response & { data?: { url: string; key: string } }>(
`${import.meta.env.VITE_API_PREFIX || ""}/v1/public/file/upload`,
{
method: "POST",
data: formData,
...(options || {}),
}
);
}