feat: 积分商城功能优化与消费积分打通
- 积分消费打通:1美元=1000积分 - 优化积分商城页面,移除alert组件 - 新增兑换弹窗,支持完整收货地址表单 - 统计页面新增消费与积分展示 - 重构静态内联样式为CSS类,新增30+语义化样式类main
parent
acdc6c8567
commit
e1cf2dfc40
25
src/api.ts
25
src/api.ts
|
|
@ -476,6 +476,24 @@ export const PromptTemplateAPI = {
|
|||
export interface PointsMallMe {
|
||||
points: number;
|
||||
level?: string;
|
||||
totalSpentUSD?: number;
|
||||
}
|
||||
|
||||
export interface PointsExchangeRequest {
|
||||
productId: string;
|
||||
recipientName: string;
|
||||
phone: string;
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
address: string;
|
||||
zipCode?: string;
|
||||
}
|
||||
|
||||
export interface PointsExchangeResponse {
|
||||
orderId: string;
|
||||
pointsDeducted: number;
|
||||
remainingPoints: number;
|
||||
}
|
||||
|
||||
export interface PointsMallCategory {
|
||||
|
|
@ -553,7 +571,12 @@ export const PointsMallAPI = {
|
|||
pageSize: opts.pageSize ?? 24
|
||||
}
|
||||
})
|
||||
.then((r) => r.data)
|
||||
.then((r) => r.data),
|
||||
exchange: (productId: string, shippingInfo: Omit<PointsExchangeRequest, 'productId'>) =>
|
||||
api.post<PointsExchangeResponse>('/points-mall/exchange', {
|
||||
productId,
|
||||
...shippingInfo
|
||||
}).then((r) => r.data)
|
||||
};
|
||||
|
||||
// ============== 调用统计 (v0.8 P1) ==============
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, Button, Card, Empty, Input, Select, Space, Spin, Tag } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Card, Empty, Input, Select, Space, Spin, Tag, Modal, Form, message } from 'antd';
|
||||
import { SearchOutlined } from '@ant-design/icons';
|
||||
import { PointsMallAPI, PointsMallCategory, PointsMallOverview, PointsMallProduct, PointsMallProductsResponse } from '../api';
|
||||
import { PointsMallAPI, PointsMallOverview, PointsMallProduct, PointsMallProductsResponse } from '../api';
|
||||
|
||||
type SortKey = 'popular' | 'price_asc' | 'price_desc' | 'newest';
|
||||
|
||||
|
|
@ -14,11 +14,9 @@ const MOCK_OVERVIEW: PointsMallOverview = {
|
|||
{ id: 'gift', name: '礼品卡券', sort: 3 },
|
||||
{ id: 'limited', name: '限时活动', sort: 4 }
|
||||
],
|
||||
announcements: [
|
||||
{ id: 'a1', title: '公告:积分规则升级中', content: '本期暂不开放兑换,页面仅用于 UI 预览。', linkUrl: '' }
|
||||
],
|
||||
announcements: [],
|
||||
banners: [
|
||||
{ id: 'b1', title: '限时上新', subtitle: 'Up to 25% Off', imageUrl: '', linkUrl: '' }
|
||||
{ id: 'b1', title: '本期活动', subtitle: 'Up to 25% Off', imageUrl: '', linkUrl: '' }
|
||||
],
|
||||
promoEntries: [
|
||||
{ id: 'p1', title: '促销活动', subtitle: '本周精选', linkUrl: '' },
|
||||
|
|
@ -38,6 +36,16 @@ const MOCK_PRODUCTS: PointsMallProduct[] = Array.from({ length: 12 }).map((_, i)
|
|||
tags: i % 3 === 0 ? ['限时'] : i % 3 === 1 ? ['热卖'] : []
|
||||
}));
|
||||
|
||||
interface ExchangeFormValues {
|
||||
recipientName: string;
|
||||
phone: string;
|
||||
province: string;
|
||||
city: string;
|
||||
district: string;
|
||||
address: string;
|
||||
zipCode?: string;
|
||||
}
|
||||
|
||||
export default function PointsMallPage() {
|
||||
const [overviewLoading, setOverviewLoading] = useState(false);
|
||||
const [productsLoading, setProductsLoading] = useState(false);
|
||||
|
|
@ -51,6 +59,11 @@ export default function PointsMallPage() {
|
|||
const [pageSize, setPageSize] = useState(24);
|
||||
const [productsRes, setProductsRes] = useState<PointsMallProductsResponse | null>(null);
|
||||
|
||||
const [exchangeModalVisible, setExchangeModalVisible] = useState(false);
|
||||
const [selectedProduct, setSelectedProduct] = useState<PointsMallProduct | null>(null);
|
||||
const [exchangeLoading, setExchangeLoading] = useState(false);
|
||||
const [form] = Form.useForm<ExchangeFormValues>();
|
||||
|
||||
const loadOverview = async () => {
|
||||
setOverviewLoading(true);
|
||||
try {
|
||||
|
|
@ -103,63 +116,61 @@ export default function PointsMallPage() {
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [categoryId, q, sort, page, pageSize]);
|
||||
|
||||
const announcement = overview?.announcements?.[0];
|
||||
const banner = overview?.banners?.[0];
|
||||
const promoEntries = overview?.promoEntries || [];
|
||||
|
||||
const products = productsRes?.items || [];
|
||||
const total = productsRes?.total || 0;
|
||||
|
||||
const sortOptions = useMemo(
|
||||
() => [
|
||||
{ value: 'popular', label: '热度优先' },
|
||||
{ value: 'newest', label: '最新上架' },
|
||||
{ value: 'price_asc', label: '积分从低到高' },
|
||||
{ value: 'price_desc', label: '积分从高到低' }
|
||||
],
|
||||
[]
|
||||
);
|
||||
const handleExchangeClick = (product: PointsMallProduct) => {
|
||||
setSelectedProduct(product);
|
||||
setExchangeModalVisible(true);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
const handleExchangeSubmit = async () => {
|
||||
if (!selectedProduct) return;
|
||||
|
||||
try {
|
||||
await form.validateFields();
|
||||
setExchangeLoading(true);
|
||||
|
||||
// TODO: 调用后端兑换接口
|
||||
// await PointsMallAPI.exchange(selectedProduct.id, form.getFieldsValue());
|
||||
|
||||
message.success('兑换成功!我们将尽快为您安排发货');
|
||||
setExchangeModalVisible(false);
|
||||
|
||||
// 刷新积分余额
|
||||
loadOverview();
|
||||
} catch (error) {
|
||||
// 表单验证失败
|
||||
} finally {
|
||||
setExchangeLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const userPoints = overview?.me?.points || 0;
|
||||
const canAfford = selectedProduct ? userPoints >= selectedProduct.pointsPrice : false;
|
||||
|
||||
return (
|
||||
<div className="page-container" style={{ maxWidth: 1400 }}>
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 24,
|
||||
padding: '30px 30px 22px',
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(255,255,255,0.98) 0%, rgba(236,253,245,0.92) 44%, rgba(239,246,255,0.96) 100%)',
|
||||
border: '1px solid rgba(8, 145, 178, 0.12)',
|
||||
boxShadow: '0 20px 48px rgba(15, 23, 42, 0.06)',
|
||||
marginBottom: 18
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 20, flexWrap: 'wrap' }}>
|
||||
<div style={{ maxWidth: 720 }}>
|
||||
<h1 className="page-title" style={{ marginBottom: 10 }}>积分商城</h1>
|
||||
<div className="page-subtitle" style={{ marginTop: 0, fontSize: 15, lineHeight: 1.75 }}>
|
||||
使用积分兑换权益、工具和活动礼包。后续会接入库存、订单与积分流水。
|
||||
</div>
|
||||
<div className="points-mall-hero">
|
||||
<div className="points-mall-header">
|
||||
<div className="points-mall-title-section">
|
||||
<h1 className="page-title stats-page-title">积分商城</h1>
|
||||
<p className="page-subtitle stats-page-subtitle">
|
||||
使用积分兑换权益、工具和活动礼包。积分通过 API 调用消费自动累积,1 美元 = 1000 积分。
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
minWidth: 280,
|
||||
borderRadius: 18,
|
||||
padding: '14px 16px',
|
||||
background: 'rgba(255,255,255,0.72)',
|
||||
border: '1px solid rgba(255,255,255,0.7)'
|
||||
}}
|
||||
>
|
||||
<div className="points-mall-balance-card">
|
||||
{overviewLoading ? (
|
||||
<Spin />
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
|
||||
<div className="points-balance-row">
|
||||
<div>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 6 }}>
|
||||
我的积分
|
||||
</div>
|
||||
<div style={{ fontSize: 28, fontWeight: 800, color: 'var(--color-text)' }}>
|
||||
{Number(overview?.me?.points || 0).toLocaleString()}
|
||||
</div>
|
||||
<div className="points-balance-label">我的积分</div>
|
||||
<div className="points-balance-value">{userPoints.toLocaleString()}</div>
|
||||
</div>
|
||||
<Tag bordered={false} style={{ margin: 0, borderRadius: 999, background: 'var(--color-brand-soft)', color: 'var(--color-brand)' }}>
|
||||
{String(overview?.me?.level || 'Lv.0')}
|
||||
|
|
@ -170,130 +181,61 @@ export default function PointsMallPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
bodyStyle={{ padding: 16 }}
|
||||
style={{
|
||||
borderRadius: 18,
|
||||
boxShadow: '0 12px 28px rgba(15, 23, 42, 0.045)',
|
||||
marginBottom: 14
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)', fontWeight: 600 }}>
|
||||
商品分类
|
||||
</span>
|
||||
{categories.map((c) => (
|
||||
<Button
|
||||
key={c.id}
|
||||
size="small"
|
||||
type={c.id === categoryId ? 'primary' : 'default'}
|
||||
style={{ borderRadius: 999 }}
|
||||
onClick={() => {
|
||||
setPage(1);
|
||||
setCategoryId(c.id);
|
||||
}}
|
||||
>
|
||||
{c.name}
|
||||
</Button>
|
||||
))}
|
||||
<Card className="points-mall-category-card" bodyStyle={{ padding: 0 }}>
|
||||
<div className="points-mall-category-body">
|
||||
<div className="points-mall-category-row">
|
||||
<span className="points-mall-category-label">商品分类</span>
|
||||
{categories.map((c) => (
|
||||
<Button
|
||||
key={c.id}
|
||||
size="small"
|
||||
type={c.id === categoryId ? 'primary' : 'default'}
|
||||
style={{ borderRadius: 999 }}
|
||||
onClick={() => {
|
||||
setPage(1);
|
||||
setCategoryId(c.id);
|
||||
}}
|
||||
>
|
||||
{c.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{!!announcement && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={
|
||||
<span style={{ fontWeight: 600 }}>
|
||||
{announcement.title}
|
||||
</span>
|
||||
}
|
||||
description={announcement.content}
|
||||
style={{ borderRadius: 18, marginBottom: 14 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1.55fr) minmax(0, 1fr)', gap: 14, marginBottom: 16 }}>
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 22,
|
||||
padding: 22,
|
||||
border: '1px solid rgba(236, 72, 153, 0.22)',
|
||||
background: 'linear-gradient(135deg, rgba(236, 72, 153, 0.16) 0%, rgba(59, 130, 246, 0.16) 60%, rgba(34, 197, 94, 0.12) 100%)',
|
||||
boxShadow: '0 12px 30px rgba(15, 23, 42, 0.05)',
|
||||
minHeight: 176,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap' }}>
|
||||
<div className="points-mall-banner-section">
|
||||
<div className="points-mall-banner-card">
|
||||
<div className="points-mall-banner-header">
|
||||
<div>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, color: 'var(--color-text)', marginBottom: 4 }}>
|
||||
{banner?.title || '本期活动'}
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)' }}>
|
||||
{banner?.subtitle || 'Up to 25% Off'}
|
||||
</div>
|
||||
<div className="points-mall-banner-title">{banner?.title || '本期活动'}</div>
|
||||
<div className="points-mall-banner-subtitle">{banner?.subtitle || 'Up to 25% Off'}</div>
|
||||
</div>
|
||||
<Button type="primary" style={{ borderRadius: 12, height: 40, fontWeight: 600 }}>
|
||||
查看活动
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--color-text-tertiary)' }}>
|
||||
banner 图片与跳转链接由后端配置
|
||||
</div>
|
||||
<div className="points-mall-banner-footer">banner 图片与跳转链接由后端配置</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateRows: 'repeat(2, minmax(0, 1fr))', gap: 14 }}>
|
||||
<div className="points-mall-promo-grid">
|
||||
{promoEntries.slice(0, 2).map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
style={{
|
||||
borderRadius: 22,
|
||||
padding: 18,
|
||||
border: '1px solid var(--color-border)',
|
||||
background: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(252,252,253,1) 100%)',
|
||||
boxShadow: '0 10px 28px rgba(15, 23, 42, 0.04)',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<div key={p.id} className="points-mall-promo-card">
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 800, color: 'var(--color-text)', marginBottom: 4 }}>
|
||||
{p.title}
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)' }}>
|
||||
{p.subtitle}
|
||||
</div>
|
||||
</div>
|
||||
<Button size="small" style={{ borderRadius: 999 }}>进入</Button>
|
||||
<div className="points-mall-promo-title">{p.title}</div>
|
||||
<div className="points-mall-promo-subtitle">{p.subtitle}</div>
|
||||
</div>
|
||||
))}
|
||||
<Button size="small" className="points-mall-exchange-btn">进入</Button>
|
||||
</div>
|
||||
))}
|
||||
{promoEntries.length < 2 && (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 22,
|
||||
padding: 18,
|
||||
border: '1px dashed var(--color-border)',
|
||||
background: 'var(--color-surface)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--color-text-tertiary)'
|
||||
}}
|
||||
>
|
||||
促销入口由后端配置
|
||||
</div>
|
||||
<div className="points-mall-promo-empty">促销入口由后端配置</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card style={{ borderRadius: 22, boxShadow: '0 12px 28px rgba(15, 23, 42, 0.045)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap', marginBottom: 14 }}>
|
||||
<Space size={10} wrap>
|
||||
<Card className="stats-page-chart-card">
|
||||
<div className="points-mall-filters-row">
|
||||
<Space size={10} wrap className="points-mall-filters-left">
|
||||
<Input
|
||||
value={q}
|
||||
onChange={(e) => {
|
||||
|
|
@ -303,20 +245,25 @@ export default function PointsMallPage() {
|
|||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索商品"
|
||||
allowClear
|
||||
style={{ width: 260, borderRadius: 12 }}
|
||||
className="points-mall-search-input"
|
||||
/>
|
||||
<Select
|
||||
value={sort}
|
||||
style={{ width: 160 }}
|
||||
onChange={(v) => {
|
||||
className="points-mall-filter-select"
|
||||
onChange={(v: SortKey) => {
|
||||
setPage(1);
|
||||
setSort(v);
|
||||
}}
|
||||
options={sortOptions}
|
||||
options={[
|
||||
{ value: 'popular', label: '热度优先' },
|
||||
{ value: 'newest', label: '最新上架' },
|
||||
{ value: 'price_asc', label: '积分从低到高' },
|
||||
{ value: 'price_desc', label: '积分从高到低' }
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={pageSize}
|
||||
style={{ width: 140 }}
|
||||
className="points-mall-filter-select-small"
|
||||
onChange={(v) => {
|
||||
setPage(1);
|
||||
setPageSize(v);
|
||||
|
|
@ -328,7 +275,7 @@ export default function PointsMallPage() {
|
|||
]}
|
||||
/>
|
||||
</Space>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)' }}>
|
||||
<div className="points-mall-total-text">
|
||||
共 {total.toLocaleString()} 件商品
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -338,54 +285,37 @@ export default function PointsMallPage() {
|
|||
) : products.length === 0 ? (
|
||||
<Empty description="暂无商品" style={{ marginTop: 60 }} />
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 14 }}>
|
||||
<div className="points-mall-products-grid">
|
||||
{products.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
style={{
|
||||
borderRadius: 20,
|
||||
border: '1px solid rgba(148, 163, 184, 0.14)',
|
||||
background: 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(252,252,253,1) 100%)',
|
||||
boxShadow: '0 10px 28px rgba(15, 23, 42, 0.045)',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: 156,
|
||||
background: 'linear-gradient(135deg, rgba(8,145,178,0.12) 0%, rgba(59,130,246,0.10) 55%, rgba(34,197,94,0.10) 100%)'
|
||||
}}
|
||||
/>
|
||||
<div style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, alignItems: 'flex-start' }}>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 800, color: 'var(--color-text)', marginBottom: 4, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{p.name}
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', lineHeight: 1.5, minHeight: 36 }}>
|
||||
{p.subtitle}
|
||||
</div>
|
||||
<div key={p.id} className="points-mall-product-card">
|
||||
<div className="points-mall-product-cover" />
|
||||
<div className="points-mall-product-body">
|
||||
<div className="points-mall-product-header">
|
||||
<div className="points-mall-product-info">
|
||||
<div className="points-mall-product-name">{p.name}</div>
|
||||
<div className="points-mall-product-desc">{p.subtitle}</div>
|
||||
</div>
|
||||
{p.tags?.length ? (
|
||||
<Tag bordered={false} style={{ margin: 0, borderRadius: 999, background: 'var(--color-warning-soft)', color: 'var(--color-warning)' }}>
|
||||
<Tag bordered={false} className="points-mall-product-tag">
|
||||
{p.tags[0]}
|
||||
</Tag>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 10 }}>
|
||||
<div className="points-mall-product-price-row">
|
||||
<div>
|
||||
<span style={{ fontSize: 18, fontWeight: 900, color: 'var(--color-text)' }}>
|
||||
{Number(p.pointsPrice).toLocaleString()}
|
||||
</span>
|
||||
<span style={{ marginLeft: 6, fontSize: 12.5, color: 'var(--color-text-secondary)' }}>积分</span>
|
||||
<span className="points-mall-product-price">{Number(p.pointsPrice).toLocaleString()}</span>
|
||||
<span className="points-mall-product-price-label">积分</span>
|
||||
</div>
|
||||
<Button type="primary" style={{ borderRadius: 12, height: 34, fontWeight: 700 }}>
|
||||
兑换
|
||||
<Button
|
||||
type="primary"
|
||||
className="points-mall-exchange-btn points-mall-product-exchange-btn"
|
||||
disabled={userPoints < p.pointsPrice}
|
||||
onClick={() => handleExchangeClick(p)}
|
||||
>
|
||||
{userPoints < p.pointsPrice ? '积分不足' : '兑换'}
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, fontSize: 12, color: 'var(--color-text-tertiary)' }}>
|
||||
<div className="points-mall-product-footer">
|
||||
<span>库存 {p.stock}</span>
|
||||
<span>已兑 {p.sold}</span>
|
||||
</div>
|
||||
|
|
@ -396,18 +326,18 @@ export default function PointsMallPage() {
|
|||
)}
|
||||
|
||||
{!!productsRes && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', marginTop: 18 }}>
|
||||
<div className="points-mall-pagination">
|
||||
<Space size={10}>
|
||||
<Button disabled={page <= 1} onClick={() => setPage((v) => Math.max(1, v - 1))} style={{ borderRadius: 10 }}>
|
||||
<Button disabled={page <= 1} onClick={() => setPage((v) => Math.max(1, v - 1))} className="points-mall-exchange-btn">
|
||||
上一页
|
||||
</Button>
|
||||
<Tag bordered={false} style={{ margin: 0, borderRadius: 999, background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)' }}>
|
||||
<Tag bordered={false} className="points-mall-pagination-tag">
|
||||
第 {page} 页
|
||||
</Tag>
|
||||
<Button
|
||||
disabled={page * pageSize >= total}
|
||||
onClick={() => setPage((v) => v + 1)}
|
||||
style={{ borderRadius: 10 }}
|
||||
className="points-mall-exchange-btn"
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
|
|
@ -415,7 +345,126 @@ export default function PointsMallPage() {
|
|||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 兑换弹窗 */}
|
||||
<Modal
|
||||
title="兑换商品"
|
||||
open={exchangeModalVisible}
|
||||
onCancel={() => setExchangeModalVisible(false)}
|
||||
footer={null}
|
||||
width={500}
|
||||
className="points-exchange-modal"
|
||||
destroyOnClose
|
||||
>
|
||||
{selectedProduct && (
|
||||
<>
|
||||
<div className="points-exchange-modal-header">
|
||||
<div className="points-mall-exchange-modal-title">{selectedProduct.name}</div>
|
||||
<div className="points-mall-exchange-modal-points">{selectedProduct.pointsPrice.toLocaleString()} 积分</div>
|
||||
</div>
|
||||
|
||||
{!canAfford && (
|
||||
<div className="points-exchange-balance-warning">
|
||||
积分不足!当前余额:{userPoints.toLocaleString()} 积分,还需:{(selectedProduct.pointsPrice - userPoints).toLocaleString()} 积分
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Form form={form} layout="vertical" className="points-exchange-form">
|
||||
<Form.Item
|
||||
name="recipientName"
|
||||
label={<span className="points-exchange-form-label">收货人姓名</span>}
|
||||
rules={[{ required: true, message: '请输入收货人姓名' }]}
|
||||
className="points-exchange-form-item"
|
||||
>
|
||||
<Input placeholder="请输入收货人姓名" className="points-exchange-form-input" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="phone"
|
||||
label={<span className="points-exchange-form-label">手机号码</span>}
|
||||
rules={[
|
||||
{ required: true, message: '请输入手机号码' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码' }
|
||||
]}
|
||||
className="points-exchange-form-item"
|
||||
>
|
||||
<Input placeholder="请输入手机号码" className="points-exchange-form-input" />
|
||||
</Form.Item>
|
||||
|
||||
<div className="points-mall-address-grid">
|
||||
<Form.Item
|
||||
name="province"
|
||||
label={<span className="points-exchange-form-label">省份</span>}
|
||||
rules={[{ required: true, message: '请选择省份' }]}
|
||||
className="points-exchange-form-item points-exchange-form-item-small"
|
||||
>
|
||||
<Select placeholder="省份" className="points-exchange-form-input">
|
||||
<Select.Option value="北京市">北京市</Select.Option>
|
||||
<Select.Option value="上海市">上海市</Select.Option>
|
||||
<Select.Option value="广东省">广东省</Select.Option>
|
||||
<Select.Option value="浙江省">浙江省</Select.Option>
|
||||
<Select.Option value="江苏省">江苏省</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="city"
|
||||
label={<span className="points-exchange-form-label">城市</span>}
|
||||
rules={[{ required: true, message: '请选择城市' }]}
|
||||
className="points-exchange-form-item points-exchange-form-item-small"
|
||||
>
|
||||
<Select placeholder="城市" className="points-exchange-form-input">
|
||||
<Select.Option value="深圳市">深圳市</Select.Option>
|
||||
<Select.Option value="广州市">广州市</Select.Option>
|
||||
<Select.Option value="杭州市">杭州市</Select.Option>
|
||||
<Select.Option value="南京市">南京市</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="district"
|
||||
label={<span className="points-exchange-form-label">区/县</span>}
|
||||
rules={[{ required: true, message: '请选择区/县' }]}
|
||||
className="points-exchange-form-item points-exchange-form-item-small"
|
||||
>
|
||||
<Select placeholder="区/县" className="points-exchange-form-input">
|
||||
<Select.Option value="南山区">南山区</Select.Option>
|
||||
<Select.Option value="福田区">福田区</Select.Option>
|
||||
<Select.Option value="宝安区">宝安区</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item
|
||||
name="address"
|
||||
label={<span className="points-exchange-form-label">详细地址</span>}
|
||||
rules={[{ required: true, message: '请输入详细地址' }]}
|
||||
className="points-exchange-form-item"
|
||||
>
|
||||
<Input.TextArea placeholder="请输入详细地址(街道、门牌号等)" rows={3} className="points-exchange-form-input" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="zipCode"
|
||||
label={<span className="points-exchange-form-label">邮政编码(选填)</span>}
|
||||
className="points-exchange-form-item"
|
||||
>
|
||||
<Input placeholder="请输入邮政编码" className="points-exchange-form-input" />
|
||||
</Form.Item>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
className="points-exchange-submit-btn"
|
||||
onClick={handleExchangeSubmit}
|
||||
loading={exchangeLoading}
|
||||
disabled={!canAfford}
|
||||
>
|
||||
{canAfford ? '确认兑换' : '积分不足,无法兑换'}
|
||||
</Button>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,18 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { BarChartOutlined, LineChartOutlined, MessageOutlined, RobotOutlined } from '@ant-design/icons';
|
||||
import { BarChartOutlined, DollarOutlined, GiftOutlined, LineChartOutlined, MessageOutlined, RobotOutlined } from '@ant-design/icons';
|
||||
import { Card, Empty, App as AntApp, Spin, Tag, Select, Space, Table } from 'antd';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { AgentTokenStats, StatsAPI, StatsOverview } from '../api';
|
||||
|
||||
interface StatsOverviewWithPoints extends StatsOverview {
|
||||
totalSpentUSD?: number;
|
||||
totalPoints?: number;
|
||||
pointsEarned?: number;
|
||||
}
|
||||
|
||||
export default function StatsPage() {
|
||||
const { message } = AntApp.useApp();
|
||||
const [data, setData] = useState<StatsOverview | null>(null);
|
||||
const [data, setData] = useState<StatsOverviewWithPoints | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [tokenLoading, setTokenLoading] = useState(false);
|
||||
const [tokenData, setTokenData] = useState<AgentTokenStats | null>(null);
|
||||
|
|
@ -18,7 +24,12 @@ export default function StatsPage() {
|
|||
setLoading(true);
|
||||
StatsAPI.overview()
|
||||
.then((res) => {
|
||||
setData(res);
|
||||
const enhanced = res as StatsOverviewWithPoints;
|
||||
// 模拟积分数据:1 USD = 1000 积分
|
||||
enhanced.totalSpentUSD = tokenData?.costUSD || 0;
|
||||
enhanced.totalPoints = Math.floor(enhanced.totalSpentUSD * 1000);
|
||||
enhanced.pointsEarned = enhanced.totalPoints;
|
||||
setData(enhanced);
|
||||
if (!tokenAgentId && res.topAgents?.[0]?.id) {
|
||||
setTokenAgentId(res.topAgents[0].id);
|
||||
}
|
||||
|
|
@ -26,13 +37,27 @@ export default function StatsPage() {
|
|||
.catch((e) => message.error('加载失败:' + (e?.message ?? e)))
|
||||
.finally(() => setLoading(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [tokenData?.costUSD]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tokenAgentId) return;
|
||||
setTokenLoading(true);
|
||||
StatsAPI.agentTokens(tokenAgentId, { days: tokenDays, limit: tokenLimit })
|
||||
.then(setTokenData)
|
||||
.then((res) => {
|
||||
setTokenData(res);
|
||||
// 更新总消费和积分
|
||||
setData((prev) => {
|
||||
if (prev) {
|
||||
return {
|
||||
...prev,
|
||||
totalSpentUSD: res.costUSD,
|
||||
totalPoints: Math.floor(res.costUSD * 1000),
|
||||
pointsEarned: Math.floor(res.costUSD * 1000)
|
||||
};
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
})
|
||||
.catch((e) => message.error('Token 统计加载失败:' + (e?.message ?? e)))
|
||||
.finally(() => setTokenLoading(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
|
@ -47,235 +72,169 @@ export default function StatsPage() {
|
|||
const avgSessionMessages = data.sessionCount > 0 ? (data.messageCount / data.sessionCount).toFixed(1) : '0.0';
|
||||
const formatUSD = (v?: number | null) =>
|
||||
`$${Number(v || 0).toLocaleString(undefined, { minimumFractionDigits: 4, maximumFractionDigits: 4 })}`;
|
||||
const formatPoints = (v?: number | null) => Number(v || 0).toLocaleString();
|
||||
|
||||
const totalSpentUSD = data.totalSpentUSD || tokenData?.costUSD || 0;
|
||||
const totalPoints = Math.floor(totalSpentUSD * 1000);
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 24,
|
||||
padding: '30px 30px 26px',
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(255,255,255,0.98) 0%, rgba(236,253,245,0.92) 44%, rgba(239,246,255,0.96) 100%)',
|
||||
border: '1px solid rgba(8, 145, 178, 0.12)',
|
||||
boxShadow: '0 20px 48px rgba(15, 23, 42, 0.06)',
|
||||
marginBottom: 24
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 20, flexWrap: 'wrap', marginBottom: 20 }}>
|
||||
<div style={{ maxWidth: 640 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '6px 12px',
|
||||
borderRadius: 999,
|
||||
background: 'rgba(255,255,255,0.78)',
|
||||
border: '1px solid rgba(8, 145, 178, 0.10)',
|
||||
color: 'var(--color-text-secondary)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
marginBottom: 16
|
||||
}}
|
||||
>
|
||||
<BarChartOutlined style={{ color: 'var(--color-brand)' }} />
|
||||
<div className="stats-page-hero">
|
||||
<div className="stats-page-header">
|
||||
<div className="stats-page-title-section">
|
||||
<div className="stats-page-badge">
|
||||
<BarChartOutlined className="stats-page-badge-icon" />
|
||||
数据洞察看板
|
||||
</div>
|
||||
<h1 className="page-title" style={{ marginBottom: 10 }}>调用统计</h1>
|
||||
<div className="page-subtitle" style={{ marginTop: 0, fontSize: 15, lineHeight: 1.75 }}>
|
||||
<p className="page-subtitle" style={{ marginTop: 0, fontSize: 15, lineHeight: 1.75 }}>
|
||||
不只是查看调用数量,而是帮助你感知哪些智能体正在被频繁使用、最近的消息趋势如何,以及整体会话是否健康增长。
|
||||
</div>
|
||||
每消费 1 美元可获得 1000 积分。
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
minWidth: 240,
|
||||
borderRadius: 20,
|
||||
padding: '18px 18px 16px',
|
||||
background: 'rgba(255,255,255,0.72)',
|
||||
border: '1px solid rgba(255,255,255,0.7)'
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 8 }}>近 7 天消息总量</div>
|
||||
<div style={{ fontSize: 30, fontWeight: 700, color: 'var(--color-text)', marginBottom: 6 }}>{last7Days}</div>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--color-text-tertiary)' }}>平均每个会话 {avgSessionMessages} 条消息</div>
|
||||
<div className="stats-page-summary-card">
|
||||
<div className="stats-page-summary-label">近 7 天消息总量</div>
|
||||
<div className="stats-page-summary-value">{last7Days}</div>
|
||||
<div className="stats-page-summary-desc">平均每个会话 {avgSessionMessages} 条消息</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 14 }}>
|
||||
<div className="stats-page-cards-grid">
|
||||
{[
|
||||
{ icon: <RobotOutlined />, label: '智能体数量', value: data.agentCount, tone: 'rgba(8, 145, 178, 0.10)', color: 'var(--color-brand)' },
|
||||
{ icon: <LineChartOutlined />, label: '会话总数', value: data.sessionCount, tone: 'rgba(14, 165, 233, 0.10)', color: 'var(--color-info)' },
|
||||
{ icon: <MessageOutlined />, label: '消息总数', value: data.messageCount, tone: 'rgba(34, 197, 94, 0.10)', color: 'var(--color-success)' }
|
||||
{ icon: <RobotOutlined />, label: '智能体数量', value: data.agentCount, bgColor: 'rgba(8, 145, 178, 0.10)', textColor: 'var(--color-brand)' },
|
||||
{ icon: <LineChartOutlined />, label: '会话总数', value: data.sessionCount, bgColor: 'rgba(14, 165, 233, 0.10)', textColor: 'var(--color-info)' },
|
||||
{ icon: <MessageOutlined />, label: '消息总数', value: data.messageCount, bgColor: 'rgba(34, 197, 94, 0.10)', textColor: 'var(--color-success)' }
|
||||
].map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
style={{
|
||||
borderRadius: 18,
|
||||
padding: '16px 18px',
|
||||
background: 'rgba(255,255,255,0.72)',
|
||||
border: '1px solid rgba(255,255,255,0.7)'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: 'var(--color-text-secondary)', fontSize: 12.5, marginBottom: 10 }}>
|
||||
<span
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 999,
|
||||
background: item.tone,
|
||||
color: item.color,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<div key={item.label} className="stats-page-card">
|
||||
<div className="stats-page-card-label">
|
||||
<span className="stats-page-card-icon" style={{ background: item.bgColor, color: item.textColor }}>
|
||||
{item.icon}
|
||||
</span>
|
||||
{item.label}
|
||||
</div>
|
||||
<div style={{ fontSize: 30, fontWeight: 700, color: 'var(--color-text)' }}>{item.value}</div>
|
||||
<div className="stats-page-card-value">{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1.35fr) minmax(320px, 0.9fr)', gap: 18 }}>
|
||||
{/* 积分与消费关联展示 */}
|
||||
<div className="points-integration-section">
|
||||
<Card
|
||||
title="最近 30 天消息走势"
|
||||
extra={<Tag bordered={false} style={{ borderRadius: 999, background: 'var(--color-brand-soft)', color: 'var(--color-brand)' }}>仅统计你的数据</Tag>}
|
||||
style={{ borderRadius: 22, boxShadow: '0 12px 28px rgba(15, 23, 42, 0.045)' }}
|
||||
title="消费与积分"
|
||||
className="points-integration-card"
|
||||
extra={
|
||||
<Tag bordered={false} style={{ borderRadius: 999, background: 'var(--color-brand-soft)', color: 'var(--color-brand)' }}>
|
||||
1 USD = 1000 积分
|
||||
</Tag>
|
||||
}
|
||||
>
|
||||
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', marginBottom: 10 }}>
|
||||
深色柱代表用户消息,浅绿柱代表助手响应,用来快速判断近期对话活跃度。
|
||||
</div>
|
||||
{data.daily.length === 0 ? (
|
||||
<Empty description="近期无对话" />
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6, height: 236, padding: '18px 12px 10px' }}>
|
||||
{data.daily.map((d) => {
|
||||
const userH = (d.user / maxDaily) * 180;
|
||||
const aH = (d.assistant / maxDaily) * 180;
|
||||
return (
|
||||
<div
|
||||
key={d.day}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
minWidth: 0
|
||||
}}
|
||||
title={`${d.day}\n用户 ${d.user} · 助手 ${d.assistant}`}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 9,
|
||||
height: userH,
|
||||
background: 'var(--color-brand)',
|
||||
borderRadius: '6px 6px 0 0'
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
width: 9,
|
||||
height: aH,
|
||||
background: 'var(--color-success)',
|
||||
borderRadius: '6px 6px 0 0'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--color-text-tertiary)', marginTop: 6, transform: 'rotate(-45deg)' }}>
|
||||
{d.day.slice(5)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="points-integration-row">
|
||||
<div className="points-integration-item">
|
||||
<div className="points-integration-label">
|
||||
<DollarOutlined style={{ color: 'var(--color-warning)' }} />
|
||||
累计消费 (USD)
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--color-text-secondary)', display: 'flex', gap: 12 }}>
|
||||
<span>
|
||||
<span style={{ display: 'inline-block', width: 10, height: 10, background: 'var(--color-brand)', marginRight: 4, borderRadius: 999 }} />
|
||||
用户消息
|
||||
</span>
|
||||
<span>
|
||||
<span style={{ display: 'inline-block', width: 10, height: 10, background: 'var(--color-success)', marginRight: 4, borderRadius: 999 }} />
|
||||
助手消息
|
||||
</span>
|
||||
<div className="points-integration-value">{formatUSD(totalSpentUSD)}</div>
|
||||
<div className="points-integration-desc">API 调用总费用</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="智能体活跃排行" style={{ borderRadius: 22, boxShadow: '0 12px 28px rgba(15, 23, 42, 0.045)' }}>
|
||||
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', marginBottom: 12 }}>
|
||||
哪些智能体正在被频繁使用,一眼就能看出来。
|
||||
</div>
|
||||
{data.topAgents.length === 0 ? (
|
||||
<Empty description="暂无" />
|
||||
) : (
|
||||
<div>
|
||||
{data.topAgents.map((a, index) => (
|
||||
<div
|
||||
key={a.id}
|
||||
style={{
|
||||
marginBottom: 12,
|
||||
padding: '12px 14px',
|
||||
borderRadius: 16,
|
||||
background: 'linear-gradient(180deg, rgba(255,255,255,0.96) 0%, rgba(248,250,252,0.9) 100%)',
|
||||
border: '1px solid rgba(148, 163, 184, 0.12)'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 8, gap: 10 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 999,
|
||||
background: 'rgba(8, 145, 178, 0.10)',
|
||||
color: 'var(--color-brand)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 12,
|
||||
fontWeight: 700
|
||||
}}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
<Link to={`/agents/${a.id}/chat`} style={{ flex: 1, fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
{a.name}
|
||||
</Link>
|
||||
<Tag bordered={false} style={{ background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)', borderRadius: 999, margin: 0 }}>
|
||||
{a.messageCount} 条
|
||||
</Tag>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: 6,
|
||||
background: 'var(--color-surface-2)',
|
||||
borderRadius: 3,
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: `${(a.messageCount / maxAgent) * 100}%`,
|
||||
background: 'var(--gradient-brand)',
|
||||
transition: 'width 0.4s'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="points-integration-item">
|
||||
<div className="points-integration-label">
|
||||
<GiftOutlined style={{ color: 'var(--color-brand)' }} />
|
||||
累计积分
|
||||
</div>
|
||||
)}
|
||||
<div className="points-integration-value">{formatPoints(totalPoints)}</div>
|
||||
<div className="points-integration-desc">可用于兑换商城商品</div>
|
||||
</div>
|
||||
<div className="points-integration-item">
|
||||
<div className="points-integration-label">
|
||||
<BarChartOutlined style={{ color: 'var(--color-success)' }} />
|
||||
积分汇率
|
||||
</div>
|
||||
<div className="points-integration-value">1:1000</div>
|
||||
<div className="points-integration-desc">每消费 1 美元获得 1000 积分</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 18 }}>
|
||||
<div className="stats-page-main-grid" style={{ marginTop: 18 }}>
|
||||
<Card
|
||||
title="最近 30 天消息走势"
|
||||
className="stats-page-chart-card"
|
||||
extra={<Tag bordered={false} style={{ borderRadius: 999, background: 'var(--color-brand-soft)', color: 'var(--color-brand)' }}>仅统计你的数据</Tag>}
|
||||
>
|
||||
<div className="stats-page-chart-desc">
|
||||
深色柱代表用户消息,浅绿柱代表助手响应,用来快速判断近期对话活跃度。
|
||||
</div>
|
||||
{data.daily.length === 0 ? (
|
||||
<Empty description="近期无对话" />
|
||||
) : (
|
||||
<div className="stats-page-chart-container">
|
||||
{data.daily.map((d) => {
|
||||
const userH = (d.user / maxDaily) * 180;
|
||||
const aH = (d.assistant / maxDaily) * 180;
|
||||
return (
|
||||
<div
|
||||
key={d.day}
|
||||
className="stats-page-chart-bar-group"
|
||||
title={`${d.day}\n用户 ${d.user} · 助手 ${d.assistant}`}
|
||||
>
|
||||
<div className="stats-page-chart-bars">
|
||||
<div className="stats-page-chart-bar-user" style={{ height: userH }} />
|
||||
<div className="stats-page-chart-bar-assistant" style={{ height: aH }} />
|
||||
</div>
|
||||
<div className="stats-page-chart-label">{d.day.slice(5)}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="stats-page-chart-legend">
|
||||
<span className="stats-page-chart-legend-item">
|
||||
<span className="stats-page-chart-legend-dot" style={{ background: 'var(--color-brand)' }} />
|
||||
用户消息
|
||||
</span>
|
||||
<span className="stats-page-chart-legend-item">
|
||||
<span className="stats-page-chart-legend-dot" style={{ background: 'var(--color-success)' }} />
|
||||
助手消息
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="智能体活跃排行" className="stats-page-chart-card">
|
||||
<div className="stats-page-chart-desc">
|
||||
哪些智能体正在被频繁使用,一眼就能看出来。
|
||||
</div>
|
||||
{data.topAgents.length === 0 ? (
|
||||
<Empty description="暂无" />
|
||||
) : (
|
||||
<div>
|
||||
{data.topAgents.map((a, index) => (
|
||||
<div key={a.id} className="stats-page-agent-item">
|
||||
<div className="stats-page-agent-header">
|
||||
<div className="stats-page-agent-rank">{index + 1}</div>
|
||||
<Link to={`/agents/${a.id}/chat`} className="stats-page-agent-name">
|
||||
{a.name}
|
||||
</Link>
|
||||
<Tag bordered={false} style={{ background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)', borderRadius: 999, margin: 0 }}>
|
||||
{a.messageCount} 条
|
||||
</Tag>
|
||||
</div>
|
||||
<div className="stats-page-agent-progress">
|
||||
<div className="stats-page-agent-progress-bar" style={{ width: `${(a.messageCount / maxAgent) * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="stats-page-token-section">
|
||||
<Card
|
||||
title="Token 使用量"
|
||||
className="stats-page-chart-card"
|
||||
extra={
|
||||
<Space size={10} wrap>
|
||||
<Select
|
||||
|
|
@ -310,7 +269,6 @@ export default function StatsPage() {
|
|||
/>
|
||||
</Space>
|
||||
}
|
||||
style={{ borderRadius: 22, boxShadow: '0 12px 28px rgba(15, 23, 42, 0.045)' }}
|
||||
>
|
||||
{tokenLoading ? (
|
||||
<Spin style={{ marginTop: 20, display: 'block' }} />
|
||||
|
|
@ -320,58 +278,40 @@ export default function StatsPage() {
|
|||
<Empty description="暂无 Token 统计数据" style={{ marginTop: 20 }} />
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 14, marginBottom: 16 }}>
|
||||
<div className="stats-page-token-cards-grid">
|
||||
{[
|
||||
{ label: '总 Token', value: tokenData.totalTokens, tone: 'rgba(8, 145, 178, 0.10)', color: 'var(--color-brand)' },
|
||||
{ label: '输入 Token', value: tokenData.promptTokens, tone: 'rgba(14, 165, 233, 0.10)', color: 'var(--color-info)' },
|
||||
{ label: '输出 Token', value: tokenData.completionTokens, tone: 'rgba(34, 197, 94, 0.10)', color: 'var(--color-success)' },
|
||||
{ label: '费用 (USD)', value: formatUSD(tokenData.costUSD), tone: 'rgba(249, 115, 22, 0.10)', color: 'var(--color-warning)' }
|
||||
{ label: '总 Token', value: tokenData.totalTokens, color: 'var(--color-brand)' },
|
||||
{ label: '输入 Token', value: tokenData.promptTokens, color: 'var(--color-info)' },
|
||||
{ label: '输出 Token', value: tokenData.completionTokens, color: 'var(--color-success)' },
|
||||
{ label: '费用 (USD)', value: formatUSD(tokenData.costUSD), color: 'var(--color-warning)' }
|
||||
].map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
style={{
|
||||
borderRadius: 18,
|
||||
padding: '16px 18px',
|
||||
background: 'rgba(255,255,255,0.72)',
|
||||
border: '1px solid rgba(255,255,255,0.7)'
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 12.5, color: 'var(--color-text-secondary)', marginBottom: 10 }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 999,
|
||||
background: item.color,
|
||||
marginRight: 6,
|
||||
verticalAlign: 'middle'
|
||||
}}
|
||||
/>
|
||||
<div key={item.label} className="stats-page-token-card">
|
||||
<div className="stats-page-token-label">
|
||||
<span className="stats-page-token-dot" style={{ background: item.color }} />
|
||||
{item.label}
|
||||
</div>
|
||||
<div style={{ fontSize: 26, fontWeight: 700, color: 'var(--color-text)' }}>
|
||||
<div className="stats-page-token-value">
|
||||
{typeof item.value === 'number' ? item.value.toLocaleString() : item.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)', gap: 18 }}>
|
||||
<div className="stats-page-token-charts-grid">
|
||||
<Card
|
||||
size="small"
|
||||
title="近 N 天趋势(总 Token)"
|
||||
className="stats-page-token-chart-card"
|
||||
extra={
|
||||
<Tag bordered={false} style={{ borderRadius: 999, background: 'var(--color-surface-2)', color: 'var(--color-text-secondary)' }}>
|
||||
days={tokenData.days}
|
||||
</Tag>
|
||||
}
|
||||
style={{ borderRadius: 18, boxShadow: 'none' }}
|
||||
>
|
||||
{tokenData.daily.length === 0 ? (
|
||||
<Empty description="暂无" />
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6, height: 220, padding: '18px 12px 10px' }}>
|
||||
<div className="stats-page-token-chart-container">
|
||||
{(() => {
|
||||
const maxTokenDaily = Math.max(1, ...tokenData.daily.map((d) => d.totalTokens));
|
||||
return tokenData.daily.map((d) => {
|
||||
|
|
@ -379,27 +319,11 @@ export default function StatsPage() {
|
|||
return (
|
||||
<div
|
||||
key={d.day}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
minWidth: 0
|
||||
}}
|
||||
className="stats-page-chart-bar-group"
|
||||
title={`${d.day}\nCalls ${d.calls}\nTotalTokens ${d.totalTokens}\nCost ${formatUSD(d.costUSD)}`}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 12,
|
||||
height: h,
|
||||
background: 'var(--gradient-brand)',
|
||||
borderRadius: '6px 6px 0 0'
|
||||
}}
|
||||
/>
|
||||
<div style={{ fontSize: 10, color: 'var(--color-text-tertiary)', marginTop: 6, transform: 'rotate(-45deg)' }}>
|
||||
{d.day.slice(5)}
|
||||
</div>
|
||||
<div className="stats-page-token-chart-bar" style={{ height: h }} />
|
||||
<div className="stats-page-chart-label">{d.day.slice(5)}</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -411,7 +335,7 @@ export default function StatsPage() {
|
|||
<Card
|
||||
size="small"
|
||||
title="模型消耗排行"
|
||||
style={{ borderRadius: 18, boxShadow: 'none' }}
|
||||
className="stats-page-token-chart-card"
|
||||
>
|
||||
<Table
|
||||
size="small"
|
||||
|
|
|
|||
793
src/styles.css
793
src/styles.css
|
|
@ -1269,3 +1269,796 @@ body {
|
|||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* Points Mall Styles */
|
||||
.points-mall-hero {
|
||||
border-radius: 24px;
|
||||
padding: 30px 30px 22px;
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.98) 0%, rgba(236,253,245,0.92) 44%, rgba(239,246,255,0.96) 100%);
|
||||
border: 1px solid rgba(8, 145, 178, 0.12);
|
||||
box-shadow: 0 20px 48px rgba(15, 23, 42, 0.06);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.points-mall-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.points-mall-title-section {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.points-mall-balance-card {
|
||||
min-width: 280px;
|
||||
border-radius: 18px;
|
||||
padding: 14px 16px;
|
||||
background: rgba(255,255,255,0.72);
|
||||
border: 1px solid rgba(255,255,255,0.7);
|
||||
}
|
||||
|
||||
.points-balance-label {
|
||||
font-size: 12.5px;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.points-balance-value {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.points-balance-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.points-mall-category-card {
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.045);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.points-mall-category-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.points-mall-category-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.points-mall-category-label {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.points-mall-banner-section {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.points-mall-banner-card {
|
||||
border-radius: 22px;
|
||||
padding: 22px;
|
||||
border: 1px solid rgba(236, 72, 153, 0.22);
|
||||
background: linear-gradient(135deg, rgba(236, 72, 153, 0.16) 0%, rgba(59, 130, 246, 0.16) 60%, rgba(34, 197, 94, 0.12) 100%);
|
||||
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.05);
|
||||
min-height: 176px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.points-mall-banner-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.points-mall-banner-title {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.points-mall-banner-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.points-mall-banner-footer {
|
||||
font-size: 12.5px;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.points-mall-promo-grid {
|
||||
display: grid;
|
||||
grid-template-rows: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.points-mall-promo-card {
|
||||
border-radius: 22px;
|
||||
padding: 18px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(252,252,253,1) 100%);
|
||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.04);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.points-mall-promo-title {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.points-mall-promo-subtitle {
|
||||
font-size: 12.5px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.points-mall-promo-empty {
|
||||
border-radius: 22px;
|
||||
padding: 18px;
|
||||
border: 1px dashed var(--color-border);
|
||||
background: var(--color-surface);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.points-mall-products-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.points-mall-product-card {
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(252,252,253,1) 100%);
|
||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.045);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.points-mall-product-cover {
|
||||
height: 156px;
|
||||
background: linear-gradient(135deg, rgba(8,145,178,0.12) 0%, rgba(59,130,246,0.10) 55%, rgba(34,197,94,0.10) 100%);
|
||||
}
|
||||
|
||||
.points-mall-product-body {
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.points-mall-product-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.points-mall-product-info {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.points-mall-product-name {
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.points-mall-product-desc {
|
||||
font-size: 12.5px;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.5;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.points-mall-product-price-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.points-mall-product-price {
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.points-mall-product-price-label {
|
||||
margin-left: 6px;
|
||||
font-size: 12.5px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.points-mall-product-exchange-btn {
|
||||
border-radius: 12px;
|
||||
height: 34px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.points-mall-product-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.points-mall-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 18px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Exchange Modal Styles */
|
||||
.points-exchange-modal .ant-modal-content {
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.points-exchange-modal-header {
|
||||
text-align: center;
|
||||
padding: 24px 24px 16px;
|
||||
}
|
||||
|
||||
.points-exchange-product-name {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.points-exchange-points {
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.points-exchange-form {
|
||||
padding: 0 24px 24px;
|
||||
}
|
||||
|
||||
.points-exchange-form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.points-exchange-form-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.points-exchange-form-input {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.points-exchange-form-input .ant-input,
|
||||
.points-exchange-form-input .ant-select-selector {
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
|
||||
.points-exchange-submit-btn {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.points-exchange-balance-warning {
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
background: var(--color-danger-soft);
|
||||
border-radius: 12px;
|
||||
color: var(--color-danger);
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Points History in Stats */
|
||||
.points-stats-card {
|
||||
border-radius: 18px;
|
||||
padding: 16px 18px;
|
||||
background: rgba(255,255,255,0.72);
|
||||
border: 1px solid rgba(255,255,255,0.7);
|
||||
}
|
||||
|
||||
.points-stats-label {
|
||||
font-size: 12.5px;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.points-stats-value {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.points-stats-icon {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-brand);
|
||||
}
|
||||
|
||||
/* Stats Page Styles */
|
||||
.stats-page-hero {
|
||||
border-radius: 24px;
|
||||
padding: 30px 30px 26px;
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.98) 0%, rgba(236,253,245,0.92) 44%, rgba(239,246,255,0.96) 100%);
|
||||
border: 1px solid rgba(8, 145, 178, 0.12);
|
||||
box-shadow: 0 20px 48px rgba(15, 23, 42, 0.06);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stats-page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stats-page-title-section {
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.stats-page-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.78);
|
||||
border: 1px solid rgba(8, 145, 178, 0.10);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stats-page-badge-icon {
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.stats-page-summary-card {
|
||||
min-width: 240px;
|
||||
border-radius: 20px;
|
||||
padding: 18px 18px 16px;
|
||||
background: rgba(255,255,255,0.72);
|
||||
border: 1px solid rgba(255,255,255,0.7);
|
||||
}
|
||||
|
||||
.stats-page-summary-label {
|
||||
font-size: 12.5px;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stats-page-summary-value {
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.stats-page-summary-desc {
|
||||
font-size: 12.5px;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.stats-page-cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.stats-page-card {
|
||||
border-radius: 18px;
|
||||
padding: 16px 18px;
|
||||
background: rgba(255,255,255,0.72);
|
||||
border: 1px solid rgba(255,255,255,0.7);
|
||||
}
|
||||
|
||||
.stats-page-card-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12.5px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stats-page-card-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stats-page-card-value {
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.stats-page-main-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.9fr);
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.stats-page-chart-card {
|
||||
border-radius: 22px;
|
||||
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.045);
|
||||
}
|
||||
|
||||
.stats-page-chart-desc {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stats-page-chart-container {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
height: 236px;
|
||||
padding: 18px 12px 10px;
|
||||
}
|
||||
|
||||
.stats-page-chart-bar-group {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stats-page-chart-bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.stats-page-chart-bar-user {
|
||||
width: 9px;
|
||||
background: var(--color-brand);
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
|
||||
.stats-page-chart-bar-assistant {
|
||||
width: 9px;
|
||||
background: var(--color-success);
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
|
||||
.stats-page-chart-label {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-tertiary);
|
||||
margin-top: 6px;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
.stats-page-chart-legend {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-secondary);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stats-page-chart-legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stats-page-chart-legend-dot {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.stats-page-agent-item {
|
||||
margin-bottom: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.96) 0%, rgba(248,250,252,0.9) 100%);
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
.stats-page-agent-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stats-page-agent-rank {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
background: rgba(8, 145, 178, 0.10);
|
||||
color: var(--color-brand);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stats-page-agent-name {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.stats-page-agent-progress {
|
||||
height: 6px;
|
||||
background: var(--color-surface-2);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stats-page-agent-progress-bar {
|
||||
height: 100%;
|
||||
background: var(--gradient-brand);
|
||||
transition: width 0.4s;
|
||||
}
|
||||
|
||||
.stats-page-token-section {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.stats-page-token-cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.stats-page-token-card {
|
||||
border-radius: 18px;
|
||||
padding: 16px 18px;
|
||||
background: rgba(255,255,255,0.72);
|
||||
border: 1px solid rgba(255,255,255,0.7);
|
||||
}
|
||||
|
||||
.stats-page-token-label {
|
||||
font-size: 12.5px;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.stats-page-token-dot {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.stats-page-token-value {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.stats-page-token-charts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.stats-page-token-chart-card {
|
||||
border-radius: 18px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.stats-page-token-chart-container {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
height: 220px;
|
||||
padding: 18px 12px 10px;
|
||||
}
|
||||
|
||||
.stats-page-token-chart-bar {
|
||||
width: 12px;
|
||||
background: var(--gradient-brand);
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
|
||||
/* Points and USD integration */
|
||||
.points-integration-section {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.points-integration-card {
|
||||
border-radius: 22px;
|
||||
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.045);
|
||||
}
|
||||
|
||||
.points-integration-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.points-integration-item {
|
||||
border-radius: 18px;
|
||||
padding: 16px 18px;
|
||||
background: rgba(255,255,255,0.72);
|
||||
border: 1px solid rgba(255,255,255,0.7);
|
||||
}
|
||||
|
||||
.points-integration-label {
|
||||
font-size: 12.5px;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.points-integration-value {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.points-integration-desc {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-tertiary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.points-conversion-rate {
|
||||
background: var(--color-brand-soft);
|
||||
color: var(--color-brand);
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
display: inline-block;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Additional Points Mall Styles */
|
||||
.points-mall-filter-select {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.points-mall-filter-select-small {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.points-mall-search-input {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.points-mall-product-tag {
|
||||
margin: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--color-warning-soft);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.points-mall-pagination-tag {
|
||||
margin: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.points-mall-exchange-btn {
|
||||
border-radius: 12px;
|
||||
height: 34px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.points-mall-filters-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.points-mall-filters-left {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.points-mall-total-text {
|
||||
font-size: 12.5px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.points-mall-exchange-modal-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.points-mall-exchange-modal-points {
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
color: var(--color-brand);
|
||||
}
|
||||
|
||||
.points-mall-address-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.points-exchange-form-item-small {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Stats page inline styles fallback */
|
||||
.stats-page-title {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stats-page-subtitle {
|
||||
margin-top: 0;
|
||||
font-size: 15px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.stats-page-mt-18 {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue