feat(wallet): 补齐钱包提现与卖家资金页面

This commit is contained in:
yml2213
2026-10-30 16:41:34 +08:00
parent a39a504b81
commit c0c9f161ae
23 changed files with 3348 additions and 0 deletions
+103
View File
@@ -0,0 +1,103 @@
# Phase 2 验收报告(B1 资金闭环)
> 日期:2026-10-30
> 项目:解忧商行(hfb_jysh)
> 范围:C 端钱包、收款账户、提现、移动端提现、卖家收益/待办/线下提号、后台支付流水/资金明细/钱包流水
> 结论:**页面与路由全部落地,tsc/build 通过,只读接口回归 200;写操作未在验收中执行**
---
## 1. C 端钱包
- 页面:`frontend/src/features/wallet/WalletPage.tsx`
- 路由:`/wallet`
- 接口:`GET /api/wallet/balance`、`GET /api/wallet/ledger`
- 能力:可用/冻结余额、账户状态、资金流水(业务、方向、金额、余额类型、变化后余额、备注、时间)与分页。
- 金额全部按分入参,展示统一 `formatMoney(cents)`;入账/出账用颜色与符号区分。
## 2. 收款账户
- 页面:`frontend/src/features/wallet/PaymentAccountsPage.tsx`
- 路由:`/wallet/payment-accounts`
- 接口:`GET/POST /api/payment-accounts`、`PUT/DELETE /api/payment-accounts/:id`、`POST /api/payment-accounts/:id/set-default`
- 能力:
- 卡片式列表,展示收款方式、脱敏账号、开户行、默认标记、凭证数量。
- 新增:收款方式(支付宝/微信/银行卡)、收款人、账号;银行卡额外填开户行与支行。
- 编辑仅允许修改支行、凭证与默认标记(后端 `UpdatePaymentAccountRequest` 不含账号字段)。
- 收款二维码凭证通过 `POST /api/files/upload`(scene `payment-cert`)上传,最多 5 张。
## 3. 提现申请
- 页面:`frontend/src/features/wallet/WithdrawalPage.tsx`
- 路由:`/wallet/withdrawal`
- 接口:`POST /api/withdrawals`、`GET /api/withdrawals`、`POST /api/withdrawals/:id/cancel`
- 能力:
- 默认账户自动选中;金额按元录入、`Math.round(yuan * 100)` 转分提交。
- 校验金额 > 0 且不超过可用余额;当前手续费 0%,页面明确「到账金额以审核结果为准」。
- 提现记录展示金额、手续费/到账、收款方式、状态、备注、申请时间;仅 `pending` 可取消。
## 4. 移动端提现
- 页面:`frontend/src/features/mobile/MobileWithdrawalPage.tsx`
- 路由:`/m/wallet/withdrawal`
- 组件:antd-mobile(NavBar / Card / Selector / Form / List / Dialog / Toast)
- 能力:余额展示、账户选择、金额录入、提交、记录列表与取消;无收款账户时引导到电脑端添加。
## 5. 卖家三页
| 页面 | 路由 | 接口 | 说明 |
|---|---|---|---|
| 号主待办 | `/seller/handoffs` | `GET /api/orders/handoffs` | 待交接/待确认结账/异常三项统计 + 订单列表(复用 `UserOrderListItem`) |
| 收益流水 | `/seller/earnings` | `GET /api/wallet/balance`、`/api/wallet/ledger` | 可用/冻结汇总 + 流水 + 提现入口 |
| 线下提号记录 | `/seller/pickups` | `GET /api/seller/pickups` | 提号编号、账号、操作人/平台、订单金额、应结算、状态与结算状态 |
文件:`frontend/src/features/seller/SellerHandoffsPage.tsx`、`SellerEarningsPage.tsx`、`SellerPickupsPage.tsx`,接口与类型扩展在 `frontend/src/features/seller/api.ts`。
## 6. 后台三页
| 页面 | 路由 | 接口 | 权限 |
|---|---|---|---|
| 支付流水 | `/console-jy8k3m/payments` | `GET /api/admin/payments` | `wallet:view` |
| 资金明细 | `/console-jy8k3m/finance/details` | `GET /api/admin/finance/details` | `wallet:view` |
| 钱包流水 | `/console-jy8k3m/wallet-ledger` | `GET /api/admin/wallet/ledger` | `wallet:view` |
- 支付流水支持订单号、业务类型、状态、渠道筛选,展示金额、支付时间与失败原因。
- 资金明细支持订单号、日期口径(下单/结算/线下结算)、日期区间与结算状态筛选,展示实付/退款、号主结算、平台净额、结算时间。
- 钱包流水支持用户 ID、订单 ID、业务类型筛选,展示方向、金额、变化后余额。
- 三者均为只读页面,未提供任何写操作入口。
## 7. 导航接入
- `frontend/src/layouts/StorefrontLayout.tsx`:账号下拉新增「我的钱包 / 卖家收益 / 号主待办 / 线下提号」。
- `frontend/src/layouts/AdminLayout.tsx`:财务组新增「资金明细、支付流水、钱包流水」,并补 `sectionMeta`。
- 新增样式 `frontend/src/styles/wallet.css`(已接入 `styles/global.css`)。
## 8. 回归结果
```text
前端 http://localhost:5289
后端 http://127.0.0.1:18089
```
| 检查项 | 状态 |
|---|---|
| `/wallet`、`/wallet/payment-accounts`、`/wallet/withdrawal` | 200 |
| `/seller/handoffs`、`/seller/earnings`、`/seller/pickups` | 200 |
| `/m/wallet/withdrawal` | 200 |
| `/console-jy8k3m/payments`、`/finance/details`、`/wallet-ledger` | 200 |
| `GET /api/admin/payments` | 200 |
| `GET /api/admin/finance/details` | 200 |
| `GET /api/admin/wallet/ledger` | 200 |
| `GET /api/wallet/balance`(无 token) | 401(符合鉴权预期) |
`npx tsc --noEmit` 通过;`npm run build` 通过,主入口 `497.95 kB` / gzip `156.88 kB`,无超大包告警。
## 9. 未覆盖
- 提现申请、取消、收款账户增删改、凭证上传均未做端到端写操作验证;开发库当前无真实用户余额与支付数据。
- 移动端提现页未在真机/移动视口做交互回归。
- 真实外部支付、短信与实名服务仍为空配置,不在本轮范围。
## 10. 下一步(B2)
线下提号(`/pickup`、`/pickup/:id`,命名统一「线下提号」并修正 `labels.ts` 现有「捡漏」译法)、客服会话工作台(`/chats`)、主站二维码池(`/qrcode-pool`)、通知/推送/邀请管理。
+3
View File
@@ -409,6 +409,9 @@ hfb_jysh/
### Phase 5:后台长尾 + 分销后台轻改(2–2.5 周)
- 财务看板、资金明细、运营支出。
- 已落地:财务看板、资金明细 `/finance/details`、资金出款、运营开支、支付流水 `/payments`、钱包流水 `/wallet-ledger`。
- 用户端资金闭环。
- 已落地:`/wallet`(余额 + 流水)、`/wallet/payment-accounts`、`/wallet/withdrawal`、移动端 `/m/wallet/withdrawal`、卖家 `/seller/earnings`、`/seller/handoffs`、`/seller/pickups`。
- 申诉仲裁、手工订单、提号、客服群、快捷回复。
- 已落地:客服群组(`/chat-support-groups`,权限 `chat:manage`),角色权限矩阵(`/roles`,权限 `role:manage`),手工录单(`/manual-entries`,权限 `cs_manual_entry:*`),快捷回复(`/chat-quick-replies`,权限 `chat:view` / `chat:send`)。
- 待落地:无(手工录单与快捷回复已迁移完毕)。
+56
View File
@@ -28,12 +28,38 @@ const SellerListingFormPage = lazy(() =>
default: module.SellerListingFormPage,
}))
)
const SellerHandoffsPage = lazy(() =>
import('@/features/seller/SellerHandoffsPage').then(module => ({
default: module.SellerHandoffsPage,
}))
)
const SellerEarningsPage = lazy(() =>
import('@/features/seller/SellerEarningsPage').then(module => ({
default: module.SellerEarningsPage,
}))
)
const SellerPickupsPage = lazy(() =>
import('@/features/seller/SellerPickupsPage').then(module => ({
default: module.SellerPickupsPage,
}))
)
const OrderListPage = lazy(() =>
import('@/features/orders/OrderListPage').then(module => ({ default: module.OrderListPage }))
)
const OrderDetailPage = lazy(() =>
import('@/features/orders/OrderDetailPage').then(module => ({ default: module.OrderDetailPage }))
)
const WalletPage = lazy(() =>
import('@/features/wallet/WalletPage').then(module => ({ default: module.WalletPage }))
)
const PaymentAccountsPage = lazy(() =>
import('@/features/wallet/PaymentAccountsPage').then(module => ({
default: module.PaymentAccountsPage,
}))
)
const WithdrawalPage = lazy(() =>
import('@/features/wallet/WithdrawalPage').then(module => ({ default: module.WithdrawalPage }))
)
const MessagesPage = lazy(() =>
import('@/features/messages/MessagesPage').then(module => ({ default: module.MessagesPage }))
)
@@ -59,6 +85,11 @@ const MobileProfilePage = lazy(() =>
default: module.MobileProfilePage,
}))
)
const MobileWithdrawalPage = lazy(() =>
import('@/features/mobile/MobileWithdrawalPage').then(module => ({
default: module.MobileWithdrawalPage,
}))
)
const MobileOrdersPage = lazy(() =>
import('@/features/mobile/MobileOrdersPage').then(module => ({
default: module.MobileOrdersPage,
@@ -200,6 +231,21 @@ const AdminManualEntriesPage = lazy(() =>
default: module.AdminManualEntriesPage,
}))
)
const AdminPaymentsPage = lazy(() =>
import('@/features/admin/AdminPaymentsPage').then(module => ({
default: module.AdminPaymentsPage,
}))
)
const AdminFinanceDetailsPage = lazy(() =>
import('@/features/admin/AdminFinanceDetailsPage').then(module => ({
default: module.AdminFinanceDetailsPage,
}))
)
const AdminWalletLedgerPage = lazy(() =>
import('@/features/admin/AdminWalletLedgerPage').then(module => ({
default: module.AdminWalletLedgerPage,
}))
)
const AdminQuickRepliesPage = lazy(() =>
import('@/features/admin/AdminQuickRepliesPage').then(module => ({
default: module.AdminQuickRepliesPage,
@@ -293,12 +339,18 @@ export function AppRouter() {
<Route path="listings/:id" element={<ListingDetailPage />} />
<Route path="orders" element={<OrderListPage />} />
<Route path="orders/:id" element={<OrderDetailPage />} />
<Route path="wallet" element={<WalletPage />} />
<Route path="wallet/payment-accounts" element={<PaymentAccountsPage />} />
<Route path="wallet/withdrawal" element={<WithdrawalPage />} />
<Route path="messages" element={<MessagesPage />} />
<Route path="messages/:id" element={<ChatPage />} />
<Route path="seller/listings" element={<SellerListingsPage />} />
<Route path="seller/listings/new" element={<SellerListingFormPage />} />
<Route path="seller/listings/:id" element={<SellerListingDetailPage />} />
<Route path="seller/listings/:id/edit" element={<SellerListingFormPage />} />
<Route path="seller/handoffs" element={<SellerHandoffsPage />} />
<Route path="seller/earnings" element={<SellerEarningsPage />} />
<Route path="seller/pickups" element={<SellerPickupsPage />} />
</Route>
<Route path="/login" element={<LoginPage />} />
@@ -318,6 +370,7 @@ export function AppRouter() {
<Route path="seller/listings/:id" element={<MobileSellerListingDetailPage />} />
<Route path="seller/listings/:id/edit" element={<MobileSellerFormPage />} />
<Route path="profile" element={<MobileProfilePage />} />
<Route path="wallet/withdrawal" element={<MobileWithdrawalPage />} />
</Route>
<Route path={`${adminBase}/login`} element={<AdminLoginPage />} />
@@ -342,6 +395,9 @@ export function AppRouter() {
<Route path="roles" element={<AdminRolesPage />} />
<Route path="admin-users" element={<AdminStaffAccountsPage />} />
<Route path="manual-entries" element={<AdminManualEntriesPage />} />
<Route path="payments" element={<AdminPaymentsPage />} />
<Route path="finance/details" element={<AdminFinanceDetailsPage />} />
<Route path="wallet-ledger" element={<AdminWalletLedgerPage />} />
<Route path="chat-quick-replies" element={<AdminQuickRepliesPage />} />
<Route path="tracking-links" element={<AdminTrackingLinksPage />} />
<Route path="chat-support-groups" element={<AdminSupportGroupsPage />} />
@@ -0,0 +1,280 @@
import { useMemo, useState } from 'react'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { Alert, Button, DatePicker, Input, Select, Space, Tag, Typography, type TableColumnsType } from 'antd'
import { ReloadOutlined } from '@ant-design/icons'
import { type Dayjs } from 'dayjs'
import { fetchFinanceDetails } from '@/features/admin/api'
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
import { DataTable } from '@/features/admin/components/DataTable'
import { FilterBar, FilterField } from '@/features/admin/components/FilterBar'
import { orderStatusMap } from '@/features/admin/labels'
import type { FinanceDetail } from '@/features/admin/types'
import { useAdminSessionStore } from '@/stores/adminSession'
import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
interface Filters {
order_no: string
date_type: 'created' | 'settled' | 'offline_settled'
range: [Dayjs, Dayjs] | null
}
const emptyFilters: Filters = { order_no: '', date_type: 'settled', range: null }
const settlementStatusOptions = [
{ value: 'pending', label: '待结算' },
{ value: 'settled', label: '已结算' },
{ value: 'frozen', label: '冻结中' },
]
function errorText(error: unknown) {
const response = (error as { response?: { data?: { message?: string } } })?.response
if (response?.data?.message) return response.data.message
return error instanceof Error ? error.message : '请求失败,请稍后重试'
}
const columns: TableColumnsType<FinanceDetail> = [
{
title: '订单',
key: 'order',
width: 230,
fixed: 'left',
render: (_, row) => (
<div className="admin-stack">
<strong>{row.order_no}</strong>
<Space size={4} wrap>
<Tag bordered={false}>{orderStatusMap[row.order_status]?.label ?? row.order_status}</Tag>
<Tag bordered={false} color={row.finance_status === 'settled' ? 'green' : 'gold'}>
{row.finance_status === 'settled' ? '已结算' : '待结算'}
</Tag>
</Space>
</div>
),
},
{
title: '租客',
key: 'renter',
width: 160,
render: (_, row) => (
<div className="admin-stack">
<span>{formatText(row.renter_nickname)}</span>
<span className="admin-mono">{formatText(row.renter_phone)}</span>
</div>
),
},
{
title: '号主',
key: 'owner',
width: 160,
render: (_, row) => (
<div className="admin-stack">
<span>{formatText(row.owner_nickname)}</span>
<span className="admin-mono">{formatText(row.owner_phone)}</span>
</div>
),
},
{
title: '实付 / 退款',
key: 'amount',
width: 160,
align: 'right',
render: (_, row) => (
<div className="admin-stack">
<span>{formatMoney(row.paid_amount_cent)}</span>
<Typography.Text type="secondary">
退款 {formatMoney(row.refunded_amount_cent)}
</Typography.Text>
</div>
),
},
{
title: '号主结算',
key: 'owner_settle',
width: 150,
align: 'right',
render: (_, row) => (
<div className="admin-stack">
<span>{formatMoney(row.owner_effective_settlement_amount_cent)}</span>
<Typography.Text type="secondary">
钱包 {formatMoney(row.owner_wallet_income_amount_cent)}
</Typography.Text>
</div>
),
},
{
title: '平台净额',
dataIndex: 'platform_net_amount_cent',
width: 130,
align: 'right',
render: value => formatMoney(value),
},
{
title: '结算模式',
key: 'mode',
width: 150,
render: (_, row) => (
<div className="admin-stack">
<Tag bordered={false}>{formatText(row.settlement_mode)}</Tag>
<span className="admin-mono">{formatText(row.offline_settlement_status)}</span>
</div>
),
},
{
title: '创建时间',
dataIndex: 'created_at',
width: 170,
render: value => formatDateTime(value),
},
{
title: '结算时间',
dataIndex: 'settled_at',
width: 170,
render: value => (value ? formatDateTime(value) : '—'),
},
]
export function AdminFinanceDetailsPage() {
const [filters, setFilters] = useState<Filters>(emptyFilters)
const [applied, setApplied] = useState<Filters>(emptyFilters)
const [settlementStatus, setSettlementStatus] = useState<string>()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const permissions = useAdminSessionStore(state => state.admin?.permissions)
const canView = Boolean(permissions?.includes('*') || permissions?.includes('wallet:view'))
const query = useMemo(
() => ({
order_no: applied.order_no.trim() || undefined,
date_type: applied.date_type,
settlement_status: settlementStatus,
start_date: applied.range?.[0]?.format('YYYY-MM-DD'),
end_date: applied.range?.[1]?.format('YYYY-MM-DD'),
page,
page_size: pageSize,
}),
[applied, settlementStatus, page, pageSize]
)
const detailsQuery = useQuery({
queryKey: ['admin-finance-details', query],
queryFn: () => fetchFinanceDetails(query),
enabled: canView,
placeholderData: keepPreviousData,
})
if (!canView) {
return (
<div className="admin-page">
<AdminPageHeader title="资金明细" eyebrow="JIEYOU OPS · 财务" />
<Alert type="warning" showIcon message="当前账号没有资金查看权限" />
</div>
)
}
return (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 财务"
title="资金明细"
description="按订单维度核对实付、退款、号主结算与平台净额。"
meta={
<Space size={8} wrap>
<Tag color="geekblue">共 {detailsQuery.data?.total ?? 0} 单</Tag>
</Space>
}
extra={
<Button
icon={<ReloadOutlined />}
loading={detailsQuery.isFetching}
onClick={() => void detailsQuery.refetch()}
>
刷新
</Button>
}
/>
<FilterBar
loading={detailsQuery.isFetching}
onSearch={() => {
setPage(1)
setApplied(filters)
}}
onReset={() => {
setFilters(emptyFilters)
setApplied(emptyFilters)
setSettlementStatus(undefined)
setPage(1)
}}
>
<FilterField label="订单号">
<Input
allowClear
value={filters.order_no}
placeholder="完整订单号"
onChange={event =>
setFilters(current => ({ ...current, order_no: event.target.value }))
}
/>
</FilterField>
<FilterField label="日期口径">
<Select
value={filters.date_type}
options={[
{ value: 'settled', label: '按结算时间' },
{ value: 'created', label: '按下单时间' },
{ value: 'offline_settled', label: '按线下结算时间' },
]}
onChange={value => setFilters(current => ({ ...current, date_type: value }))}
/>
</FilterField>
<FilterField label="日期区间" width={280}>
<DatePicker.RangePicker
style={{ width: '100%' }}
value={filters.range}
allowEmpty={[true, true]}
onChange={value =>
setFilters(current => ({
...current,
range: value && value[0] && value[1] ? [value[0], value[1]] : null,
}))
}
/>
</FilterField>
<FilterField label="结算状态">
<Select
allowClear
value={settlementStatus}
options={settlementStatusOptions}
placeholder="全部状态"
onChange={value => setSettlementStatus(value)}
/>
</FilterField>
</FilterBar>
{detailsQuery.isError ? (
<Alert
type="error"
showIcon
className="admin-table-alert"
message="资金明细加载失败"
description={errorText(detailsQuery.error)}
/>
) : null}
<DataTable<FinanceDetail>
columns={columns}
dataSource={detailsQuery.data?.items ?? []}
rowKey="order_id"
loading={detailsQuery.isFetching}
total={detailsQuery.data?.total ?? 0}
page={detailsQuery.data?.page ?? page}
pageSize={detailsQuery.data?.page_size ?? pageSize}
onPageChange={(nextPage, nextSize) => {
setPage(nextPage)
setPageSize(nextSize)
}}
emptyDescription="所选区间还没有资金明细"
/>
</div>
)
}
@@ -0,0 +1,269 @@
import { useMemo, useState } from 'react'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { Alert, Button, Input, Select, Space, Tag, Typography, type TableColumnsType } from 'antd'
import { ReloadOutlined } from '@ant-design/icons'
import { fetchAdminPayments } from '@/features/admin/api'
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
import { DataTable } from '@/features/admin/components/DataTable'
import { FilterBar, FilterField } from '@/features/admin/components/FilterBar'
import type { AdminPaymentQuery, AdminPaymentRow } from '@/features/admin/types'
import { useAdminSessionStore } from '@/stores/adminSession'
import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
type Filters = Pick<AdminPaymentQuery, 'order_no' | 'biz_type' | 'status' | 'provider'>
const emptyFilters: Filters = { order_no: '', biz_type: undefined, status: undefined, provider: undefined }
const statusOptions = [
{ value: 'pending', label: '待支付' },
{ value: 'paid', label: '已支付' },
{ value: 'failed', label: '支付失败' },
{ value: 'closed', label: '已关闭' },
]
const bizTypeOptions = [
{ value: 'order_pay', label: '订单支付' },
{ value: 'main_recharge', label: '充值' },
]
const providerOptions = [
{ value: 'leshua', label: '拉卡拉' },
{ value: 'shuncheng', label: '顺诚' },
{ value: 'mock', label: '模拟支付' },
]
const statusMeta: Record<string, { label: string; color: string }> = {
pending: { label: '待支付', color: 'gold' },
paid: { label: '已支付', color: 'green' },
failed: { label: '支付失败', color: 'red' },
closed: { label: '已关闭', color: 'default' },
}
function statusTag(value?: string | null) {
if (!value) return <Tag>未知</Tag>
const meta = statusMeta[value] ?? { label: value, color: 'default' }
return (
<Tag bordered={false} color={meta.color}>
{meta.label}
</Tag>
)
}
function errorText(error: unknown) {
const response = (error as { response?: { data?: { message?: string } } })?.response
if (response?.data?.message) return response.data.message
return error instanceof Error ? error.message : '请求失败,请稍后重试'
}
const columns: TableColumnsType<AdminPaymentRow> = [
{
title: '支付单号',
key: 'payment',
width: 240,
fixed: 'left',
render: (_, row) => (
<div className="admin-stack">
<strong>{row.payment_no}</strong>
<span className="admin-mono">{formatText(row.order_no)}</span>
</div>
),
},
{
title: '用户',
key: 'user',
width: 170,
render: (_, row) => (
<div className="admin-stack">
<span>{formatText(row.user_phone)}</span>
<span className="admin-mono">UID {row.user_id}</span>
</div>
),
},
{
title: '渠道 / 支付方式',
key: 'channel',
width: 170,
render: (_, row) => (
<div className="admin-stack">
<span>{formatText(row.provider)}</span>
<span className="admin-mono">{formatText(row.pay_way)}</span>
</div>
),
},
{
title: '金额',
dataIndex: 'amount_cent',
width: 130,
align: 'right',
render: value => <strong>{formatMoney(value)}</strong>,
},
{
title: '状态',
dataIndex: 'status',
width: 110,
render: value => statusTag(value),
},
{
title: '支付时间',
dataIndex: 'paid_at',
width: 170,
render: value => (value ? formatDateTime(value) : '—'),
},
{
title: '失败原因',
key: 'error',
render: (_, row) =>
row.error_message ? (
<Typography.Text type="danger">
{row.error_code ? `${row.error_code} · ` : ''}
{row.error_message}
</Typography.Text>
) : (
<Typography.Text type="secondary">—</Typography.Text>
),
},
{
title: '创建时间',
dataIndex: 'created_at',
width: 170,
render: value => formatDateTime(value),
},
]
export function AdminPaymentsPage() {
const [filters, setFilters] = useState<Filters>(emptyFilters)
const [applied, setApplied] = useState<Filters>(emptyFilters)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const permissions = useAdminSessionStore(state => state.admin?.permissions)
const canView = Boolean(permissions?.includes('*') || permissions?.includes('wallet:view'))
const query = useMemo(
() => ({
order_no: applied.order_no?.trim() || undefined,
biz_type: applied.biz_type,
status: applied.status,
provider: applied.provider,
page,
page_size: pageSize,
}),
[applied, page, pageSize]
)
const paymentsQuery = useQuery({
queryKey: ['admin-payments', query],
queryFn: () => fetchAdminPayments(query),
enabled: canView,
placeholderData: keepPreviousData,
})
if (!canView) {
return (
<div className="admin-page">
<AdminPageHeader title="支付流水" eyebrow="JIEYOU OPS · 财务" />
<Alert type="warning" showIcon message="当前账号没有资金查看权限" />
</div>
)
}
return (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 财务"
title="支付流水"
description="查看订单支付、充值等渠道流水,核对金额与回调状态。"
meta={
<Space size={8} wrap>
<Tag color="geekblue">共 {paymentsQuery.data?.total ?? 0} 条</Tag>
</Space>
}
extra={
<Button
icon={<ReloadOutlined />}
loading={paymentsQuery.isFetching}
onClick={() => void paymentsQuery.refetch()}
>
刷新
</Button>
}
/>
<FilterBar
loading={paymentsQuery.isFetching}
onSearch={() => {
setPage(1)
setApplied(filters)
}}
onReset={() => {
setFilters(emptyFilters)
setApplied(emptyFilters)
setPage(1)
}}
>
<FilterField label="订单号">
<Input
allowClear
value={filters.order_no}
placeholder="支持完整订单号"
onChange={event =>
setFilters(current => ({ ...current, order_no: event.target.value }))
}
/>
</FilterField>
<FilterField label="业务类型">
<Select
allowClear
value={filters.biz_type}
options={bizTypeOptions}
placeholder="全部业务"
onChange={value => setFilters(current => ({ ...current, biz_type: value }))}
/>
</FilterField>
<FilterField label="状态">
<Select
allowClear
value={filters.status}
options={statusOptions}
placeholder="全部状态"
onChange={value => setFilters(current => ({ ...current, status: value }))}
/>
</FilterField>
<FilterField label="渠道">
<Select
allowClear
value={filters.provider}
options={providerOptions}
placeholder="全部渠道"
onChange={value => setFilters(current => ({ ...current, provider: value }))}
/>
</FilterField>
</FilterBar>
{paymentsQuery.isError ? (
<Alert
type="error"
showIcon
className="admin-table-alert"
message="支付流水加载失败"
description={errorText(paymentsQuery.error)}
/>
) : null}
<DataTable<AdminPaymentRow>
columns={columns}
dataSource={paymentsQuery.data?.items ?? []}
rowKey="id"
loading={paymentsQuery.isFetching}
total={paymentsQuery.data?.total ?? 0}
page={paymentsQuery.data?.page ?? page}
pageSize={paymentsQuery.data?.page_size ?? pageSize}
onPageChange={(nextPage, nextSize) => {
setPage(nextPage)
setPageSize(nextSize)
}}
emptyDescription="还没有支付流水"
/>
</div>
)
}
@@ -0,0 +1,221 @@
import { useState } from 'react'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { Alert, Button, InputNumber, Select, Space, Tag, type TableColumnsType } from 'antd'
import { ReloadOutlined } from '@ant-design/icons'
import { fetchAdminWalletLedger } from '@/features/admin/api'
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
import { DataTable } from '@/features/admin/components/DataTable'
import { FilterBar, FilterField } from '@/features/admin/components/FilterBar'
import type { AdminWalletLedgerQuery, AdminWalletLedgerRow } from '@/features/admin/types'
import { useAdminSessionStore } from '@/stores/adminSession'
import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
const bizTypeOptions = [
{ value: 'order', label: '订单结算' },
{ value: 'order_pay', label: '订单支付' },
{ value: 'withdraw_apply', label: '提现申请' },
{ value: 'withdraw_complete', label: '提现到账' },
{ value: 'admin_pickup', label: '线下提号' },
{ value: 'invite_rebate', label: '邀请返利' },
]
function errorText(error: unknown) {
const response = (error as { response?: { data?: { message?: string } } })?.response
if (response?.data?.message) return response.data.message
return error instanceof Error ? error.message : '请求失败,请稍后重试'
}
const columns: TableColumnsType<AdminWalletLedgerRow> = [
{
title: '流水号',
key: 'ledger',
width: 240,
fixed: 'left',
render: (_, row) => (
<div className="admin-stack">
<strong>{row.ledger_no}</strong>
<span className="admin-mono">{formatText(row.order_no)}</span>
</div>
),
},
{
title: '用户',
key: 'user',
width: 180,
render: (_, row) => (
<div className="admin-stack">
<span>{row.user_nickname || formatText(row.user_phone)}</span>
<span className="admin-mono">UID {row.user_id}</span>
</div>
),
},
{
title: '业务',
dataIndex: 'biz_type',
width: 140,
render: value => <Tag bordered={false}>{formatText(value)}</Tag>,
},
{
title: '方向',
dataIndex: 'direction',
width: 90,
render: value => (
<Tag bordered={false} color={value === 'income' ? 'green' : 'volcano'}>
{value === 'income' ? '入账' : '出账'}
</Tag>
),
},
{
title: '金额',
dataIndex: 'amount_cent',
width: 130,
align: 'right',
render: (value: number, row) => (
<strong style={{ color: row.direction === 'income' ? 'var(--success)' : 'var(--danger)' }}>
{row.direction === 'income' ? '+' : '-'}
{formatMoney(Math.abs(value))}
</strong>
),
},
{
title: '变化后余额',
dataIndex: 'balance_after_cent',
width: 140,
align: 'right',
render: value => formatMoney(value),
},
{
title: '备注',
dataIndex: 'remark',
ellipsis: true,
render: value => formatText(value),
},
{
title: '时间',
dataIndex: 'created_at',
width: 170,
render: value => formatDateTime(value),
},
]
export function AdminWalletLedgerPage() {
const [filters, setFilters] = useState<{ user_id?: number; order_id?: number; biz_type?: string }>({})
const [applied, setApplied] = useState(filters)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const permissions = useAdminSessionStore(state => state.admin?.permissions)
const canView = Boolean(permissions?.includes('*') || permissions?.includes('wallet:view'))
const query: AdminWalletLedgerQuery = {
user_id: applied.user_id,
order_id: applied.order_id,
biz_type: applied.biz_type,
page,
page_size: pageSize,
}
const ledgerQuery = useQuery({
queryKey: ['admin-wallet-ledger', query],
queryFn: () => fetchAdminWalletLedger(query),
enabled: canView,
placeholderData: keepPreviousData,
})
if (!canView) {
return (
<div className="admin-page">
<AdminPageHeader title="钱包流水" eyebrow="JIEYOU OPS · 财务" />
<Alert type="warning" showIcon message="当前账号没有资金查看权限" />
</div>
)
}
return (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 财务"
title="钱包流水"
description="按用户、订单与业务类型核对每一笔余额变动。"
meta={
<Space size={8} wrap>
<Tag color="geekblue">共 {ledgerQuery.data?.total ?? 0} 条</Tag>
</Space>
}
extra={
<Button
icon={<ReloadOutlined />}
loading={ledgerQuery.isFetching}
onClick={() => void ledgerQuery.refetch()}
>
刷新
</Button>
}
/>
<FilterBar
loading={ledgerQuery.isFetching}
onSearch={() => {
setPage(1)
setApplied(filters)
}}
onReset={() => {
setFilters({})
setApplied({})
setPage(1)
}}
>
<FilterField label="用户 ID" width={160}>
<InputNumber
min={1}
style={{ width: '100%' }}
value={filters.user_id}
onChange={value => setFilters(current => ({ ...current, user_id: value ?? undefined }))}
/>
</FilterField>
<FilterField label="订单 ID" width={160}>
<InputNumber
min={1}
style={{ width: '100%' }}
value={filters.order_id}
onChange={value => setFilters(current => ({ ...current, order_id: value ?? undefined }))}
/>
</FilterField>
<FilterField label="业务类型">
<Select
allowClear
value={filters.biz_type}
options={bizTypeOptions}
placeholder="全部业务"
onChange={value => setFilters(current => ({ ...current, biz_type: value }))}
/>
</FilterField>
</FilterBar>
{ledgerQuery.isError ? (
<Alert
type="error"
showIcon
className="admin-table-alert"
message="钱包流水加载失败"
description={errorText(ledgerQuery.error)}
/>
) : null}
<DataTable<AdminWalletLedgerRow>
columns={columns}
dataSource={ledgerQuery.data?.items ?? []}
rowKey="id"
loading={ledgerQuery.isFetching}
total={ledgerQuery.data?.total ?? 0}
page={ledgerQuery.data?.page ?? page}
pageSize={ledgerQuery.data?.page_size ?? pageSize}
onPageChange={(nextPage, nextSize) => {
setPage(nextPage)
setPageSize(nextSize)
}}
emptyDescription="还没有钱包流水"
/>
</div>
)
}
+31
View File
@@ -98,6 +98,10 @@ import type {
FinanceDetailPage,
FinanceDisbursementPage,
FinanceDisbursementQuery,
AdminPaymentPage,
AdminPaymentQuery,
AdminWalletLedgerPage,
AdminWalletLedgerQuery,
ManualDisbursementPayload,
OperatingExpensePage,
OperatingExpensePayload,
@@ -1365,3 +1369,30 @@ export async function voidOperatingExpense(id: number | string, reason: string)
)
return data.data
}
export async function fetchAdminPayments(query: AdminPaymentQuery = {}) {
const { data } = await adminClient.get<ApiResponse<Partial<AdminPaymentPage>>>(
'/admin/payments',
{ params: compactParams(query) }
)
return {
items: data.data.items ?? [],
total: data.data.total ?? 0,
page: data.data.page ?? query.page ?? 1,
page_size: data.data.page_size ?? query.page_size ?? 20,
}
}
export async function fetchAdminWalletLedger(query: AdminWalletLedgerQuery = {}) {
const { data } = await adminClient.get<ApiResponse<Partial<AdminWalletLedgerPage>>>(
'/admin/wallet/ledger',
{ params: compactParams(query) }
)
return {
items: data.data.items ?? [],
total: data.data.total ?? 0,
page: data.data.page ?? query.page ?? 1,
page_size: data.data.page_size ?? query.page_size ?? 20,
}
}
+75
View File
@@ -534,6 +534,81 @@ export interface FinanceDetailPage {
page_size: number
}
export interface AdminPaymentRow {
id: number
payment_no: string
order_id: number
order_no: string
user_id: number
payment_config_id: number
user_phone: string
provider: string
merchant_id: string
third_order_id: string
provider_order_id: string
pay_way: string
amount_cent: number
biz_type: string
status: string
error_code: string
error_message: string
paid_at?: string | null
notified_at?: string | null
created_at: string
updated_at: string
}
export interface AdminPaymentPage {
items: AdminPaymentRow[]
total: number
page: number
page_size: number
}
export interface AdminPaymentQuery {
user_id?: number
order_id?: number
order_no?: string
biz_type?: string
status?: string
provider?: string
page?: number
page_size?: number
}
export interface AdminWalletLedgerRow {
id: number
ledger_no: string
user_id: number
user_phone: string
user_nickname: string
order_id?: number | null
order_no: string
direction: string
amount_cent: number
balance_after_cent: number
balance_type: string
biz_type: string
biz_no: string
remark: string
created_at: string
}
export interface AdminWalletLedgerPage {
items: AdminWalletLedgerRow[]
total: number
page: number
page_size: number
}
export interface AdminWalletLedgerQuery {
user_id?: number
order_id?: number
biz_type?: string
page?: number
page_size?: number
}
export interface AdminAnnouncementQuery {
category?: string
status?: string
@@ -0,0 +1,231 @@
import { useEffect, useMemo, useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
Button,
Card,
Dialog,
ErrorBlock,
Form,
Input,
List,
NavBar,
Selector,
Skeleton,
Tag,
Toast,
} from 'antd-mobile'
import { useNavigate } from 'react-router-dom'
import {
cancelWithdrawal,
createWithdrawal,
fetchPaymentAccounts,
fetchWalletBalance,
fetchWithdrawals,
} from '@/features/wallet/api'
import { paymentAccountTypeLabel, withdrawalStatusMeta } from '@/features/wallet/labels'
import { useSessionStore } from '@/stores/session'
import { formatDateTime, formatMoney } from '@/shared/utils/format'
interface WithdrawFormValues {
amount: string
}
function errorText(error: unknown) {
const response = (error as { response?: { data?: { message?: string } } })?.response
if (response?.data?.message) return response.data.message
return error instanceof Error ? error.message : '请求失败,请稍后重试'
}
export function MobileWithdrawalPage() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const [form] = Form.useForm<WithdrawFormValues>()
const [accountId, setAccountId] = useState<number>()
const [submitting, setSubmitting] = useState(false)
const user = useSessionStore(state => state.user)
const balanceQuery = useQuery({
queryKey: ['wallet-balance'],
queryFn: fetchWalletBalance,
enabled: Boolean(user),
})
const accountsQuery = useQuery({
queryKey: ['payment-accounts', 1],
queryFn: () => fetchPaymentAccounts(1, 50),
enabled: Boolean(user),
})
const withdrawalsQuery = useQuery({
queryKey: ['withdrawals', 1, 20],
queryFn: () => fetchWithdrawals(1, 20),
enabled: Boolean(user),
})
const available = balanceQuery.data?.available_balance_cent ?? 0
const accounts = useMemo(() => accountsQuery.data?.items ?? [], [accountsQuery.data])
const withdrawals = withdrawalsQuery.data?.items ?? []
useEffect(() => {
if (accountId) return
const preferred = accounts.find(item => item.is_default) ?? accounts[0]
if (preferred) setAccountId(preferred.id)
}, [accounts, accountId])
const submit = async () => {
const values = await form.validateFields()
if (!accountId) {
Toast.show({ content: '请先选择收款账户' })
return
}
const amountCent = Math.round(Number(values.amount) * 100)
if (!Number.isFinite(amountCent) || amountCent <= 0) {
Toast.show({ content: '请输入正确的提现金额' })
return
}
if (amountCent > available) {
Toast.show({ content: '提现金额不能超过可用余额' })
return
}
setSubmitting(true)
try {
await createWithdrawal({ payment_account_id: accountId, amount_cent: amountCent })
Toast.show({ icon: 'success', content: '提现申请已提交' })
form.setFieldValue('amount', '')
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['withdrawals'] }),
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] }),
queryClient.invalidateQueries({ queryKey: ['wallet-ledger'] }),
])
} catch (error) {
Toast.show({ content: errorText(error) })
} finally {
setSubmitting(false)
}
}
const cancel = async (id: number, withdrawNo: string) => {
const confirmed = await Dialog.confirm({
content: `确认取消提现 ${withdrawNo}?取消后金额退回可用余额。`,
confirmText: '取消提现',
cancelText: '返回',
})
if (!confirmed) return
try {
await cancelWithdrawal(id)
Toast.show({ icon: 'success', content: '提现已取消' })
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['withdrawals'] }),
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] }),
])
} catch (error) {
Toast.show({ content: errorText(error) })
}
}
return (
<div className="mobile-wallet">
<NavBar onBack={() => navigate('/m/profile')}>提现</NavBar>
<Card className="mobile-wallet__balance">
<div className="mobile-wallet__label">可用余额</div>
{balanceQuery.isPending ? (
<Skeleton.Title animated />
) : (
<div className="mobile-wallet__amount">{formatMoney(available)}</div>
)}
<Button
size="mini"
color="primary"
fill="outline"
onClick={() => navigate('/m/profile')}
>
返回我的
</Button>
</Card>
{accounts.length === 0 && !accountsQuery.isPending ? (
<ErrorBlock
status="empty"
title="还没有收款账户"
description="请先在电脑端「我的钱包 · 收款账户」添加账户后再提现。"
/>
) : (
<>
<Card title="收款账户">
{accountsQuery.isPending ? (
<Skeleton.Paragraph lineCount={2} animated />
) : (
<Selector
value={accountId ? [String(accountId)] : []}
onChange={values => setAccountId(values[0] ? Number(values[0]) : undefined)}
options={accounts.map(account => ({
label: `${paymentAccountTypeLabel(account.account_type)} · ${account.account_name}`,
description: account.account_no,
value: String(account.id),
}))}
/>
)}
</Card>
<Card title="提现金额">
<Form form={form} layout="horizontal">
<Form.Item
name="amount"
label="金额(元)"
rules={[{ required: true, message: '请输入提现金额' }]}
>
<Input type="number" placeholder="0.00" inputMode="decimal" />
</Form.Item>
</Form>
<div className="mobile-wallet__hint">
可提现 {formatMoney(available)},当前手续费 0%,到账金额以审核结果为准。
</div>
<Button
block
color="primary"
loading={submitting}
onClick={() => void submit()}
style={{ marginTop: 12 }}
>
提交提现申请
</Button>
</Card>
</>
)}
<Card title="提现记录">
{withdrawalsQuery.isPending ? (
<Skeleton.Paragraph lineCount={4} animated />
) : withdrawals.length === 0 ? (
<ErrorBlock status="empty" title="还没有提现记录" />
) : (
<List>
{withdrawals.map(item => {
const meta = withdrawalStatusMeta(item.status)
return (
<List.Item
key={item.id}
description={`${formatDateTime(item.created_at)} · 手续费 ${formatMoney(item.fee_cent)}`}
extra={
<Tag color={meta.color === 'default' ? 'default' : meta.color}>
{meta.label}
</Tag>
}
clickable={item.status === 'pending'}
onClick={() => {
if (item.status === 'pending') void cancel(item.id, item.withdraw_no)
}}
>
<div>{formatMoney(item.amount_cent)}</div>
<div className="mobile-wallet__sub">
{item.withdraw_no} · 到账 {formatMoney(item.actual_amount_cent)}
</div>
</List.Item>
)
})}
</List>
)}
</Card>
</div>
)
}
@@ -0,0 +1,161 @@
import { useState } from 'react'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { Alert, Button, Space, Table, Tag, Typography, type TableColumnsType } from 'antd'
import { ReloadOutlined } from '@ant-design/icons'
import { useNavigate } from 'react-router-dom'
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
import { fetchWalletBalance, fetchWalletLedger } from '@/features/wallet/api'
import { ledgerBalanceTypeLabel, walletBizTypeLabel } from '@/features/wallet/labels'
import type { WalletLedger } from '@/features/wallet/types'
import { useSessionStore } from '@/stores/session'
import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
const columns: TableColumnsType<WalletLedger> = [
{
title: '流水号',
dataIndex: 'ledger_no',
width: 220,
render: value => <span className="wallet-mono">{formatText(value)}</span>,
},
{
title: '业务',
dataIndex: 'biz_type',
width: 150,
render: value => <Tag bordered={false}>{walletBizTypeLabel(value)}</Tag>,
},
{
title: '方向',
dataIndex: 'direction',
width: 90,
render: value => (
<Tag bordered={false} color={value === 'income' ? 'green' : 'volcano'}>
{value === 'income' ? '入账' : '出账'}
</Tag>
),
},
{
title: '金额',
dataIndex: 'amount_cent',
width: 140,
align: 'right',
render: (value: number, row) => (
<strong className={row.direction === 'income' ? 'wallet-amount-up' : 'wallet-amount-down'}>
{row.direction === 'income' ? '+' : '-'}
{formatMoney(Math.abs(value))}
</strong>
),
},
{
title: '余额类型',
dataIndex: 'balance_type',
width: 120,
render: value => (
<Typography.Text type="secondary">{ledgerBalanceTypeLabel(value)}</Typography.Text>
),
},
{
title: '变化后余额',
dataIndex: 'balance_after_cent',
width: 150,
align: 'right',
render: value => formatMoney(value),
},
{
title: '备注',
dataIndex: 'remark',
ellipsis: true,
render: value => formatText(value),
},
{
title: '时间',
dataIndex: 'created_at',
width: 170,
render: value => formatDateTime(value),
},
]
export function SellerEarningsPage() {
const navigate = useNavigate()
const user = useSessionStore(state => state.user)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const balanceQuery = useQuery({
queryKey: ['wallet-balance'],
queryFn: fetchWalletBalance,
enabled: Boolean(user),
})
const ledgerQuery = useQuery({
queryKey: ['wallet-ledger', page, pageSize],
queryFn: () => fetchWalletLedger(page, pageSize),
enabled: Boolean(user),
placeholderData: keepPreviousData,
})
return (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU · 卖家中心"
title="收益流水"
description="查看订单结算收入、线下提号结算与提现记录,全部以分为单位结算。"
meta={
<Space size={8} wrap>
<Tag color="green">可用 {formatMoney(balanceQuery.data?.available_balance_cent ?? 0)}</Tag>
<Tag>冻结 {formatMoney(balanceQuery.data?.frozen_balance_cent ?? 0)}</Tag>
</Space>
}
extra={
<Space>
<Button type="primary" onClick={() => navigate('/wallet/withdrawal')}>
申请提现
</Button>
<Button onClick={() => navigate('/wallet')}>钱包明细</Button>
<Button
icon={<ReloadOutlined />}
loading={ledgerQuery.isFetching}
onClick={() => {
void ledgerQuery.refetch()
void balanceQuery.refetch()
}}
>
刷新
</Button>
</Space>
}
/>
{ledgerQuery.isError ? (
<Alert
type="error"
showIcon
className="admin-table-alert"
message="收益流水加载失败"
description={(ledgerQuery.error as Error)?.message}
/>
) : null}
<Table<WalletLedger>
rowKey="id"
size="middle"
columns={columns}
dataSource={ledgerQuery.data?.items ?? []}
loading={ledgerQuery.isFetching}
scroll={{ x: 'max-content' }}
locale={{ emptyText: '还没有收益流水' }}
pagination={{
current: ledgerQuery.data?.page ?? page,
pageSize: ledgerQuery.data?.page_size ?? pageSize,
total: ledgerQuery.data?.total ?? 0,
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50'],
showTotal: (count, range) => `第 ${range[0]}-${range[1]} 条 / 共 ${count} 条`,
onChange: (nextPage, nextSize) => {
setPage(nextPage)
setPageSize(nextSize)
},
}}
/>
</div>
)
}
@@ -0,0 +1,195 @@
import { useState } from 'react'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { Alert, Button, Card, Select, Space, Tag, Typography, type TableColumnsType } from 'antd'
import { ReloadOutlined, RightOutlined } from '@ant-design/icons'
import { useNavigate } from 'react-router-dom'
import { DataTable } from '@/features/admin/components/DataTable'
import { fetchSellerHandoffs } from '@/features/seller/api'
import { handoffStatusMeta, orderStatusMeta } from '@/features/orders/labels'
import type { UserOrderListItem } from '@/features/orders/types'
import { useSessionStore } from '@/stores/session'
import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
const statusOptions = [
{ value: '', label: '全部' },
{ value: 'pending_handoff', label: '待交接' },
{ value: 'pending_checkout_accept', label: '待确认结账' },
{ value: 'abnormal', label: '异常订单' },
]
export function SellerHandoffsPage() {
const navigate = useNavigate()
const user = useSessionStore(state => state.user)
const [status, setStatus] = useState('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const handoffsQuery = useQuery({
queryKey: ['seller-handoffs', status, page, pageSize],
queryFn: () => fetchSellerHandoffs<UserOrderListItem>(page, pageSize, status),
enabled: Boolean(user),
placeholderData: keepPreviousData,
})
const metrics = handoffsQuery.data?.metrics
const columns: TableColumnsType<UserOrderListItem> = [
{
title: '订单',
key: 'order',
width: 260,
render: (_, row) => (
<div className="admin-stack">
<button
type="button"
className="admin-order-link"
onClick={() => navigate(`/orders/${row.id}`)}
>
{row.order_no}
</button>
<strong>{row.title}</strong>
<span className="admin-mono">{row.listing_no}</span>
</div>
),
},
{
title: '状态',
key: 'status',
width: 200,
render: (_, row) => (
<Space size={4} wrap>
<Tag bordered={false}>{orderStatusMeta(row.status).label}</Tag>
<Tag bordered={false} color="blue">
{handoffStatusMeta(row.handoff_status).label}
</Tag>
</Space>
),
},
{
title: '商品信息',
key: 'listing',
render: (_, row) => (
<div className="admin-stack">
<span>{formatText(row.server_region)}</span>
<Typography.Text type="secondary">{formatText(row.login_platform)}</Typography.Text>
</div>
),
},
{
title: '金额',
key: 'amount',
width: 150,
align: 'right',
render: (_, row) => (
<div className="admin-stack">
<strong>{formatMoney(row.display_amount_cent)}</strong>
<span className="admin-mono">押金 {formatMoney(row.deposit_amount_cent)}</span>
</div>
),
},
{
title: '创建时间',
dataIndex: 'created_at',
width: 170,
render: value => formatDateTime(value),
},
{
title: '操作',
key: 'actions',
width: 100,
fixed: 'right',
render: (_, row) => (
<Button
type="link"
size="small"
icon={<RightOutlined />}
onClick={() => navigate(`/orders/${row.id}`)}
>
处理
</Button>
),
},
]
return (
<div className="admin-page">
<div className="admin-page__head">
<div className="admin-page__head-main">
<Typography.Text className="admin-page__eyebrow">JIEYOU · 卖家中心</Typography.Text>
<Typography.Title level={2} className="brand-title">
号主待办
</Typography.Title>
<Typography.Text type="secondary" className="admin-page__desc">
处理待交接、待确认结账与异常订单;点击订单进入详情完成后续操作。
</Typography.Text>
</div>
<div className="admin-page__actions">
<Space>
<Select
value={status}
options={statusOptions}
style={{ width: 160 }}
onChange={value => {
setStatus(value)
setPage(1)
}}
/>
<Button
icon={<ReloadOutlined />}
loading={handoffsQuery.isFetching}
onClick={() => void handoffsQuery.refetch()}
>
刷新
</Button>
</Space>
</div>
</div>
<div className="wallet-metrics" style={{ marginBottom: 16 }}>
<div className="wallet-metric">
<span>待交接</span>
<strong>{metrics?.pending_handoff ?? 0}</strong>
<small>需要尽快上号</small>
</div>
<div className="wallet-metric">
<span>待确认结账</span>
<strong>{metrics?.pending_checkout ?? 0}</strong>
<small>核对消耗后确认</small>
</div>
<div className="wallet-metric">
<span>异常订单</span>
<strong>{metrics?.abnormal ?? 0}</strong>
<small>需与客服协同处理</small>
</div>
</div>
{handoffsQuery.isError ? (
<Alert
type="error"
showIcon
className="admin-table-alert"
message="待办列表加载失败"
description={(handoffsQuery.error as Error)?.message}
/>
) : null}
<Card className="admin-table-card" title="待办订单">
<DataTable<UserOrderListItem>
columns={columns}
dataSource={handoffsQuery.data?.items ?? []}
rowKey="id"
loading={handoffsQuery.isFetching}
total={handoffsQuery.data?.total ?? 0}
page={handoffsQuery.data?.page ?? page}
pageSize={handoffsQuery.data?.page_size ?? pageSize}
onPageChange={(nextPage, nextSize) => {
setPage(nextPage)
setPageSize(nextSize)
}}
emptyDescription="当前没有需要处理的订单"
/>
</Card>
</div>
)
}
@@ -0,0 +1,196 @@
import { useState } from 'react'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { Alert, Button, Select, Space, Tag, Typography, type TableColumnsType } from 'antd'
import { ReloadOutlined } from '@ant-design/icons'
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
import { DataTable } from '@/features/admin/components/DataTable'
import { fetchSellerPickups } from '@/features/seller/api'
import type { SellerPickup } from '@/features/seller/api'
import { useSessionStore } from '@/stores/session'
import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
const statusMeta: Record<string, { label: string; color: string }> = {
picking_up: { label: '提号中', color: 'gold' },
completed: { label: '已完成', color: 'green' },
cancelled: { label: '已取消', color: 'default' },
}
const settlementMeta: Record<string, { label: string; color: string }> = {
none: { label: '未结算', color: 'default' },
pending: { label: '待结算', color: 'gold' },
settled: { label: '已结算', color: 'green' },
}
const statusOptions = [
{ value: '', label: '全部状态' },
{ value: 'picking_up', label: '提号中' },
{ value: 'completed', label: '已完成' },
{ value: 'cancelled', label: '已取消' },
]
const columns: TableColumnsType<SellerPickup> = [
{
title: '提号编号',
key: 'pickup',
width: 260,
fixed: 'left',
render: (_, row) => (
<div className="admin-stack">
<strong>{row.pickup_no}</strong>
<span className="admin-mono">{formatText(row.listing_no)}</span>
</div>
),
},
{
title: '账号',
key: 'account',
render: (_, row) => (
<div className="admin-stack">
<span>{formatText(row.account_title)}</span>
<Typography.Text type="secondary">
{formatText(row.server_region)} · {formatText(row.login_platform)}
</Typography.Text>
</div>
),
},
{
title: '操作人 / 平台',
key: 'operator',
width: 170,
render: (_, row) => (
<div className="admin-stack">
<span>{formatText(row.operator)}</span>
<span className="admin-mono">{formatText(row.platform)}</span>
</div>
),
},
{
title: '订单金额',
dataIndex: 'order_amount_cent',
width: 130,
align: 'right',
render: value => formatMoney(value),
},
{
title: '应结算',
key: 'settle',
width: 150,
align: 'right',
render: (_, row) => (
<div className="admin-stack">
<strong>{formatMoney(row.effective_settle_amount_cent)}</strong>
<span className="admin-mono">原 {formatMoney(row.settle_amount_cent)}</span>
</div>
),
},
{
title: '状态',
key: 'status',
width: 190,
render: (_, row) => {
const status = statusMeta[row.status] ?? { label: row.status || '未知', color: 'default' }
const settlement =
settlementMeta[row.offline_settlement_status] ?? {
label: row.offline_settlement_status || '—',
color: 'default',
}
return (
<Space size={4} wrap>
<Tag bordered={false} color={status.color}>
{status.label}
</Tag>
<Tag bordered={false} color={settlement.color}>
{settlement.label}
</Tag>
</Space>
)
},
},
{
title: '创建时间',
dataIndex: 'created_at',
width: 170,
render: value => formatDateTime(value),
},
{
title: '完成时间',
dataIndex: 'completed_at',
width: 170,
render: value => (value ? formatDateTime(value) : '—'),
},
]
export function SellerPickupsPage() {
const user = useSessionStore(state => state.user)
const [status, setStatus] = useState('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const pickupsQuery = useQuery({
queryKey: ['seller-pickups', status, page, pageSize],
queryFn: () => fetchSellerPickups(page, pageSize, status),
enabled: Boolean(user),
placeholderData: keepPreviousData,
})
return (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU · 卖家中心"
title="线下提号记录"
description="查看账号被平台线下提号的记录、应结算金额与结算状态。"
meta={
<Space size={8} wrap>
<Tag color="geekblue">共 {pickupsQuery.data?.total ?? 0} 条</Tag>
</Space>
}
extra={
<Space>
<Select
value={status}
options={statusOptions}
style={{ width: 150 }}
onChange={value => {
setStatus(value)
setPage(1)
}}
/>
<Button
icon={<ReloadOutlined />}
loading={pickupsQuery.isFetching}
onClick={() => void pickupsQuery.refetch()}
>
刷新
</Button>
</Space>
}
/>
{pickupsQuery.isError ? (
<Alert
type="error"
showIcon
className="admin-table-alert"
message="提号记录加载失败"
description={(pickupsQuery.error as Error)?.message}
/>
) : null}
<DataTable<SellerPickup>
columns={columns}
dataSource={pickupsQuery.data?.items ?? []}
rowKey="id"
loading={pickupsQuery.isFetching}
total={pickupsQuery.data?.total ?? 0}
page={pickupsQuery.data?.page ?? page}
pageSize={pickupsQuery.data?.page_size ?? pageSize}
onPageChange={(nextPage, nextSize) => {
setPage(nextPage)
setPageSize(nextSize)
}}
emptyDescription="还没有线下提号记录"
/>
</div>
)
}
+62
View File
@@ -59,3 +59,65 @@ export function publishResultText(listing: Listing, editing: boolean) {
if (published) return editing ? '修改成功,已上架' : '发布成功,已上架'
return editing ? '修改成功,等待后台审核' : '发布成功,等待后台审核'
}
export interface SellerHandoffMetrics {
pending_handoff: number
pending_checkout: number
abnormal: number
}
export interface SellerHandoffPage<T> {
items: T[]
total: number
page: number
page_size: number
metrics: SellerHandoffMetrics
}
export interface SellerPickup {
id: number
pickup_no: string
listing_no: string
account_title: string
server_region: string
login_platform: string
operator: string
platform: string
status: string
offline_settlement_status: string
order_amount_cent: number
settle_amount_cent: number
effective_settle_amount_cent: number
profit_amount_cent: number
created_at: string
completed_at?: string | null
cancelled_at?: string | null
}
export interface SellerPickupPage {
items: SellerPickup[]
total: number
page: number
page_size: number
}
export async function fetchSellerHandoffs<T>(page = 1, pageSize = 20, status = '') {
return unwrapData<SellerHandoffPage<T>>(
apiClient.get<ApiResponse<SellerHandoffPage<T>>>('/orders/handoffs', {
params: { page, page_size: pageSize, status: status || undefined },
})
)
}
export async function fetchSellerPickups(page = 1, pageSize = 20, status = '') {
return unwrapData<Partial<SellerPickupPage>>(
apiClient.get<ApiResponse<Partial<SellerPickupPage>>>('/seller/pickups', {
params: { page, page_size: pageSize, status: status || undefined },
})
).then(data => ({
items: data.items ?? [],
total: data.total ?? 0,
page: data.page ?? page,
page_size: data.page_size ?? pageSize,
}))
}
@@ -0,0 +1,375 @@
import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
App,
Button,
Card,
Empty,
Form,
Input,
Modal,
Select,
Space,
Tag,
Typography,
Upload,
type UploadFile,
} from 'antd'
import {
DeleteOutlined,
EditOutlined,
PlusOutlined,
ReloadOutlined,
StarFilled,
StarOutlined,
} from '@ant-design/icons'
import { useNavigate } from 'react-router-dom'
import { uploadFile } from '@/shared/api/files'
import {
createPaymentAccount,
deletePaymentAccount,
fetchPaymentAccounts,
setDefaultPaymentAccount,
updatePaymentAccount,
} from '@/features/wallet/api'
import { paymentAccountTypeLabel } from '@/features/wallet/labels'
import type {
CreatePaymentAccountPayload,
PaymentAccount,
PaymentAccountType,
} from '@/features/wallet/types'
import { useSessionStore } from '@/stores/session'
import { formatDateTime, formatText } from '@/shared/utils/format'
interface AccountFormValues {
account_type: PaymentAccountType
account_name: string
account_no: string
bank_name?: string
bank_branch?: string
}
function errorText(error: unknown) {
const response = (error as { response?: { data?: { message?: string } } })?.response
if (response?.data?.message) return response.data.message
return error instanceof Error ? error.message : '请求失败,请稍后重试'
}
export function PaymentAccountsPage() {
const navigate = useNavigate()
const { message, modal } = App.useApp()
const queryClient = useQueryClient()
const [form] = Form.useForm<AccountFormValues>()
const [editorOpen, setEditorOpen] = useState(false)
const [editing, setEditing] = useState<PaymentAccount>()
const [certificates, setCertificates] = useState<string[]>([])
const [uploading, setUploading] = useState(false)
const [submitting, setSubmitting] = useState(false)
const user = useSessionStore(state => state.user)
const accountType = Form.useWatch('account_type', form)
const accountsQuery = useQuery({
queryKey: ['payment-accounts', 1],
queryFn: () => fetchPaymentAccounts(1, 50),
enabled: Boolean(user),
})
const accounts = accountsQuery.data?.items ?? []
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['payment-accounts'] })
const openCreate = () => {
setEditing(undefined)
setCertificates([])
form.setFieldsValue({
account_type: 'alipay',
account_name: '',
account_no: '',
bank_name: '',
bank_branch: '',
})
setEditorOpen(true)
}
const openEdit = (account: PaymentAccount) => {
setEditing(account)
setCertificates(account.certificate_urls ?? [])
form.setFieldsValue({
account_type: account.account_type,
account_name: account.account_name,
account_no: account.account_no,
bank_name: account.bank_name,
bank_branch: account.bank_branch,
})
setEditorOpen(true)
}
const save = async () => {
const values = await form.validateFields()
setSubmitting(true)
try {
if (editing) {
await updatePaymentAccount(editing.id, {
bank_branch: values.bank_branch?.trim(),
certificate_urls: certificates,
})
message.success('收款账户已更新')
} else {
const payload: CreatePaymentAccountPayload = {
account_type: values.account_type,
account_name: values.account_name.trim(),
account_no: values.account_no.trim(),
bank_name: values.bank_name?.trim(),
bank_branch: values.bank_branch?.trim(),
certificate_urls: certificates,
}
await createPaymentAccount(payload)
message.success('收款账户已添加')
}
setEditorOpen(false)
await invalidate()
} catch (error) {
message.error(errorText(error))
} finally {
setSubmitting(false)
}
}
const remove = (account: PaymentAccount) => {
modal.confirm({
title: `确认删除收款账户「${account.account_name}」?`,
content: '删除后该账户不再出现在提现选择列表中,已提交的提现不受影响。',
okText: '删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
await deletePaymentAccount(account.id)
message.success('收款账户已删除')
await invalidate()
} catch (error) {
message.error(errorText(error))
}
},
})
}
const setDefault = async (account: PaymentAccount) => {
try {
await setDefaultPaymentAccount(account.id)
message.success('已设为默认收款账户')
await invalidate()
} catch (error) {
message.error(errorText(error))
}
}
const handleUpload = async (file: File) => {
setUploading(true)
try {
const uploaded = await uploadFile(file, 'payment-cert')
setCertificates(current => [...current, uploaded.url])
message.success('收款凭证已上传')
} catch (error) {
message.error(errorText(error))
} finally {
setUploading(false)
}
return false
}
const fileList: UploadFile[] = certificates.map((url, index) => ({
uid: `${index}`,
name: `收款凭证 ${index + 1}`,
status: 'done',
url,
thumbUrl: url,
}))
return (
<div className="wallet-page">
<section className="wallet-hero wallet-hero--compact">
<div>
<Typography.Text className="wallet-eyebrow">JIEYOU · 资金</Typography.Text>
<Typography.Title level={1} className="brand-title wallet-title">
收款账户
</Typography.Title>
<Typography.Paragraph className="wallet-desc">
提现前需维护至少一个收款账户;默认账户会在提现表单中自动选中。
</Typography.Paragraph>
</div>
<div className="wallet-hero__action">
<span>可用账户</span>
<strong>{accounts.length}</strong>
<Space size={8} wrap>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
添加账户
</Button>
<Button onClick={() => navigate('/wallet')}>返回钱包</Button>
</Space>
</div>
</section>
<Card
className="wallet-ledger-card"
title={`收款账户(${accounts.length})`}
extra={
<Button
icon={<ReloadOutlined />}
loading={accountsQuery.isFetching}
onClick={() => void accountsQuery.refetch()}
>
刷新
</Button>
}
>
{accounts.length === 0 ? (
<Empty description="还没有收款账户,请先添加后再申请提现" />
) : (
<div className="pay-account-grid">
{accounts.map(account => (
<div key={account.id} className="pay-account-card">
<div className="pay-account-card__head">
<Space size={6} wrap>
<Tag bordered={false} color="geekblue">
{paymentAccountTypeLabel(account.account_type)}
</Tag>
{account.is_default ? (
<Tag bordered={false} color="green">
默认
</Tag>
) : null}
</Space>
<span className="wallet-mono">{account.account_no}</span>
</div>
<strong>{account.account_name}</strong>
{account.bank_name ? (
<span className="pay-account-card__bank">
{account.bank_name}
{account.bank_branch ? ` · ${account.bank_branch}` : ''}
</span>
) : null}
<span className="pay-account-card__time">
添加于 {formatDateTime(account.created_at)}
</span>
{account.certificate_urls?.length ? (
<span className="pay-account-card__cert">
凭证 {account.certificate_urls.length} 张
</span>
) : null}
<Space size={4} wrap style={{ marginTop: 8 }}>
<Button
type="link"
size="small"
icon={account.is_default ? <StarFilled /> : <StarOutlined />}
disabled={account.is_default}
onClick={() => void setDefault(account)}
>
设为默认
</Button>
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => openEdit(account)}
>
编辑
</Button>
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => remove(account)}
>
删除
</Button>
</Space>
</div>
))}
</div>
)}
</Card>
<Modal
open={editorOpen}
title={editing ? `编辑收款账户 · ${editing.account_name}` : '添加收款账户'}
okText="保存"
cancelText="取消"
confirmLoading={submitting}
onOk={() => void save()}
onCancel={() => (submitting ? undefined : setEditorOpen(false))}
maskClosable={!submitting}
>
<Form form={form} layout="vertical" requiredMark={false}>
<Form.Item
name="account_type"
label="收款方式"
rules={[{ required: true, message: '请选择收款方式' }]}
>
<Select
disabled={Boolean(editing)}
options={[
{ value: 'alipay', label: '支付宝' },
{ value: 'wechat', label: '微信' },
{ value: 'bank', label: '银行卡' },
]}
/>
</Form.Item>
<Form.Item
name="account_name"
label="收款人"
rules={[{ required: true, message: '请输入收款人姓名' }]}
>
<Input placeholder="需与实名信息一致" disabled={Boolean(editing)} />
</Form.Item>
<Form.Item
name="account_no"
label="收款账号"
rules={[{ required: true, message: '请输入收款账号' }]}
>
<Input placeholder="支付宝/微信账号或银行卡号" disabled={Boolean(editing)} />
</Form.Item>
{accountType === 'bank' ? (
<>
<Form.Item name="bank_name" label="开户行">
<Input placeholder="例如:招商银行" disabled={Boolean(editing)} />
</Form.Item>
<Form.Item name="bank_branch" label="开户支行">
<Input placeholder="例如:深圳分行科技园支行" />
</Form.Item>
</>
) : null}
<Form.Item label="收款凭证(可选)" extra="支持上传收款码截图,便于财务核对。">
<Upload
listType="picture-card"
fileList={fileList}
accept="image/*"
beforeUpload={file => {
void handleUpload(file as File)
return false
}}
onRemove={file => {
const index = Number(file.uid)
setCertificates(current => current.filter((_, i) => i !== index))
}}
>
{certificates.length >= 5 || uploading ? null : (
<div>
<PlusOutlined />
<div style={{ marginTop: 6 }}>上传</div>
</div>
)}
</Upload>
</Form.Item>
</Form>
</Modal>
<Typography.Paragraph type="secondary" style={{ marginTop: 16 }}>
当前共 {accounts.length} 个账户,其中默认账户 {accounts.filter(item => item.is_default).length} 个
{formatText('')}
</Typography.Paragraph>
</div>
)
}
+215
View File
@@ -0,0 +1,215 @@
import { useState } from 'react'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { Alert, Button, Card, Space, Table, Tag, Typography, type TableColumnsType } from 'antd'
import { ArrowDownOutlined, ArrowUpOutlined, ReloadOutlined } from '@ant-design/icons'
import { useNavigate } from 'react-router-dom'
import { fetchWalletBalance, fetchWalletLedger } from '@/features/wallet/api'
import {
ledgerBalanceTypeLabel,
walletBizTypeLabel,
walletStatusLabel,
} from '@/features/wallet/labels'
import type { WalletLedger } from '@/features/wallet/types'
import { useSessionStore } from '@/stores/session'
import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
const PAGE_SIZE = 20
function directionTag(value?: string | null) {
if (value === 'income') {
return (
<Tag color="green" icon={<ArrowUpOutlined />} bordered={false}>
入账
</Tag>
)
}
return (
<Tag color="volcano" icon={<ArrowDownOutlined />} bordered={false}>
出账
</Tag>
)
}
const columns: TableColumnsType<WalletLedger> = [
{
title: '流水号',
dataIndex: 'ledger_no',
width: 210,
render: value => <span className="wallet-mono">{formatText(value)}</span>,
},
{
title: '业务',
dataIndex: 'biz_type',
width: 140,
render: value => <Tag bordered={false}>{walletBizTypeLabel(value)}</Tag>,
},
{
title: '方向',
dataIndex: 'direction',
width: 100,
render: value => directionTag(value),
},
{
title: '金额',
dataIndex: 'amount_cent',
width: 140,
align: 'right',
render: (value: number, row) => (
<strong className={row.direction === 'income' ? 'wallet-amount-up' : 'wallet-amount-down'}>
{row.direction === 'income' ? '+' : '-'}
{formatMoney(Math.abs(value))}
</strong>
),
},
{
title: '余额类型',
dataIndex: 'balance_type',
width: 120,
render: value => (
<Typography.Text type="secondary">{ledgerBalanceTypeLabel(value)}</Typography.Text>
),
},
{
title: '变化后余额',
dataIndex: 'balance_after_cent',
width: 150,
align: 'right',
render: value => formatMoney(value),
},
{
title: '备注',
dataIndex: 'remark',
ellipsis: true,
render: value => formatText(value),
},
{
title: '时间',
dataIndex: 'created_at',
width: 170,
render: value => formatDateTime(value),
},
]
export function WalletPage() {
const navigate = useNavigate()
const user = useSessionStore(state => state.user)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE)
const balanceQuery = useQuery({
queryKey: ['wallet-balance'],
queryFn: fetchWalletBalance,
enabled: Boolean(user),
})
const ledgerQuery = useQuery({
queryKey: ['wallet-ledger', page, pageSize],
queryFn: () => fetchWalletLedger(page, pageSize),
enabled: Boolean(user),
placeholderData: keepPreviousData,
})
const account = balanceQuery.data
const ledger = ledgerQuery.data
return (
<div className="wallet-page">
<section className="wallet-hero">
<div>
<Typography.Text className="wallet-eyebrow">JIEYOU · 资金</Typography.Text>
<Typography.Title level={1} className="brand-title wallet-title">
资金账户
</Typography.Title>
<Typography.Paragraph className="wallet-desc">
查看卖家结算收入、冻结押金与每一笔资金变化;提现需先维护收款账户。
</Typography.Paragraph>
</div>
<div className="wallet-hero__action">
<span>当前可用</span>
<strong>{formatMoney(account?.available_balance_cent ?? 0)}</strong>
<Space size={8} wrap>
<Button type="primary" onClick={() => navigate('/wallet/withdrawal')}>
申请提现
</Button>
<Button onClick={() => navigate('/wallet/payment-accounts')}>收款账户</Button>
</Space>
</div>
</section>
<div className="wallet-metrics">
<div className="wallet-metric">
<span>可用余额</span>
<strong>{formatMoney(account?.available_balance_cent ?? 0)}</strong>
<small>可随时申请提现</small>
</div>
<div className="wallet-metric">
<span>冻结余额</span>
<strong>{formatMoney(account?.frozen_balance_cent ?? 0)}</strong>
<small>提现处理中或订单押金暂扣</small>
</div>
<div className="wallet-metric">
<span>账户状态</span>
<strong>{walletStatusLabel(account?.status)}</strong>
<small>异常时请联系客服核实</small>
</div>
</div>
{balanceQuery.isError ? (
<Alert
type="error"
showIcon
message="资金账户加载失败"
description={(balanceQuery.error as Error)?.message}
/>
) : null}
<Card
className="wallet-ledger-card"
title={
<div>
<Typography.Title level={4} style={{ margin: 0 }}>
资金流水
</Typography.Title>
<Typography.Text type="secondary">
共 {ledger?.total ?? 0} 条记录,最近变动优先展示。
</Typography.Text>
</div>
}
extra={
<Button
icon={<ReloadOutlined />}
loading={ledgerQuery.isFetching}
onClick={() => {
void ledgerQuery.refetch()
void balanceQuery.refetch()
}}
>
刷新
</Button>
}
>
<Table<WalletLedger>
rowKey="id"
size="middle"
columns={columns}
dataSource={ledger?.items ?? []}
loading={ledgerQuery.isFetching}
scroll={{ x: 'max-content' }}
locale={{ emptyText: '暂无资金流水' }}
pagination={{
current: ledger?.page ?? page,
pageSize: ledger?.page_size ?? pageSize,
total: ledger?.total ?? 0,
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50'],
showTotal: (count, range) => `第 ${range[0]}-${range[1]} 条 / 共 ${count} 条`,
onChange: (nextPage, nextSize) => {
setPage(nextPage)
setPageSize(nextSize)
},
}}
/>
</Card>
</div>
)
}
@@ -0,0 +1,347 @@
import { useEffect, useMemo, useState } from 'react'
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Alert,
App,
Button,
Card,
Form,
InputNumber,
Select,
Space,
Table,
Tag,
Typography,
type TableColumnsType,
} from 'antd'
import { ReloadOutlined } from '@ant-design/icons'
import { useNavigate } from 'react-router-dom'
import {
cancelWithdrawal,
createWithdrawal,
fetchPaymentAccounts,
fetchWalletBalance,
fetchWithdrawals,
} from '@/features/wallet/api'
import {
paymentAccountTypeLabel,
withdrawalStatusMeta,
} from '@/features/wallet/labels'
import type { PaymentAccount, WithdrawalRecord } from '@/features/wallet/types'
import { useSessionStore } from '@/stores/session'
import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
const PAGE_SIZE = 20
interface WithdrawFormValues {
payment_account_id: number
amount_yuan: number
}
function errorText(error: unknown) {
const response = (error as { response?: { data?: { message?: string } } })?.response
if (response?.data?.message) return response.data.message
return error instanceof Error ? error.message : '请求失败,请稍后重试'
}
const columns: TableColumnsType<WithdrawalRecord> = [
{
title: '提现单号',
dataIndex: 'withdraw_no',
width: 200,
render: value => <span className="wallet-mono">{formatText(value)}</span>,
},
{
title: '提现金额',
dataIndex: 'amount_cent',
width: 130,
align: 'right',
render: value => <strong>{formatMoney(value)}</strong>,
},
{
title: '手续费 / 到账',
key: 'fee',
width: 170,
align: 'right',
render: (_, row) => (
<div className="wallet-stack">
<span>{formatMoney(row.actual_amount_cent)}</span>
<span className="wallet-muted">手续费 {formatMoney(row.fee_cent)}</span>
</div>
),
},
{
title: '收款方式',
key: 'account',
width: 190,
render: (_, row) => (
<div className="wallet-stack">
<span>{paymentAccountTypeLabel(row.account_type)}</span>
<span className="wallet-muted">
{formatText(row.account_name)} · {formatText(row.account_no)}
</span>
</div>
),
},
{
title: '状态',
dataIndex: 'status',
width: 110,
render: value => {
const meta = withdrawalStatusMeta(value)
return (
<Tag bordered={false} color={meta.color}>
{meta.label}
</Tag>
)
},
},
{
title: '备注',
dataIndex: 'review_remark',
ellipsis: true,
render: value => formatText(value),
},
{
title: '申请时间',
dataIndex: 'created_at',
width: 170,
render: value => formatDateTime(value),
},
]
export function WithdrawalPage() {
const navigate = useNavigate()
const { message, modal } = App.useApp()
const queryClient = useQueryClient()
const [form] = Form.useForm<WithdrawFormValues>()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE)
const [submitting, setSubmitting] = useState(false)
const user = useSessionStore(state => state.user)
const amountYuan = Form.useWatch('amount_yuan', form)
const balanceQuery = useQuery({
queryKey: ['wallet-balance'],
queryFn: fetchWalletBalance,
enabled: Boolean(user),
})
const accountsQuery = useQuery({
queryKey: ['payment-accounts', 1],
queryFn: () => fetchPaymentAccounts(1, 50),
enabled: Boolean(user),
})
const withdrawalsQuery = useQuery({
queryKey: ['withdrawals', page, pageSize],
queryFn: () => fetchWithdrawals(page, pageSize),
enabled: Boolean(user),
placeholderData: keepPreviousData,
})
const available = balanceQuery.data?.available_balance_cent ?? 0
const accounts: PaymentAccount[] = accountsQuery.data?.items ?? []
const withdrawals = withdrawalsQuery.data
const accountOptions = useMemo(
() =>
accounts.map(account => ({
value: account.id,
label: `${paymentAccountTypeLabel(account.account_type)} · ${account.account_name} (${account.account_no})`,
})),
[accounts]
)
useEffect(() => {
if (form.getFieldValue('payment_account_id')) return
const preferred = accounts.find(item => item.is_default) ?? accounts[0]
if (preferred) form.setFieldValue('payment_account_id', preferred.id)
}, [accounts, form])
const submit = async () => {
const values = await form.validateFields()
const amountCent = Math.round(Number(values.amount_yuan) * 100)
if (amountCent <= 0) {
message.warning('提现金额必须大于 0')
return
}
if (amountCent > available) {
message.warning('提现金额不能超过可用余额')
return
}
setSubmitting(true)
try {
await createWithdrawal({
payment_account_id: values.payment_account_id,
amount_cent: amountCent,
})
message.success('提现申请已提交,等待审核')
form.setFieldValue('amount_yuan', undefined)
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['withdrawals'] }),
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] }),
queryClient.invalidateQueries({ queryKey: ['wallet-ledger'] }),
])
} catch (error) {
message.error(errorText(error))
} finally {
setSubmitting(false)
}
}
const cancel = (record: WithdrawalRecord) => {
modal.confirm({
title: `确认取消提现「${record.withdraw_no}」?`,
content: '取消后冻结金额会退回可用余额,需重新提交提现申请。',
okText: '取消提现',
okButtonProps: { danger: true },
cancelText: '返回',
onOk: async () => {
try {
await cancelWithdrawal(record.id)
message.success('提现已取消')
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['withdrawals'] }),
queryClient.invalidateQueries({ queryKey: ['wallet-balance'] }),
])
} catch (error) {
message.error(errorText(error))
}
},
})
}
const rows: WithdrawalRecord[] = withdrawals?.items ?? []
return (
<div className="wallet-page">
<section className="wallet-hero wallet-hero--compact">
<div>
<Typography.Text className="wallet-eyebrow">JIEYOU · 资金</Typography.Text>
<Typography.Title level={1} className="brand-title wallet-title">
申请提现
</Typography.Title>
<Typography.Paragraph className="wallet-desc">
提现申请提交后进入人工审核,审核通过后由财务打款到所选收款账户。
</Typography.Paragraph>
</div>
<div className="wallet-hero__action">
<span>当前可用</span>
<strong>{formatMoney(available)}</strong>
<Space size={8} wrap>
<Button onClick={() => navigate('/wallet')}>资金流水</Button>
<Button onClick={() => navigate('/wallet/payment-accounts')}>收款账户</Button>
</Space>
</div>
</section>
{accounts.length === 0 ? (
<Alert
type="warning"
showIcon
message="还没有收款账户"
description="请先添加收款账户,再提交提现申请。"
action={
<Button size="small" onClick={() => navigate('/wallet/payment-accounts')}>
去添加
</Button>
}
/>
) : null}
<Card className="wallet-ledger-card" title="提现信息">
<Form form={form} layout="vertical" requiredMark={false} style={{ maxWidth: 520 }}>
<Form.Item
name="payment_account_id"
label="收款账户"
rules={[{ required: true, message: '请选择收款账户' }]}
>
<Select
placeholder="选择收款账户"
options={accountOptions}
disabled={accounts.length === 0}
/>
</Form.Item>
<Form.Item
name="amount_yuan"
label="提现金额(元)"
rules={[{ required: true, message: '请输入提现金额' }]}
extra={`可提现 ${formatMoney(available)};当前手续费为 0%,到账金额以审核结果为准。`}
>
<InputNumber
min={0.01}
max={available / 100}
precision={2}
style={{ width: '100%' }}
prefix="¥"
placeholder="0.00"
/>
</Form.Item>
<Typography.Paragraph type="secondary">
预计到账 {formatMoney(Math.round(Number(amountYuan ?? 0) * 100))}
</Typography.Paragraph>
<Button
type="primary"
loading={submitting}
disabled={accounts.length === 0}
onClick={() => void submit()}
>
提交提现申请
</Button>
</Form>
</Card>
<Card
className="wallet-ledger-card"
title="提现记录"
extra={
<Button
icon={<ReloadOutlined />}
loading={withdrawalsQuery.isFetching}
onClick={() => void withdrawalsQuery.refetch()}
>
刷新
</Button>
}
>
<Table<WithdrawalRecord>
rowKey="id"
size="middle"
columns={[
...columns,
{
title: '操作',
key: 'actions',
width: 110,
fixed: 'right',
render: (_, row) =>
row.status === 'pending' ? (
<Button type="link" size="small" danger onClick={() => cancel(row)}>
取消提现
</Button>
) : (
<Typography.Text type="secondary">—</Typography.Text>
),
},
]}
dataSource={rows}
loading={withdrawalsQuery.isFetching}
scroll={{ x: 'max-content' }}
locale={{ emptyText: '还没有提现记录' }}
pagination={{
current: withdrawals?.page ?? page,
pageSize: withdrawals?.page_size ?? pageSize,
total: withdrawals?.total ?? 0,
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50'],
showTotal: (count, range) => `第 ${range[0]}-${range[1]} 条 / 共 ${count} 条`,
onChange: (nextPage, nextSize) => {
setPage(nextPage)
setPageSize(nextSize)
},
}}
/>
</Card>
</div>
)
}
+95
View File
@@ -0,0 +1,95 @@
import { apiClient, unwrapData } from '@/shared/api/client'
import type { ApiResponse } from '@/shared/api/types'
import type {
CreatePaymentAccountPayload,
CreateWithdrawalPayload,
PaymentAccount,
PaymentAccountPage,
UpdatePaymentAccountPayload,
WalletAccount,
WalletLedger,
WalletLedgerPage,
WithdrawalPage,
WithdrawalRecord,
} from '@/features/wallet/types'
export const WALLET_PAGE_SIZE = 20
export function fetchWalletBalance() {
return unwrapData<WalletAccount>(apiClient.get<ApiResponse<WalletAccount>>('/wallet/balance'))
}
export function fetchWalletLedger(page = 1, pageSize = WALLET_PAGE_SIZE) {
return unwrapData<Partial<WalletLedgerPage>>(
apiClient.get<ApiResponse<Partial<WalletLedgerPage>>>('/wallet/ledger', {
params: { page, page_size: pageSize },
})
).then(data => ({
items: (data.items ?? []) as WalletLedger[],
total: data.total ?? 0,
page: data.page ?? page,
page_size: data.page_size ?? pageSize,
}))
}
export function fetchPaymentAccounts(page = 1, pageSize = WALLET_PAGE_SIZE) {
return unwrapData<Partial<PaymentAccountPage>>(
apiClient.get<ApiResponse<Partial<PaymentAccountPage>>>('/payment-accounts', {
params: { page, page_size: pageSize },
})
).then(data => ({
items: (data.items ?? []) as PaymentAccount[],
total: data.total ?? 0,
page: data.page ?? page,
page_size: data.page_size ?? pageSize,
}))
}
export function createPaymentAccount(payload: CreatePaymentAccountPayload) {
return unwrapData<PaymentAccount>(
apiClient.post<ApiResponse<PaymentAccount>>('/payment-accounts', payload)
)
}
export function updatePaymentAccount(id: number, payload: UpdatePaymentAccountPayload) {
return unwrapData<PaymentAccount>(
apiClient.put<ApiResponse<PaymentAccount>>(`/payment-accounts/${id}`, payload)
)
}
export function deletePaymentAccount(id: number) {
return unwrapData<{ deleted: boolean }>(
apiClient.delete<ApiResponse<{ deleted: boolean }>>(`/payment-accounts/${id}`)
)
}
export function setDefaultPaymentAccount(id: number) {
return unwrapData<{ updated: boolean }>(
apiClient.post<ApiResponse<{ updated: boolean }>>(`/payment-accounts/${id}/set-default`)
)
}
export function fetchWithdrawals(page = 1, pageSize = WALLET_PAGE_SIZE) {
return unwrapData<Partial<WithdrawalPage>>(
apiClient.get<ApiResponse<Partial<WithdrawalPage>>>('/withdrawals', {
params: { page, page_size: pageSize },
})
).then(data => ({
items: (data.items ?? []) as WithdrawalRecord[],
total: data.total ?? 0,
page: data.page ?? page,
page_size: data.page_size ?? pageSize,
}))
}
export function createWithdrawal(payload: CreateWithdrawalPayload) {
return unwrapData<WithdrawalRecord>(
apiClient.post<ApiResponse<WithdrawalRecord>>('/withdrawals', payload)
)
}
export function cancelWithdrawal(id: number) {
return unwrapData<{ cancelled: boolean }>(
apiClient.post<ApiResponse<{ cancelled: boolean }>>(`/withdrawals/${id}/cancel`)
)
}
+75
View File
@@ -0,0 +1,75 @@
import type { PaymentAccountType } from '@/features/wallet/types'
const bizTypeLabels: Record<string, string> = {
order: '订单结算',
order_pay: '订单支付',
cancel_refund: '取消退款',
checkout_refund: '结账退款',
admin_refund: '后台退款',
arbitration_owner_income: '仲裁号主收入',
arbitration_release_frozen: '仲裁解冻',
dispute: '申诉',
withdraw_apply: '提现申请',
withdraw_freeze: '提现冻结',
withdraw_complete: '提现到账',
withdraw_reject: '提现驳回',
withdraw_cancel: '提现取消',
invite_rebate: '邀请返利',
main_recharge: '充值',
admin_pickup: '线下提号',
admin_pickup_settle: '线下提号结算',
admin_pickup_adjustment: '线下提号调整',
distribution_settlement: '分销结算',
distribution_reversal: '分销冲正',
site_income: '站点收入',
site_withdraw: '站点提现',
mohong_order: '摸红订单',
}
export function walletBizTypeLabel(value?: string | null) {
if (!value) return '其他'
return bizTypeLabels[value] ?? value
}
export function ledgerDirectionLabel(value?: string | null) {
if (value === 'income') return '入账'
if (value === 'expense') return '出账'
return value || '—'
}
export function ledgerBalanceTypeLabel(value?: string | null) {
if (value === 'available') return '可用余额'
if (value === 'frozen') return '冻结余额'
return value || '—'
}
const accountTypeLabels: Record<string, string> = {
alipay: '支付宝',
wechat: '微信',
bank: '银行卡',
}
export function paymentAccountTypeLabel(value?: PaymentAccountType | string | null) {
if (!value) return '—'
return accountTypeLabels[value] ?? value
}
const withdrawalStatusLabels: Record<string, { label: string; color: string }> = {
pending: { label: '待审核', color: 'gold' },
processing: { label: '打款中', color: 'blue' },
completed: { label: '已到账', color: 'green' },
rejected: { label: '已驳回', color: 'red' },
cancelled: { label: '已取消', color: 'default' },
}
export function withdrawalStatusMeta(value?: string | null) {
if (!value) return { label: '未知', color: 'default' }
return withdrawalStatusLabels[value] ?? { label: value, color: 'default' }
}
export function walletStatusLabel(value?: string | null) {
if (value === 'active') return '正常'
if (value === 'frozen') return '已冻结'
if (value === 'closed') return '已关闭'
return value || '—'
}
+115
View File
@@ -0,0 +1,115 @@
export type WalletStatus = 'active' | 'frozen' | 'closed' | string
export type LedgerDirection = 'income' | 'expense' | string
export type LedgerBalanceType = 'available' | 'frozen' | string
export interface WalletAccount {
user_id: number
distribution_site_id?: number
available_balance_cent: number
frozen_balance_cent: number
status: WalletStatus
}
export interface WalletLedger {
id: number
ledger_no: string
user_id: number
distribution_site_id?: number
order_id?: number | null
direction: LedgerDirection
amount_cent: number
balance_after_cent: number
balance_type: LedgerBalanceType
biz_type: string
biz_no: string
remark: string
created_at: string
}
export interface WalletLedgerPage {
items: WalletLedger[]
total: number
page: number
page_size: number
}
export type PaymentAccountType = 'alipay' | 'wechat' | 'bank'
export interface PaymentAccount {
id: number
user_id: number
account_type: PaymentAccountType
account_name: string
account_no: string
bank_name: string
bank_branch: string
certificate_urls: string[]
is_default: boolean
status: string
created_at: string
updated_at: string
}
export interface PaymentAccountPage {
items: PaymentAccount[]
total: number
page: number
page_size: number
}
export interface CreatePaymentAccountPayload {
account_type: PaymentAccountType
account_name: string
account_no: string
bank_name?: string
bank_branch?: string
certificate_urls?: string[]
}
export interface UpdatePaymentAccountPayload {
bank_branch?: string
certificate_urls?: string[]
is_default?: boolean
}
export type WithdrawalStatus =
| 'pending'
| 'processing'
| 'completed'
| 'rejected'
| 'cancelled'
| string
export interface WithdrawalRecord {
id: number
withdraw_no: string
user_id: number
distribution_site_id?: number
amount_cent: number
fee_cent: number
actual_amount_cent: number
account_type: PaymentAccountType | string
account_name: string
account_no: string
bank_name: string
status: WithdrawalStatus
review_remark: string
created_at: string
updated_at: string
reviewed_at?: string | null
paid_at?: string | null
}
export interface WithdrawalPage {
items: WithdrawalRecord[]
total: number
page: number
page_size: number
}
export interface CreateWithdrawalPayload {
payment_account_id: number
amount_cent: number
}
+6
View File
@@ -85,7 +85,10 @@ const allNavGroups: NavGroup[] = [
icon: <AccountBookOutlined />,
children: [
{ key: '/finance', label: '财务看板', permission: 'wallet:view' },
{ key: '/finance/details', label: '资金明细', permission: 'wallet:view' },
{ key: '/finance/disbursements', label: '资金出款', permission: 'wallet:view' },
{ key: '/payments', label: '支付流水', permission: 'wallet:view' },
{ key: '/wallet-ledger', label: '钱包流水', permission: 'wallet:view' },
{
key: '/finance/operating-expenses',
label: '运营开支',
@@ -164,6 +167,9 @@ const sectionMeta: Record<string, { title: string; description: string }> = {
'/listings/review': { title: '商品审核', description: '审核待上架商品与账号截图。' },
'/users': { title: '用户管理', description: '查看用户、实名状态与交易记录。' },
'/finance': { title: '财务看板', description: '查看收入、退款、结算与资金趋势。' },
'/finance/details': { title: '资金明细', description: '按订单核对实付、退款、号主结算与平台净额。' },
'/payments': { title: '支付流水', description: '核对渠道支付单、回调状态与失败原因。' },
'/wallet-ledger': { title: '钱包流水', description: '按用户、订单与业务类型核对余额变动。' },
'/finance/disbursements': { title: '资金出款', description: '查看代管出款、提现与线下出款明细。' },
'/finance/operating-expenses': { title: '运营开支', description: '登记与核对日常运营支出。' },
'/withdrawals': { title: '提现审核', description: '审核提现申请与打款确认。' },
+20
View File
@@ -52,6 +52,26 @@ export function StorefrontLayout() {
label: '我的消息',
onClick: () => navigate('/messages'),
},
{
key: 'wallet',
label: '我的钱包',
onClick: () => navigate('/wallet'),
},
{
key: 'seller-earnings',
label: '卖家收益',
onClick: () => navigate('/seller/earnings'),
},
{
key: 'seller-handoffs',
label: '号主待办',
onClick: () => navigate('/seller/handoffs'),
},
{
key: 'seller-pickups',
label: '线下提号',
onClick: () => navigate('/seller/pickups'),
},
{
key: 'seller',
label: '卖家中心',
+1
View File
@@ -4,6 +4,7 @@
@import './storefront-sections.css';
@import './storefront-listings.css';
@import './storefront-content.css';
@import './wallet.css';
@import './auth.css';
@import './admin-misc.css';
@import './admin-login.css';
+216
View File
@@ -0,0 +1,216 @@
/* 资金域:钱包、收款账户、提现(PC 与移动端共用部分) */
.wallet-page {
width: min(1180px, 100%);
margin: 0 auto;
padding: 32px 24px 64px;
display: grid;
gap: 20px;
}
.wallet-hero {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
padding: 28px 32px;
border: 1px solid var(--line);
border-radius: 14px;
background: linear-gradient(135deg, var(--paper-warm), #f3ece0);
}
.wallet-hero--compact {
align-items: center;
padding: 22px 26px;
}
.wallet-eyebrow {
font-size: 12px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--ink-muted);
}
.wallet-title {
margin: 8px 0 6px !important;
font-size: 30px !important;
}
.wallet-desc {
margin-bottom: 0 !important;
max-width: 560px;
color: var(--ink-secondary);
}
.wallet-hero__action {
display: grid;
gap: 8px;
justify-items: end;
text-align: right;
}
.wallet-hero__action > span {
font-size: 12px;
letter-spacing: 0.08em;
color: var(--ink-muted);
}
.wallet-hero__action > strong {
font-family: 'Noto Serif SC', 'Songti SC', serif;
font-size: 32px;
color: var(--brand-ink);
line-height: 1.1;
}
.wallet-metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 14px;
}
.wallet-metric {
display: grid;
gap: 4px;
padding: 18px 20px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--paper-warm);
}
.wallet-metric > span {
font-size: 12px;
color: var(--ink-muted);
}
.wallet-metric > strong {
font-size: 22px;
color: var(--brand-ink);
}
.wallet-metric > small {
color: var(--ink-secondary);
font-size: 12px;
}
.wallet-ledger-card {
border-radius: 12px;
}
.wallet-ledger-card .ant-card-head-title {
white-space: normal;
}
.wallet-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
color: var(--ink-secondary);
}
.wallet-stack {
display: grid;
gap: 2px;
}
.wallet-muted {
font-size: 12px;
color: var(--ink-muted);
}
.wallet-amount-up {
color: var(--success);
}
.wallet-amount-down {
color: var(--danger);
}
.pay-account-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px;
}
.pay-account-card {
display: grid;
gap: 6px;
padding: 16px 18px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--paper-warm);
}
.pay-account-card__head {
display: grid;
gap: 6px;
}
.pay-account-card > strong {
font-size: 16px;
}
.pay-account-card__bank {
font-size: 13px;
color: var(--ink-secondary);
}
.pay-account-card__time,
.pay-account-card__cert {
font-size: 12px;
color: var(--ink-muted);
}
/* 移动端提现 */
.mobile-wallet {
display: grid;
gap: 12px;
padding-bottom: 24px;
}
.mobile-wallet__balance .adm-card-body {
display: grid;
gap: 8px;
justify-items: start;
}
.mobile-wallet__label {
font-size: 12px;
color: var(--ink-muted);
}
.mobile-wallet__amount {
font-family: 'Noto Serif SC', 'Songti SC', serif;
font-size: 30px;
color: var(--brand-ink);
}
.mobile-wallet__hint {
margin-top: 8px;
font-size: 12px;
color: var(--ink-muted);
line-height: 1.6;
}
.mobile-wallet__sub {
font-size: 12px;
color: var(--ink-muted);
}
@media (max-width: 768px) {
.wallet-page {
padding: 20px 14px 48px;
}
.wallet-hero {
align-items: flex-start;
flex-direction: column;
padding: 20px;
}
.wallet-hero__action {
justify-items: start;
text-align: left;
}
.wallet-title {
font-size: 24px !important;
}
}