feat(admin): 重做后台工作台并补齐运营明细看板
This commit is contained in:
@@ -1,23 +1,31 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Empty,
|
||||
Row,
|
||||
Skeleton,
|
||||
Statistic,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
type TableColumnsType,
|
||||
} from 'antd'
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
ArrowRightOutlined,
|
||||
BellOutlined,
|
||||
ClockCircleOutlined,
|
||||
CustomerServiceOutlined,
|
||||
FallOutlined,
|
||||
FileSearchOutlined,
|
||||
ShoppingCartOutlined,
|
||||
FileTextOutlined,
|
||||
ReloadOutlined,
|
||||
RiseOutlined,
|
||||
SafetyOutlined,
|
||||
SettingOutlined,
|
||||
ShopOutlined,
|
||||
SwapOutlined,
|
||||
TeamOutlined,
|
||||
WalletOutlined,
|
||||
} from '@ant-design/icons'
|
||||
@@ -25,7 +33,14 @@ import dayjs from 'dayjs'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { fetchAdminDashboard } from '@/features/admin/api'
|
||||
import type { RecentOrder } from '@/features/admin/types'
|
||||
import type {
|
||||
DashboardListingChannelStats,
|
||||
DashboardListingDayStats,
|
||||
DashboardListingUploaderStats,
|
||||
RecentDispute,
|
||||
RecentOrder,
|
||||
} from '@/features/admin/types'
|
||||
import { formatMoney } from '@/shared/utils/format'
|
||||
|
||||
const statusMap: Record<string, { label: string; color: string }> = {
|
||||
pending_payment: { label: '待支付', color: 'gold' },
|
||||
@@ -41,18 +56,210 @@ const statusMap: Record<string, { label: string; color: string }> = {
|
||||
abnormal: { label: '异常', color: 'red' },
|
||||
}
|
||||
|
||||
function money(cents: number) {
|
||||
return `¥${(cents / 100).toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`
|
||||
const disputeStatusMap: Record<string, { label: string; color: string }> = {
|
||||
open: { label: '待处理', color: 'red' },
|
||||
processing: { label: '处理中', color: 'orange' },
|
||||
resolved: { label: '已解决', color: 'green' },
|
||||
rejected: { label: '已驳回', color: 'default' },
|
||||
}
|
||||
|
||||
type ListingTrendField = 'published_count' | 'active_offline_count' | 'trade_leave_count'
|
||||
|
||||
function shortDate(date: string) {
|
||||
return date.length >= 10 ? date.slice(5) : date
|
||||
}
|
||||
|
||||
function signed(value: number) {
|
||||
return value > 0 ? `+${value}` : String(value)
|
||||
}
|
||||
|
||||
function barHeight(value: number, max: number) {
|
||||
if (max <= 0) return '8%'
|
||||
return `${Math.max(8, Math.round((value / max) * 100))}%`
|
||||
}
|
||||
|
||||
function trendMax(rows: DashboardListingDayStats[], field: ListingTrendField) {
|
||||
return Math.max(1, ...rows.map(row => Number(row[field] ?? 0)))
|
||||
}
|
||||
|
||||
function ListingTrendCard({
|
||||
title,
|
||||
hint,
|
||||
value,
|
||||
rows,
|
||||
field,
|
||||
tone,
|
||||
icon,
|
||||
}: {
|
||||
title: string
|
||||
hint: string
|
||||
value: number
|
||||
rows: DashboardListingDayStats[]
|
||||
field: ListingTrendField
|
||||
tone: 'publish' | 'offline' | 'trade'
|
||||
icon: ReactNode
|
||||
}) {
|
||||
const max = trendMax(rows, field)
|
||||
return (
|
||||
<div className={`dash-listing-card is-${tone}`}>
|
||||
<div className="dash-listing-card__head">
|
||||
<span className="dash-listing-card__icon">{icon}</span>
|
||||
<div>
|
||||
<span className="dash-listing-card__label">{title}</span>
|
||||
<strong className="dash-listing-card__value">{value}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p className="dash-listing-card__hint">{hint}</p>
|
||||
<div className="dash-bars">
|
||||
{rows.map(row => (
|
||||
<div
|
||||
key={`${field}-${row.date}`}
|
||||
className="dash-bar-col"
|
||||
title={`${row.date}:${row[field]}`}
|
||||
>
|
||||
<span className="dash-bar-value">{row[field]}</span>
|
||||
<div
|
||||
className={`dash-bar is-${tone}`}
|
||||
style={{ height: barHeight(Number(row[field] ?? 0), max) }}
|
||||
/>
|
||||
<em>{shortDate(row.date)}</em>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const listingTrendColumns: TableColumnsType<DashboardListingDayStats> = [
|
||||
{
|
||||
title: '统计日期',
|
||||
dataIndex: 'date',
|
||||
width: 120,
|
||||
render: value => <span className="admin-mono">{value}</span>,
|
||||
},
|
||||
{
|
||||
title: '上架量',
|
||||
dataIndex: 'published_count',
|
||||
width: 110,
|
||||
align: 'right',
|
||||
render: value => <span className="dash-trend-number is-publish">+{value}</span>,
|
||||
},
|
||||
{
|
||||
title: '主动下架',
|
||||
dataIndex: 'active_offline_count',
|
||||
width: 110,
|
||||
align: 'right',
|
||||
render: value => <span className="dash-trend-number is-offline">-{value}</span>,
|
||||
},
|
||||
{
|
||||
title: '交易离架',
|
||||
dataIndex: 'trade_leave_count',
|
||||
width: 110,
|
||||
align: 'right',
|
||||
render: value => <span className="dash-trend-number is-trade">-{value}</span>,
|
||||
},
|
||||
{
|
||||
title: '净增',
|
||||
key: 'net',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: (_, row) => {
|
||||
const net =
|
||||
Number(row.published_count ?? 0) -
|
||||
Number(row.active_offline_count ?? 0) -
|
||||
Number(row.trade_leave_count ?? 0)
|
||||
return (
|
||||
<span
|
||||
className={`dash-trend-number${net > 0 ? ' is-publish' : net < 0 ? ' is-danger' : ' is-muted'}`}
|
||||
>
|
||||
{signed(net)}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const channelTrendColumns: TableColumnsType<DashboardListingChannelStats> = [
|
||||
{
|
||||
title: '统计日期',
|
||||
dataIndex: 'date',
|
||||
width: 120,
|
||||
render: value => <span className="admin-mono">{value}</span>,
|
||||
},
|
||||
{
|
||||
title: '渠道来源',
|
||||
dataIndex: 'source_channel',
|
||||
width: 140,
|
||||
render: value => <Tag bordered={false}>{value || '未填写'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '上架',
|
||||
dataIndex: 'published_count',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: value => <span className="dash-trend-number is-publish">+{value}</span>,
|
||||
},
|
||||
{
|
||||
title: '主动下架',
|
||||
dataIndex: 'active_offline_count',
|
||||
width: 110,
|
||||
align: 'right',
|
||||
render: value => <span className="dash-trend-number is-offline">-{value}</span>,
|
||||
},
|
||||
{
|
||||
title: '交易离架',
|
||||
dataIndex: 'trade_leave_count',
|
||||
width: 110,
|
||||
align: 'right',
|
||||
render: value => <span className="dash-trend-number is-trade">-{value}</span>,
|
||||
},
|
||||
{
|
||||
title: '净增',
|
||||
key: 'net',
|
||||
width: 90,
|
||||
align: 'right',
|
||||
render: (_, row) => {
|
||||
const net =
|
||||
Number(row.published_count ?? 0) -
|
||||
Number(row.active_offline_count ?? 0) -
|
||||
Number(row.trade_leave_count ?? 0)
|
||||
return (
|
||||
<span
|
||||
className={`dash-trend-number${net > 0 ? ' is-publish' : net < 0 ? ' is-danger' : ' is-muted'}`}
|
||||
>
|
||||
{signed(net)}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const uploaderTrendColumns: TableColumnsType<DashboardListingUploaderStats> = [
|
||||
{
|
||||
title: '统计日期',
|
||||
dataIndex: 'date',
|
||||
width: 120,
|
||||
render: value => <span className="admin-mono">{value}</span>,
|
||||
},
|
||||
{
|
||||
title: '客服 / 上传人',
|
||||
dataIndex: 'uploader_name',
|
||||
render: value => value || '未填写',
|
||||
},
|
||||
{
|
||||
title: '上传账号数',
|
||||
dataIndex: 'upload_count',
|
||||
width: 130,
|
||||
align: 'right',
|
||||
render: value => <strong className="dash-trend-number is-publish">{value}</strong>,
|
||||
},
|
||||
]
|
||||
|
||||
const orderColumns: TableColumnsType<RecentOrder> = [
|
||||
{
|
||||
title: '订单号',
|
||||
dataIndex: 'order_no',
|
||||
width: 150,
|
||||
width: 160,
|
||||
render: value => <span className="admin-mono">{value}</span>,
|
||||
},
|
||||
{
|
||||
@@ -74,14 +281,14 @@ const orderColumns: TableColumnsType<RecentOrder> = [
|
||||
dataIndex: 'rent_amount_cent',
|
||||
width: 120,
|
||||
align: 'right',
|
||||
render: value => <span className="admin-money">{money(value)}</span>,
|
||||
render: value => <span className="admin-money">{formatMoney(value)}</span>,
|
||||
},
|
||||
{
|
||||
title: '押金',
|
||||
dataIndex: 'deposit_amount_cent',
|
||||
width: 120,
|
||||
align: 'right',
|
||||
render: value => <span className="admin-money">{money(value)}</span>,
|
||||
render: value => <span className="admin-money">{formatMoney(value)}</span>,
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
@@ -91,9 +298,35 @@ const orderColumns: TableColumnsType<RecentOrder> = [
|
||||
},
|
||||
]
|
||||
|
||||
const disputeColumns: TableColumnsType<RecentDispute> = [
|
||||
{
|
||||
title: '订单号',
|
||||
dataIndex: 'order_no',
|
||||
width: 160,
|
||||
render: value => <span className="admin-mono">{value}</span>,
|
||||
},
|
||||
{ title: '商品', dataIndex: 'title', ellipsis: true },
|
||||
{ title: '类型', dataIndex: 'type', width: 130 },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 110,
|
||||
render: value => {
|
||||
const item = disputeStatusMap[value] || { label: value, color: 'default' }
|
||||
return <Tag color={item.color}>{item.label}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '发起时间',
|
||||
dataIndex: 'created_at',
|
||||
width: 170,
|
||||
render: value => dayjs(value).format('YYYY-MM-DD HH:mm'),
|
||||
},
|
||||
]
|
||||
|
||||
export function AdminDashboardPage() {
|
||||
const navigate = useNavigate()
|
||||
const { data, isPending, isError, refetch } = useQuery({
|
||||
const { data, isPending, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: ['admin-dashboard'],
|
||||
queryFn: fetchAdminDashboard,
|
||||
staleTime: 30_000,
|
||||
@@ -117,9 +350,122 @@ export function AdminDashboardPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const metrics = data?.metrics
|
||||
const pending = data?.pending
|
||||
const listingDaily = data?.listing_daily
|
||||
const trend = listingDaily?.trend ?? []
|
||||
const today = listingDaily?.today
|
||||
const channels = (listingDaily?.today_channels ?? []).filter(
|
||||
row =>
|
||||
row.published_count + row.active_offline_count + row.trade_leave_count > 0 ||
|
||||
Boolean(row.source_channel)
|
||||
)
|
||||
const uploaders = (listingDaily?.today_uploaders ?? []).filter(row => row.upload_count > 0)
|
||||
const manualEntries = (data?.manual_entry_today ?? []).filter(row => row.count > 0)
|
||||
const channelTrend = [...(listingDaily?.channel_trend ?? [])]
|
||||
.filter(
|
||||
row => row.published_count + row.active_offline_count + row.trade_leave_count > 0
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.date.localeCompare(a.date) ||
|
||||
b.published_count - a.published_count ||
|
||||
(a.source_channel || '').localeCompare(b.source_channel || '')
|
||||
)
|
||||
const uploaderTrend = [...(listingDaily?.uploader_trend ?? [])]
|
||||
.filter(row => row.upload_count > 0)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.date.localeCompare(a.date) ||
|
||||
b.upload_count - a.upload_count ||
|
||||
(a.uploader_name || '').localeCompare(b.uploader_name || '')
|
||||
)
|
||||
const recentOrders = data?.recent_orders ?? []
|
||||
const recentDisputes = data?.recent_disputes ?? []
|
||||
|
||||
const metricCards = [
|
||||
{
|
||||
key: 'users',
|
||||
label: '用户总数',
|
||||
value: metrics?.total_users ?? 0,
|
||||
sub: `已实名 ${metrics?.verified_users ?? 0} 人`,
|
||||
icon: <TeamOutlined />,
|
||||
tone: 'indigo',
|
||||
},
|
||||
{
|
||||
key: 'listings',
|
||||
label: '商品总数',
|
||||
value: metrics?.total_listings ?? 0,
|
||||
sub: `在架 ${metrics?.published_listings ?? 0} 个`,
|
||||
icon: <ShopOutlined />,
|
||||
tone: 'emerald',
|
||||
},
|
||||
{
|
||||
key: 'orders',
|
||||
label: '订单总数',
|
||||
value: metrics?.total_orders ?? 0,
|
||||
sub: `使用中 ${metrics?.renting_orders ?? 0} 个`,
|
||||
icon: <FileTextOutlined />,
|
||||
tone: 'amber',
|
||||
},
|
||||
{
|
||||
key: 'flow',
|
||||
label: '今日流水',
|
||||
value: formatMoney(metrics?.today_ledger_amount_cent ?? 0),
|
||||
sub: `今日订单 ${metrics?.today_orders ?? 0} 个`,
|
||||
icon: <WalletOutlined />,
|
||||
tone: 'rose',
|
||||
},
|
||||
]
|
||||
|
||||
const pendingItems = [
|
||||
{
|
||||
key: 'disputes',
|
||||
label: '待仲裁申诉',
|
||||
desc: '需要人工介入处理的用户争议',
|
||||
count: pending?.disputes ?? 0,
|
||||
path: 'disputes',
|
||||
icon: <SafetyOutlined />,
|
||||
},
|
||||
{
|
||||
key: 'reviews',
|
||||
label: '待审核商品',
|
||||
desc: '卖家新提交上架的账号资产审核',
|
||||
count: pending?.listing_reviews ?? 0,
|
||||
path: 'listings/review',
|
||||
icon: <FileSearchOutlined />,
|
||||
},
|
||||
{
|
||||
key: 'handoffs',
|
||||
label: '待交接订单',
|
||||
desc: '等待号主确认交接发货',
|
||||
count: pending?.pending_handoffs ?? 0,
|
||||
path: 'orders',
|
||||
icon: <SwapOutlined />,
|
||||
},
|
||||
{
|
||||
key: 'checkouts',
|
||||
label: '待结账确认',
|
||||
desc: '租期结束等待双方确认结算',
|
||||
count: pending?.pending_return_confirms ?? 0,
|
||||
path: 'orders',
|
||||
icon: <ClockCircleOutlined />,
|
||||
},
|
||||
]
|
||||
const pendingTotal = pendingItems.reduce((sum, item) => sum + item.count, 0)
|
||||
|
||||
const quickNav = [
|
||||
{ label: '用户管理', path: 'users', icon: <TeamOutlined />, tone: 'indigo' },
|
||||
{ label: '订单管理', path: 'orders', icon: <FileTextOutlined />, tone: 'amber' },
|
||||
{ label: '仲裁中心', path: 'disputes', icon: <SafetyOutlined />, tone: 'rose' },
|
||||
{ label: '商品管理', path: 'listings', icon: <ShopOutlined />, tone: 'emerald' },
|
||||
{ label: '客服群聊', path: 'chats', icon: <CustomerServiceOutlined />, tone: 'teal' },
|
||||
{ label: '系统配置', path: 'system-configs', icon: <SettingOutlined />, tone: 'slate' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page__head">
|
||||
<div className="admin-page dash-page">
|
||||
<header className="dash-head">
|
||||
<div>
|
||||
<Typography.Text className="admin-page__eyebrow">TODAY AT JIEYOU</Typography.Text>
|
||||
<Typography.Title level={2} className="brand-title">
|
||||
@@ -130,123 +476,367 @@ export function AdminDashboardPage() {
|
||||
{data?.generated_at ? dayjs(data.generated_at).format('HH:mm:ss') : '加载中'}。
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Button type="primary" onClick={() => navigate('orders')}>
|
||||
去处理订单 <ArrowRightOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
<Space wrap>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={isFetching}
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
刷新数据
|
||||
</Button>
|
||||
<Button type="primary" onClick={() => navigate('orders')}>
|
||||
去处理订单 <ArrowRightOutlined />
|
||||
</Button>
|
||||
</Space>
|
||||
</header>
|
||||
|
||||
{isPending ? (
|
||||
<Row gutter={[16, 16]}>
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<Col xs={24} sm={12} xl={6} key={index}>
|
||||
<Card className="admin-kpi-card">
|
||||
<Skeleton active paragraph={{ rows: 1 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<div className="dash-metric-grid">
|
||||
{[0, 1, 2, 3].map(index => (
|
||||
<div className="dash-metric-card" key={index}>
|
||||
<Skeleton active paragraph={{ rows: 1 }} />
|
||||
</div>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
) : (
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} sm={12} xl={6}>
|
||||
<Card className="admin-kpi-card">
|
||||
<div className="admin-kpi-card__icon">
|
||||
<ShoppingCartOutlined />
|
||||
<div className="dash-metric-grid">
|
||||
{metricCards.map(card => (
|
||||
<div key={card.key} className={`dash-metric-card is-${card.tone}`}>
|
||||
<span className="dash-metric-card__icon">{card.icon}</span>
|
||||
<div className="dash-metric-card__body">
|
||||
<span className="dash-metric-card__label">{card.label}</span>
|
||||
<strong className="dash-metric-card__value">{card.value}</strong>
|
||||
<span className="dash-metric-card__sub">{card.sub}</span>
|
||||
</div>
|
||||
<Statistic title="今日订单" value={data?.metrics.today_orders ?? 0} />
|
||||
<span className="admin-kpi-card__foot">
|
||||
总订单 {data?.metrics.total_orders ?? 0}
|
||||
</span>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} xl={6}>
|
||||
<Card className="admin-kpi-card">
|
||||
<div className="admin-kpi-card__icon">
|
||||
<TeamOutlined />
|
||||
</div>
|
||||
<Statistic title="用户总数" value={data?.metrics.total_users ?? 0} />
|
||||
<span className="admin-kpi-card__foot">
|
||||
已实名 {data?.metrics.verified_users ?? 0}
|
||||
</span>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} xl={6}>
|
||||
<Card className="admin-kpi-card">
|
||||
<div className="admin-kpi-card__icon">
|
||||
<FileSearchOutlined />
|
||||
</div>
|
||||
<Statistic title="在架商品" value={data?.metrics.published_listings ?? 0} />
|
||||
<span className="admin-kpi-card__foot">
|
||||
商品总数 {data?.metrics.total_listings ?? 0}
|
||||
</span>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} xl={6}>
|
||||
<Card className="admin-kpi-card admin-kpi-card--accent">
|
||||
<div className="admin-kpi-card__icon">
|
||||
<WalletOutlined />
|
||||
</div>
|
||||
<Statistic
|
||||
title="今日入账"
|
||||
value={money(data?.metrics.today_ledger_amount_cent ?? 0)}
|
||||
/>
|
||||
<span className="admin-kpi-card__foot">
|
||||
在租订单 {data?.metrics.renting_orders ?? 0}
|
||||
</span>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="admin-section-title">
|
||||
<Typography.Title level={4}>待办</Typography.Title>
|
||||
<Typography.Text type="secondary">客服和运营每天先处理这四类</Typography.Text>
|
||||
<section className="dash-section">
|
||||
<div className="dash-section__head">
|
||||
<div className="dash-section__title">
|
||||
<ShopOutlined />
|
||||
<strong>账号上下架趋势</strong>
|
||||
<span className="dash-section__badge">
|
||||
按北京时间自然日 · 近 {listingDaily?.days ?? 7} 日
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPending ? (
|
||||
<Skeleton active paragraph={{ rows: 4 }} />
|
||||
) : (
|
||||
<>
|
||||
<div className="dash-listing-grid">
|
||||
<ListingTrendCard
|
||||
title="今日上架"
|
||||
hint="免审发布 / 审核通过 / 取消订单恢复上架"
|
||||
value={today?.published_count ?? 0}
|
||||
rows={trend}
|
||||
field="published_count"
|
||||
tone="publish"
|
||||
icon={<RiseOutlined />}
|
||||
/>
|
||||
<ListingTrendCard
|
||||
title="今日主动下架"
|
||||
hint="号主手动下架 + 后台管理下架"
|
||||
value={today?.active_offline_count ?? 0}
|
||||
rows={trend}
|
||||
field="active_offline_count"
|
||||
tone="offline"
|
||||
icon={<FallOutlined />}
|
||||
/>
|
||||
<ListingTrendCard
|
||||
title="今日交易离架"
|
||||
hint="租出 / 售出完成 / 封存 / 仲裁归档"
|
||||
value={today?.trade_leave_count ?? 0}
|
||||
rows={trend}
|
||||
field="trade_leave_count"
|
||||
tone="trade"
|
||||
icon={<SwapOutlined />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="dash-subhead">今日各渠道上下架汇总</div>
|
||||
{channels.length > 0 ? (
|
||||
<div className="dash-channel-grid">
|
||||
{channels.map(row => (
|
||||
<div className="dash-channel-item" key={row.source_channel || 'unknown'}>
|
||||
<div className="dash-channel-item__head">
|
||||
<span>{row.source_channel || '未填写'}</span>
|
||||
<strong>
|
||||
{row.published_count + row.active_offline_count + row.trade_leave_count}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="dash-channel-item__counts">
|
||||
<span className="is-publish">上架 {row.published_count}</span>
|
||||
<span className="is-offline">主动下架 {row.active_offline_count}</span>
|
||||
<span className="is-trade">交易离架 {row.trade_leave_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="dash-empty-line">今日暂无上下架记录。</div>
|
||||
)}
|
||||
|
||||
{uploaders.length > 0 || manualEntries.length > 0 ? (
|
||||
<div className="dash-people-grid">
|
||||
{uploaders.length > 0 ? (
|
||||
<div className="dash-people-card is-upload">
|
||||
<div className="dash-people-card__head">
|
||||
<span className="dash-people-card__title">今日外部上传账号</span>
|
||||
<strong>
|
||||
{uploaders.reduce((sum, row) => sum + row.upload_count, 0)}
|
||||
<small> 个</small>
|
||||
</strong>
|
||||
</div>
|
||||
<div className="dash-people-list">
|
||||
{uploaders.map(row => (
|
||||
<div
|
||||
className="dash-person-row"
|
||||
key={`${row.uploader_id}-${row.uploader_name}`}
|
||||
>
|
||||
<span className="dash-person-name">{row.uploader_name || '未填写'}</span>
|
||||
<strong>{row.upload_count}</strong>
|
||||
<small>个账号</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{manualEntries.length > 0 ? (
|
||||
<div className="dash-people-card is-manual">
|
||||
<div className="dash-people-card__head">
|
||||
<span className="dash-people-card__title">今日客服手动录单</span>
|
||||
<strong>
|
||||
{manualEntries.reduce((sum, row) => sum + row.count, 0)}
|
||||
<small> 单</small>
|
||||
</strong>
|
||||
</div>
|
||||
<div className="dash-people-list">
|
||||
{manualEntries.map(row => (
|
||||
<div className="dash-person-row" key={row.admin_id}>
|
||||
<span className="dash-person-name">{row.admin_name || '未填写'}</span>
|
||||
<strong>{row.count}</strong>
|
||||
<small>单</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="dash-table-stack">
|
||||
<article className="dash-table-block">
|
||||
<div className="dash-table-block__head">
|
||||
<div>
|
||||
<strong>近 {listingDaily?.days ?? 7} 日账号流转明细</strong>
|
||||
<span>逐日核对账号上架、主动下架与交易离架,净增为三项差值。</span>
|
||||
</div>
|
||||
<span className="dash-table-block__meta">
|
||||
累计上架 {trend.reduce((sum, row) => sum + row.published_count, 0)}
|
||||
</span>
|
||||
</div>
|
||||
<Table<DashboardListingDayStats>
|
||||
rowKey="date"
|
||||
size="small"
|
||||
columns={listingTrendColumns}
|
||||
dataSource={trend}
|
||||
pagination={false}
|
||||
scroll={{ x: 560 }}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无账号流转数据" />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</article>
|
||||
|
||||
{channelTrend.length > 0 ? (
|
||||
<article className="dash-table-block">
|
||||
<div className="dash-table-block__head">
|
||||
<div>
|
||||
<strong>近 {listingDaily?.days ?? 7} 日渠道流转明细</strong>
|
||||
<span>按日期与渠道拆分,定位不同来源的账号供给变化。</span>
|
||||
</div>
|
||||
<span className="dash-table-block__meta">{channelTrend.length} 条渠道记录</span>
|
||||
</div>
|
||||
<Table<DashboardListingChannelStats>
|
||||
rowKey={row => `${row.date}-${row.source_channel || 'unknown'}`}
|
||||
size="small"
|
||||
columns={channelTrendColumns}
|
||||
dataSource={channelTrend}
|
||||
pagination={
|
||||
channelTrend.length > 8
|
||||
? {
|
||||
pageSize: 8,
|
||||
size: 'small',
|
||||
showSizeChanger: false,
|
||||
showTotal: total => `共 ${total} 条`,
|
||||
}
|
||||
: false
|
||||
}
|
||||
scroll={{ x: 680 }}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="暂无渠道流转数据"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</article>
|
||||
) : null}
|
||||
|
||||
{uploaderTrend.length > 0 ? (
|
||||
<article className="dash-table-block">
|
||||
<div className="dash-table-block__head">
|
||||
<div>
|
||||
<strong>近 {listingDaily?.days ?? 7} 日客服上传明细</strong>
|
||||
<span>按日期和上传人统计外部账号录入量。</span>
|
||||
</div>
|
||||
<span className="dash-table-block__meta">
|
||||
{uploaderTrend.reduce((sum, row) => sum + row.upload_count, 0)} 个账号
|
||||
</span>
|
||||
</div>
|
||||
<Table<DashboardListingUploaderStats>
|
||||
rowKey={row => `${row.date}-${row.uploader_id}-${row.uploader_name}`}
|
||||
size="small"
|
||||
columns={uploaderTrendColumns}
|
||||
dataSource={uploaderTrend}
|
||||
pagination={
|
||||
uploaderTrend.length > 8
|
||||
? {
|
||||
pageSize: 8,
|
||||
size: 'small',
|
||||
showSizeChanger: false,
|
||||
showTotal: total => `共 ${total} 条`,
|
||||
}
|
||||
: false
|
||||
}
|
||||
scroll={{ x: 520 }}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="暂无客服上传数据"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</article>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="dash-panels-grid">
|
||||
<section className="dash-section">
|
||||
<div className="dash-section__head">
|
||||
<div className="dash-section__title">
|
||||
<BellOutlined />
|
||||
<strong>待处理事项</strong>
|
||||
<span className={`dash-pending-total${pendingTotal > 0 ? ' is-active' : ''}`}>
|
||||
{pendingTotal} 项待办
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dash-pending-list">
|
||||
{pendingItems.map(item => (
|
||||
<button
|
||||
type="button"
|
||||
key={item.key}
|
||||
className="dash-pending-item"
|
||||
onClick={() => navigate(item.path)}
|
||||
>
|
||||
<span className="dash-pending-item__icon">{item.icon}</span>
|
||||
<span className="dash-pending-item__info">
|
||||
<strong>{item.label}</strong>
|
||||
<small>{item.desc}</small>
|
||||
</span>
|
||||
<span
|
||||
className={`dash-pending-item__count${item.count > 0 ? ' is-urgent' : ''}`}
|
||||
>
|
||||
{item.count}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="dash-section">
|
||||
<div className="dash-section__head">
|
||||
<div className="dash-section__title">
|
||||
<AppstoreOutlined />
|
||||
<strong>快捷业务导航</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dash-quick-grid">
|
||||
{quickNav.map(item => (
|
||||
<button
|
||||
type="button"
|
||||
key={item.path}
|
||||
className={`dash-quick-tile is-${item.tone}`}
|
||||
onClick={() => navigate(item.path)}
|
||||
>
|
||||
<span className="dash-quick-tile__icon">{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
{[
|
||||
['商品待审核', data?.pending.listing_reviews ?? 0, '去审核', 'listings/review'],
|
||||
['待交接', data?.pending.pending_handoffs ?? 0, '去查看', 'orders'],
|
||||
['待确认归还', data?.pending.pending_return_confirms ?? 0, '去查看', 'orders'],
|
||||
['进行中申诉', data?.pending.disputes ?? 0, '去处理', 'disputes'],
|
||||
].map(([label, count, action, path]) => (
|
||||
<Col xs={24} sm={12} xl={6} key={String(label)}>
|
||||
<Card className="admin-pending-card">
|
||||
<div>
|
||||
<span className="admin-pending-card__label">{label}</span>
|
||||
<strong>{count}</strong>
|
||||
</div>
|
||||
<Button type="link" onClick={() => navigate(String(path))}>
|
||||
{action} <ArrowRightOutlined />
|
||||
</Button>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Card
|
||||
className="admin-table-card"
|
||||
title={
|
||||
<span>
|
||||
<ClockCircleOutlined /> 最近订单
|
||||
</span>
|
||||
}
|
||||
extra={
|
||||
<section className="dash-section">
|
||||
<div className="dash-section__head">
|
||||
<div className="dash-section__title">
|
||||
<ClockCircleOutlined />
|
||||
<strong>最近订单动态</strong>
|
||||
</div>
|
||||
<Button type="link" onClick={() => navigate('orders')}>
|
||||
查看全部
|
||||
查看全部订单 <ArrowRightOutlined />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
</div>
|
||||
<Table<RecentOrder>
|
||||
rowKey="id"
|
||||
size="small"
|
||||
columns={orderColumns}
|
||||
dataSource={data?.recent_orders ?? []}
|
||||
dataSource={recentOrders}
|
||||
pagination={false}
|
||||
locale={{
|
||||
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没有订单" />,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{recentDisputes.length > 0 ? (
|
||||
<section className="dash-section">
|
||||
<div className="dash-section__head">
|
||||
<div className="dash-section__title">
|
||||
<SafetyOutlined />
|
||||
<strong>最近申诉记录</strong>
|
||||
</div>
|
||||
<Button type="link" onClick={() => navigate('disputes')}>
|
||||
前往仲裁中心 <ArrowRightOutlined />
|
||||
</Button>
|
||||
</div>
|
||||
<Table<RecentDispute>
|
||||
rowKey="id"
|
||||
size="small"
|
||||
columns={disputeColumns}
|
||||
dataSource={recentDisputes}
|
||||
pagination={false}
|
||||
locale={{
|
||||
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无申诉记录" />,
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -51,6 +51,9 @@ export function AdminPushPage() {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-push-rules'] }),
|
||||
])
|
||||
|
||||
const channels = Array.isArray(channelsQuery.data) ? channelsQuery.data : []
|
||||
const rules = Array.isArray(rulesQuery.data) ? rulesQuery.data : []
|
||||
|
||||
const create = async () => {
|
||||
const values = await form.validateFields()
|
||||
let config: Record<string, unknown>
|
||||
@@ -90,7 +93,7 @@ export function AdminPushPage() {
|
||||
description="维护推送渠道(Webhook/企业微信等)与通知规则。"
|
||||
meta={
|
||||
<Tag color="geekblue">
|
||||
渠道 {channelsQuery.data?.length ?? 0} · 规则 {rulesQuery.data?.length ?? 0}
|
||||
渠道 {channels.length} · 规则 {rules.length}
|
||||
</Tag>
|
||||
}
|
||||
extra={
|
||||
@@ -184,7 +187,7 @@ export function AdminPushPage() {
|
||||
),
|
||||
},
|
||||
]}
|
||||
dataSource={channelsQuery.data ?? []}
|
||||
dataSource={channels}
|
||||
rowKey="id"
|
||||
loading={channelsQuery.isFetching}
|
||||
extra={
|
||||
@@ -238,8 +241,12 @@ export function AdminPushPage() {
|
||||
checked={row.enabled}
|
||||
disabled={!canManage}
|
||||
onChange={async checked => {
|
||||
await updatePushRule(row.id, { enabled: checked })
|
||||
await invalidate()
|
||||
try {
|
||||
await updatePushRule(row.id, { enabled: checked })
|
||||
await invalidate()
|
||||
} catch (error) {
|
||||
message.error(errorText(error))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
@@ -266,7 +273,7 @@ export function AdminPushPage() {
|
||||
),
|
||||
},
|
||||
]}
|
||||
dataSource={rulesQuery.data ?? []}
|
||||
dataSource={rules}
|
||||
rowKey="id"
|
||||
loading={rulesQuery.isFetching}
|
||||
emptyDescription="还没有通知规则"
|
||||
|
||||
@@ -10,16 +10,21 @@ import {
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
Tag,
|
||||
Typography,
|
||||
type TableColumnsType,
|
||||
} from 'antd'
|
||||
import {
|
||||
AccountBookOutlined,
|
||||
CopyOutlined,
|
||||
EditOutlined,
|
||||
EyeOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
RiseOutlined,
|
||||
ShoppingOutlined,
|
||||
TeamOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons'
|
||||
|
||||
import {
|
||||
@@ -108,6 +113,60 @@ export function AdminTrackingLinksPage() {
|
||||
|
||||
const links = linksQuery.data?.items ?? []
|
||||
const overview = overviewQuery.data
|
||||
const totalUv = overview?.total_uv ?? 0
|
||||
const registerRate = totalUv > 0 ? ((overview?.total_registers ?? 0) / totalUv) * 100 : 0
|
||||
const orderRate = totalUv > 0 ? ((overview?.total_paid_orders ?? 0) / totalUv) * 100 : 0
|
||||
|
||||
const kpiCards = [
|
||||
{
|
||||
key: 'pv',
|
||||
label: '累计 PV',
|
||||
value: (overview?.total_pv ?? 0).toLocaleString('zh-CN'),
|
||||
hint: '全渠道访问量',
|
||||
icon: <EyeOutlined />,
|
||||
tone: 'blue',
|
||||
},
|
||||
{
|
||||
key: 'uv',
|
||||
label: '累计 UV',
|
||||
value: (overview?.total_uv ?? 0).toLocaleString('zh-CN'),
|
||||
hint: '独立访客',
|
||||
icon: <UserOutlined />,
|
||||
tone: 'green',
|
||||
},
|
||||
{
|
||||
key: 'register',
|
||||
label: '注册用户',
|
||||
value: (overview?.total_registers ?? 0).toLocaleString('zh-CN'),
|
||||
hint: `注册转化 ${registerRate.toFixed(1)}%`,
|
||||
icon: <TeamOutlined />,
|
||||
tone: 'cyan',
|
||||
},
|
||||
{
|
||||
key: 'orders',
|
||||
label: '成交订单',
|
||||
value: (overview?.total_paid_orders ?? 0).toLocaleString('zh-CN'),
|
||||
hint: `成交转化 ${orderRate.toFixed(1)}%`,
|
||||
icon: <ShoppingOutlined />,
|
||||
tone: 'orange',
|
||||
},
|
||||
{
|
||||
key: 'gmv',
|
||||
label: '成交金额',
|
||||
value: formatMoney(overview?.total_paid_gmv_cent ?? 0),
|
||||
hint: '已支付订单总额',
|
||||
icon: <AccountBookOutlined />,
|
||||
tone: 'gold',
|
||||
},
|
||||
{
|
||||
key: 'today',
|
||||
label: '今日 UV',
|
||||
value: (overview?.today_uv ?? 0).toLocaleString('zh-CN'),
|
||||
hint: '今日独立访客',
|
||||
icon: <RiseOutlined />,
|
||||
tone: 'red',
|
||||
},
|
||||
]
|
||||
|
||||
const invalidate = () =>
|
||||
Promise.all([
|
||||
@@ -360,18 +419,17 @@ export function AdminTrackingLinksPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="admin-kpi-grid" style={{ marginBottom: 16 }}>
|
||||
<Statistic title="累计 PV" value={overview?.total_pv ?? 0} />
|
||||
<Statistic title="累计 UV" value={overview?.total_uv ?? 0} />
|
||||
<Statistic title="注册用户" value={overview?.total_registers ?? 0} />
|
||||
<Statistic title="成交订单" value={overview?.total_paid_orders ?? 0} />
|
||||
<Statistic
|
||||
title="成交金额"
|
||||
value={overview?.total_paid_gmv_cent ? overview.total_paid_gmv_cent / 100 : 0}
|
||||
precision={2}
|
||||
prefix="¥"
|
||||
/>
|
||||
<Statistic title="今日 UV" value={overview?.today_uv ?? 0} />
|
||||
<div className="admin-kpi-grid">
|
||||
{kpiCards.map(card => (
|
||||
<div key={card.key} className={`admin-kpi-card is-${card.tone}`}>
|
||||
<div className="admin-kpi-card__top">
|
||||
<span className="admin-kpi-card__icon">{card.icon}</span>
|
||||
<span className="admin-kpi-card__label">{card.label}</span>
|
||||
</div>
|
||||
<strong className="admin-kpi-card__value">{card.value}</strong>
|
||||
<small>{card.hint}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<FilterBar
|
||||
@@ -432,6 +490,12 @@ export function AdminTrackingLinksPage() {
|
||||
) : null}
|
||||
|
||||
<DataTable<AdminTrackingLink>
|
||||
title={<span>推广链接</span>}
|
||||
extra={
|
||||
<Typography.Text type="secondary">
|
||||
{linksQuery.isFetching ? '加载中' : `共 ${linksQuery.data?.total ?? 0} 条`}
|
||||
</Typography.Text>
|
||||
}
|
||||
columns={columns}
|
||||
dataSource={links}
|
||||
rowKey="id"
|
||||
|
||||
@@ -1758,8 +1758,10 @@ export async function markAllAdminNotificationsRead() {
|
||||
}
|
||||
|
||||
export async function fetchPushChannels() {
|
||||
const { data } = await adminClient.get<ApiResponse<AdminPushChannel[]>>('/admin/push-channels')
|
||||
return data.data ?? []
|
||||
const { data } = await adminClient.get<ApiResponse<{ items?: AdminPushChannel[] }>>(
|
||||
'/admin/push-channels'
|
||||
)
|
||||
return data.data?.items ?? []
|
||||
}
|
||||
|
||||
export async function createPushChannel(payload: {
|
||||
@@ -1797,8 +1799,10 @@ export async function testPushChannel(id: number) {
|
||||
}
|
||||
|
||||
export async function fetchPushRules() {
|
||||
const { data } = await adminClient.get<ApiResponse<AdminPushRule[]>>('/admin/push-rules')
|
||||
return data.data ?? []
|
||||
const { data } = await adminClient.get<ApiResponse<{ items?: AdminPushRule[] }>>(
|
||||
'/admin/push-rules'
|
||||
)
|
||||
return data.data?.items ?? []
|
||||
}
|
||||
|
||||
export async function updatePushRule(
|
||||
|
||||
@@ -59,10 +59,59 @@ export interface RecentOrder {
|
||||
export interface DashboardData {
|
||||
metrics: DashboardMetrics
|
||||
pending: DashboardPending
|
||||
listing_daily: DashboardListingDaily
|
||||
manual_entry_today: DashboardManualEntryAgent[]
|
||||
recent_orders: RecentOrder[]
|
||||
recent_disputes: RecentDispute[]
|
||||
generated_at: string
|
||||
}
|
||||
|
||||
export interface DashboardListingDayStats {
|
||||
date: string
|
||||
published_count: number
|
||||
active_offline_count: number
|
||||
trade_leave_count: number
|
||||
}
|
||||
|
||||
export interface DashboardListingChannelStats extends DashboardListingDayStats {
|
||||
source_channel: string
|
||||
}
|
||||
|
||||
export interface DashboardListingUploaderStats {
|
||||
date: string
|
||||
uploader_id: number
|
||||
uploader_name: string
|
||||
upload_count: number
|
||||
}
|
||||
|
||||
export interface DashboardListingDaily {
|
||||
today: DashboardListingDayStats
|
||||
trend: DashboardListingDayStats[]
|
||||
today_channels: DashboardListingChannelStats[]
|
||||
channel_trend: DashboardListingChannelStats[]
|
||||
today_uploaders: DashboardListingUploaderStats[]
|
||||
uploader_trend: DashboardListingUploaderStats[]
|
||||
days: number
|
||||
timezone: string
|
||||
}
|
||||
|
||||
export interface DashboardManualEntryAgent {
|
||||
admin_id: number
|
||||
admin_name: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface RecentDispute {
|
||||
id: number
|
||||
order_id: number
|
||||
order_no: string
|
||||
title: string
|
||||
type: string
|
||||
status: string
|
||||
initiator_id: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AdminOrderQuery {
|
||||
page?: number
|
||||
page_size?: number
|
||||
|
||||
@@ -398,6 +398,117 @@
|
||||
color: var(--brand-seal);
|
||||
}
|
||||
|
||||
/* 运营看板 KPI */
|
||||
.admin-kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.admin-kpi-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--paper-warm);
|
||||
transition:
|
||||
border-color 0.16s ease,
|
||||
box-shadow 0.16s ease;
|
||||
}
|
||||
|
||||
.admin-kpi-card:hover {
|
||||
border-color: var(--brand-ink);
|
||||
box-shadow: 0 8px 20px rgba(30, 77, 66, 0.07);
|
||||
}
|
||||
|
||||
.admin-kpi-card__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-kpi-card__icon {
|
||||
display: grid;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex: 0 0 26px;
|
||||
place-items: center;
|
||||
border-radius: 7px;
|
||||
background: #eef2ec;
|
||||
color: var(--brand-ink);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.admin-kpi-card__label {
|
||||
color: var(--ink-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-kpi-card__value {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
color: var(--brand-ink);
|
||||
font-family: 'Inter', 'DIN Alternate', sans-serif;
|
||||
font-size: 24px;
|
||||
line-height: 1.2;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.admin-kpi-card small {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--ink-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.admin-kpi-card.is-blue .admin-kpi-card__icon {
|
||||
background: #e8f0fb;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.admin-kpi-card.is-green .admin-kpi-card__icon {
|
||||
background: #e8f5ee;
|
||||
color: #0f7b6c;
|
||||
}
|
||||
|
||||
.admin-kpi-card.is-cyan .admin-kpi-card__icon {
|
||||
background: #e6f4f6;
|
||||
color: #0e7490;
|
||||
}
|
||||
|
||||
.admin-kpi-card.is-orange .admin-kpi-card__icon {
|
||||
background: #fff3e6;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.admin-kpi-card.is-gold .admin-kpi-card__icon {
|
||||
background: #fdf3df;
|
||||
color: #a16207;
|
||||
}
|
||||
|
||||
.admin-kpi-card.is-red .admin-kpi-card__icon {
|
||||
background: #fdecec;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.admin-kpi-card.is-gold .admin-kpi-card__value {
|
||||
font-size: 21px;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.admin-kpi-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.admin-kpi-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.admin-mono {
|
||||
color: var(--ink-secondary);
|
||||
font-family: 'Inter', monospace;
|
||||
@@ -1064,3 +1175,707 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 工作台 */
|
||||
.dash-page {
|
||||
max-width: 1320px;
|
||||
}
|
||||
|
||||
.dash-head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.dash-head h2 {
|
||||
margin: 6px 0 8px !important;
|
||||
}
|
||||
|
||||
.dash-metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.dash-metric-card {
|
||||
display: flex;
|
||||
min-height: 112px;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: var(--paper-warm);
|
||||
}
|
||||
|
||||
.dash-metric-card__icon {
|
||||
display: grid;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex: 0 0 36px;
|
||||
place-items: center;
|
||||
border-radius: 9px;
|
||||
background: #eef2ec;
|
||||
color: var(--brand-ink);
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.dash-metric-card__body {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.dash-metric-card__label {
|
||||
color: var(--ink-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dash-metric-card__value {
|
||||
color: var(--brand-ink);
|
||||
font-family: 'Inter', 'DIN Alternate', sans-serif;
|
||||
font-size: 26px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.dash-metric-card__sub {
|
||||
align-self: flex-start;
|
||||
margin-top: 3px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: #f1ece0;
|
||||
color: var(--ink-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.dash-metric-card.is-indigo .dash-metric-card__icon {
|
||||
background: #e8ecfb;
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
.dash-metric-card.is-emerald .dash-metric-card__icon {
|
||||
background: #e8f5ee;
|
||||
color: #0f7b6c;
|
||||
}
|
||||
|
||||
.dash-metric-card.is-amber .dash-metric-card__icon {
|
||||
background: #fff3e2;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.dash-metric-card.is-rose .dash-metric-card__icon {
|
||||
background: #fdecec;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.dash-section {
|
||||
margin-bottom: 18px;
|
||||
padding: 18px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: var(--paper-warm);
|
||||
}
|
||||
|
||||
.dash-section__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.dash-section__title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--brand-ink);
|
||||
}
|
||||
|
||||
.dash-section__title .anticon {
|
||||
color: var(--brand-seal);
|
||||
}
|
||||
|
||||
.dash-section__title strong {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dash-section__badge {
|
||||
color: var(--ink-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.dash-subhead {
|
||||
margin: 18px 0 10px;
|
||||
color: var(--ink-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dash-listing-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.dash-listing-card {
|
||||
padding: 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.dash-listing-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dash-listing-card__icon {
|
||||
display: grid;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
flex: 0 0 30px;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
background: #eef2ec;
|
||||
color: var(--brand-ink);
|
||||
}
|
||||
|
||||
.dash-listing-card__label {
|
||||
display: block;
|
||||
color: var(--ink-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dash-listing-card__value {
|
||||
display: block;
|
||||
color: var(--brand-ink);
|
||||
font-family: 'Inter', 'DIN Alternate', sans-serif;
|
||||
font-size: 24px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.dash-listing-card__hint {
|
||||
margin: 10px 0 0;
|
||||
color: var(--ink-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.dash-listing-card.is-publish .dash-listing-card__icon {
|
||||
background: #e8f5ee;
|
||||
color: #0f7b6c;
|
||||
}
|
||||
|
||||
.dash-listing-card.is-offline .dash-listing-card__icon {
|
||||
background: #fff3e2;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.dash-listing-card.is-trade .dash-listing-card__icon {
|
||||
background: #e8f0fb;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.dash-bars {
|
||||
display: flex;
|
||||
height: 88px;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
margin-top: 14px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.dash-bar-col {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.dash-bar-value {
|
||||
color: var(--ink-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.dash-bar {
|
||||
width: 70%;
|
||||
max-width: 22px;
|
||||
min-height: 4px;
|
||||
border-radius: 3px 3px 0 0;
|
||||
background: #0f7b6c;
|
||||
}
|
||||
|
||||
.dash-bar.is-offline {
|
||||
background: #d97706;
|
||||
}
|
||||
|
||||
.dash-bar.is-trade {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.dash-bar-col em {
|
||||
color: var(--ink-muted);
|
||||
font-size: 10px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.dash-channel-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dash-channel-item {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.dash-channel-item__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
color: var(--ink-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dash-channel-item__head strong {
|
||||
color: var(--brand-ink);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.dash-channel-item__counts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 10px;
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.dash-channel-item__counts .is-publish {
|
||||
color: #0f7b6c;
|
||||
}
|
||||
|
||||
.dash-channel-item__counts .is-offline {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.dash-channel-item__counts .is-trade {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.dash-empty-line {
|
||||
padding: 18px;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 8px;
|
||||
color: var(--ink-muted);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dash-people-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.dash-people-card {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
background: #fcfbf7;
|
||||
}
|
||||
|
||||
.dash-people-card.is-upload {
|
||||
border-color: #dbe7df;
|
||||
background: #f7fbf8;
|
||||
}
|
||||
|
||||
.dash-people-card.is-manual {
|
||||
border-color: #eadfce;
|
||||
background: #fdfaf4;
|
||||
}
|
||||
|
||||
.dash-people-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-bottom: 9px;
|
||||
border-bottom: 1px dashed var(--line);
|
||||
}
|
||||
|
||||
.dash-people-card__head strong {
|
||||
color: var(--brand-ink);
|
||||
font-family: 'Inter', 'DIN Alternate', sans-serif;
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.dash-people-card__head strong small {
|
||||
color: var(--ink-muted);
|
||||
font-family: inherit;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.dash-people-card__title {
|
||||
color: var(--ink-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dash-people-list {
|
||||
display: flex;
|
||||
max-height: 142px;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin-top: 6px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.dash-person-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
padding: 6px 4px;
|
||||
border-radius: 6px;
|
||||
color: var(--ink-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dash-person-row:hover {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.dash-person-name {
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dash-person-row strong {
|
||||
color: var(--brand-ink);
|
||||
font-family: 'Inter', 'DIN Alternate', sans-serif;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.dash-person-row small {
|
||||
color: var(--ink-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.dash-table-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-top: 18px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.dash-table-block {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.dash-table-block__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: #fcfbf7;
|
||||
}
|
||||
|
||||
.dash-table-block__head > div {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.dash-table-block__head strong {
|
||||
color: var(--ink);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dash-table-block__head span {
|
||||
color: var(--ink-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.dash-table-block__meta {
|
||||
flex: 0 0 auto;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: #f1ece0;
|
||||
color: var(--ink-secondary) !important;
|
||||
font-size: 11px !important;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dash-table-block .ant-table-thead > tr > th {
|
||||
background: #f7f3ea !important;
|
||||
color: var(--ink-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dash-table-block .ant-table-tbody > tr > td {
|
||||
color: var(--ink-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dash-table-block .ant-table-tbody > tr:hover > td {
|
||||
background: #fcfbf7 !important;
|
||||
}
|
||||
|
||||
.dash-table-block .ant-pagination {
|
||||
margin: 12px 14px !important;
|
||||
}
|
||||
|
||||
.dash-trend-number {
|
||||
font-family: 'Inter', 'DIN Alternate', sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dash-trend-number.is-publish {
|
||||
color: #0f7b6c;
|
||||
}
|
||||
|
||||
.dash-trend-number.is-offline {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.dash-trend-number.is-trade {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.dash-trend-number.is-danger {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.dash-trend-number.is-muted {
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.dash-panels-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.dash-pending-total {
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: #f1ece0;
|
||||
color: var(--ink-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.dash-pending-total.is-active {
|
||||
background: #fdecec;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.dash-pending-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dash-pending-item {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.16s ease,
|
||||
box-shadow 0.16s ease;
|
||||
}
|
||||
|
||||
.dash-pending-item:hover {
|
||||
border-color: var(--brand-ink);
|
||||
box-shadow: 0 6px 16px rgba(30, 77, 66, 0.06);
|
||||
}
|
||||
|
||||
.dash-pending-item__icon {
|
||||
display: grid;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
flex: 0 0 30px;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
background: #f7f3ea;
|
||||
color: var(--brand-seal);
|
||||
}
|
||||
|
||||
.dash-pending-item__info {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.dash-pending-item__info strong {
|
||||
color: var(--ink);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dash-pending-item__info small {
|
||||
color: var(--ink-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.dash-pending-item__count {
|
||||
margin-left: auto;
|
||||
color: var(--ink-muted);
|
||||
font-family: 'Inter', 'DIN Alternate', sans-serif;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.dash-pending-item__count.is-urgent {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.dash-quick-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dash-quick-tile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 9px;
|
||||
background: #fff;
|
||||
color: var(--ink-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.16s ease,
|
||||
color 0.16s ease;
|
||||
}
|
||||
|
||||
.dash-quick-tile:hover {
|
||||
border-color: var(--brand-ink);
|
||||
color: var(--brand-ink);
|
||||
}
|
||||
|
||||
.dash-quick-tile__icon {
|
||||
display: grid;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
background: #eef2ec;
|
||||
color: var(--brand-ink);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.dash-quick-tile.is-rose .dash-quick-tile__icon {
|
||||
background: #fdecec;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.dash-quick-tile.is-amber .dash-quick-tile__icon {
|
||||
background: #fff3e2;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.dash-quick-tile.is-emerald .dash-quick-tile__icon {
|
||||
background: #e8f5ee;
|
||||
color: #0f7b6c;
|
||||
}
|
||||
|
||||
.dash-quick-tile.is-teal .dash-quick-tile__icon {
|
||||
background: #e6f4f6;
|
||||
color: #0e7490;
|
||||
}
|
||||
|
||||
.dash-quick-tile.is-indigo .dash-quick-tile__icon {
|
||||
background: #e8ecfb;
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
.dash-quick-tile.is-slate .dash-quick-tile__icon {
|
||||
background: #eef0f3;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.dash-section .ant-table {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.dash-metric-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.dash-listing-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.dash-panels-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.dash-head {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dash-listing-grid,
|
||||
.dash-people-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dash-quick-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.dash-metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dash-section {
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user