{
+ return [
+ {
+ title: '订单',
+ dataIndex: 'order_no',
+ width: 200,
+ fixed: 'left',
+ render: (_, row) => (
+
+ {canViewDetail ? (
+
+ ) : (
+ {row.order_no}
+ )}
+ {formatDateTime(row.created_at)}
+ 商品 {formatText(row.listing_no || row.listing_id)}
+
+ ),
+ },
+ {
+ title: '商品',
+ dataIndex: 'title',
+ width: 210,
+ render: (_, row) => (
+
+ {formatText(row.title)}
+
+ {formatText(row.server_region)} · {formatText(row.login_platform)}
+
+
+ ),
+ },
+ {
+ title: '租客 / 号主',
+ key: 'parties',
+ width: 190,
+ render: (_, row) => (
+
+ 租客 {formatText(row.renter_phone)}
+ {row.handoff_mode === 'platform' || row.settlement_mode === 'platform_managed' ? (
+ 号主 平台代管
+ ) : (
+ 号主 {formatText(row.owner_phone)}
+ )}
+
+ ),
+ },
+ {
+ title: '金额',
+ key: 'amount',
+ width: 150,
+ align: 'right',
+ render: (_, row) => (
+
+ {formatMoney(row.rent_amount_cent)}
+ 押金 {formatMoney(row.deposit_amount_cent)}
+ {typeof row.owner_income_amount_cent === 'number' ? (
+
+ 到手 {formatMoney(row.owner_income_amount_cent)}
+
+ ) : null}
+
+ ),
+ },
+ {
+ title: '订单状态',
+ dataIndex: 'status',
+ width: 170,
+ render: value => ,
+ },
+ {
+ title: '交接',
+ key: 'handoff',
+ width: 180,
+ render: (_, row) => (
+
+
+ {row.handoff_mode === 'platform' || row.settlement_mode === 'platform_managed' ? (
+ 平台代管
+ ) : null}
+
+ ),
+ },
+ {
+ title: '结算',
+ dataIndex: 'settlement_status',
+ width: 140,
+ render: value => ,
+ },
+ {
+ title: '操作',
+ key: 'actions',
+ width: 96,
+ fixed: 'right',
+ render: (_, row) =>
+ canViewDetail ? (
+ } onClick={() => onOpen(row)}>
+ 详情
+
+ ) : (
+ 无详情权限
+ ),
+ },
+ ]
+}
+
+export function AdminOrdersPage() {
+ const [searchParams] = useSearchParams()
+ const initialKeyword = searchParams.get('keyword')?.trim() ?? ''
+ const initialUserId = Number(searchParams.get('user_id')) || undefined
+
+ const [draft, setDraft] = useState(() => ({
+ ...emptyFilters,
+ keyword: initialKeyword,
+ user_id: initialUserId,
+ }))
+ const [applied, setApplied] = useState(() => ({
+ ...emptyFilters,
+ keyword: initialKeyword,
+ user_id: initialUserId,
+ }))
+ const [page, setPage] = useState(1)
+ const [pageSize, setPageSize] = useState(20)
+ const [detailId, setDetailId] = useState(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) => {
+ 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 (
+
+ )
+}
diff --git a/frontend/src/features/admin/api.ts b/frontend/src/features/admin/api.ts
index 375087c..3acb234 100644
--- a/frontend/src/features/admin/api.ts
+++ b/frontend/src/features/admin/api.ts
@@ -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>('/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>>('/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>(`/admin/orders/${id}`)
+ return data.data
+}
diff --git a/frontend/src/features/admin/components/AdminPageHeader.tsx b/frontend/src/features/admin/components/AdminPageHeader.tsx
new file mode 100644
index 0000000..c49ed0a
--- /dev/null
+++ b/frontend/src/features/admin/components/AdminPageHeader.tsx
@@ -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 (
+
+
+
{eyebrow}
+
+ {title}
+
+ {description ? (
+
+ {description}
+
+ ) : null}
+ {meta ?
{meta}
: null}
+
+ {extra ? {extra}
: null}
+
+ )
+}
diff --git a/frontend/src/features/admin/components/DataTable.tsx b/frontend/src/features/admin/components/DataTable.tsx
new file mode 100644
index 0000000..c53642d
--- /dev/null
+++ b/frontend/src/features/admin/components/DataTable.tsx
@@ -0,0 +1,77 @@
+import type { ReactNode } from 'react'
+import { Card, Empty, Table, type TableColumnsType, type TableProps } from 'antd'
+
+interface DataTableProps {
+ title?: ReactNode
+ extra?: ReactNode
+ columns: TableColumnsType
+ dataSource: T[]
+ rowKey: TableProps['rowKey']
+ loading?: boolean
+ total?: number
+ page?: number
+ pageSize?: number
+ onPageChange?: (page: number, pageSize: number) => void
+ onRowClick?: (row: T) => void
+ emptyDescription?: ReactNode
+ scroll?: TableProps['scroll']
+ className?: string
+}
+
+/**
+ * 后台表格原型:统一卡片容器、空态、分页文案和行点击样式。
+ * 列定义仍由页面提供,保证每页可以按业务定制。
+ */
+export function DataTable({
+ title,
+ extra,
+ columns,
+ dataSource,
+ rowKey,
+ loading,
+ total = 0,
+ page = 1,
+ pageSize = 20,
+ onPageChange,
+ onRowClick,
+ emptyDescription = '没有符合条件的数据',
+ scroll = { x: 'max-content' },
+ className,
+}: DataTableProps) {
+ return (
+
+
+ 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: ,
+ }}
+ pagination={
+ onPageChange
+ ? {
+ current: page,
+ pageSize,
+ total,
+ showSizeChanger: true,
+ pageSizeOptions: ['10', '20', '50'],
+ showTotal: (count, range) => `第 ${range[0]}-${range[1]} 条 / 共 ${count} 条`,
+ onChange: onPageChange,
+ }
+ : false
+ }
+ />
+
+ )
+}
diff --git a/frontend/src/features/admin/components/DetailDrawer.tsx b/frontend/src/features/admin/components/DetailDrawer.tsx
new file mode 100644
index 0000000..1752ad7
--- /dev/null
+++ b/frontend/src/features/admin/components/DetailDrawer.tsx
@@ -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 (
+
+ {title}
+ {subtitle ? {subtitle}
: null}
+ {tags ? {tags}
: null}
+
+ }
+ >
+ {loading ? (
+
+ ) : error ? (
+
+ 重试
+
+ ) : null
+ }
+ />
+ ) : sections.length === 0 ? (
+
+ ) : (
+
+ {sections.map(section => (
+
+
+
+ {section.icon}
+ {section.title}
+
+ {section.extra}
+
+ ({
+ key: field.key,
+ label: field.label,
+ children: field.value,
+ span: field.span,
+ }))}
+ />
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/features/admin/components/FilterBar.tsx b/frontend/src/features/admin/components/FilterBar.tsx
new file mode 100644
index 0000000..d2d3359
--- /dev/null
+++ b/frontend/src/features/admin/components/FilterBar.tsx
@@ -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 (
+
+ )
+}
+
+interface FilterFieldProps {
+ label: string
+ children: ReactNode
+ width?: number
+}
+
+export function FilterField({ label, children, width }: FilterFieldProps) {
+ return (
+
+ {label}
+ {children}
+
+ )
+}
diff --git a/frontend/src/features/admin/components/StatusTag.tsx b/frontend/src/features/admin/components/StatusTag.tsx
new file mode 100644
index 0000000..7f10e95
--- /dev/null
+++ b/frontend/src/features/admin/components/StatusTag.tsx
@@ -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 (
+
+ {icon}
+ {meta.label}
+
+ )
+}
diff --git a/frontend/src/features/admin/labels.ts b/frontend/src/features/admin/labels.ts
new file mode 100644
index 0000000..f24f0be
--- /dev/null
+++ b/frontend/src/features/admin/labels.ts
@@ -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): StatusOption[] {
+ return Object.entries(map).map(([value, meta]) => ({ value, label: meta.label }))
+}
+
+export const orderStatusMap: Record = {
+ 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 = {
+ 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 = {
+ 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 = {
+ none: { label: '无需线下结算', tone: 'default' },
+ pending: { label: '待确认线下结算', tone: 'gold' },
+ settled: { label: '已确认线下结算', tone: 'success' },
+}
+
+export const handoffModeMap: Record = {
+ owner: { label: '正常订单', tone: 'default' },
+ platform: { label: '平台代管', tone: 'geekblue' },
+}
+
+export const settlementModeMap: Record = {
+ owner_wallet: { label: '号主钱包', tone: 'default' },
+ platform_managed: { label: '平台代管', tone: 'geekblue' },
+}
+
+export const checkoutStatusMap: Record = {
+ submitted: { label: '待确认', tone: 'gold' },
+ countered: { label: '已修正待确认', tone: 'orange' },
+ accepted: { label: '已接受', tone: 'success' },
+ disputed: { label: '争议中', tone: 'red' },
+ settled: { label: '仲裁已结案', tone: 'purple' },
+}
+
+export const refundStatusMap: Record = {
+ 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, 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 = {
+ 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,
+}))
diff --git a/frontend/src/features/admin/types.ts b/frontend/src/features/admin/types.ts
index 85dbe58..2e43250 100644
--- a/frontend/src/features/admin/types.ts
+++ b/frontend/src/features/admin/types.ts
@@ -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 | 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
+}
diff --git a/frontend/src/layouts/AdminLayout.tsx b/frontend/src/layouts/AdminLayout.tsx
index 6c389d8..14f8d1c 100644
--- a/frontend/src/layouts/AdminLayout.tsx
+++ b/frontend/src/layouts/AdminLayout.tsx
@@ -58,7 +58,7 @@ const allNavGroups: NavGroup[] = [
label: '交易',
icon: ,
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 ? (
解忧商行 · 运营后台
- Phase 1 Shell
+ Phase 2 · 交易与商品
) : null}
diff --git a/frontend/src/shared/utils/format.ts b/frontend/src/shared/utils/format.ts
new file mode 100644
index 0000000..ece36ea
--- /dev/null
+++ b/frontend/src/shared/utils/format.ts
@@ -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
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index bc92eca..3bf825b 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -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;