From 067c6116c566a14feab13dd31332b183f0529e8a Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 1 Nov 2026 18:27:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E9=87=8D=E5=81=9A=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E5=B7=A5=E4=BD=9C=E5=8F=B0=E5=B9=B6=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E8=BF=90=E8=90=A5=E6=98=8E=E7=BB=86=E7=9C=8B=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/admin/AdminDashboardPage.tsx | 818 +++++++++++++++--- frontend/src/features/admin/AdminPushPage.tsx | 17 +- .../features/admin/AdminTrackingLinksPage.tsx | 90 +- frontend/src/features/admin/api.ts | 12 +- frontend/src/features/admin/types.ts | 49 ++ frontend/src/styles/admin-shell.css | 815 +++++++++++++++++ 6 files changed, 1665 insertions(+), 136 deletions(-) diff --git a/frontend/src/features/admin/AdminDashboardPage.tsx b/frontend/src/features/admin/AdminDashboardPage.tsx index be37250..668e3b1 100644 --- a/frontend/src/features/admin/AdminDashboardPage.tsx +++ b/frontend/src/features/admin/AdminDashboardPage.tsx @@ -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 = { pending_payment: { label: '待支付', color: 'gold' }, @@ -41,18 +56,210 @@ const statusMap: Record = { abnormal: { label: '异常', color: 'red' }, } -function money(cents: number) { - return `¥${(cents / 100).toLocaleString('zh-CN', { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })}` +const disputeStatusMap: Record = { + 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 ( +
+
+ {icon} +
+ {title} + {value} +
+
+

{hint}

+
+ {rows.map(row => ( +
+ {row[field]} +
+ {shortDate(row.date)} +
+ ))} +
+
+ ) +} + +const listingTrendColumns: TableColumnsType = [ + { + title: '统计日期', + dataIndex: 'date', + width: 120, + render: value => {value}, + }, + { + title: '上架量', + dataIndex: 'published_count', + width: 110, + align: 'right', + render: value => +{value}, + }, + { + title: '主动下架', + dataIndex: 'active_offline_count', + width: 110, + align: 'right', + render: value => -{value}, + }, + { + title: '交易离架', + dataIndex: 'trade_leave_count', + width: 110, + align: 'right', + render: value => -{value}, + }, + { + 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 ( + 0 ? ' is-publish' : net < 0 ? ' is-danger' : ' is-muted'}`} + > + {signed(net)} + + ) + }, + }, +] + +const channelTrendColumns: TableColumnsType = [ + { + title: '统计日期', + dataIndex: 'date', + width: 120, + render: value => {value}, + }, + { + title: '渠道来源', + dataIndex: 'source_channel', + width: 140, + render: value => {value || '未填写'}, + }, + { + title: '上架', + dataIndex: 'published_count', + width: 100, + align: 'right', + render: value => +{value}, + }, + { + title: '主动下架', + dataIndex: 'active_offline_count', + width: 110, + align: 'right', + render: value => -{value}, + }, + { + title: '交易离架', + dataIndex: 'trade_leave_count', + width: 110, + align: 'right', + render: value => -{value}, + }, + { + 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 ( + 0 ? ' is-publish' : net < 0 ? ' is-danger' : ' is-muted'}`} + > + {signed(net)} + + ) + }, + }, +] + +const uploaderTrendColumns: TableColumnsType = [ + { + title: '统计日期', + dataIndex: 'date', + width: 120, + render: value => {value}, + }, + { + title: '客服 / 上传人', + dataIndex: 'uploader_name', + render: value => value || '未填写', + }, + { + title: '上传账号数', + dataIndex: 'upload_count', + width: 130, + align: 'right', + render: value => {value}, + }, +] + const orderColumns: TableColumnsType = [ { title: '订单号', dataIndex: 'order_no', - width: 150, + width: 160, render: value => {value}, }, { @@ -74,14 +281,14 @@ const orderColumns: TableColumnsType = [ dataIndex: 'rent_amount_cent', width: 120, align: 'right', - render: value => {money(value)}, + render: value => {formatMoney(value)}, }, { title: '押金', dataIndex: 'deposit_amount_cent', width: 120, align: 'right', - render: value => {money(value)}, + render: value => {formatMoney(value)}, }, { title: '创建时间', @@ -91,9 +298,35 @@ const orderColumns: TableColumnsType = [ }, ] +const disputeColumns: TableColumnsType = [ + { + title: '订单号', + dataIndex: 'order_no', + width: 160, + render: value => {value}, + }, + { 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 {item.label} + }, + }, + { + 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: , + tone: 'indigo', + }, + { + key: 'listings', + label: '商品总数', + value: metrics?.total_listings ?? 0, + sub: `在架 ${metrics?.published_listings ?? 0} 个`, + icon: , + tone: 'emerald', + }, + { + key: 'orders', + label: '订单总数', + value: metrics?.total_orders ?? 0, + sub: `使用中 ${metrics?.renting_orders ?? 0} 个`, + icon: , + tone: 'amber', + }, + { + key: 'flow', + label: '今日流水', + value: formatMoney(metrics?.today_ledger_amount_cent ?? 0), + sub: `今日订单 ${metrics?.today_orders ?? 0} 个`, + icon: , + tone: 'rose', + }, + ] + + const pendingItems = [ + { + key: 'disputes', + label: '待仲裁申诉', + desc: '需要人工介入处理的用户争议', + count: pending?.disputes ?? 0, + path: 'disputes', + icon: , + }, + { + key: 'reviews', + label: '待审核商品', + desc: '卖家新提交上架的账号资产审核', + count: pending?.listing_reviews ?? 0, + path: 'listings/review', + icon: , + }, + { + key: 'handoffs', + label: '待交接订单', + desc: '等待号主确认交接发货', + count: pending?.pending_handoffs ?? 0, + path: 'orders', + icon: , + }, + { + key: 'checkouts', + label: '待结账确认', + desc: '租期结束等待双方确认结算', + count: pending?.pending_return_confirms ?? 0, + path: 'orders', + icon: , + }, + ] + const pendingTotal = pendingItems.reduce((sum, item) => sum + item.count, 0) + + const quickNav = [ + { label: '用户管理', path: 'users', icon: , tone: 'indigo' }, + { label: '订单管理', path: 'orders', icon: , tone: 'amber' }, + { label: '仲裁中心', path: 'disputes', icon: , tone: 'rose' }, + { label: '商品管理', path: 'listings', icon: , tone: 'emerald' }, + { label: '客服群聊', path: 'chats', icon: , tone: 'teal' }, + { label: '系统配置', path: 'system-configs', icon: , tone: 'slate' }, + ] + return ( -
-
+
+
TODAY AT JIEYOU @@ -130,123 +476,367 @@ export function AdminDashboardPage() { {data?.generated_at ? dayjs(data.generated_at).format('HH:mm:ss') : '加载中'}。
- -
+ + + + + {isPending ? ( - - {Array.from({ length: 4 }).map((_, index) => ( - - - - - +
+ {[0, 1, 2, 3].map(index => ( +
+ +
))} - +
) : ( - - - -
- +
+ {metricCards.map(card => ( +
+ {card.icon} +
+ {card.label} + {card.value} + {card.sub}
- - - 总订单 {data?.metrics.total_orders ?? 0} - - - - - -
- -
- - - 已实名 {data?.metrics.verified_users ?? 0} - -
- - - -
- -
- - - 商品总数 {data?.metrics.total_listings ?? 0} - -
- - - -
- -
- - - 在租订单 {data?.metrics.renting_orders ?? 0} - -
- - +
+ ))} +
)} -
- 待办 - 客服和运营每天先处理这四类 +
+
+
+ + 账号上下架趋势 + + 按北京时间自然日 · 近 {listingDaily?.days ?? 7} 日 + +
+
+ + {isPending ? ( + + ) : ( + <> +
+ } + /> + } + /> + } + /> +
+ +
今日各渠道上下架汇总
+ {channels.length > 0 ? ( +
+ {channels.map(row => ( +
+
+ {row.source_channel || '未填写'} + + {row.published_count + row.active_offline_count + row.trade_leave_count} + +
+
+ 上架 {row.published_count} + 主动下架 {row.active_offline_count} + 交易离架 {row.trade_leave_count} +
+
+ ))} +
+ ) : ( +
今日暂无上下架记录。
+ )} + + {uploaders.length > 0 || manualEntries.length > 0 ? ( +
+ {uploaders.length > 0 ? ( +
+
+ 今日外部上传账号 + + {uploaders.reduce((sum, row) => sum + row.upload_count, 0)} + 个 + +
+
+ {uploaders.map(row => ( +
+ {row.uploader_name || '未填写'} + {row.upload_count} + 个账号 +
+ ))} +
+
+ ) : null} + {manualEntries.length > 0 ? ( +
+
+ 今日客服手动录单 + + {manualEntries.reduce((sum, row) => sum + row.count, 0)} + 单 + +
+
+ {manualEntries.map(row => ( +
+ {row.admin_name || '未填写'} + {row.count} + 单 +
+ ))} +
+
+ ) : null} +
+ ) : null} + +
+
+
+
+ 近 {listingDaily?.days ?? 7} 日账号流转明细 + 逐日核对账号上架、主动下架与交易离架,净增为三项差值。 +
+ + 累计上架 {trend.reduce((sum, row) => sum + row.published_count, 0)} + +
+ + rowKey="date" + size="small" + columns={listingTrendColumns} + dataSource={trend} + pagination={false} + scroll={{ x: 560 }} + locale={{ + emptyText: ( + + ), + }} + /> +
+ + {channelTrend.length > 0 ? ( +
+
+
+ 近 {listingDaily?.days ?? 7} 日渠道流转明细 + 按日期与渠道拆分,定位不同来源的账号供给变化。 +
+ {channelTrend.length} 条渠道记录 +
+ + 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: ( + + ), + }} + /> +
+ ) : null} + + {uploaderTrend.length > 0 ? ( +
+
+
+ 近 {listingDaily?.days ?? 7} 日客服上传明细 + 按日期和上传人统计外部账号录入量。 +
+ + {uploaderTrend.reduce((sum, row) => sum + row.upload_count, 0)} 个账号 + +
+ + 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: ( + + ), + }} + /> +
+ ) : null} +
+ + )} +
+ +
+
+
+
+ + 待处理事项 + 0 ? ' is-active' : ''}`}> + {pendingTotal} 项待办 + +
+
+
+ {pendingItems.map(item => ( + + ))} +
+
+ +
+
+
+ + 快捷业务导航 +
+
+
+ {quickNav.map(item => ( + + ))} +
+
- - {[ - ['商品待审核', 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]) => ( - - -
- {label} - {count} -
- -
- - ))} -
- - - 最近订单 - - } - extra={ +
+
+
+ + 最近订单动态 +
- } - > +
rowKey="id" size="small" columns={orderColumns} - dataSource={data?.recent_orders ?? []} + dataSource={recentOrders} pagination={false} locale={{ emptyText: , }} /> - +
+ + {recentDisputes.length > 0 ? ( +
+
+
+ + 最近申诉记录 +
+ +
+ + rowKey="id" + size="small" + columns={disputeColumns} + dataSource={recentDisputes} + pagination={false} + locale={{ + emptyText: , + }} + /> +
+ ) : null}
) } diff --git a/frontend/src/features/admin/AdminPushPage.tsx b/frontend/src/features/admin/AdminPushPage.tsx index 0779b39..4f138d5 100644 --- a/frontend/src/features/admin/AdminPushPage.tsx +++ b/frontend/src/features/admin/AdminPushPage.tsx @@ -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 @@ -90,7 +93,7 @@ export function AdminPushPage() { description="维护推送渠道(Webhook/企业微信等)与通知规则。" meta={ - 渠道 {channelsQuery.data?.length ?? 0} · 规则 {rulesQuery.data?.length ?? 0} + 渠道 {channels.length} · 规则 {rules.length} } 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="还没有通知规则" diff --git a/frontend/src/features/admin/AdminTrackingLinksPage.tsx b/frontend/src/features/admin/AdminTrackingLinksPage.tsx index 6ddbe67..5c25334 100644 --- a/frontend/src/features/admin/AdminTrackingLinksPage.tsx +++ b/frontend/src/features/admin/AdminTrackingLinksPage.tsx @@ -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: , + tone: 'blue', + }, + { + key: 'uv', + label: '累计 UV', + value: (overview?.total_uv ?? 0).toLocaleString('zh-CN'), + hint: '独立访客', + icon: , + tone: 'green', + }, + { + key: 'register', + label: '注册用户', + value: (overview?.total_registers ?? 0).toLocaleString('zh-CN'), + hint: `注册转化 ${registerRate.toFixed(1)}%`, + icon: , + tone: 'cyan', + }, + { + key: 'orders', + label: '成交订单', + value: (overview?.total_paid_orders ?? 0).toLocaleString('zh-CN'), + hint: `成交转化 ${orderRate.toFixed(1)}%`, + icon: , + tone: 'orange', + }, + { + key: 'gmv', + label: '成交金额', + value: formatMoney(overview?.total_paid_gmv_cent ?? 0), + hint: '已支付订单总额', + icon: , + tone: 'gold', + }, + { + key: 'today', + label: '今日 UV', + value: (overview?.today_uv ?? 0).toLocaleString('zh-CN'), + hint: '今日独立访客', + icon: , + tone: 'red', + }, + ] const invalidate = () => Promise.all([ @@ -360,18 +419,17 @@ export function AdminTrackingLinksPage() { } /> -
- - - - - - +
+ {kpiCards.map(card => ( +
+
+ {card.icon} + {card.label} +
+ {card.value} + {card.hint} +
+ ))}
+ title={推广链接} + extra={ + + {linksQuery.isFetching ? '加载中' : `共 ${linksQuery.data?.total ?? 0} 条`} + + } columns={columns} dataSource={links} rowKey="id" diff --git a/frontend/src/features/admin/api.ts b/frontend/src/features/admin/api.ts index 1b4526d..9472212 100644 --- a/frontend/src/features/admin/api.ts +++ b/frontend/src/features/admin/api.ts @@ -1758,8 +1758,10 @@ export async function markAllAdminNotificationsRead() { } export async function fetchPushChannels() { - const { data } = await adminClient.get>('/admin/push-channels') - return data.data ?? [] + const { data } = await adminClient.get>( + '/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>('/admin/push-rules') - return data.data ?? [] + const { data } = await adminClient.get>( + '/admin/push-rules' + ) + return data.data?.items ?? [] } export async function updatePushRule( diff --git a/frontend/src/features/admin/types.ts b/frontend/src/features/admin/types.ts index 4cc71fc..ac9b885 100644 --- a/frontend/src/features/admin/types.ts +++ b/frontend/src/features/admin/types.ts @@ -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 diff --git a/frontend/src/styles/admin-shell.css b/frontend/src/styles/admin-shell.css index 2b025f8..a70d4d5 100644 --- a/frontend/src/styles/admin-shell.css +++ b/frontend/src/styles/admin-shell.css @@ -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; + } +} +