feat(admin): 新增后台订单列表与详情抽屉

This commit is contained in:
yml2213
2026-10-30 12:36:02 +08:00
parent ef01486e53
commit 17b985a4a4
17 changed files with 1656 additions and 6 deletions
+3 -3
View File
@@ -182,12 +182,12 @@ dist/assets/MobileListingDetailPage-*.js 27.31 kB │ gzip 10.70 kB
## 5. 已知限制
1. 移动端订单与消息页仍是占位,下单、客服会话尚未接入。
2. 后台 43 个页面目前只有仪表盘是真实页面,其余为统一占位。
2. 后台除仪表盘、订单列表与订单详情外,其余页面仍为统一占位。
3. 分销后台尚未做品牌轻改。
4. 后台菜单权限码基于旧项目推断,后续需与后端权限表逐一核对。
4. 后台菜单权限码基于旧项目推断:订单页已核对为 `order:list`(列表)与 `order:view`(详情),其余页面仍需逐一核对。
5. SVG 中文尚未转曲。
6. 首屏包体仍偏大。
7. 本移动端批次尚未提交 Git。
7. 移动端批次已提交为 `ef01486`;后台订单列表与详情在后续批次提交。
---
+92
View File
@@ -0,0 +1,92 @@
# Phase 2 验收报告(后台订单管理)
> 日期:2026-10-30
> 项目:解忧商行(hfb_jysh)
> 范围:后台订单列表、订单详情抽屉、通用列表/详情原型
> 结论:**本切片通过,可继续迁移其它后台列表页**
---
## 1. 本批次完成内容
### 1.1 接口与类型
- `fetchAdminOrders(query)` → `GET /api/admin/orders`
- 支持 `page`、`page_size`、`keyword`、`status`、`handoff_status`、`handoff_mode`、`settlement_status`、`offline_settlement_status`、`user_id`
- 空字符串参数自动剔除,避免后端收到无效筛选
- `fetchAdminOrder(id)` → `GET /api/admin/orders/:id`
- 新增列表 `AdminOrderListItem`、分页 `AdminOrderPage`、详情 `AdminOrderDetail`、结账 `AdminCheckoutInfo`、操作位 `AdminOrderActions` 等类型
- 列表只使用列表 DTO;详情字段只从详情接口读取,符合后端“最小暴露”设计
### 1.2 权限码核对
| 入口 | 后端实际权限码 | 前端菜单 |
| --- | --- | --- |
| 订单列表 | `order:list` | 已从推断值 `order:view` 修正为 `order:list` |
| 订单详情 | `order:view` | 无 `order:view` 时隐藏详情入口与行点击,操作列显示“无详情权限” |
### 1.3 通用组件原型
位于 `frontend/src/features/admin/components/`,供后续后台页面复用:
- `AdminPageHeader`:眉标 + 标题 + 描述 + 标签 + 右侧操作
- `FilterBar` / `FilterField`:可换行筛选区,统一查询/重置按钮与回车提交
- `DataTable`:卡片容器、空态、分页文案、行点击样式、横向滚动
- `DetailDrawer`:抽屉标题区、状态标签、分段 `Descriptions`、加载/错误/空态
- `StatusTag`:统一状态标签外观
配套:
- `frontend/src/features/admin/labels.ts`:订单、交接、结算、线下结算、结账、退款状态文案与色调
- `frontend/src/shared/utils/format.ts`:金额(分→元)、日期时间、数字、文本空值
### 1.4 订单列表页
`frontend/src/features/admin/AdminOrdersPage.tsx`
- 头部:订单总数、用户筛选提示、刷新按钮
- 筛选:关键词、订单状态、交接状态、订单类型、结算状态、线下结算
- 列:订单信息、商品、租客/号主、金额、订单状态、交接、结算、操作
- 分页:10 / 20 / 50,切换页长自动回到第一页
- 支持 `?keyword=` 与 `?user_id=` 深链(为后续用户管理“明细”入口准备)
- 加载使用 `keepPreviousData`,翻页不闪空表
### 1.5 订单详情抽屉
`frontend/src/features/admin/AdminOrderDetailDrawer.tsx`
- 标题区展示订单号、商品标题、创建时间、订单/交接/结算三个状态标签
- 分段:订单概览、商品与账号、交易双方、金额明细、交接与结算
- 存在结账单时展示协商轮次、消耗品、哈夫币、押金扣除、租客可退、号主到手、短收/超收、结账内容与凭证
- 存在进行中申诉时展示申诉单信息
- 支持复制订单号、加载骨架、错误重试
## 2. 验证结果
```text
npx tsc --noEmit 通过
npm run build 通过(主包 887.21 kB,gzip 289.49 kB,仍超 700 kB 警告)
GET /console-jy8k3m/orders 200(Vite SPA 回退)
GET /console-jy8k3m/login 200
GET /api/admin/orders(未登录) 401
GET /api/admin/orders(管理员 Cookie) ok,返回 items/total/page/page_size
GET /api/admin/orders?status=renting&handoff_status=received&... ok
GET /api/admin/orders/1(不存在) 404 {"code":"not_found","message":"订单不存在"}
```
说明:当前开发库 `rental_orders` 为 0 条,列表验证以空态与接口契约为准;首次产生真实订单后需目视复核列宽与抽屉信息密度。
## 3. 已知限制
1. 本批次只读,不包含关闭、退款、强制交接、线下结算等操作按钮;后端操作接口已存在,留待 Phase 4 逐项接入并补齐二次确认与权限校验。
2. 详情抽屉未加载交接记录时间线与过程事件,后续可复用 `GET /api/admin/orders/:id/handoff-records` 与 `/process-events`。
3. 列表未做列显隐、导出与批量选择。
4. 主包体积仍未达标。
## 4. 下一步建议
1. 后台商品管理列表 + 商品审核,复用本批 `DataTable` / `FilterBar` / `DetailDrawer`。
2. 用户管理列表与用户详情,接通订单深链 `orders?user_id=`。
3. 交接记录与过程事件时间线组件。
4. 移动端 `/m/orders` 与 `/m/messages` 真实接口。
5. 分销后台品牌轻改与 AntD 首包拆分。
+2
View File
@@ -365,6 +365,8 @@ hfb_jysh/
### Phase 2:前端基础设施(1 周)
> 状态:**进行中(2026-10-30)**。已完成双认证、401 单飞刷新、路由守卫与分站头;通用 `DataTable` / `FilterBar` / `DetailDrawer` / `StatusTag` / `AdminPageHeader` 原型已随后台订单页落地。
- Axios 客户端、响应解包、错误提示、请求日志。
- 用户 Bearer 与管理员 Cookie 双认证。
- 401 单飞刷新队列。
+6 -1
View File
@@ -48,6 +48,11 @@ const AdminDashboardPage = lazy(() =>
default: module.AdminDashboardPage,
}))
)
const AdminOrdersPage = lazy(() =>
import('@/features/admin/AdminOrdersPage').then(module => ({
default: module.AdminOrdersPage,
}))
)
const AdminLayout = lazy(() =>
import('@/layouts/AdminLayout').then(module => ({ default: module.AdminLayout }))
)
@@ -56,7 +61,6 @@ const AdminSectionPlaceholder = lazy(() =>
)
const adminSections: Array<{ path: string; title: string; description: string }> = [
{ path: 'orders', title: '订单管理', description: '查看全量订单、交接记录与结账状态。' },
{ path: 'orders/refund-review', title: '退款审核', description: '审核退款申请并核对资金影响。' },
{ path: 'disputes', title: '申诉仲裁', description: '处理订单争议、证据与仲裁结果。' },
{ path: 'listings', title: '商品管理', description: '查看商品、账号资产与上下架状态。' },
@@ -113,6 +117,7 @@ export function AppRouter() {
<Route path="change-password" element={<AdminChangePasswordPage />} />
<Route element={<AdminLayout />}>
<Route index element={<AdminDashboardPage />} />
<Route path="orders" element={<AdminOrdersPage />} />
{adminSections.map(section => (
<Route
key={section.path}
@@ -0,0 +1,306 @@
import { useQuery } from '@tanstack/react-query'
import { Button, message, Space, Tag, Typography } from 'antd'
import { CopyOutlined } from '@ant-design/icons'
import dayjs from 'dayjs'
import { fetchAdminOrder } from '@/features/admin/api'
import { DetailDrawer, type DetailSection } from '@/features/admin/components/DetailDrawer'
import { StatusTag } from '@/features/admin/components/StatusTag'
import {
checkoutStatusMap,
handoffModeMap,
handoffStatusMeta,
offlineSettlementStatusMap,
orderStatusMap,
readStatusMeta,
refundStatusMap,
settlementModeMap,
settlementStatusMap,
} from '@/features/admin/labels'
import { emptyText, formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
import type { AdminOrderDetail } from '@/features/admin/types'
interface AdminOrderDetailDrawerProps {
orderId: number | null
open: boolean
onClose: () => void
}
function buildSections(order: AdminOrderDetail): DetailSection[] {
const sections: DetailSection[] = [
{
key: 'overview',
title: '订单概览',
fields: [
{ key: 'order_no', label: '订单号', value: <span className="admin-mono">{order.order_no}</span> },
{
key: 'status',
label: '订单状态',
value: <StatusTag meta={readStatusMeta(orderStatusMap, order.status)} />,
},
{ key: 'created_at', label: '创建时间', value: formatDateTime(order.created_at) },
{ key: 'updated_at', label: '更新时间', value: formatDateTime(order.updated_at) },
{ key: 'payment_deadline', label: '支付截止', value: formatDateTime(order.payment_deadline_at) },
{ key: 'listing_no', label: '商品编号', value: formatText(order.listing_no) },
],
},
{
key: 'account',
title: '商品与账号',
fields: [
{ key: 'title', label: '商品标题', value: formatText(order.title), span: 2 },
{ key: 'server_region', label: '区服', value: formatText(order.server_region) },
{ key: 'login_platform', label: '登录平台', value: formatText(order.login_platform) },
{ key: 'account_source', label: '账号来源', value: formatText(order.account_source) },
{
key: 'duration',
label: '预计租期',
value: order.estimated_duration_hours ? `${order.estimated_duration_hours} 小时` : emptyText,
},
{ key: 'estimated_end', label: '预计结束', value: formatDateTime(order.estimated_end_at) },
{ key: 'rented_at', label: '起租时间', value: formatDateTime(order.rented_at) },
{
key: 'distribution_site',
label: '来源分站',
value: order.distribution_site_id ? `#${order.distribution_site_id}` : '主站',
},
],
},
{
key: 'parties',
title: '交易双方',
fields: [
{
key: 'owner',
label: '号主',
value: (
<Space size={6} wrap>
<span>{formatText(order.owner_phone)}</span>
<Typography.Text type="secondary">ID {order.owner_id}</Typography.Text>
</Space>
),
},
{
key: 'renter',
label: '租客',
value: (
<Space size={6} wrap>
<span>{formatText(order.renter_phone)}</span>
<Typography.Text type="secondary">ID {order.renter_id}</Typography.Text>
</Space>
),
},
{ key: 'track_code', label: '来源追踪码', value: formatText(order.source_track_code) },
{ key: 'account_id', label: '账号 ID', value: formatText(order.account_id) },
],
},
{
key: 'amounts',
title: '金额明细',
fields: [
{ key: 'display', label: '展示金额', value: formatMoney(order.display_amount_cent) },
{ key: 'rent', label: '租客租金', value: formatMoney(order.rent_amount_cent) },
{ key: 'owner_rent', label: '号主租金', value: formatMoney(order.owner_rent_amount_cent) },
{ key: 'platform_fee', label: '平台服务费', value: formatMoney(order.platform_fee_cent) },
{ key: 'deposit', label: '实收押金', value: formatMoney(order.deposit_amount_cent) },
{ key: 'deposit_original', label: '原始押金', value: formatMoney(order.deposit_original_amount_cent) },
{ key: 'deposit_waived', label: '减免押金', value: formatMoney(order.deposit_waived_amount_cent) },
{
key: 'refund',
label: '退款',
value: (
<Space size={6} wrap>
<StatusTag meta={readStatusMeta(refundStatusMap, order.refund_status || 'none')} />
<span>{formatMoney(order.refund_amount_cent)}</span>
</Space>
),
},
],
},
{
key: 'fulfillment',
title: '交接与结算',
fields: [
{
key: 'handoff_mode',
label: '订单类型',
value: <StatusTag meta={readStatusMeta(handoffModeMap, order.handoff_mode)} />,
},
{ key: 'handoff_status', label: '交接状态', value: <StatusTag meta={handoffStatusMeta(order.handoff_status, order)} /> },
{
key: 'settlement_mode',
label: '结算方式',
value: <StatusTag meta={readStatusMeta(settlementModeMap, order.settlement_mode)} />,
},
{
key: 'settlement_status',
label: '结算状态',
value: <StatusTag meta={readStatusMeta(settlementStatusMap, order.settlement_status)} />,
},
{
key: 'offline_status',
label: '线下结算',
value: (
<StatusTag
meta={readStatusMeta(
offlineSettlementStatusMap,
order.offline_settlement_status || 'none'
)}
/>
),
},
{ key: 'offline_amount', label: '线下结算金额', value: formatMoney(order.offline_settlement_amount_cent) },
{ key: 'offline_remark', label: '线下结算备注', value: formatText(order.offline_settlement_remark), span: 2 },
{ key: 'offline_at', label: '线下结算时间', value: formatDateTime(order.offline_settled_at) },
{
key: 'deposit_hold',
label: '押金冻结',
value: (
<Space size={6} wrap>
<span>{formatText(order.deposit_hold_status)}</span>
<span>{formatMoney(order.deposit_hold_amount_cent)}</span>
</Space>
),
},
{ key: 'deposit_hold_reason', label: '冻结原因', value: formatText(order.deposit_hold_reason), span: 2 },
],
},
]
if (order.checkout) {
const checkout = order.checkout
sections.push({
key: 'checkout',
title: '结账单',
fields: [
{
key: 'checkout_status',
label: '结账状态',
value: <StatusTag meta={readStatusMeta(checkoutStatusMap, checkout.status)} />,
},
{
key: 'checkout_round',
label: '协商轮次',
value: `${checkout.round_count} / ${checkout.max_rounds}`,
},
{ key: 'checkout_consumable', label: '消耗品费用', value: formatMoney(checkout.consumable_amount_cent) },
{ key: 'checkout_coin', label: '哈夫币消耗', value: `${checkout.coin_consumed_m} M` },
{ key: 'checkout_other', label: '其他费用', value: formatMoney(checkout.other_amount_cent) },
{ key: 'checkout_deduct', label: '押金扣除', value: formatMoney(checkout.deposit_deduct_amount_cent) },
{ key: 'checkout_renter_refund', label: '租客可退', value: formatMoney(checkout.renter_refund_amount_cent) },
{ key: 'checkout_owner_income', label: '号主到手', value: formatMoney(checkout.owner_income_amount_cent) },
{
key: 'checkout_shortfall',
label: '差额',
value: (
<Space size={6} wrap>
<span>短收 {formatMoney(checkout.shortfall_cent)}</span>
<span>超收 {formatMoney(checkout.overshoot_amount_cent)}</span>
</Space>
),
},
{ key: 'checkout_time', label: '最近更新', value: formatDateTime(checkout.updated_at) },
{
key: 'checkout_content',
label: '结账内容',
span: 2,
value: <div className="admin-detail__pre">{checkout.content || emptyText}</div>,
},
{
key: 'checkout_evidence',
label: '凭证',
span: 2,
value:
(checkout.evidence_urls?.length ?? 0) > 0 ? (
<Space size={4} wrap>
{checkout.evidence_urls.map((url, index) => (
<a key={url} href={url} target="_blank" rel="noreferrer">
<Tag>凭证 {index + 1}</Tag>
</a>
))}
</Space>
) : (
emptyText
),
},
],
})
}
if (order.active_dispute) {
sections.push({
key: 'dispute',
title: '进行中的申诉',
fields: [
{ key: 'dispute_id', label: '申诉单号', value: `#${order.active_dispute.id}` },
{ key: 'dispute_type', label: '申诉类型', value: formatText(order.active_dispute.type) },
{ key: 'dispute_status', label: '状态', value: formatText(order.active_dispute.status) },
{
key: 'dispute_initiator',
label: '发起方',
value: `${order.active_dispute.initiator_type} · ${order.active_dispute.initiator_id}`,
},
],
})
}
return sections
}
export function AdminOrderDetailDrawer({ orderId, open, onClose }: AdminOrderDetailDrawerProps) {
const query = useQuery({
queryKey: ['admin-order', orderId],
queryFn: () => fetchAdminOrder(orderId as number),
enabled: open && Boolean(orderId),
staleTime: 15_000,
})
const order = query.data
const errorMessage = query.error instanceof Error ? query.error.message : null
const copyOrderNo = async () => {
if (!order) return
try {
await navigator.clipboard.writeText(order.order_no)
message.success('订单号已复制')
} catch {
message.warning('当前环境不支持剪贴板,请手动复制')
}
}
return (
<DetailDrawer
open={open}
onClose={onClose}
loading={query.isPending && Boolean(orderId)}
error={query.isError ? errorMessage || '后台会话可能已失效,请重试。' : null}
onRetry={() => void query.refetch()}
title={order?.order_no ? <span className="admin-mono">{order.order_no}</span> : '订单详情'}
subtitle={
order ? (
<span>
{order.title} · 创建于 {dayjs(order.created_at).format('YYYY-MM-DD HH:mm')}
</span>
) : null
}
tags={
order ? (
<Space size={6} wrap>
<StatusTag meta={readStatusMeta(orderStatusMap, order.status)} />
<StatusTag meta={handoffStatusMeta(order.handoff_status, order)} />
<StatusTag meta={readStatusMeta(settlementStatusMap, order.settlement_status)} />
</Space>
) : null
}
sections={order ? buildSections(order) : []}
footer={
<Space>
<Button icon={<CopyOutlined />} onClick={() => void copyOrderNo()} disabled={!order}>
复制订单号
</Button>
<Button onClick={onClose}>关闭</Button>
</Space>
}
/>
)
}
@@ -0,0 +1,341 @@
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 { EyeOutlined, ReloadOutlined } from '@ant-design/icons'
import { useSearchParams } from 'react-router-dom'
import { fetchAdminOrders } from '@/features/admin/api'
import { AdminOrderDetailDrawer } from '@/features/admin/AdminOrderDetailDrawer'
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
import { DataTable } from '@/features/admin/components/DataTable'
import { FilterBar, FilterField } from '@/features/admin/components/FilterBar'
import { StatusTag } from '@/features/admin/components/StatusTag'
import {
handoffModeOptions,
handoffStatusMeta,
handoffStatusOptions,
offlineSettlementStatusOptions,
orderStatusMap,
orderStatusOptions,
readStatusMeta,
settlementStatusMap,
settlementStatusOptions,
} from '@/features/admin/labels'
import type { AdminOrderListItem, AdminOrderQuery } from '@/features/admin/types'
import { useAdminSessionStore } from '@/stores/adminSession'
import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
const emptyFilters: AdminOrderQuery = {
keyword: '',
status: '',
handoff_status: '',
handoff_mode: '',
settlement_status: '',
offline_settlement_status: '',
}
function buildColumns(
onOpen: (row: AdminOrderListItem) => void,
canViewDetail: boolean
): TableColumnsType<AdminOrderListItem> {
return [
{
title: '订单',
dataIndex: 'order_no',
width: 200,
fixed: 'left',
render: (_, row) => (
<div className="admin-stack">
{canViewDetail ? (
<button type="button" className="admin-order-link" onClick={() => onOpen(row)}>
{row.order_no}
</button>
) : (
<span className="admin-mono">{row.order_no}</span>
)}
<span>{formatDateTime(row.created_at)}</span>
<span>商品 {formatText(row.listing_no || row.listing_id)}</span>
</div>
),
},
{
title: '商品',
dataIndex: 'title',
width: 210,
render: (_, row) => (
<div className="admin-stack">
<strong>{formatText(row.title)}</strong>
<span>
{formatText(row.server_region)} · {formatText(row.login_platform)}
</span>
</div>
),
},
{
title: '租客 / 号主',
key: 'parties',
width: 190,
render: (_, row) => (
<div className="admin-stack">
<span>租客 {formatText(row.renter_phone)}</span>
{row.handoff_mode === 'platform' || row.settlement_mode === 'platform_managed' ? (
<span className="admin-stack__accent">号主 平台代管</span>
) : (
<span>号主 {formatText(row.owner_phone)}</span>
)}
</div>
),
},
{
title: '金额',
key: 'amount',
width: 150,
align: 'right',
render: (_, row) => (
<div className="admin-amount">
<strong>{formatMoney(row.rent_amount_cent)}</strong>
<span>押金 {formatMoney(row.deposit_amount_cent)}</span>
{typeof row.owner_income_amount_cent === 'number' ? (
<span className="admin-amount__accent">
到手 {formatMoney(row.owner_income_amount_cent)}
</span>
) : null}
</div>
),
},
{
title: '订单状态',
dataIndex: 'status',
width: 170,
render: value => <StatusTag meta={readStatusMeta(orderStatusMap, value)} />,
},
{
title: '交接',
key: 'handoff',
width: 180,
render: (_, row) => (
<div className="admin-stack admin-stack--tiny">
<StatusTag meta={handoffStatusMeta(row.handoff_status, row)} />
{row.handoff_mode === 'platform' || row.settlement_mode === 'platform_managed' ? (
<Tag color="geekblue">平台代管</Tag>
) : null}
</div>
),
},
{
title: '结算',
dataIndex: 'settlement_status',
width: 140,
render: value => <StatusTag meta={readStatusMeta(settlementStatusMap, value)} />,
},
{
title: '操作',
key: 'actions',
width: 96,
fixed: 'right',
render: (_, row) =>
canViewDetail ? (
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => onOpen(row)}>
详情
</Button>
) : (
<Typography.Text type="secondary">无详情权限</Typography.Text>
),
},
]
}
export function AdminOrdersPage() {
const [searchParams] = useSearchParams()
const initialKeyword = searchParams.get('keyword')?.trim() ?? ''
const initialUserId = Number(searchParams.get('user_id')) || undefined
const [draft, setDraft] = useState<AdminOrderQuery>(() => ({
...emptyFilters,
keyword: initialKeyword,
user_id: initialUserId,
}))
const [applied, setApplied] = useState<AdminOrderQuery>(() => ({
...emptyFilters,
keyword: initialKeyword,
user_id: initialUserId,
}))
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [detailId, setDetailId] = useState<number | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const permissions = useAdminSessionStore(state => state.admin?.permissions)
const canViewDetail = Boolean(permissions?.includes('*') || permissions?.includes('order:view'))
const { data, isPending, isFetching, isError, error, refetch } = useQuery({
queryKey: ['admin-orders', applied, page, pageSize],
queryFn: () => fetchAdminOrders({ ...applied, page, page_size: pageSize }),
placeholderData: keepPreviousData,
})
const columns = useMemo(
() =>
buildColumns(row => {
setDetailId(row.id)
setDetailOpen(true)
}, canViewDetail),
[canViewDetail]
)
const patchFilter = (patch: Partial<AdminOrderQuery>) => {
setDraft(current => ({ ...current, ...patch }))
}
const applyFilters = () => {
setApplied({ ...draft, keyword: draft.keyword?.trim() ?? '' })
setPage(1)
}
const resetFilters = () => {
setDraft(emptyFilters)
setApplied(emptyFilters)
setPage(1)
}
const hasActiveFilters = Object.entries(draft).some(
([, value]) => value !== '' && value != null
)
const errorText = error instanceof Error ? error.message : '请稍后重试或重新登录后台。'
return (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 交易"
title="订单管理"
description="全量订单、交接进度、金额与结算状态统一在这里核对;点击订单号查看详情。"
meta={
<Space size={8} wrap>
<Tag color="geekblue">共 {data?.total ?? 0} 单</Tag>
{applied.user_id ? (
<Tag color="purple">筛选用户 #{applied.user_id}</Tag>
) : null}
{isFetching && !isPending ? <Tag color="processing">正在更新</Tag> : null}
</Space>
}
extra={
<Button icon={<ReloadOutlined />} onClick={() => void refetch()} loading={isFetching}>
刷新数据
</Button>
}
/>
<FilterBar
onSearch={applyFilters}
onReset={hasActiveFilters ? resetFilters : undefined}
loading={isFetching}
>
<FilterField label="关键词" width={300}>
<Input
allowClear
value={draft.keyword}
placeholder="订单号 / 商品编号 / 账号 / 手机号"
onChange={event => patchFilter({ keyword: event.target.value })}
/>
</FilterField>
<FilterField label="订单状态" width={180}>
<Select
allowClear
value={draft.status || undefined}
placeholder="全部状态"
options={orderStatusOptions}
onChange={value => patchFilter({ status: value ?? '' })}
/>
</FilterField>
<FilterField label="交接状态" width={180}>
<Select
allowClear
value={draft.handoff_status || undefined}
placeholder="全部交接"
options={handoffStatusOptions}
onChange={value => patchFilter({ handoff_status: value ?? '' })}
/>
</FilterField>
<FilterField label="订单类型" width={150}>
<Select
allowClear
value={draft.handoff_mode || undefined}
placeholder="全部类型"
options={handoffModeOptions}
onChange={value => patchFilter({ handoff_mode: value ?? '' })}
/>
</FilterField>
<FilterField label="结算状态" width={160}>
<Select
allowClear
value={draft.settlement_status || undefined}
placeholder="全部结算"
options={settlementStatusOptions}
onChange={value => patchFilter({ settlement_status: value ?? '' })}
/>
</FilterField>
<FilterField label="线下结算" width={160}>
<Select
allowClear
value={draft.offline_settlement_status || undefined}
placeholder="全部"
options={offlineSettlementStatusOptions}
onChange={value => patchFilter({ offline_settlement_status: value ?? '' })}
/>
</FilterField>
</FilterBar>
{isError ? (
<Alert
type="error"
showIcon
className="admin-table-alert"
message="订单列表没有加载出来"
description={errorText}
action={
<Button size="small" onClick={() => void refetch()}>
重试
</Button>
}
/>
) : null}
<DataTable<AdminOrderListItem>
title={<span>订单列表</span>}
extra={
<Typography.Text type="secondary">
{data ? `第 ${data.page} 页 · 当前 ${data.items.length} 条` : '加载中'}
</Typography.Text>
}
rowKey="id"
columns={columns}
dataSource={data?.items ?? []}
loading={isFetching}
total={data?.total ?? 0}
page={data?.page ?? page}
pageSize={data?.page_size ?? pageSize}
onPageChange={(nextPage, nextPageSize) => {
setPage(nextPageSize !== pageSize ? 1 : nextPage)
setPageSize(nextPageSize)
}}
onRowClick={
canViewDetail
? row => {
setDetailId(row.id)
setDetailOpen(true)
}
: undefined
}
emptyDescription="没有匹配的订单,换个筛选条件试试"
/>
{canViewDetail ? (
<AdminOrderDetailDrawer
orderId={detailId}
open={detailOpen}
onClose={() => setDetailOpen(false)}
/>
) : null}
</div>
)
}
+26
View File
@@ -3,6 +3,9 @@ import type { ApiResponse } from '@/shared/api/types'
import type {
AdminCaptcha,
AdminLoginData,
AdminOrderDetail,
AdminOrderPage,
AdminOrderQuery,
AdminUser,
DashboardData,
} from '@/features/admin/types'
@@ -47,3 +50,26 @@ export async function fetchAdminDashboard() {
const { data } = await adminClient.get<ApiResponse<DashboardData>>('/admin/dashboard')
return data.data
}
function compactParams(query: AdminOrderQuery) {
return Object.fromEntries(
Object.entries(query).filter(([, value]) => value !== '' && value != null)
)
}
export async function fetchAdminOrders(query: AdminOrderQuery) {
const { data } = await adminClient.get<ApiResponse<Partial<AdminOrderPage>>>('/admin/orders', {
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 fetchAdminOrder(id: number | string) {
const { data } = await adminClient.get<ApiResponse<AdminOrderDetail>>(`/admin/orders/${id}`)
return data.data
}
@@ -0,0 +1,37 @@
import type { ReactNode } from 'react'
import { Typography } from 'antd'
interface AdminPageHeaderProps {
eyebrow?: string
title: string
description?: ReactNode
meta?: ReactNode
extra?: ReactNode
}
/** 后台页面统一抬头:眉标 + 标题 + 说明 + 右侧操作。 */
export function AdminPageHeader({
eyebrow = 'JIEYOU OPS',
title,
description,
meta,
extra,
}: AdminPageHeaderProps) {
return (
<header className="admin-page__head">
<div className="admin-page__head-main">
<Typography.Text className="admin-page__eyebrow">{eyebrow}</Typography.Text>
<Typography.Title level={2} className="brand-title">
{title}
</Typography.Title>
{description ? (
<Typography.Text type="secondary" className="admin-page__desc">
{description}
</Typography.Text>
) : null}
{meta ? <div className="admin-page__meta">{meta}</div> : null}
</div>
{extra ? <div className="admin-page__actions">{extra}</div> : null}
</header>
)
}
@@ -0,0 +1,77 @@
import type { ReactNode } from 'react'
import { Card, Empty, Table, type TableColumnsType, type TableProps } from 'antd'
interface DataTableProps<T extends object> {
title?: ReactNode
extra?: ReactNode
columns: TableColumnsType<T>
dataSource: T[]
rowKey: TableProps<T>['rowKey']
loading?: boolean
total?: number
page?: number
pageSize?: number
onPageChange?: (page: number, pageSize: number) => void
onRowClick?: (row: T) => void
emptyDescription?: ReactNode
scroll?: TableProps<T>['scroll']
className?: string
}
/**
* 后台表格原型:统一卡片容器、空态、分页文案和行点击样式。
* 列定义仍由页面提供,保证每页可以按业务定制。
*/
export function DataTable<T extends object>({
title,
extra,
columns,
dataSource,
rowKey,
loading,
total = 0,
page = 1,
pageSize = 20,
onPageChange,
onRowClick,
emptyDescription = '没有符合条件的数据',
scroll = { x: 'max-content' },
className,
}: DataTableProps<T>) {
return (
<Card className={['admin-table-card', className].filter(Boolean).join(' ')} title={title} extra={extra}>
<Table<T>
rowKey={rowKey}
size="middle"
columns={columns}
dataSource={dataSource}
loading={loading}
scroll={scroll}
onRow={
onRowClick
? row => ({
onClick: () => onRowClick(row),
className: 'admin-table__row--clickable',
})
: undefined
}
locale={{
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={emptyDescription} />,
}}
pagination={
onPageChange
? {
current: page,
pageSize,
total,
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50'],
showTotal: (count, range) => `第 ${range[0]}-${range[1]} 条 / 共 ${count} 条`,
onChange: onPageChange,
}
: false
}
/>
</Card>
)
}
@@ -0,0 +1,111 @@
import type { ReactNode } from 'react'
import { Alert, Button, Descriptions, Drawer, Empty, Skeleton } from 'antd'
export interface DetailField {
key: string
label: ReactNode
value: ReactNode
span?: number
}
export interface DetailSection {
key: string
title: string
icon?: ReactNode
extra?: ReactNode
fields: DetailField[]
}
interface DetailDrawerProps {
open: boolean
onClose: () => void
title: ReactNode
subtitle?: ReactNode
tags?: ReactNode
loading?: boolean
error?: string | null
onRetry?: () => void
sections?: DetailSection[]
footer?: ReactNode
width?: number
emptyDescription?: ReactNode
}
/** 详情抽屉原型:统一标题区、段落标题、描述列表和加载/错误态。 */
export function DetailDrawer({
open,
onClose,
title,
subtitle,
tags,
loading,
error,
onRetry,
sections = [],
footer,
width = 760,
emptyDescription = '暂无可展示的详情',
}: DetailDrawerProps) {
return (
<Drawer
open={open}
onClose={onClose}
width={width}
footer={footer}
destroyOnHidden
className="admin-detail-drawer"
title={
<div className="admin-detail-drawer__head">
<div className="admin-detail-drawer__title">{title}</div>
{subtitle ? <div className="admin-detail-drawer__subtitle">{subtitle}</div> : null}
{tags ? <div className="admin-detail-drawer__tags">{tags}</div> : null}
</div>
}
>
{loading ? (
<Skeleton active paragraph={{ rows: 12 }} />
) : error ? (
<Alert
type="error"
showIcon
message="详情没有加载出来"
description={error}
action={
onRetry ? (
<Button size="small" onClick={onRetry}>
重试
</Button>
) : null
}
/>
) : sections.length === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={emptyDescription} />
) : (
<div className="admin-detail">
{sections.map(section => (
<section className="admin-detail__section" key={section.key}>
<div className="admin-detail__section-head">
<h4>
{section.icon}
{section.title}
</h4>
{section.extra}
</div>
<Descriptions
size="small"
colon={false}
column={2}
items={section.fields.map(field => ({
key: field.key,
label: field.label,
children: field.value,
span: field.span,
}))}
/>
</section>
))}
</div>
)}
</Drawer>
)
}
@@ -0,0 +1,57 @@
import type { ReactNode } from 'react'
import { Button, Flex } from 'antd'
import { ReloadOutlined, SearchOutlined } from '@ant-design/icons'
interface FilterBarProps {
children: ReactNode
onSearch?: () => void
onReset?: () => void
loading?: boolean
extra?: ReactNode
}
/**
* 列表页筛选条原型:字段区自适应换行,操作区固定在右侧。
* 后续 40+ 个后台列表页复用它,避免每页各写一套间距与按钮顺序。
*/
export function FilterBar({ children, onSearch, onReset, loading, extra }: FilterBarProps) {
return (
<form
className="admin-filter-bar"
onSubmit={event => {
event.preventDefault()
onSearch?.()
}}
>
<div className="admin-filter-bar__fields">{children}</div>
<Flex className="admin-filter-bar__actions" gap={8} align="flex-end">
{extra}
{onReset ? (
<Button icon={<ReloadOutlined />} onClick={onReset} disabled={loading}>
重置
</Button>
) : null}
{onSearch ? (
<Button type="primary" htmlType="submit" icon={<SearchOutlined />} loading={loading}>
查询
</Button>
) : null}
</Flex>
</form>
)
}
interface FilterFieldProps {
label: string
children: ReactNode
width?: number
}
export function FilterField({ label, children, width }: FilterFieldProps) {
return (
<div className="admin-filter-field" style={width ? { width } : undefined}>
<span className="admin-filter-field__label">{label}</span>
{children}
</div>
)
}
@@ -0,0 +1,19 @@
import type { ReactNode } from 'react'
import { Tag } from 'antd'
import type { StatusMeta } from '@/features/admin/labels'
interface StatusTagProps {
meta: StatusMeta
icon?: ReactNode
bordered?: boolean
}
export function StatusTag({ meta, icon, bordered = false }: StatusTagProps) {
return (
<Tag color={meta.tone} bordered={bordered} className="admin-status-tag">
{icon}
{meta.label}
</Tag>
)
}
+157
View File
@@ -0,0 +1,157 @@
export type TagTone =
| 'default'
| 'success'
| 'processing'
| 'error'
| 'warning'
| 'gold'
| 'blue'
| 'cyan'
| 'orange'
| 'red'
| 'purple'
| 'geekblue'
| 'magenta'
| 'volcano'
| 'green'
export interface StatusMeta {
label: string
tone: TagTone
}
export interface StatusOption {
label: string
value: string
}
function toOptions(map: Record<string, StatusMeta>): StatusOption[] {
return Object.entries(map).map(([value, meta]) => ({ value, label: meta.label }))
}
export const orderStatusMap: Record<string, StatusMeta> = {
pending_payment: { label: '待支付', tone: 'gold' },
pending_handoff: { label: '待交接', tone: 'blue' },
renting: { label: '使用中', tone: 'cyan' },
overdue: { label: '已逾期', tone: 'volcano' },
pending_checkout_confirm: { label: '待号主确认结账', tone: 'orange' },
pending_checkout_accept: { label: '待租客确认修正', tone: 'orange' },
checkout_disputing: { label: '结账争议中', tone: 'red' },
disputing: { label: '申诉中', tone: 'red' },
completed: { label: '已完成', tone: 'success' },
cancelled: { label: '租客已取消', tone: 'default' },
closed: { label: '客服已关闭', tone: 'default' },
abnormal: { label: '客服介入', tone: 'magenta' },
}
export const handoffStatusMap: Record<string, StatusMeta> = {
none: { label: '未开始', tone: 'default' },
pending_owner: { label: '待号主交接', tone: 'blue' },
owner_timeout: { label: '号主交接超时', tone: 'volcano' },
pending_renter_confirm: { label: '待租客确认', tone: 'blue' },
received: { label: '租客已收号', tone: 'cyan' },
return_overdue: { label: '归还逾期', tone: 'volcano' },
pending_owner_checkout: { label: '待号主确认结账', tone: 'orange' },
pending_renter_checkout: { label: '待租客确认修正', tone: 'orange' },
checkout_disputed: { label: '结账争议中', tone: 'red' },
returned: { label: '已归还', tone: 'success' },
owner_return_confirm_timeout: { label: '号主确认结账超时', tone: 'red' },
owner_checkout_confirm_timeout: { label: '号主确认结账超时', tone: 'red' },
renter_confirm_timeout: { label: '租客确认超时', tone: 'red' },
cancelled: { label: '租客已取消', tone: 'default' },
admin_closed: { label: '客服已关闭', tone: 'default' },
admin_abnormal: { label: '客服介入', tone: 'magenta' },
arbitrated: { label: '已仲裁', tone: 'purple' },
}
export const settlementStatusMap: Record<string, StatusMeta> = {
unsettled: { label: '未结算', tone: 'default' },
pending: { label: '待结算', tone: 'gold' },
frozen: { label: '冻结中', tone: 'magenta' },
settled: { label: '已结算', tone: 'success' },
refunded: { label: '已退款', tone: 'blue' },
cancelled: { label: '结算已取消', tone: 'default' },
closed: { label: '结算已关闭', tone: 'default' },
disputed: { label: '争议中', tone: 'red' },
arbitrated: { label: '已仲裁', tone: 'purple' },
}
export const offlineSettlementStatusMap: Record<string, StatusMeta> = {
none: { label: '无需线下结算', tone: 'default' },
pending: { label: '待确认线下结算', tone: 'gold' },
settled: { label: '已确认线下结算', tone: 'success' },
}
export const handoffModeMap: Record<string, StatusMeta> = {
owner: { label: '正常订单', tone: 'default' },
platform: { label: '平台代管', tone: 'geekblue' },
}
export const settlementModeMap: Record<string, StatusMeta> = {
owner_wallet: { label: '号主钱包', tone: 'default' },
platform_managed: { label: '平台代管', tone: 'geekblue' },
}
export const checkoutStatusMap: Record<string, StatusMeta> = {
submitted: { label: '待确认', tone: 'gold' },
countered: { label: '已修正待确认', tone: 'orange' },
accepted: { label: '已接受', tone: 'success' },
disputed: { label: '争议中', tone: 'red' },
settled: { label: '仲裁已结案', tone: 'purple' },
}
export const refundStatusMap: Record<string, StatusMeta> = {
none: { label: '未退款', tone: 'default' },
pending: { label: '待退款', tone: 'gold' },
pending_review: { label: '待客服审核退款', tone: 'orange' },
refunding: { label: '退款中', tone: 'processing' },
refunded: { label: '已退款', tone: 'success' },
failed: { label: '退款失败', tone: 'red' },
}
export function readStatusMeta(map: Record<string, StatusMeta>, value?: string | null): StatusMeta {
if (!value) return { label: '—', tone: 'default' }
return map[value] ?? { label: value, tone: 'default' }
}
/**
* 平台代管订单的交接动作由客服执行,展示文案需要区别于普通号主订单。
*/
export function handoffStatusMeta(
status?: string | null,
order?: { handoff_mode?: string; settlement_mode?: string } | null
) {
const platformManaged =
order?.handoff_mode === 'platform' || order?.settlement_mode === 'platform_managed'
if (!platformManaged || !status) return readStatusMeta(handoffStatusMap, status)
const platformCopy: Record<string, StatusMeta> = {
pending_owner: { label: '待客服交接', tone: 'blue' },
owner_timeout: { label: '客服交接超时', tone: 'volcano' },
pending_owner_checkout: { label: '待客服确认结账', tone: 'orange' },
owner_checkout_confirm_timeout: { label: '客服确认结账超时', tone: 'red' },
}
return platformCopy[status] ?? readStatusMeta(handoffStatusMap, status)
}
export const orderStatusOptions = toOptions(orderStatusMap)
export const handoffStatusOptions = [
'pending_owner',
'owner_timeout',
'pending_renter_confirm',
'received',
'return_overdue',
'pending_owner_checkout',
'pending_renter_checkout',
'checkout_disputed',
'returned',
'admin_closed',
'admin_abnormal',
].map(value => ({ value, label: handoffStatusMap[value].label }))
export const handoffModeOptions = toOptions(handoffModeMap)
export const settlementStatusOptions = toOptions(settlementStatusMap)
export const offlineSettlementStatusOptions = ['pending', 'settled'].map(value => ({
value,
label: offlineSettlementStatusMap[value].label,
}))
+146
View File
@@ -62,3 +62,149 @@ export interface DashboardData {
recent_orders: RecentOrder[]
generated_at: string
}
export interface AdminOrderQuery {
page?: number
page_size?: number
keyword?: string
status?: string
handoff_status?: string
handoff_mode?: string
settlement_status?: string
offline_settlement_status?: string
user_id?: number
}
/** 列表接口只返回表格需要的字段,敏感详情必须再请求详情接口。 */
export interface AdminOrderListItem {
id: number
order_no: string
listing_id: number
listing_no: string
owner_phone?: string
renter_phone?: string
title: string
server_region: string
login_platform: string
rent_amount_cent: number
deposit_amount_cent: number
status: string
handoff_status: string
handoff_mode: string
settlement_mode: string
settlement_status: string
created_at: string
owner_income_amount_cent?: number | null
}
export interface AdminOrderPage {
items: AdminOrderListItem[]
total: number
page: number
page_size: number
}
export interface AdminCheckoutInfo {
id: number
order_id: number
initiated_by: number
status: string
round_count: number
turn: string
max_rounds: number
content: string
evidence_urls: string[]
rent_amount_cent?: number | null
owner_rent_amount_cent?: number | null
platform_fee_cent?: number | null
deposit_amount_cent: number
consumable_amount_cent: number
coin_consumed_m: number
other_amount_cent: number
deposit_deduct_amount_cent: number
renter_refund_amount_cent?: number | null
owner_income_amount_cent?: number | null
shortfall_cent: number
overshoot_amount_cent: number
owner_adjustment_reason?: string
owner_adjusted_at?: string | null
renter_confirmed_at?: string | null
renter_rejected_at?: string | null
created_at: string
updated_at: string
}
export interface AdminActionState {
enabled: boolean
label: string
}
export interface AdminOrderActions {
reset_handoff?: AdminActionState
platform_handoff?: AdminActionState
force_handoff?: AdminActionState
platform_checkout_confirm?: AdminActionState
platform_checkout_counter?: AdminActionState
platform_checkout_dispute?: AdminActionState
platform_offline_settlement?: AdminActionState
}
export interface AdminActiveDispute {
id: number
initiator_id: number
initiator_type: string
initiator_admin_id?: number
type: string
status: string
}
export interface AdminOrderDetail {
id: number
order_no: string
listing_id: number
listing_no: string
account_id: number
owner_id: number
renter_id: number
owner_phone?: string
renter_phone?: string
title: string
server_region: string
login_platform: string
rented_at?: string | null
estimated_duration_hours: number
estimated_end_at?: string | null
display_amount_cent: number
rent_amount_cent?: number | null
owner_rent_amount_cent?: number | null
platform_fee_cent?: number | null
deposit_amount_cent: number
deposit_original_amount_cent: number
deposit_waived_amount_cent: number
account_snapshot?: Record<string, unknown> | null
account_source: string
status: string
handoff_status: string
handoff_mode: string
settlement_mode: string
settlement_status: string
offline_settlement_status?: string
offline_settlement_amount_cent: number
offline_settlement_remark?: string
offline_settled_at?: string | null
refund_status?: string
refund_amount_cent: number
deposit_hold_status?: string
deposit_hold_amount_cent: number
deposit_hold_reason?: string
deposit_held_at?: string | null
deposit_hold_released_at?: string | null
active_dispute?: AdminActiveDispute | null
checkout?: AdminCheckoutInfo | null
admin_actions?: AdminOrderActions | null
payment_deadline_at?: string | null
distribution_site_id?: number | null
source_track_code?: string
created_at: string
updated_at: string
}
+2 -2
View File
@@ -58,7 +58,7 @@ const allNavGroups: NavGroup[] = [
label: '交易',
icon: <ShoppingCartOutlined />,
children: [
{ key: '/orders', label: '订单管理', permission: 'order:view' },
{ key: '/orders', label: '订单管理', permission: 'order:list' },
{ key: '/orders/refund-review', label: '退款审核', permission: 'order:close' },
{ key: '/disputes', label: '申诉仲裁', permission: 'dispute:view' },
],
@@ -206,7 +206,7 @@ export function AdminLayout() {
{!collapsed ? (
<div className="admin-sider__foot">
<Typography.Text type="secondary">解忧商行 · 运营后台</Typography.Text>
<Typography.Text type="secondary">Phase 1 Shell</Typography.Text>
<Typography.Text type="secondary">Phase 2 · 交易与商品</Typography.Text>
</div>
) : null}
</Sider>
+41
View File
@@ -0,0 +1,41 @@
import dayjs from 'dayjs'
const EMPTY = '—'
/** 金额统一以「分」入参,展示为带千分位的两位小数。 */
export function formatMoney(cents?: number | null) {
if (cents == null || Number.isNaN(cents)) return EMPTY
return `¥${(cents / 100).toLocaleString('zh-CN', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`
}
export function formatDateTime(value?: string | null) {
if (!value) return EMPTY
return dayjs(value).format('YYYY-MM-DD HH:mm')
}
export function formatDate(value?: string | null) {
if (!value) return EMPTY
return dayjs(value).format('YYYY-MM-DD')
}
export function formatNumber(value?: number | null) {
if (value == null || Number.isNaN(value)) return EMPTY
return value.toLocaleString('zh-CN')
}
export function formatText(value?: string | number | null) {
if (value === '' || value == null) return EMPTY
return String(value)
}
/** 只保留末四位之前的号码,避免列表页大面积暴露完整手机号。 */
export function maskPhone(value?: string | null) {
if (!value) return EMPTY
if (value.length < 7) return value
return `${value.slice(0, 3)}****${value.slice(-4)}`
}
export const emptyText = EMPTY
+233
View File
@@ -1104,6 +1104,30 @@ a {
margin-bottom: 24px;
}
.admin-page__head-main {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
min-width: 0;
}
.admin-page__desc {
display: block;
max-width: 720px;
}
.admin-page__meta {
margin-top: 10px;
}
.admin-page__actions {
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 4px;
}
.admin-page__head h2 {
margin: 6px 0 8px !important;
color: var(--brand-ink) !important;
@@ -1253,6 +1277,215 @@ a {
font-size: 12px;
}
.admin-filter-bar {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
padding: 16px 18px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--paper-warm);
}
.admin-filter-bar__fields {
display: flex;
flex: 1 1 640px;
flex-wrap: wrap;
gap: 12px 14px;
}
.admin-filter-bar__actions {
padding-bottom: 1px;
}
.admin-filter-field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 140px;
}
.admin-filter-field__label {
color: var(--ink-muted);
font-size: 11px;
letter-spacing: 0.08em;
}
.admin-filter-field .ant-select,
.admin-filter-field .ant-input-affix-wrapper {
width: 100%;
}
.admin-status-tag {
margin-inline-end: 0;
font-size: 11px;
}
.admin-stack {
display: flex;
flex-direction: column;
gap: 3px;
line-height: 1.35;
}
.admin-stack strong {
color: var(--ink);
font-size: 12px;
font-weight: 600;
}
.admin-stack span {
color: var(--ink-muted);
font-size: 11px;
}
.admin-stack__accent {
color: var(--brand-seal) !important;
}
.admin-stack--tiny {
align-items: flex-start;
gap: 4px;
}
.admin-order-link {
padding: 0;
border: 0;
background: none;
color: var(--brand-ink);
font-family: 'Inter', monospace;
font-size: 12px;
letter-spacing: 0.02em;
text-align: left;
cursor: pointer;
}
.admin-order-link:hover {
color: var(--brand-seal);
text-decoration: underline;
text-underline-offset: 3px;
}
.admin-amount {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.admin-amount strong {
color: var(--brand-ink);
font-family: 'Inter', monospace;
font-size: 13px;
}
.admin-amount span {
color: var(--ink-muted);
font-size: 11px;
}
.admin-amount__accent {
color: var(--success) !important;
}
.admin-table__row--clickable {
cursor: pointer;
}
.ant-table-wrapper .ant-table-tbody > tr.ant-table-row.admin-table__row--clickable:hover > td {
background: #f3f7f4;
}
.admin-table-alert {
margin-bottom: 16px;
}
.admin-detail {
display: flex;
flex-direction: column;
gap: 22px;
}
.admin-detail__section {
padding-bottom: 18px;
border-bottom: 1px dashed var(--line);
}
.admin-detail__section:last-child {
padding-bottom: 0;
border-bottom: 0;
}
.admin-detail__section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.admin-detail__section-head h4 {
display: flex;
align-items: center;
gap: 6px;
margin: 0;
color: var(--brand-ink);
font-size: 13px;
letter-spacing: 0.06em;
}
.admin-detail__section .ant-descriptions-item-label {
color: var(--ink-muted);
font-size: 12px;
}
.admin-detail__section .ant-descriptions-item-content {
color: var(--ink);
font-size: 12px;
}
.admin-detail__pre {
max-height: 220px;
padding: 10px 12px;
overflow: auto;
border: 1px solid var(--line);
border-radius: 6px;
background: #fbf8f1;
color: var(--ink-secondary);
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.admin-detail-drawer .ant-drawer-header {
align-items: flex-start;
}
.admin-detail-drawer__head {
display: flex;
flex-direction: column;
gap: 4px;
}
.admin-detail-drawer__title {
color: var(--brand-ink);
font-family: 'Inter', monospace;
font-size: 15px;
}
.admin-detail-drawer__subtitle {
color: var(--ink-muted);
font-size: 12px;
font-weight: 400;
}
.admin-detail-drawer__tags {
margin-top: 4px;
}
.admin-placeholder-card {
position: relative;
max-width: 760px;