feat(admin): 重做后台工作台并补齐运营明细看板

This commit is contained in:
yml2213
2026-11-01 18:27:31 +08:00
parent 079e04823f
commit 067c6116c5
6 changed files with 1665 additions and 136 deletions
+704 -114
View File
@@ -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>
)
}
+12 -5
View File
@@ -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"
+8 -4
View File
@@ -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(
+49
View File
@@ -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
+815
View File
@@ -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;
}
}