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:
40
src/api/modules/exc.ts
Normal file
40
src/api/modules/exc.ts
Normal 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}`)
|
||||||
|
}
|
||||||
1075
src/api/types.gen.ts
1075
src/api/types.gen.ts
File diff suppressed because it is too large
Load Diff
184
src/api/types.ts
184
src/api/types.ts
@ -351,3 +351,187 @@ export interface RetranslateResult {
|
|||||||
translation_version_id: number
|
translation_version_id: number
|
||||||
queued_blocks: 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
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
Bot,
|
Bot,
|
||||||
|
ClipboardList,
|
||||||
Flag,
|
Flag,
|
||||||
FolderTree,
|
FolderTree,
|
||||||
Hash,
|
Hash,
|
||||||
@ -12,9 +13,12 @@ import {
|
|||||||
MessageSquareWarning,
|
MessageSquareWarning,
|
||||||
Newspaper,
|
Newspaper,
|
||||||
Palette,
|
Palette,
|
||||||
|
Receipt,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
|
Send,
|
||||||
Smartphone,
|
Smartphone,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
UserCheck,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
export interface NavItem {
|
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: '内容审核',
|
label: '内容审核',
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
123
src/features/exc/labels.tsx
Normal file
123
src/features/exc/labels.tsx
Normal 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') : '—'
|
||||||
|
}
|
||||||
@ -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 AuthedStudioCategoryBriefsRouteImport } from './routes/_authed/studio/category-briefs'
|
||||||
import { Route as AuthedStudioArticlesRouteImport } from './routes/_authed/studio/articles'
|
import { Route as AuthedStudioArticlesRouteImport } from './routes/_authed/studio/articles'
|
||||||
import { Route as AuthedStudioAgentTokensRouteImport } from './routes/_authed/studio/agent-tokens'
|
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'
|
import { Route as AuthedCategoriesPendingRouteImport } from './routes/_authed/categories/pending'
|
||||||
|
|
||||||
const LoginRoute = LoginRouteImport.update({
|
const LoginRoute = LoginRouteImport.update({
|
||||||
@ -122,6 +126,26 @@ const AuthedStudioAgentTokensRoute = AuthedStudioAgentTokensRouteImport.update({
|
|||||||
path: '/studio/agent-tokens',
|
path: '/studio/agent-tokens',
|
||||||
getParentRoute: () => AuthedRoute,
|
getParentRoute: () => AuthedRoute,
|
||||||
} as any)
|
} 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({
|
const AuthedCategoriesPendingRoute = AuthedCategoriesPendingRouteImport.update({
|
||||||
id: '/categories/pending',
|
id: '/categories/pending',
|
||||||
path: '/categories/pending',
|
path: '/categories/pending',
|
||||||
@ -139,6 +163,10 @@ export interface FileRoutesByFullPath {
|
|||||||
'/robots': typeof AuthedRobotsRoute
|
'/robots': typeof AuthedRobotsRoute
|
||||||
'/sid': typeof AuthedSidRoute
|
'/sid': typeof AuthedSidRoute
|
||||||
'/categories/pending': typeof AuthedCategoriesPendingRoute
|
'/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/agent-tokens': typeof AuthedStudioAgentTokensRoute
|
||||||
'/studio/articles': typeof AuthedStudioArticlesRoute
|
'/studio/articles': typeof AuthedStudioArticlesRoute
|
||||||
'/studio/category-briefs': typeof AuthedStudioCategoryBriefsRoute
|
'/studio/category-briefs': typeof AuthedStudioCategoryBriefsRoute
|
||||||
@ -159,6 +187,10 @@ export interface FileRoutesByTo {
|
|||||||
'/sid': typeof AuthedSidRoute
|
'/sid': typeof AuthedSidRoute
|
||||||
'/': typeof AuthedIndexRoute
|
'/': typeof AuthedIndexRoute
|
||||||
'/categories/pending': typeof AuthedCategoriesPendingRoute
|
'/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/agent-tokens': typeof AuthedStudioAgentTokensRoute
|
||||||
'/studio/articles': typeof AuthedStudioArticlesRoute
|
'/studio/articles': typeof AuthedStudioArticlesRoute
|
||||||
'/studio/category-briefs': typeof AuthedStudioCategoryBriefsRoute
|
'/studio/category-briefs': typeof AuthedStudioCategoryBriefsRoute
|
||||||
@ -181,6 +213,10 @@ export interface FileRoutesById {
|
|||||||
'/_authed/sid': typeof AuthedSidRoute
|
'/_authed/sid': typeof AuthedSidRoute
|
||||||
'/_authed/': typeof AuthedIndexRoute
|
'/_authed/': typeof AuthedIndexRoute
|
||||||
'/_authed/categories/pending': typeof AuthedCategoriesPendingRoute
|
'/_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/agent-tokens': typeof AuthedStudioAgentTokensRoute
|
||||||
'/_authed/studio/articles': typeof AuthedStudioArticlesRoute
|
'/_authed/studio/articles': typeof AuthedStudioArticlesRoute
|
||||||
'/_authed/studio/category-briefs': typeof AuthedStudioCategoryBriefsRoute
|
'/_authed/studio/category-briefs': typeof AuthedStudioCategoryBriefsRoute
|
||||||
@ -203,6 +239,10 @@ export interface FileRouteTypes {
|
|||||||
| '/robots'
|
| '/robots'
|
||||||
| '/sid'
|
| '/sid'
|
||||||
| '/categories/pending'
|
| '/categories/pending'
|
||||||
|
| '/exc/demands'
|
||||||
|
| '/exc/dispatches'
|
||||||
|
| '/exc/orders'
|
||||||
|
| '/exc/providers'
|
||||||
| '/studio/agent-tokens'
|
| '/studio/agent-tokens'
|
||||||
| '/studio/articles'
|
| '/studio/articles'
|
||||||
| '/studio/category-briefs'
|
| '/studio/category-briefs'
|
||||||
@ -223,6 +263,10 @@ export interface FileRouteTypes {
|
|||||||
| '/sid'
|
| '/sid'
|
||||||
| '/'
|
| '/'
|
||||||
| '/categories/pending'
|
| '/categories/pending'
|
||||||
|
| '/exc/demands'
|
||||||
|
| '/exc/dispatches'
|
||||||
|
| '/exc/orders'
|
||||||
|
| '/exc/providers'
|
||||||
| '/studio/agent-tokens'
|
| '/studio/agent-tokens'
|
||||||
| '/studio/articles'
|
| '/studio/articles'
|
||||||
| '/studio/category-briefs'
|
| '/studio/category-briefs'
|
||||||
@ -244,6 +288,10 @@ export interface FileRouteTypes {
|
|||||||
| '/_authed/sid'
|
| '/_authed/sid'
|
||||||
| '/_authed/'
|
| '/_authed/'
|
||||||
| '/_authed/categories/pending'
|
| '/_authed/categories/pending'
|
||||||
|
| '/_authed/exc/demands'
|
||||||
|
| '/_authed/exc/dispatches'
|
||||||
|
| '/_authed/exc/orders'
|
||||||
|
| '/_authed/exc/providers'
|
||||||
| '/_authed/studio/agent-tokens'
|
| '/_authed/studio/agent-tokens'
|
||||||
| '/_authed/studio/articles'
|
| '/_authed/studio/articles'
|
||||||
| '/_authed/studio/category-briefs'
|
| '/_authed/studio/category-briefs'
|
||||||
@ -387,6 +435,34 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthedStudioAgentTokensRouteImport
|
preLoaderRoute: typeof AuthedStudioAgentTokensRouteImport
|
||||||
parentRoute: typeof AuthedRoute
|
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': {
|
'/_authed/categories/pending': {
|
||||||
id: '/_authed/categories/pending'
|
id: '/_authed/categories/pending'
|
||||||
path: '/categories/pending'
|
path: '/categories/pending'
|
||||||
@ -407,6 +483,10 @@ interface AuthedRouteChildren {
|
|||||||
AuthedSidRoute: typeof AuthedSidRoute
|
AuthedSidRoute: typeof AuthedSidRoute
|
||||||
AuthedIndexRoute: typeof AuthedIndexRoute
|
AuthedIndexRoute: typeof AuthedIndexRoute
|
||||||
AuthedCategoriesPendingRoute: typeof AuthedCategoriesPendingRoute
|
AuthedCategoriesPendingRoute: typeof AuthedCategoriesPendingRoute
|
||||||
|
AuthedExcDemandsRoute: typeof AuthedExcDemandsRoute
|
||||||
|
AuthedExcDispatchesRoute: typeof AuthedExcDispatchesRoute
|
||||||
|
AuthedExcOrdersRoute: typeof AuthedExcOrdersRoute
|
||||||
|
AuthedExcProvidersRoute: typeof AuthedExcProvidersRoute
|
||||||
AuthedStudioAgentTokensRoute: typeof AuthedStudioAgentTokensRoute
|
AuthedStudioAgentTokensRoute: typeof AuthedStudioAgentTokensRoute
|
||||||
AuthedStudioArticlesRoute: typeof AuthedStudioArticlesRoute
|
AuthedStudioArticlesRoute: typeof AuthedStudioArticlesRoute
|
||||||
AuthedStudioCategoryBriefsRoute: typeof AuthedStudioCategoryBriefsRoute
|
AuthedStudioCategoryBriefsRoute: typeof AuthedStudioCategoryBriefsRoute
|
||||||
@ -427,6 +507,10 @@ const AuthedRouteChildren: AuthedRouteChildren = {
|
|||||||
AuthedSidRoute: AuthedSidRoute,
|
AuthedSidRoute: AuthedSidRoute,
|
||||||
AuthedIndexRoute: AuthedIndexRoute,
|
AuthedIndexRoute: AuthedIndexRoute,
|
||||||
AuthedCategoriesPendingRoute: AuthedCategoriesPendingRoute,
|
AuthedCategoriesPendingRoute: AuthedCategoriesPendingRoute,
|
||||||
|
AuthedExcDemandsRoute: AuthedExcDemandsRoute,
|
||||||
|
AuthedExcDispatchesRoute: AuthedExcDispatchesRoute,
|
||||||
|
AuthedExcOrdersRoute: AuthedExcOrdersRoute,
|
||||||
|
AuthedExcProvidersRoute: AuthedExcProvidersRoute,
|
||||||
AuthedStudioAgentTokensRoute: AuthedStudioAgentTokensRoute,
|
AuthedStudioAgentTokensRoute: AuthedStudioAgentTokensRoute,
|
||||||
AuthedStudioArticlesRoute: AuthedStudioArticlesRoute,
|
AuthedStudioArticlesRoute: AuthedStudioArticlesRoute,
|
||||||
AuthedStudioCategoryBriefsRoute: AuthedStudioCategoryBriefsRoute,
|
AuthedStudioCategoryBriefsRoute: AuthedStudioCategoryBriefsRoute,
|
||||||
|
|||||||
188
src/routes/_authed/exc/demands.tsx
Normal file
188
src/routes/_authed/exc/demands.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
139
src/routes/_authed/exc/dispatches.tsx
Normal file
139
src/routes/_authed/exc/dispatches.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
197
src/routes/_authed/exc/orders.tsx
Normal file
197
src/routes/_authed/exc/orders.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
170
src/routes/_authed/exc/providers.tsx
Normal file
170
src/routes/_authed/exc/providers.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user