feat(exc): 专长交易 read-only monitoring pages

New nav group with four pages against the Exc admin API:
- /exc/demands — status/mode filters, detail sheet with related
  services, quotes and dispatch rounds
- /exc/dispatches — status/round filters
- /exc/orders — status/payment filters, detail sheet with items and
  状态流转 audit trail
- /exc/providers — status/verified filters, detail sheet with dispatch
  settings and status logs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 14:54:58 +08:00
parent f5a7249969
commit ddf07bbf58
10 changed files with 2211 additions and 2 deletions

40
src/api/modules/exc.ts Normal file
View File

@ -0,0 +1,40 @@
import { api } from '@/api/client'
import type {
ApiResponse,
ExcDemand,
ExcDispatch,
ExcOrder,
ExcProvider,
ListParams,
PaginatedResponse,
} from '@/api/types'
const BASE = '/api/admin/v1/exc'
export function fetchExcDemands(params: ListParams) {
return api.get<PaginatedResponse<ExcDemand>>(`${BASE}/demands`, params)
}
export function fetchExcDemand(id: number) {
return api.get<ApiResponse<ExcDemand>>(`${BASE}/demands/${id}`)
}
export function fetchExcDispatches(params: ListParams) {
return api.get<PaginatedResponse<ExcDispatch>>(`${BASE}/dispatches`, params)
}
export function fetchExcOrders(params: ListParams) {
return api.get<PaginatedResponse<ExcOrder>>(`${BASE}/orders`, params)
}
export function fetchExcOrder(id: number) {
return api.get<ApiResponse<ExcOrder>>(`${BASE}/orders/${id}`)
}
export function fetchExcProviders(params: ListParams) {
return api.get<PaginatedResponse<ExcProvider>>(`${BASE}/providers`, params)
}
export function fetchExcProvider(uid: string) {
return api.get<ApiResponse<ExcProvider>>(`${BASE}/providers/${uid}`)
}

File diff suppressed because it is too large Load Diff

View File

@ -351,3 +351,187 @@ export interface RetranslateResult {
translation_version_id: number
queued_blocks: number
}
// --- Exc 专长交易(只读监控)---
export interface ExcUser {
uid: string
display_name?: string | null
}
export type ExcDemandStatus =
| 'draft'
| 'open'
| 'quotation_closed'
| 'order_placed'
| 'fulfilled'
| 'broadcasting'
| 'matched'
| 'converted'
| 'expired'
| 'canceled'
export type ExcDispatchMode = 'instant_accept' | 'quote' | 'scheduled_booking' | 'manual_selection'
export interface ExcDemand {
kind?: string
id: number
title?: string | null
status: ExcDemandStatus
dispatch_mode?: ExcDispatchMode | null
service_type?: string | null
buyer?: ExcUser | null
category_id?: string | null
currency_code?: string | null
ref_amount?: number | null
matched_provider_uid?: string | null
matched_at?: string | null
quotes_count: number
dispatches_count: number
required_at?: string | null
published_at?: string | null
created_at?: string
// detail-only
summary?: string | null
content?: string | null
address?: unknown
quote_deadline?: string | null
related_services?: Array<{ id: number; title?: string | null; category_name?: string | null }>
quotes?: Array<{
id: number
user_uid: string
price: number
currency_code?: string
message?: string | null
user?: ExcUser | null
created_at?: string
}>
dispatches?: ExcDemandDispatchRow[]
}
export interface ExcDemandDispatchRow {
id: number
provider?: ExcUser | null
round_no: number
status: ExcDispatchStatus
score?: number | null
distance_meters?: number | null
eta_minutes?: number | null
responded_at?: string | null
}
export type ExcDispatchStatus =
| 'sent'
| 'delivered'
| 'viewed'
| 'accepted'
| 'rejected'
| 'ignored'
| 'expired'
| 'taken_by_other'
export interface ExcDispatch {
kind?: string
id: number
demand?: { id: number; title?: string | null } | null
provider?: ExcUser | null
round_no: number
status: ExcDispatchStatus
score?: number | null
similarity?: number | null
distance_meters?: number | null
eta_minutes?: number | null
auto_grab_eligible: boolean
sent_at?: string | null
viewed_at?: string | null
responded_at?: string | null
expires_at?: string | null
}
export type ExcOrderStatus =
| 'created'
| 'confirmed'
| 'started'
| 'consumed'
| 'paid'
| 'fulfilled'
| 'canceled'
export interface ExcOrder {
kind?: string
id: number
title?: string | null
status: ExcOrderStatus
payment_status?: 'UNPAID' | 'PAID' | null
buyer?: ExcUser | null
provider?: ExcUser | null
currency_code?: string | null
total_amount?: number | null
actual_total_amount?: number | null
platform_fee_amount?: number | null
provider_net_amount?: number | null
commission_rate?: number | null
cancellation_fee_amount?: number | null
paid_at?: string | null
settled_at?: string | null
escrow_release_at?: string | null
escrow_released_at?: string | null
order_date?: string | null
created_at?: string
// detail-only
buyer_remarks?: string | null
pay_order_no?: string | null
paid_channel?: string | null
items?: Array<{
id: number
name?: string | null
status?: string
price?: number
quantity?: number
line_total?: number
service_type?: string
}>
transactions?: Array<{
id: number
previous_status?: string | null
new_status?: string
changed_by?: string | null
changed_by_type?: string | null
reason?: string | null
changed_at?: string
}>
}
export type ExcProviderStatus = 'offline' | 'online' | 'inactive' | 'busy'
export interface ExcProvider {
kind?: string
user?: ExcUser | null
user_uid: string
status: ExcProviderStatus
is_disabled: boolean
is_verified: boolean
rating?: number | null
completed_jobs?: number | null
online_at?: string | null
offline_at?: string | null
last_location_at?: string | null
current_service_order_id?: number | null
created_at?: string
// detail-only
dispatch_setting?: {
auto_grab_enabled?: boolean
service_whitelist?: string[]
min_price?: number | null
max_radius_meters?: number | null
max_eta_minutes?: number | null
daily_accept_cap?: number | null
accepted_today?: number
} | null
status_logs?: Array<{
from_status?: string | null
to_status?: string
reason?: string | null
created_at?: string
}>
}

View File

@ -1,6 +1,7 @@
import type { LucideIcon } from 'lucide-react'
import {
Bot,
ClipboardList,
Flag,
FolderTree,
Hash,
@ -12,9 +13,12 @@ import {
MessageSquareWarning,
Newspaper,
Palette,
Receipt,
ScrollText,
Send,
Smartphone,
Sparkles,
UserCheck,
} from 'lucide-react'
export interface NavItem {
@ -55,6 +59,15 @@ export const NAV_GROUPS: NavGroup[] = [
},
],
},
{
label: '专长交易',
items: [
{ label: '需求监控', to: '/exc/demands', icon: ClipboardList },
{ label: '派单监控', to: '/exc/dispatches', icon: Send },
{ label: '订单监控', to: '/exc/orders', icon: Receipt },
{ label: '服务商监控', to: '/exc/providers', icon: UserCheck },
],
},
{
label: '内容审核',
items: [

123
src/features/exc/labels.tsx Normal file
View File

@ -0,0 +1,123 @@
import type {
ExcDemandStatus,
ExcDispatchMode,
ExcDispatchStatus,
ExcOrderStatus,
ExcProviderStatus,
} from '@/api/types'
import { Badge } from '@/components/ui/badge'
export const DEMAND_STATUS_LABELS: Record<ExcDemandStatus, string> = {
draft: '草稿',
open: '开放中',
quotation_closed: '报价截止',
order_placed: '已下单',
fulfilled: '已完成',
broadcasting: '派单中',
matched: '已匹配',
converted: '已转单',
expired: '已过期',
canceled: '已取消',
}
export const DISPATCH_MODE_LABELS: Record<ExcDispatchMode, string> = {
instant_accept: '即时抢单',
quote: '报价',
scheduled_booking: '预约',
manual_selection: '手动指派',
}
export const DISPATCH_STATUS_LABELS: Record<ExcDispatchStatus, string> = {
sent: '已发送',
delivered: '已送达',
viewed: '已查看',
accepted: '已接单',
rejected: '已拒绝',
ignored: '未响应',
expired: '已过期',
taken_by_other: '他人接单',
}
export const ORDER_STATUS_LABELS: Record<ExcOrderStatus, string> = {
created: '已创建',
confirmed: '已确认',
started: '服务中',
consumed: '已消费',
paid: '已支付',
fulfilled: '已完成',
canceled: '已取消',
}
export const PROVIDER_STATUS_LABELS: Record<ExcProviderStatus, string> = {
offline: '离线',
online: '在线',
inactive: '不活跃',
busy: '忙碌',
}
const POSITIVE = 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-400'
const ACTIVE = 'bg-blue-500/12 text-blue-700 dark:text-blue-400'
const WARN = 'bg-amber-500/12 text-amber-700 dark:text-amber-400'
const NEGATIVE = 'bg-red-500/12 text-red-700 dark:text-red-400'
const DEMAND_STATUS_CLASSES: Partial<Record<ExcDemandStatus, string>> = {
open: ACTIVE,
broadcasting: WARN,
matched: POSITIVE,
converted: POSITIVE,
order_placed: POSITIVE,
fulfilled: POSITIVE,
expired: NEGATIVE,
canceled: NEGATIVE,
}
const DISPATCH_STATUS_CLASSES: Partial<Record<ExcDispatchStatus, string>> = {
accepted: POSITIVE,
rejected: NEGATIVE,
expired: NEGATIVE,
taken_by_other: WARN,
}
const ORDER_STATUS_CLASSES: Partial<Record<ExcOrderStatus, string>> = {
started: ACTIVE,
paid: POSITIVE,
fulfilled: POSITIVE,
canceled: NEGATIVE,
}
const PROVIDER_STATUS_CLASSES: Partial<Record<ExcProviderStatus, string>> = {
online: POSITIVE,
busy: WARN,
inactive: NEGATIVE,
}
function statusBadge(label: string, className?: string) {
return className ? <Badge className={className}>{label}</Badge> : <Badge variant="secondary">{label}</Badge>
}
export function DemandStatusBadge({ status }: { status: ExcDemandStatus }) {
return statusBadge(DEMAND_STATUS_LABELS[status] ?? status, DEMAND_STATUS_CLASSES[status])
}
export function DispatchStatusBadge({ status }: { status: ExcDispatchStatus }) {
return statusBadge(DISPATCH_STATUS_LABELS[status] ?? status, DISPATCH_STATUS_CLASSES[status])
}
export function OrderStatusBadge({ status }: { status: ExcOrderStatus }) {
return statusBadge(ORDER_STATUS_LABELS[status] ?? status, ORDER_STATUS_CLASSES[status])
}
export function ProviderStatusBadge({ status }: { status: ExcProviderStatus }) {
return statusBadge(PROVIDER_STATUS_LABELS[status] ?? status, PROVIDER_STATUS_CLASSES[status])
}
/** Amounts are stored in cents. */
export function formatAmount(cents?: number | null, currency?: string | null): string {
if (cents == null) return '—'
const value = (cents / 100).toFixed(2)
return currency === 'CNY' || !currency ? `¥${value}` : `${currency} ${value}`
}
export function formatTime(value?: string | null): string {
return value ? new Date(value).toLocaleString('zh-CN') : '—'
}

View File

@ -27,6 +27,10 @@ import { Route as AuthedStudioRobotAuthorsRouteImport } from './routes/_authed/s
import { Route as AuthedStudioCategoryBriefsRouteImport } from './routes/_authed/studio/category-briefs'
import { Route as AuthedStudioArticlesRouteImport } from './routes/_authed/studio/articles'
import { Route as AuthedStudioAgentTokensRouteImport } from './routes/_authed/studio/agent-tokens'
import { Route as AuthedExcProvidersRouteImport } from './routes/_authed/exc/providers'
import { Route as AuthedExcOrdersRouteImport } from './routes/_authed/exc/orders'
import { Route as AuthedExcDispatchesRouteImport } from './routes/_authed/exc/dispatches'
import { Route as AuthedExcDemandsRouteImport } from './routes/_authed/exc/demands'
import { Route as AuthedCategoriesPendingRouteImport } from './routes/_authed/categories/pending'
const LoginRoute = LoginRouteImport.update({
@ -122,6 +126,26 @@ const AuthedStudioAgentTokensRoute = AuthedStudioAgentTokensRouteImport.update({
path: '/studio/agent-tokens',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedExcProvidersRoute = AuthedExcProvidersRouteImport.update({
id: '/exc/providers',
path: '/exc/providers',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedExcOrdersRoute = AuthedExcOrdersRouteImport.update({
id: '/exc/orders',
path: '/exc/orders',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedExcDispatchesRoute = AuthedExcDispatchesRouteImport.update({
id: '/exc/dispatches',
path: '/exc/dispatches',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedExcDemandsRoute = AuthedExcDemandsRouteImport.update({
id: '/exc/demands',
path: '/exc/demands',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedCategoriesPendingRoute = AuthedCategoriesPendingRouteImport.update({
id: '/categories/pending',
path: '/categories/pending',
@ -139,6 +163,10 @@ export interface FileRoutesByFullPath {
'/robots': typeof AuthedRobotsRoute
'/sid': typeof AuthedSidRoute
'/categories/pending': typeof AuthedCategoriesPendingRoute
'/exc/demands': typeof AuthedExcDemandsRoute
'/exc/dispatches': typeof AuthedExcDispatchesRoute
'/exc/orders': typeof AuthedExcOrdersRoute
'/exc/providers': typeof AuthedExcProvidersRoute
'/studio/agent-tokens': typeof AuthedStudioAgentTokensRoute
'/studio/articles': typeof AuthedStudioArticlesRoute
'/studio/category-briefs': typeof AuthedStudioCategoryBriefsRoute
@ -159,6 +187,10 @@ export interface FileRoutesByTo {
'/sid': typeof AuthedSidRoute
'/': typeof AuthedIndexRoute
'/categories/pending': typeof AuthedCategoriesPendingRoute
'/exc/demands': typeof AuthedExcDemandsRoute
'/exc/dispatches': typeof AuthedExcDispatchesRoute
'/exc/orders': typeof AuthedExcOrdersRoute
'/exc/providers': typeof AuthedExcProvidersRoute
'/studio/agent-tokens': typeof AuthedStudioAgentTokensRoute
'/studio/articles': typeof AuthedStudioArticlesRoute
'/studio/category-briefs': typeof AuthedStudioCategoryBriefsRoute
@ -181,6 +213,10 @@ export interface FileRoutesById {
'/_authed/sid': typeof AuthedSidRoute
'/_authed/': typeof AuthedIndexRoute
'/_authed/categories/pending': typeof AuthedCategoriesPendingRoute
'/_authed/exc/demands': typeof AuthedExcDemandsRoute
'/_authed/exc/dispatches': typeof AuthedExcDispatchesRoute
'/_authed/exc/orders': typeof AuthedExcOrdersRoute
'/_authed/exc/providers': typeof AuthedExcProvidersRoute
'/_authed/studio/agent-tokens': typeof AuthedStudioAgentTokensRoute
'/_authed/studio/articles': typeof AuthedStudioArticlesRoute
'/_authed/studio/category-briefs': typeof AuthedStudioCategoryBriefsRoute
@ -203,6 +239,10 @@ export interface FileRouteTypes {
| '/robots'
| '/sid'
| '/categories/pending'
| '/exc/demands'
| '/exc/dispatches'
| '/exc/orders'
| '/exc/providers'
| '/studio/agent-tokens'
| '/studio/articles'
| '/studio/category-briefs'
@ -223,6 +263,10 @@ export interface FileRouteTypes {
| '/sid'
| '/'
| '/categories/pending'
| '/exc/demands'
| '/exc/dispatches'
| '/exc/orders'
| '/exc/providers'
| '/studio/agent-tokens'
| '/studio/articles'
| '/studio/category-briefs'
@ -244,6 +288,10 @@ export interface FileRouteTypes {
| '/_authed/sid'
| '/_authed/'
| '/_authed/categories/pending'
| '/_authed/exc/demands'
| '/_authed/exc/dispatches'
| '/_authed/exc/orders'
| '/_authed/exc/providers'
| '/_authed/studio/agent-tokens'
| '/_authed/studio/articles'
| '/_authed/studio/category-briefs'
@ -387,6 +435,34 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedStudioAgentTokensRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/exc/providers': {
id: '/_authed/exc/providers'
path: '/exc/providers'
fullPath: '/exc/providers'
preLoaderRoute: typeof AuthedExcProvidersRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/exc/orders': {
id: '/_authed/exc/orders'
path: '/exc/orders'
fullPath: '/exc/orders'
preLoaderRoute: typeof AuthedExcOrdersRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/exc/dispatches': {
id: '/_authed/exc/dispatches'
path: '/exc/dispatches'
fullPath: '/exc/dispatches'
preLoaderRoute: typeof AuthedExcDispatchesRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/exc/demands': {
id: '/_authed/exc/demands'
path: '/exc/demands'
fullPath: '/exc/demands'
preLoaderRoute: typeof AuthedExcDemandsRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/categories/pending': {
id: '/_authed/categories/pending'
path: '/categories/pending'
@ -407,6 +483,10 @@ interface AuthedRouteChildren {
AuthedSidRoute: typeof AuthedSidRoute
AuthedIndexRoute: typeof AuthedIndexRoute
AuthedCategoriesPendingRoute: typeof AuthedCategoriesPendingRoute
AuthedExcDemandsRoute: typeof AuthedExcDemandsRoute
AuthedExcDispatchesRoute: typeof AuthedExcDispatchesRoute
AuthedExcOrdersRoute: typeof AuthedExcOrdersRoute
AuthedExcProvidersRoute: typeof AuthedExcProvidersRoute
AuthedStudioAgentTokensRoute: typeof AuthedStudioAgentTokensRoute
AuthedStudioArticlesRoute: typeof AuthedStudioArticlesRoute
AuthedStudioCategoryBriefsRoute: typeof AuthedStudioCategoryBriefsRoute
@ -427,6 +507,10 @@ const AuthedRouteChildren: AuthedRouteChildren = {
AuthedSidRoute: AuthedSidRoute,
AuthedIndexRoute: AuthedIndexRoute,
AuthedCategoriesPendingRoute: AuthedCategoriesPendingRoute,
AuthedExcDemandsRoute: AuthedExcDemandsRoute,
AuthedExcDispatchesRoute: AuthedExcDispatchesRoute,
AuthedExcOrdersRoute: AuthedExcOrdersRoute,
AuthedExcProvidersRoute: AuthedExcProvidersRoute,
AuthedStudioAgentTokensRoute: AuthedStudioAgentTokensRoute,
AuthedStudioArticlesRoute: AuthedStudioArticlesRoute,
AuthedStudioCategoryBriefsRoute: AuthedStudioCategoryBriefsRoute,

View File

@ -0,0 +1,188 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import type { ExcDemand } from '@/api/types'
import { fetchExcDemand, fetchExcDemands } from '@/api/modules/exc'
import { useListPage } from '@/lib/list-page'
import { DataTable } from '@/components/data-table/data-table'
import { PageHeader } from '@/components/page-header'
import { DetailSheet } from '@/features/studio/detail-sheet'
import {
DEMAND_STATUS_LABELS,
DISPATCH_MODE_LABELS,
DemandStatusBadge,
formatAmount,
formatTime,
} from '@/features/exc/labels'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
export const Route = createFileRoute('/_authed/exc/demands')({
component: ExcDemandsPage,
})
function ExcDemandsPage() {
const [status, setStatus] = useState<string>('all')
const [mode, setMode] = useState<string>('all')
const [detailId, setDetailId] = useState<number | null>(null)
const filters: Record<string, string> = {}
if (status !== 'all') filters.status = status
if (mode !== 'all') filters.dispatch_mode = mode
const list = useListPage('exc-demands', fetchExcDemands, filters)
const detail = useQuery({
queryKey: ['exc-demand', detailId],
queryFn: () => fetchExcDemand(detailId as number),
enabled: detailId !== null,
})
const demand = detail.data?.data
const columns: ColumnDef<ExcDemand>[] = [
{
header: '需求',
cell: ({ row }) => (
<Button
variant="link"
className="h-auto max-w-72 justify-start p-0 text-left"
onClick={() => setDetailId(row.original.id)}
>
<div className="min-w-0">
<div className="truncate font-medium">{row.original.title ?? '—'}</div>
<code className="font-mono text-xs text-muted-foreground">#{row.original.id}</code>
</div>
</Button>
),
},
{
header: '状态',
cell: ({ row }) => <DemandStatusBadge status={row.original.status} />,
},
{
header: '模式',
cell: ({ row }) =>
row.original.dispatch_mode ? DISPATCH_MODE_LABELS[row.original.dispatch_mode] : '—',
},
{
header: '买家',
cell: ({ row }) => row.original.buyer?.display_name ?? '—',
},
{
header: '参考价',
cell: ({ row }) => formatAmount(row.original.ref_amount, row.original.currency_code),
},
{
header: '报价/派单',
cell: ({ row }) => `${row.original.quotes_count} / ${row.original.dispatches_count}`,
},
{
header: '匹配服务商',
cell: ({ row }) =>
row.original.matched_provider_uid ? (
<code className="font-mono text-xs">{row.original.matched_provider_uid}</code>
) : (
'—'
),
},
{
header: '创建时间',
cell: ({ row }) => formatTime(row.original.created_at),
},
]
return (
<div>
<PageHeader
title="需求监控"
description="全站服务需求及其报价与派单情况(只读)"
actions={
<div className="flex items-center gap-2">
<Select value={status} onValueChange={setStatus}>
<SelectTrigger size="sm" className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{Object.entries(DEMAND_STATUS_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={mode} onValueChange={setMode}>
<SelectTrigger size="sm" className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{Object.entries(DISPATCH_MODE_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
}
/>
<DataTable
columns={columns}
data={list.rows}
pagination={list.pagination}
loading={list.loading}
emptyText="没有需求记录"
onPageChange={list.setPage}
onPageSizeChange={list.setPageSize}
/>
<DetailSheet
open={detailId !== null}
onOpenChange={(open) => !open && setDetailId(null)}
title={demand?.title ?? `需求 #${detailId ?? ''}`}
description={demand ? `状态 ${DEMAND_STATUS_LABELS[demand.status] ?? demand.status}` : undefined}
fields={[
{ label: '摘要', value: demand?.summary ?? '—' },
{ label: '买家', value: demand?.buyer?.display_name ?? '—' },
{ label: '参考价', value: formatAmount(demand?.ref_amount, demand?.currency_code) },
{ label: '报价截止', value: formatTime(demand?.quote_deadline) },
{ label: '需求时间', value: formatTime(demand?.required_at) },
{ label: '匹配时间', value: formatTime(demand?.matched_at) },
]}
jsonBlocks={[
{ label: '关联服务', value: demand?.related_services?.length ? demand.related_services : null },
{
label: `报价(${demand?.quotes?.length ?? 0}`,
value: demand?.quotes?.length
? demand.quotes.map((q) => ({
user: q.user?.display_name ?? q.user_uid,
price: formatAmount(q.price, q.currency_code),
message: q.message,
}))
: null,
},
{
label: `派单(${demand?.dispatches?.length ?? 0}`,
value: demand?.dispatches?.length
? demand.dispatches.map((d) => ({
provider: d.provider?.display_name ?? '—',
round: d.round_no,
status: d.status,
score: d.score,
responded_at: d.responded_at,
}))
: null,
},
{ label: '地址', value: demand?.address ?? null },
]}
/>
</div>
)
}

View File

@ -0,0 +1,139 @@
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import type { ExcDispatch } from '@/api/types'
import { fetchExcDispatches } from '@/api/modules/exc'
import { useListPage } from '@/lib/list-page'
import { DataTable } from '@/components/data-table/data-table'
import { PageHeader } from '@/components/page-header'
import { DISPATCH_STATUS_LABELS, DispatchStatusBadge, formatTime } from '@/features/exc/labels'
import { Badge } from '@/components/ui/badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
export const Route = createFileRoute('/_authed/exc/dispatches')({
component: ExcDispatchesPage,
})
function ExcDispatchesPage() {
const [status, setStatus] = useState<string>('all')
const [round, setRound] = useState<string>('all')
const filters: Record<string, string> = {}
if (status !== 'all') filters.status = status
if (round !== 'all') filters.round_no = round
const list = useListPage('exc-dispatches', fetchExcDispatches, filters)
const columns: ColumnDef<ExcDispatch>[] = [
{
header: '需求',
cell: ({ row }) => (
<div className="max-w-56">
<div className="truncate">{row.original.demand?.title ?? '—'}</div>
<code className="font-mono text-xs text-muted-foreground">
#{row.original.demand?.id ?? '—'}
</code>
</div>
),
},
{
header: '服务商',
cell: ({ row }) => (
<div>
<div className="text-sm">{row.original.provider?.display_name ?? '—'}</div>
<code className="font-mono text-xs text-muted-foreground">
{row.original.provider?.uid ?? ''}
</code>
</div>
),
},
{
header: '轮次',
cell: ({ row }) => <Badge variant="secondary">R{row.original.round_no}</Badge>,
},
{
header: '状态',
cell: ({ row }) => <DispatchStatusBadge status={row.original.status} />,
},
{
header: '评分',
cell: ({ row }) => (row.original.score != null ? row.original.score.toFixed(3) : '—'),
},
{
header: '距离/ETA',
cell: ({ row }) => {
const distance =
row.original.distance_meters != null
? `${(row.original.distance_meters / 1000).toFixed(1)}km`
: '—'
const eta = row.original.eta_minutes != null ? `${row.original.eta_minutes}min` : '—'
return `${distance} / ${eta}`
},
},
{
header: '自动抢单',
cell: ({ row }) => (row.original.auto_grab_eligible ? '是' : '否'),
},
{
header: '发送时间',
cell: ({ row }) => formatTime(row.original.sent_at),
},
{
header: '响应时间',
cell: ({ row }) => formatTime(row.original.responded_at),
},
]
return (
<div>
<PageHeader
title="派单监控"
description="即时派单的每一次报盘及其响应情况(只读)"
actions={
<div className="flex items-center gap-2">
<Select value={status} onValueChange={setStatus}>
<SelectTrigger size="sm" className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{Object.entries(DISPATCH_STATUS_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={round} onValueChange={setRound}>
<SelectTrigger size="sm" className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="0">R0</SelectItem>
<SelectItem value="1">R1</SelectItem>
<SelectItem value="2">R2</SelectItem>
<SelectItem value="3">R3</SelectItem>
</SelectContent>
</Select>
</div>
}
/>
<DataTable
columns={columns}
data={list.rows}
pagination={list.pagination}
loading={list.loading}
emptyText="没有派单记录"
onPageChange={list.setPage}
onPageSizeChange={list.setPageSize}
/>
</div>
)
}

View File

@ -0,0 +1,197 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import type { ExcOrder } from '@/api/types'
import { fetchExcOrder, fetchExcOrders } from '@/api/modules/exc'
import { useListPage } from '@/lib/list-page'
import { DataTable } from '@/components/data-table/data-table'
import { PageHeader } from '@/components/page-header'
import { DetailSheet } from '@/features/studio/detail-sheet'
import {
ORDER_STATUS_LABELS,
OrderStatusBadge,
formatAmount,
formatTime,
} from '@/features/exc/labels'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
export const Route = createFileRoute('/_authed/exc/orders')({
component: ExcOrdersPage,
})
function ExcOrdersPage() {
const [status, setStatus] = useState<string>('all')
const [payment, setPayment] = useState<string>('all')
const [detailId, setDetailId] = useState<number | null>(null)
const filters: Record<string, string> = {}
if (status !== 'all') filters.status = status
if (payment !== 'all') filters.payment_status = payment
const list = useListPage('exc-orders', fetchExcOrders, filters)
const detail = useQuery({
queryKey: ['exc-order', detailId],
queryFn: () => fetchExcOrder(detailId as number),
enabled: detailId !== null,
})
const order = detail.data?.data
const columns: ColumnDef<ExcOrder>[] = [
{
header: '订单',
cell: ({ row }) => (
<Button
variant="link"
className="h-auto max-w-64 justify-start p-0 text-left"
onClick={() => setDetailId(row.original.id)}
>
<div className="min-w-0">
<div className="truncate font-medium">{row.original.title ?? '—'}</div>
<code className="font-mono text-xs text-muted-foreground">#{row.original.id}</code>
</div>
</Button>
),
},
{
header: '状态',
cell: ({ row }) => <OrderStatusBadge status={row.original.status} />,
},
{
header: '支付',
cell: ({ row }) =>
row.original.payment_status === 'PAID' ? (
<Badge className="bg-emerald-500/12 text-emerald-700 dark:text-emerald-400"></Badge>
) : (
<Badge variant="secondary"></Badge>
),
},
{
header: '买家 / 服务商',
cell: ({ row }) => (
<div className="text-sm">
<div>{row.original.buyer?.display_name ?? '—'}</div>
<div className="text-muted-foreground">{row.original.provider?.display_name ?? '—'}</div>
</div>
),
},
{
header: '金额',
cell: ({ row }) =>
formatAmount(
row.original.actual_total_amount ?? row.original.total_amount,
row.original.currency_code,
),
},
{
header: '平台费',
cell: ({ row }) => formatAmount(row.original.platform_fee_amount, row.original.currency_code),
},
{
header: '结算',
cell: ({ row }) =>
row.original.settled_at
? `已结算 ${formatTime(row.original.settled_at)}`
: row.original.escrow_released_at
? '已放款'
: row.original.escrow_release_at
? `待放款 ${formatTime(row.original.escrow_release_at)}`
: '—',
},
{
header: '下单时间',
cell: ({ row }) => formatTime(row.original.created_at),
},
]
return (
<div>
<PageHeader
title="订单监控"
description="服务订单、支付与结算进度(只读)"
actions={
<div className="flex items-center gap-2">
<Select value={status} onValueChange={setStatus}>
<SelectTrigger size="sm" className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={payment} onValueChange={setPayment}>
<SelectTrigger size="sm" className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="UNPAID"></SelectItem>
<SelectItem value="PAID"></SelectItem>
</SelectContent>
</Select>
</div>
}
/>
<DataTable
columns={columns}
data={list.rows}
pagination={list.pagination}
loading={list.loading}
emptyText="没有订单记录"
onPageChange={list.setPage}
onPageSizeChange={list.setPageSize}
/>
<DetailSheet
open={detailId !== null}
onOpenChange={(open) => !open && setDetailId(null)}
title={order?.title ?? `订单 #${detailId ?? ''}`}
description={order ? `状态 ${ORDER_STATUS_LABELS[order.status] ?? order.status}` : undefined}
fields={[
{ label: '买家', value: order?.buyer?.display_name ?? '—' },
{ label: '服务商', value: order?.provider?.display_name ?? '—' },
{ label: '订单金额', value: formatAmount(order?.total_amount, order?.currency_code) },
{ label: '实付金额', value: formatAmount(order?.actual_total_amount, order?.currency_code) },
{ label: '平台费', value: formatAmount(order?.platform_fee_amount, order?.currency_code) },
{ label: '服务商净额', value: formatAmount(order?.provider_net_amount, order?.currency_code) },
{ label: '抽成比例', value: order?.commission_rate != null ? `${(order.commission_rate * 100).toFixed(1)}%` : '—' },
{ label: '支付单号', value: order?.pay_order_no ?? '—' },
{ label: '支付渠道', value: order?.paid_channel ?? '—' },
{ label: '支付时间', value: formatTime(order?.paid_at) },
{ label: '结算时间', value: formatTime(order?.settled_at) },
{ label: '放款时间', value: formatTime(order?.escrow_released_at) },
{ label: '买家备注', value: order?.buyer_remarks ?? '—' },
]}
jsonBlocks={[
{ label: `订单项(${order?.items?.length ?? 0}`, value: order?.items?.length ? order.items : null },
{
label: '状态流转',
value: order?.transactions?.length
? order.transactions.map((t) => ({
from: t.previous_status,
to: t.new_status,
by: t.changed_by,
by_type: t.changed_by_type,
reason: t.reason,
at: t.changed_at,
}))
: null,
},
]}
/>
</div>
)
}

View File

@ -0,0 +1,170 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import type { ColumnDef } from '@tanstack/react-table'
import type { ExcProvider } from '@/api/types'
import { fetchExcProvider, fetchExcProviders } from '@/api/modules/exc'
import { useListPage } from '@/lib/list-page'
import { DataTable } from '@/components/data-table/data-table'
import { PageHeader } from '@/components/page-header'
import { DetailSheet } from '@/features/studio/detail-sheet'
import {
PROVIDER_STATUS_LABELS,
ProviderStatusBadge,
formatTime,
} from '@/features/exc/labels'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
export const Route = createFileRoute('/_authed/exc/providers')({
component: ExcProvidersPage,
})
function ExcProvidersPage() {
const [status, setStatus] = useState<string>('all')
const [verified, setVerified] = useState<string>('all')
const [detailUid, setDetailUid] = useState<string | null>(null)
const filters: Record<string, string> = {}
if (status !== 'all') filters.status = status
if (verified !== 'all') filters.is_verified = verified
const list = useListPage('exc-providers', fetchExcProviders, filters)
const detail = useQuery({
queryKey: ['exc-provider', detailUid],
queryFn: () => fetchExcProvider(detailUid as string),
enabled: detailUid !== null,
})
const provider = detail.data?.data
const columns: ColumnDef<ExcProvider>[] = [
{
header: '服务商',
cell: ({ row }) => (
<Button
variant="link"
className="h-auto justify-start p-0 text-left"
onClick={() => setDetailUid(row.original.user_uid)}
>
<div className="min-w-0">
<div className="font-medium">{row.original.user?.display_name ?? '—'}</div>
<code className="font-mono text-xs text-muted-foreground">{row.original.user_uid}</code>
</div>
</Button>
),
},
{
header: '状态',
cell: ({ row }) => <ProviderStatusBadge status={row.original.status} />,
},
{
header: '认证/禁用',
cell: ({ row }) => (
<div className="flex gap-1">
{row.original.is_verified && (
<Badge className="bg-blue-500/12 text-blue-700 dark:text-blue-400"></Badge>
)}
{row.original.is_disabled && (
<Badge className="bg-red-500/12 text-red-700 dark:text-red-400"></Badge>
)}
{!row.original.is_verified && !row.original.is_disabled && '—'}
</div>
),
},
{
header: '评分',
cell: ({ row }) => (row.original.rating != null ? row.original.rating.toFixed(1) : '—'),
},
{
header: '完成单数',
cell: ({ row }) => row.original.completed_jobs ?? 0,
},
{
header: '当前订单',
cell: ({ row }) =>
row.original.current_service_order_id ? `#${row.original.current_service_order_id}` : '—',
},
{
header: '最近定位',
cell: ({ row }) => formatTime(row.original.last_location_at),
},
{
header: '上线时间',
cell: ({ row }) => formatTime(row.original.online_at),
},
]
return (
<div>
<PageHeader
title="服务商监控"
description="服务商在线状态、评分与派单设置(只读)"
actions={
<div className="flex items-center gap-2">
<Select value={status} onValueChange={setStatus}>
<SelectTrigger size="sm" className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{Object.entries(PROVIDER_STATUS_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={verified} onValueChange={setVerified}>
<SelectTrigger size="sm" className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="1"></SelectItem>
<SelectItem value="0"></SelectItem>
</SelectContent>
</Select>
</div>
}
/>
<DataTable
columns={columns}
data={list.rows}
pagination={list.pagination}
loading={list.loading}
emptyText="没有服务商记录"
onPageChange={list.setPage}
onPageSizeChange={list.setPageSize}
/>
<DetailSheet
open={detailUid !== null}
onOpenChange={(open) => !open && setDetailUid(null)}
title={provider?.user?.display_name ?? detailUid ?? ''}
description={provider ? `状态 ${PROVIDER_STATUS_LABELS[provider.status] ?? provider.status}` : undefined}
fields={[
{ label: 'UID', value: <code className="font-mono text-xs">{provider?.user_uid}</code> },
{ label: '评分', value: provider?.rating != null ? provider.rating.toFixed(1) : '—' },
{ label: '完成单数', value: provider?.completed_jobs ?? 0 },
{ label: '已认证', value: provider?.is_verified ? '是' : '否' },
{ label: '已禁用', value: provider?.is_disabled ? '是' : '否' },
{ label: '当前订单', value: provider?.current_service_order_id ? `#${provider.current_service_order_id}` : '—' },
{ label: '上线时间', value: formatTime(provider?.online_at) },
{ label: '离线时间', value: formatTime(provider?.offline_at) },
{ label: '最近定位', value: formatTime(provider?.last_location_at) },
]}
jsonBlocks={[
{ label: '派单设置', value: provider?.dispatch_setting ?? null },
{ label: '状态日志最近10条', value: provider?.status_logs?.length ? provider.status_logs : null },
]}
/>
</div>
)
}