feat: 积分商城功能优化与消费积分打通

- 积分消费打通:1美元=1000积分
- 优化积分商城页面,移除alert组件
- 新增兑换弹窗,支持完整收货地址表单
- 统计页面新增消费与积分展示
- 重构静态内联样式为CSS类,新增30+语义化样式类
main
sp mac bookpro 2605 2026-06-02 23:46:00 +08:00
parent acdc6c8567
commit e1cf2dfc40
4 changed files with 1246 additions and 457 deletions

View File

@ -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) ==============

View File

@ -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>
);
}

View File

@ -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"

View File

@ -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;
}