初始提交: Qihu-AIFlow v2.0.1 完整项目

- 前端 React+TypeScript (源码+构建产物)
- 后端 Python FastAPI
- 数据库迁移 001-015
- Docker部署配置
- 微信ClawBot API频道绑定
- 项目文档 README.md
This commit is contained in:
qyyfw committed 2026-07-02 14:40:46 +08:00
commit 78ff261b5a
199 files changed
+69126

No files matched your search

+114
View File
@@ -0,0 +1,114 @@
/* ===== Ads Management ===== */
.ads-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
z-index: var(--z-modal);
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 6vh;
animation: fadeIn var(--transition-normal);
}
.ads-modal {
background: #FFFFFF;
border-radius: var(--radius-2xl);
width: 580px;
max-height: 84vh;
overflow-y: auto;
padding: var(--space-3xl);
box-shadow: var(--shadow-xl);
}
.ads-modal-title {
font-size: var(--text-lg);
font-weight: 700;
color: var(--color-text-primary);
margin: 0 0 var(--space-2xl);
}
/* Preview Card */
.ads-form-group {
margin-bottom: var(--space-xl);
}
.ads-preview-card {
padding: var(--space-2xl);
border-radius: var(--radius-lg);
color: #FFFFFF;
position: relative;
overflow: hidden;
min-height: 100px;
}
.ads-preview-badge {
display: inline-block;
padding: 2px 10px;
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(4px);
border-radius: var(--radius-full);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--space-md);
}
.ads-preview-title {
font-size: var(--text-xl);
font-weight: 700;
margin: 0 0 var(--space-xs);
position: relative;
z-index: 1;
}
.ads-preview-sub {
font-size: var(--text-sm);
opacity: 0.85;
margin: 0;
position: relative;
z-index: 1;
}
.ads-preview-visual {
position: absolute;
right: var(--space-2xl);
top: 50%;
transform: translateY(-50%);
width: 100px;
height: 100px;
}
.ads-vc {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
}
.ads-vc1 {
width: 90px;
height: 90px;
top: 5px;
right: 0;
}
.ads-vc2 {
width: 60px;
height: 60px;
top: 30px;
right: 30px;
background: rgba(255, 255, 255, 0.18);
}
.ads-vc3 {
width: 35px;
height: 35px;
top: 10px;
right: 15px;
background: rgba(255, 255, 255, 0.22);
}
+493
View File
@@ -0,0 +1,493 @@
import React, { useState, useEffect } from 'react';
import { adminAPI, marketAPI } from '@/api';
import type { AdBanner, MarketApp } from '@/types';
import './Ads.css';
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
const typeLabels: Record<string, string> = { skill: 'Skill', agent: 'Agent', mcp: 'MCP' };
const Ads: React.FC = () => {
const [banners, setBanners] = useState<AdBanner[]>([]);
const [appOptions, setAppOptions] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [editingBanner, setEditingBanner] = useState<AdBanner | null>(null);
const [showModal, setShowModal] = useState(false);
const [isNew, setIsNew] = useState(false);
const [uploading, setUploading] = useState(false);
const [generating, setGenerating] = useState(false);
const [genPrompt, setGenPrompt] = useState('');
const emptyForm: AdBanner = {
id: '', title: '', imageUrl: '',
linkAppId: '', linkAppName: '',
sortOrder: banners.length + 1, clickCount: 0,
status: 'active', createdAt: new Date().toISOString(),
};
const [form, setForm] = useState<AdBanner>(emptyForm);
const loadBanners = async () => {
try {
setLoading(true);
const res: any = await adminAPI.getAds();
const data = Array.isArray(res) ? res : (res.data || []);
setBanners(data.map((a: any) => ({
id: a.id,
title: a.title,
imageUrl: a.image_url || a.imageUrl || '',
linkAppId: a.link_app_id || a.linkAppId || '',
linkAppName: a.link_app_name || a.linkAppName || '',
sortOrder: a.sort_order ?? a.sortOrder ?? 0,
clickCount: a.click_count ?? a.clickCount ?? 0,
status: a.status || 'active',
createdAt: a.created_at || a.createdAt || new Date().toISOString(),
})));
} catch (e) {
console.error('加载广告失败:', e);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadBanners();
// 加载应用列表用于关联选择
marketAPI.getApps({}).then((res: any) => {
const items = res.data?.items || [];
setAppOptions(items);
}).catch(() => {});
}, []);
const handleCreate = () => {
setIsNew(true);
setForm({ ...emptyForm, sortOrder: banners.length + 1 });
setEditingBanner(null);
setGenPrompt('');
setShowModal(true);
};
const handleEdit = (banner: AdBanner) => {
setIsNew(false);
setEditingBanner(banner);
setForm({ ...banner });
setGenPrompt('');
setShowModal(true);
};
const handleSave = async () => {
try {
if (isNew) {
await adminAPI.addAd({
title: form.title,
image_url: form.imageUrl || undefined,
link_app_id: form.linkAppId || undefined,
link_app_name: form.linkAppName || undefined,
sort_order: form.sortOrder,
status: form.status,
});
} else if (editingBanner) {
await adminAPI.updateAd(editingBanner.id, {
title: form.title,
image_url: form.imageUrl || undefined,
link_app_id: form.linkAppId || undefined,
link_app_name: form.linkAppName || undefined,
sort_order: form.sortOrder,
status: form.status,
});
}
setShowModal(false);
setEditingBanner(null);
loadBanners();
} catch (err: any) {
alert('保存失败:' + (err.detail || err.message));
}
};
const handleDelete = async (id: string) => {
if (!window.confirm('确定要删除此广告吗?')) return;
try {
await adminAPI.deleteAd(id);
loadBanners();
} catch (err: any) {
alert('删除失败:' + (err.detail || err.message));
}
};
const toggleStatus = async (id: string, currentStatus: string) => {
const newStatus = currentStatus === 'active' ? 'inactive' : 'active';
try {
await adminAPI.updateAd(id, { status: newStatus });
loadBanners();
} catch (err: any) {
alert('切换状态失败:' + (err.detail || err.message));
}
};
const handleAppSelect = (appId: string) => {
const app = appOptions.find(a => a.id === appId);
setForm(prev => ({
...prev,
linkAppId: appId,
linkAppName: app ? app.name : '',
}));
};
// 真实图片上传
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const formData = new FormData();
formData.append('file', file);
const token = localStorage.getItem('aiflow_token');
const res = await fetch('/app/api/admin/ads/upload-image', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: '上传失败' }));
throw new Error(err.detail || '上传失败');
}
const data = await res.json();
setForm({ ...form, imageUrl: data.image_url });
} catch (err: any) {
alert('图片上传失败:' + err.message);
} finally {
setUploading(false);
}
};
// AI生成图片
const handleAIGenerate = async () => {
setGenerating(true);
try {
const token = localStorage.getItem('aiflow_token');
const prompt = genPrompt || form.title || 'AI智能平台广告横幅';
const res = await fetch(`/app/api/admin/ads/generate-image?prompt=${encodeURIComponent(prompt)}`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
});
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: '生成失败' }));
throw new Error(err.detail || '生成失败');
}
const data = await res.json();
setForm({ ...form, imageUrl: data.image_url });
} catch (err: any) {
alert('AI绘图失败:' + err.message);
} finally {
setGenerating(false);
}
};
// 生成渐变色预览色块
const getPreviewGradient = (appId?: string): string => {
const gradients: Record<string, string> = {
a1: 'linear-gradient(135deg, #667eea, #764ba2)',
a2: 'linear-gradient(135deg, #f093fb, #f5576c)',
a3: 'linear-gradient(135deg, #4facfe, #00f2fe)',
a4: 'linear-gradient(135deg, #43e97b, #38f9d7)',
a5: 'linear-gradient(135deg, #fa709a, #fee140)',
a6: 'linear-gradient(135deg, #a18cd1, #fbc2eb)',
a8: 'linear-gradient(135deg, #ffecd2, #fcb69f)',
};
return gradients[appId || ''] || 'linear-gradient(135deg, #5B5FED, #A78BFA)';
};
return (
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-xl)' }}>
<h1 className="admin-page-title" style={{ marginBottom: 0 }}>广告位管理</h1>
<button className="admin-btn admin-btn-primary" onClick={handleCreate}>
<SVGIcon size={14}><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></SVGIcon>
新增广告位
</button>
</div>
{/* Banner List */}
{loading ? (
<div style={{ textAlign: 'center', padding: 40 }}>加载中...</div>
) : banners.length === 0 ? (
<div className="admin-card" style={{ textAlign: 'center', padding: 40, color: 'var(--color-text-tertiary)' }}>
暂无广告位,点击"新增广告位"按钮创建
</div>
) : (
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">轮播图广告位列表</h3>
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>
共 {banners.length} 个广告位,活跃 {banners.filter(b => b.status === 'active').length} 个
</span>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th style={{ width: 60 }}>预览</th>
<th>标题</th>
<th>关联应用</th>
<th>排序</th>
<th>点击次数</th>
<th>状态</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{banners.sort((a, b) => a.sortOrder - b.sortOrder).map(banner => (
<tr key={banner.id}>
<td>
<div
style={{
width: 48, height: 32, borderRadius: 'var(--radius-sm)',
background: getPreviewGradient(banner.linkAppId),
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<SVGIcon size={14}><rect x="1" y="3" width="22" height="16" rx="2"/><polyline points="23 7 13 17 8 12"/></SVGIcon>
</div>
</td>
<td style={{ fontWeight: 600 }}>{banner.title}</td>
<td>
{banner.linkAppName ? (
<span className="admin-badge badge-info" style={{ fontSize: 'var(--text-xs)' }}>
{banner.linkAppName}
</span>
) : (
<span style={{ color: 'var(--color-text-tertiary)', fontSize: 'var(--text-xs)' }}>未关联</span>
)}
</td>
<td style={{ fontFamily: 'var(--font-mono)', textAlign: 'center' }}>{banner.sortOrder}</td>
<td style={{ fontFamily: 'var(--font-mono)', textAlign: 'center' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<SVGIcon size={12}><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></SVGIcon>
{banner.clickCount.toLocaleString()}
</span>
</td>
<td>
<span
className={`admin-badge ${banner.status === 'active' ? 'badge-success' : 'badge-neutral'}`}
style={{ cursor: 'pointer' }}
onClick={() => toggleStatus(banner.id, banner.status)}
>
{banner.status === 'active' ? '投放中' : '已暂停'}
</span>
</td>
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>
{new Date(banner.createdAt).toLocaleDateString()}
</td>
<td>
<div style={{ display: 'flex', gap: 'var(--space-xs)' }}>
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={() => handleEdit(banner)}>
<SVGIcon size={12}><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></SVGIcon>
编辑
</button>
<button
className="admin-btn admin-btn-outline admin-btn-sm"
style={{ color: 'var(--color-danger)', borderColor: 'var(--color-danger)' }}
onClick={() => handleDelete(banner.id)}
>
<SVGIcon size={12}><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></SVGIcon>
删除
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Edit/Create Modal */}
{showModal && (
<div
className="ads-modal-overlay"
onClick={() => { setShowModal(false); setEditingBanner(null); }}
>
<div className="ads-modal" onClick={e => e.stopPropagation()}>
<h3 className="ads-modal-title">
{isNew ? '新增广告位' : `编辑 - ${editingBanner?.title}`}
</h3>
{/* Preview */}
<div className="ads-form-group">
<label className="admin-form-label">广告位预览</label>
<div
className="ads-preview-card"
style={{ background: getPreviewGradient(form.linkAppId) }}
>
<div className="ads-preview-badge">推荐</div>
<h4 className="ads-preview-title">{form.title || '广告标题'}</h4>
{form.linkAppName && (
<p className="ads-preview-sub">关联应用: {form.linkAppName}</p>
)}
<div className="ads-preview-visual">
<div className="ads-vc ads-vc1"/>
<div className="ads-vc ads-vc2"/>
<div className="ads-vc ads-vc3"/>
</div>
</div>
</div>
{/* Title */}
<div className="admin-form-group">
<label className="admin-form-label">广告标题</label>
<input
className="admin-form-input"
value={form.title}
onChange={e => setForm({ ...form, title: e.target.value })}
placeholder="输入轮播图展示标题"
/>
</div>
{/* Image URL + Upload + AIGC */}
<div className="admin-form-group">
<label className="admin-form-label">背景图片</label>
<div style={{ display: 'flex', gap: 'var(--space-sm)', marginBottom: 'var(--space-sm)' }}>
<input
className="admin-form-input"
style={{ flex: 1 }}
value={form.imageUrl}
onChange={e => setForm({ ...form, imageUrl: e.target.value })}
placeholder="图片URL / 上传后自动填入 / 留空使用渐变色"
/>
</div>
<div style={{ display: 'flex', gap: 'var(--space-sm)', alignItems: 'center', flexWrap: 'wrap' }}>
<label
className="admin-btn admin-btn-outline admin-btn-sm"
style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 4 }}
>
<SVGIcon size={12}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></SVGIcon>
{uploading ? '上传中...' : '上传图片'}
<input
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={handleImageUpload}
disabled={uploading}
/>
</label>
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
<input
type="text"
className="admin-form-input"
style={{ width: 180, fontSize: 'var(--text-xs)' }}
value={genPrompt}
onChange={e => setGenPrompt(e.target.value)}
placeholder="AI绘图提示词..."
/>
<button
type="button"
className="admin-btn admin-btn-outline admin-btn-sm"
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, whiteSpace: 'nowrap' }}
onClick={handleAIGenerate}
disabled={generating}
>
<SVGIcon size={12}><path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/></SVGIcon>
{generating ? '生成中...' : 'AI绘图'}
</button>
</div>
{form.imageUrl && (
<button
type="button"
className="admin-btn admin-btn-outline admin-btn-sm"
style={{ color: 'var(--color-error)', borderColor: 'var(--color-error)', display: 'inline-flex', alignItems: 'center', gap: 4 }}
onClick={() => setForm({ ...form, imageUrl: '' })}
>
<SVGIcon size={12}><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></SVGIcon>
清除图片
</button>
)}
</div>
{form.imageUrl && (
<div style={{ marginTop: 'var(--space-sm)', borderRadius: 'var(--radius-md)', overflow: 'hidden', border: '1px solid var(--color-border)', maxWidth: 320, height: 80 }}>
<img src={form.imageUrl} alt="预览" style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
</div>
)}
</div>
{/* Link App */}
<div className="admin-form-group">
<label className="admin-form-label">关联应用(点击跳转)</label>
<select
className="admin-form-input"
value={form.linkAppId || ''}
onChange={e => handleAppSelect(e.target.value)}
>
<option value="">-- 不关联应用 --</option>
{appOptions.map((app: any) => (
<option key={app.id} value={app.id}>
[{typeLabels[app.type] || 'app'}] {app.name}
</option>
))}
</select>
</div>
{/* Sort Order */}
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">排序</label>
<input
className="admin-form-input"
type="number"
value={form.sortOrder}
onChange={e => setForm({ ...form, sortOrder: Number(e.target.value) })}
min={1}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">状态</label>
<div style={{ display: 'flex', gap: 'var(--space-sm)', paddingTop: 4 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-xs)', cursor: 'pointer', fontSize: 'var(--text-sm)' }}>
<input
type="radio"
name="adStatus"
checked={form.status === 'active'}
onChange={() => setForm({ ...form, status: 'active' })}
/> 投放中
</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-xs)', cursor: 'pointer', fontSize: 'var(--text-sm)' }}>
<input
type="radio"
name="adStatus"
checked={form.status === 'inactive'}
onChange={() => setForm({ ...form, status: 'inactive' })}
/> 已暂停
</label>
</div>
</div>
</div>
<div className="admin-form-actions">
<button
className="admin-btn admin-btn-primary"
onClick={handleSave}
disabled={!form.title.trim()}
>
{isNew ? '创建' : '保存'}
</button>
<button
className="admin-btn admin-btn-outline"
onClick={() => { setShowModal(false); setEditingBanner(null); }}
>
取消
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default Ads;
+227
View File
@@ -0,0 +1,227 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { adminAPI } from '@/api';
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 20 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
const quickEntries = [
{ path: '/admin/models', label: '大模型库', desc: '管理AI模型与供应商', icon: <SVGIcon><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></SVGIcon> },
{ path: '/admin/settings', label: '网站设置', desc: '品牌、邮件、短信配置', icon: <SVGIcon><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></SVGIcon> },
{ path: '/admin/payment', label: '支付配置', desc: '微信、支付宝渠道配置', icon: <SVGIcon><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></SVGIcon> },
{ path: '/admin/services', label: '增值服务', desc: '服务管理与定制订单', icon: <SVGIcon><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></SVGIcon> },
{ path: '/admin/membership', label: '会员体系', desc: '订阅计划与自动化规则', icon: <SVGIcon><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></SVGIcon> },
{ path: '/admin/market', label: '应用市场', desc: '应用审核与优惠券管理', icon: <SVGIcon><rect x="3" y="3" width="7" height="7" rx="1.2"/><rect x="14" y="3" width="7" height="7" rx="1.2"/><rect x="3" y="14" width="7" height="7" rx="1.2"/><rect x="14" y="14" width="7" height="7" rx="1.2"/></SVGIcon> },
{ path: '/admin/logs', label: '日志风控', desc: '操作/调用/付费日志与风控', icon: <SVGIcon><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></SVGIcon> },
];
const Dashboard: React.FC = () => {
const navigate = useNavigate();
const [stats, setStats] = useState({ active_users: 0, enabled_models: 0, today_orders: 0, today_tokens: 0, total_tokens: 0 });
const [operationLogs, setOperationLogs] = useState<any[]>([]);
const [auditLogs, setAuditLogs] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const [statsRes, logsRes, auditRes] = await Promise.all([
adminAPI.getStats(),
adminAPI.getRecentLogs(),
adminAPI.getRecentAudit(),
]);
if (statsRes.data) setStats(statsRes.data);
if (logsRes.data) setOperationLogs(Array.isArray(logsRes.data) ? logsRes.data : []);
if (auditRes.data) setAuditLogs(Array.isArray(auditRes.data) ? auditRes.data : []);
} catch (err) {
console.error('Dashboard数据加载失败:', err);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
const formatDate = (d: string) => {
if (!d) return '-';
const dt = new Date(d);
if (isNaN(dt.getTime())) return d.slice(0, 19) || '-';
return dt.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
};
const formatTokens = (n: number): string => {
if (n >= 10000) return (n / 10000).toFixed(1) + '万';
return n.toLocaleString('en-US');
};
const statCards = [
{ label: '用户总数', value: stats.active_users.toLocaleString(), color: '#6C63FF', bg: '#EEEDFF', icon: <SVGIcon size={22}><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></SVGIcon> },
{ label: '活跃模型数', value: stats.enabled_models.toString(), color: '#219B5E', bg: '#E6F7EE', icon: <SVGIcon size={22}><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></SVGIcon> },
{ label: '今日订单数', value: stats.today_orders.toString(), color: '#E8922C', bg: '#FEF4E8', icon: <SVGIcon size={22}><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></SVGIcon> },
{ label: '今日Token消耗', value: formatTokens(stats.today_tokens), color: '#3B82F6', bg: '#EBF2FE', icon: <SVGIcon size={22}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon> },
];
if (loading) {
return (
<div style={{ animation: 'fadeIn var(--transition-normal)', padding: 'var(--space-2xl)', textAlign: 'center' }}>
<div className="audit-spinner" />
<span style={{ marginLeft: 'var(--space-md)', color: 'var(--color-text-tertiary)' }}>加载仪表盘数据...</span>
</div>
);
}
return (
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
<h1 className="admin-page-title">平台驾驶舱</h1>
{/* Stats Cards */}
<div className="admin-stats">
{statCards.map((s, i) => (
<div key={i} className="admin-stat-card" style={{ display: 'flex', alignItems: 'flex-start', gap: 'var(--space-lg)' }}>
<div style={{
width: 48, height: 48, borderRadius: 'var(--radius-lg)',
background: s.bg, color: s.color,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
{s.icon}
</div>
<div>
<div className="admin-stat-label">{s.label}</div>
<div className="admin-stat-value">{s.value}</div>
</div>
</div>
))}
</div>
{/* Platform Cumulative Token Stats */}
<div className="admin-card" style={{ marginBottom: 'var(--space-xl)' }}>
<div className="admin-card-header">
<h3 className="admin-card-title">
<SVGIcon size={18}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon>
{' '}平台累计Token消耗
</h3>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 'var(--space-xl)' }}>
{[
{ label: '今日消耗', value: formatTokens(stats.today_tokens), unit: 'Tokens', color: '#3B82F6' },
{ label: '累计总消耗', value: formatTokens(stats.total_tokens || 0), unit: 'Tokens', color: '#6C63FF' },
{ label: '日均消耗(近30天)', value: formatTokens(Math.round((stats.total_tokens || 0) / 30)), unit: 'Tokens', color: '#219B5E' },
{ label: '活跃调用模型', value: stats.enabled_models.toString(), unit: '个', color: '#E8922C' },
].map((item, i) => (
<div key={i} style={{ textAlign: 'center', padding: 'var(--space-lg)' }}>
<div style={{ fontSize: 'var(--text-2xl)', fontWeight: 800, color: item.color, fontFamily: 'var(--font-mono)' }}>
{item.value}
</div>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-secondary)', marginTop: 'var(--space-xs)' }}>
{item.label}
</div>
<div style={{ fontSize: '11px', color: 'var(--color-text-tertiary)' }}>{item.unit}</div>
</div>
))}
</div>
</div>
{/* Quick Entry Cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 'var(--space-lg)', marginBottom: 'var(--space-2xl)' }}>
{quickEntries.map((entry) => (
<div
key={entry.path}
onClick={() => navigate(entry.path)}
style={{
background: '#FFFFFF', borderRadius: 'var(--radius-xl)', border: '1px solid var(--color-border)',
padding: 'var(--space-xl)', cursor: 'pointer', transition: 'all var(--transition-fast)',
display: 'flex', flexDirection: 'column', alignItems: 'center', textAlign: 'center', gap: 'var(--space-sm)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = 'var(--color-accent)';
e.currentTarget.style.boxShadow = 'var(--shadow-md)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--color-border)';
e.currentTarget.style.boxShadow = 'none';
}}
>
<div style={{ color: 'var(--color-accent)', marginBottom: 'var(--space-xs)' }}>{entry.icon}</div>
<div style={{ fontSize: 'var(--text-sm)', fontWeight: 600, color: 'var(--color-text-primary)' }}>{entry.label}</div>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{entry.desc}</div>
</div>
))}
</div>
{/* Recent Operation Logs */}
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">最近操作日志</h3>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>用户</th>
<th>操作</th>
<th>目标</th>
<th>详情</th>
<th>IP</th>
<th>时间</th>
</tr>
</thead>
<tbody>
{operationLogs.length > 0 ? operationLogs.map(log => (
<tr key={log.id}>
<td style={{ fontWeight: 500 }}>{log.username}</td>
<td><span className="admin-badge badge-info">{log.action}</span></td>
<td>{log.target}</td>
<td style={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{log.detail}</td>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{log.ip}</td>
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{formatDate(log.created_at)}</td>
</tr>
)) : (
<tr><td colSpan={6} style={{ textAlign: 'center', color: 'var(--color-text-tertiary)', padding: 'var(--space-lg)' }}>暂无操作日志</td></tr>
)}
</tbody>
</table>
</div>
</div>
{/* Recent Audit Logs */}
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">最近审计日志</h3>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>操作人</th>
<th>操作类型</th>
<th>目标类型</th>
<th>详情</th>
<th>时间</th>
</tr>
</thead>
<tbody>
{auditLogs.length > 0 ? auditLogs.map(log => (
<tr key={log.id}>
<td style={{ fontWeight: 500 }}>{log.admin_name}</td>
<td><span className="admin-badge badge-warning">{log.action}</span></td>
<td><span className="admin-badge badge neutral">{log.target_type}</span></td>
<td>{log.detail}</td>
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{formatDate(log.created_at)}</td>
</tr>
)) : (
<tr><td colSpan={5} style={{ textAlign: 'center', color: 'var(--color-text-tertiary)', padding: 'var(--space-lg)' }}>暂无审计日志</td></tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
};
export default Dashboard;
+83
View File
@@ -0,0 +1,83 @@
/* ===== Logs Page Styles ===== */
.log-stats-overview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: var(--space-md);
margin-bottom: var(--space-2xl);
}
.log-stat-item {
background: #FFFFFF;
border-radius: var(--radius-lg);
border: 1px solid var(--color-border);
padding: var(--space-lg);
text-align: center;
}
.log-stat-value {
font-size: var(--text-xl);
font-weight: 700;
color: var(--color-text-primary);
margin-bottom: var(--space-xs);
}
.log-stat-label {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
}
/* Filter Bar */
.log-filter-bar {
background: #FFFFFF;
border-radius: var(--radius-lg);
border: 1px solid var(--color-border);
padding: var(--space-lg);
margin-bottom: var(--space-lg);
}
.log-filter-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-md);
align-items: flex-end;
}
.log-filter-item {
flex: 0 0 auto;
min-width: 150px;
}
.log-filter-item label {
display: block;
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-tertiary);
margin-bottom: 4px;
}
.log-filter-input {
height: 34px !important;
padding: 4px 10px !important;
font-size: var(--text-xs) !important;
}
/* Pagination */
.log-pagination {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: var(--space-lg);
margin-top: var(--space-lg);
border-top: 1px solid var(--color-border-light);
}
.log-pagination-info {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
}
.log-pagination-btns {
display: flex;
gap: var(--space-sm);
}
+693
View File
@@ -0,0 +1,693 @@
import React, { useState, useEffect, useCallback } from 'react';
import { adminAPI } from '@/api';
import './Logs.css';
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
type TabKey = 'operation' | 'token' | 'payment' | 'audit' | 'api_stats' | 'slow';
interface LogStats {
today: {
api_calls: number;
errors: number;
error_rate: number;
operation_logs: number;
audit_logs: number;
active_users: number;
total_tokens: number;
slow_requests: number;
};
total: {
users: number;
};
}
interface FilterState {
keyword: string;
startDate: string;
endDate: string;
userId: string;
username: string;
action: string;
status: string;
adminName: string;
}
const defaultFilters: FilterState = {
keyword: '',
startDate: '',
endDate: '',
userId: '',
username: '',
action: '',
status: '',
adminName: '',
};
const Logs: React.FC = () => {
const [activeTab, setActiveTab] = useState<TabKey>('operation');
const [filters, setFilters] = useState<FilterState>(defaultFilters);
const [showFilters, setShowFilters] = useState(false);
// Data states
const [data, setData] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(20);
const [loading, setLoading] = useState(false);
const [stats, setStats] = useState<LogStats | null>(null);
// Action types for filter dropdowns
const [actionTypes, setActionTypes] = useState<{
operation_actions: string[];
audit_actions: string[];
audit_target_types: string[];
}>({ operation_actions: [], audit_actions: [], audit_target_types: [] });
// Fetch action types on mount
useEffect(() => {
adminAPI.getActionTypes().then(res => {
if (res.data) setActionTypes(res.data);
}).catch(() => {});
}, []);
// Fetch stats
useEffect(() => {
adminAPI.getLogStats().then(res => {
if (res.data) setStats(res.data);
}).catch(() => {});
}, []);
// Fetch data when tab or filters change
const fetchData = useCallback(async () => {
setLoading(true);
try {
const params: any = { page, page_size: pageSize };
if (filters.keyword) params.keyword = filters.keyword;
if (filters.startDate) params.start_date = filters.startDate;
if (filters.endDate) params.end_date = filters.endDate;
if (filters.userId) params.user_id = filters.userId;
if (filters.username) params.username = filters.username;
if (filters.action) params.action = filters.action;
if (filters.status) params.status = filters.status;
if (filters.adminName) params.admin_name = filters.adminName;
let res: any;
switch (activeTab) {
case 'operation':
res = await adminAPI.getOperationLogs(params);
break;
case 'token':
res = await adminAPI.getTokenLogs(params);
break;
case 'payment':
res = await adminAPI.getPaymentLogs(params);
break;
case 'audit':
res = await adminAPI.getAuditLogs(params);
break;
case 'api_stats':
res = await adminAPI.getApiStats(params);
break;
case 'slow':
res = await adminAPI.getSlowRequests(params);
break;
}
if (res.data) {
setData(res.data.items || []);
setTotal(res.data.total || 0);
}
} catch (err) {
console.error('Failed to fetch data:', err);
} finally {
setLoading(false);
}
}, [activeTab, page, pageSize, filters]);
useEffect(() => {
if (activeTab === 'api_stats' || activeTab === 'slow') {
fetchData();
}
}, [activeTab, page, fetchData]);
useEffect(() => {
if (activeTab === 'operation' || activeTab === 'token' || activeTab === 'payment' || activeTab === 'audit') {
fetchData();
}
}, [activeTab, page, filters, fetchData]);
// Reset filters when tab changes
useEffect(() => {
setFilters(defaultFilters);
setPage(1);
setShowFilters(false);
}, [activeTab]);
// Export handler
const handleExport = async () => {
try {
const params: any = {};
if (filters.keyword) params.keyword = filters.keyword;
if (filters.startDate) params.start_date = filters.startDate;
if (filters.endDate) params.end_date = filters.endDate;
if (filters.userId) params.user_id = filters.userId;
if (filters.action) params.action = filters.action;
if (filters.adminName) params.admin_name = filters.adminName;
let blob: Blob;
let filename: string;
const ts = new Date().toISOString().slice(0, 19).replace(/:/g, '-');
switch (activeTab) {
case 'operation': {
const qs = new URLSearchParams(params).toString();
const resp = await fetch(`/app/api/admin/logs/export/operations?${qs}`, {
headers: { Authorization: `Bearer ${localStorage.getItem('aiflow_token')}` },
});
blob = await resp.blob();
filename = `operations_${ts}.csv`;
break;
}
case 'token': {
const qs = new URLSearchParams(params).toString();
const resp = await fetch(`/app/api/admin/logs/export/token-logs?${qs}`, {
headers: { Authorization: `Bearer ${localStorage.getItem('aiflow_token')}` },
});
blob = await resp.blob();
filename = `token_logs_${ts}.csv`;
break;
}
case 'audit': {
const qs = new URLSearchParams(params).toString();
const resp = await fetch(`/app/api/admin/logs/export/audit-logs?${qs}`, {
headers: { Authorization: `Bearer ${localStorage.getItem('aiflow_token')}` },
});
blob = await resp.blob();
filename = `audit_logs_${ts}.csv`;
break;
}
default:
alert('当前Tab不支持导出');
return;
}
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
} catch (err) {
console.error('Export failed:', err);
alert('导出失败');
}
};
const tabs: { key: TabKey; label: string }[] = [
{ key: 'operation', label: '操作日志' },
{ key: 'token', label: '调用日志' },
{ key: 'payment', label: '付费日志' },
{ key: 'audit', label: '审计日志' },
{ key: 'api_stats', label: 'API统计' },
{ key: 'slow', label: '慢请求' },
];
const totalPages = Math.ceil(total / pageSize);
// Render filter bar
const renderFilters = () => {
if (!showFilters) return null;
return (
<div className="log-filter-bar">
<div className="log-filter-row">
{(['operation', 'token', 'payment'] as TabKey[]).includes(activeTab) && (
<>
<div className="log-filter-item">
<label>关键词</label>
<input
type="text"
placeholder="搜索目标/详情/模型名称"
value={filters.keyword}
onChange={e => { setFilters(f => ({ ...f, keyword: e.target.value })); setPage(1); }}
className="admin-form-input log-filter-input"
/>
</div>
<div className="log-filter-item">
<label>用户ID</label>
<input
type="text"
placeholder="用户ID"
value={filters.userId}
onChange={e => { setFilters(f => ({ ...f, userId: e.target.value })); setPage(1); }}
className="admin-form-input log-filter-input"
/>
</div>
</>
)}
{activeTab === 'operation' && (
<div className="log-filter-item">
<label>操作类型</label>
<select
value={filters.action}
onChange={e => { setFilters(f => ({ ...f, action: e.target.value })); setPage(1); }}
className="admin-form-input log-filter-input"
>
<option value="">全部</option>
{actionTypes.operation_actions.map(a => (
<option key={a} value={a}>{a}</option>
))}
</select>
</div>
)}
{activeTab === 'audit' && (
<>
<div className="log-filter-item">
<label>管理员</label>
<input
type="text"
placeholder="管理员名称"
value={filters.adminName}
onChange={e => { setFilters(f => ({ ...f, adminName: e.target.value })); setPage(1); }}
className="admin-form-input log-filter-input"
/>
</div>
<div className="log-filter-item">
<label>操作类型</label>
<select
value={filters.action}
onChange={e => { setFilters(f => ({ ...f, action: e.target.value })); setPage(1); }}
className="admin-form-input log-filter-input"
>
<option value="">全部</option>
{actionTypes.audit_actions.map(a => (
<option key={a} value={a}>{a}</option>
))}
</select>
</div>
</>
)}
<div className="log-filter-item">
<label>开始日期</label>
<input
type="date"
value={filters.startDate}
onChange={e => { setFilters(f => ({ ...f, startDate: e.target.value })); setPage(1); }}
className="admin-form-input log-filter-input"
/>
</div>
<div className="log-filter-item">
<label>结束日期</label>
<input
type="date"
value={filters.endDate}
onChange={e => { setFilters(f => ({ ...f, endDate: e.target.value })); setPage(1); }}
className="admin-form-input log-filter-input"
/>
</div>
<div className="log-filter-item" style={{ alignSelf: 'flex-end' }}>
<button
className="admin-btn admin-btn-outline admin-btn-sm"
onClick={() => { setFilters(defaultFilters); setPage(1); }}
>
重置
</button>
</div>
</div>
</div>
);
};
// Render stats overview
const renderStats = () => {
if (!stats) return null;
const s = stats.today;
return (
<div className="log-stats-overview">
<div className="log-stat-item">
<div className="log-stat-value">{s.api_calls.toLocaleString()}</div>
<div className="log-stat-label">今日API调用</div>
</div>
<div className="log-stat-item">
<div className="log-stat-value" style={{ color: s.error_rate > 10 ? 'var(--color-error)' : 'var(--color-success)' }}>
{s.errors}
</div>
<div className="log-stat-label">今日错误 ({s.error_rate}%)</div>
</div>
<div className="log-stat-item">
<div className="log-stat-value">{s.active_users}</div>
<div className="log-stat-label">今日活跃用户</div>
</div>
<div className="log-stat-item">
<div className="log-stat-value">{s.total_tokens.toLocaleString()}</div>
<div className="log-stat-label">今日Token消耗</div>
</div>
<div className="log-stat-item">
<div className="log-stat-value">{s.operation_logs}</div>
<div className="log-stat-label">今日操作日志</div>
</div>
<div className="log-stat-item">
<div className="log-stat-value" style={{ color: s.slow_requests > 0 ? 'var(--color-warning)' : 'var(--color-success)' }}>
{s.slow_requests}
</div>
<div className="log-stat-label">慢请求数</div>
</div>
</div>
);
};
// Render pagination
const renderPagination = () => (
<div className="log-pagination">
<span className="log-pagination-info">
共 {total} 条记录,第 {page}/{totalPages} 页
</span>
<div className="log-pagination-btns">
<button
className="admin-btn admin-btn-outline admin-btn-sm"
disabled={page <= 1}
onClick={() => setPage(p => Math.max(1, p - 1))}
>
上一页
</button>
<button
className="admin-btn admin-btn-outline admin-btn-sm"
disabled={page >= totalPages}
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
>
下一页
</button>
</div>
</div>
);
// Render operation log table
const renderOperationTable = () => (
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>用户</th>
<th>操作</th>
<th>目标</th>
<th>详情</th>
<th>IP</th>
<th>时间</th>
</tr>
</thead>
<tbody>
{data.map((log: any) => (
<tr key={log.id}>
<td style={{ fontWeight: 500 }}>{log.username}</td>
<td><span className="admin-badge badge-info">{log.action}</span></td>
<td>{log.target}</td>
<td style={{ maxWidth: 240, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{log.detail}</td>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{log.ip}</td>
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{log.created_at}</td>
</tr>
))}
{data.length === 0 && (
<tr><td colSpan={6} style={{ textAlign: 'center', color: 'var(--color-text-disabled)' }}>暂无数据</td></tr>
)}
</tbody>
</table>
</div>
);
// Render token log table
const renderTokenTable = () => (
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>用户</th>
<th>模型</th>
<th>应用</th>
<th>输入Token</th>
<th>输出Token</th>
<th>总Token</th>
<th>费用</th>
<th>时间</th>
</tr>
</thead>
<tbody>
{data.map((log: any) => (
<tr key={log.id}>
<td style={{ fontWeight: 500 }}>{log.user_id}</td>
<td>{log.model_name}</td>
<td>{log.app_name ? <span className="admin-badge badge-neutral">{log.app_name}</span> : '-'}</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>{log.prompt_tokens.toLocaleString()}</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>{log.completion_tokens.toLocaleString()}</td>
<td style={{ fontFamily: 'var(--font-mono)', fontWeight: 600 }}>{log.total_tokens.toLocaleString()}</td>
<td style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-error)' }}>{Number(log.cost).toFixed(4)} 元</td>
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{log.created_at}</td>
</tr>
))}
{data.length === 0 && (
<tr><td colSpan={8} style={{ textAlign: 'center', color: 'var(--color-text-disabled)' }}>暂无数据</td></tr>
)}
</tbody>
</table>
</div>
);
// Render payment table
const renderPaymentTable = () => (
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>订单号</th>
<th>用户</th>
<th>类型</th>
<th>项目</th>
<th>金额</th>
<th>状态</th>
<th>时间</th>
</tr>
</thead>
<tbody>
{data.map((o: any) => (
<tr key={o.id}>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)' }}>{o.id}</td>
<td style={{ fontWeight: 500 }}>{o.user_id}</td>
<td>
<span className={`admin-badge ${o.order_type === 'membership' ? 'badge-info' : o.order_type === 'app' ? 'badge-success' : 'badge-neutral'}`}>
{o.order_type === 'membership' ? '会员' : o.order_type === 'app' ? '应用' : '服务'}
</span>
</td>
<td>{o.item_name}</td>
<td style={{ fontFamily: 'var(--font-mono)', fontWeight: 600 }}>{Number(o.amount).toFixed(2)} 元</td>
<td>
<span className={`admin-badge ${o.status === 'paid' ? 'badge-success' : o.status === 'pending' ? 'badge-warning' : 'badge-error'}`}>
{o.status === 'paid' ? '已支付' : o.status === 'pending' ? '待支付' : o.status === 'refunded' ? '已退款' : o.status}
</span>
</td>
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{o.created_at}</td>
</tr>
))}
{data.length === 0 && (
<tr><td colSpan={7} style={{ textAlign: 'center', color: 'var(--color-text-disabled)' }}>暂无数据</td></tr>
)}
</tbody>
</table>
</div>
);
// Render audit table
const renderAuditTable = () => (
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>管理员</th>
<th>操作</th>
<th>目标类型</th>
<th>详情</th>
<th>时间</th>
</tr>
</thead>
<tbody>
{data.map((log: any) => (
<tr key={log.id}>
<td style={{ fontWeight: 500 }}>{log.admin_name}</td>
<td><span className="admin-badge badge-warning">{log.action}</span></td>
<td><span className="admin-badge badge-neutral">{log.target_type}</span></td>
<td style={{ maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{log.detail}</td>
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{log.created_at}</td>
</tr>
))}
{data.length === 0 && (
<tr><td colSpan={5} style={{ textAlign: 'center', color: 'var(--color-text-disabled)' }}>暂无数据</td></tr>
)}
</tbody>
</table>
</div>
);
// Render API stats table
const renderApiStatsTable = () => (
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>端点</th>
<th>方法</th>
<th>调用次数</th>
<th>平均响应时间</th>
<th>错误数</th>
<th>错误率</th>
<th>最近调用</th>
</tr>
</thead>
<tbody>
{data.map((s: any) => (
<tr key={s.id}>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)' }}>{s.endpoint}</td>
<td><span className="admin-badge badge-info">{s.method}</span></td>
<td style={{ fontWeight: 600 }}>{s.call_count.toLocaleString()}</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>
<span style={{ color: s.avg_response_time > 3 ? 'var(--color-error)' : s.avg_response_time > 1 ? 'var(--color-warning)' : 'var(--color-success)' }}>
{(s.avg_response_time * 1000).toFixed(0)}ms
</span>
</td>
<td style={{ color: s.error_count > 0 ? 'var(--color-error)' : 'inherit' }}>{s.error_count}</td>
<td>
<span className={`admin-badge ${s.error_rate > 10 ? 'badge-error' : s.error_rate > 5 ? 'badge-warning' : 'badge-success'}`}>
{s.error_rate}%
</span>
</td>
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{s.last_called_at || '-'}</td>
</tr>
))}
{data.length === 0 && (
<tr><td colSpan={7} style={{ textAlign: 'center', color: 'var(--color-text-disabled)' }}>暂无API调用统计数据</td></tr>
)}
</tbody>
</table>
</div>
);
// Render slow request table
const renderSlowTable = () => (
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>方法</th>
<th>端点</th>
<th>状态码</th>
<th>响应时间</th>
<th>阈值</th>
<th>时间</th>
</tr>
</thead>
<tbody>
{data.map((log: any) => (
<tr key={log.id}>
<td><span className="admin-badge badge-info">{log.method}</span></td>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)', maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{log.endpoint}</td>
<td>
<span className={`admin-badge ${log.status_code >= 400 ? 'badge-error' : 'badge-success'}`}>
{log.status_code}
</span>
</td>
<td style={{ fontFamily: 'var(--font-mono)', fontWeight: 600, color: 'var(--color-error)' }}>
{(log.response_time * 1000).toFixed(0)}ms
</td>
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>
{(log.threshold * 1000).toFixed(0)}ms
</td>
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{log.created_at}</td>
</tr>
))}
{data.length === 0 && (
<tr><td colSpan={6} style={{ textAlign: 'center', color: 'var(--color-text-disabled)' }}>暂无慢请求记录</td></tr>
)}
</tbody>
</table>
</div>
);
return (
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-2xl)' }}>
<h1 className="admin-page-title" style={{ marginBottom: 0 }}>日志与风控管理</h1>
<div style={{ display: 'flex', gap: 'var(--space-sm)' }}>
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={() => setShowFilters(!showFilters)}>
<SVGIcon><line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/></SVGIcon>
筛选
</button>
{['operation', 'token', 'audit'].includes(activeTab) && (
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={handleExport}>
<SVGIcon><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></SVGIcon>
导出
</button>
)}
</div>
</div>
{/* Stats Overview */}
{renderStats()}
{/* Tabs */}
<div style={{ display: 'flex', gap: 'var(--space-sm)', marginBottom: 'var(--space-lg)', borderBottom: '1px solid var(--color-border)', paddingBottom: 'var(--space-lg)' }}>
{tabs.map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
style={{
padding: 'var(--space-sm) var(--space-lg)',
borderRadius: 'var(--radius-md)',
fontSize: 'var(--text-sm)', fontWeight: 600,
background: activeTab === tab.key ? 'var(--color-accent-light)' : 'transparent',
color: activeTab === tab.key ? 'var(--color-accent)' : 'var(--color-text-secondary)',
transition: 'all var(--transition-fast)',
}}
>
{tab.label}
</button>
))}
</div>
{/* Filters */}
{renderFilters()}
{/* Data Table */}
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">
{tabs.find(t => t.key === activeTab)?.label}
{total > 0 && <span style={{ fontWeight: 400, fontSize: 'var(--text-sm)', color: 'var(--color-text-tertiary)', marginLeft: 'var(--space-sm)' }}>({total} 条记录)</span>}
</h3>
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={fetchData}>
<SVGIcon size={14}><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></SVGIcon>
刷新
</button>
</div>
{loading ? (
<div style={{ textAlign: 'center', padding: 'var(--space-2xl)', color: 'var(--color-text-disabled)' }}>
加载中...
</div>
) : (
<>
{activeTab === 'operation' && renderOperationTable()}
{activeTab === 'token' && renderTokenTable()}
{activeTab === 'payment' && renderPaymentTable()}
{activeTab === 'audit' && renderAuditTable()}
{activeTab === 'api_stats' && renderApiStatsTable()}
{activeTab === 'slow' && renderSlowTable()}
{renderPagination()}
</>
)}
</div>
</div>
);
};
export default Logs;
+342
View File
@@ -0,0 +1,342 @@
import React, { useState } from 'react';
import { mockApps, mockCoupons } from '@/mock/data';
import type { MarketApp, Coupon } from '@/types';
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
const Market: React.FC = () => {
const [apps, setApps] = useState<MarketApp[]>(mockApps);
const [coupons, setCoupons] = useState<Coupon[]>(mockCoupons);
const [activeTab, setActiveTab] = useState<'review' | 'coupon' | 'official'>('review');
const [showCouponModal, setShowCouponModal] = useState(false);
const [couponForm, setCouponForm] = useState({
code: '', type: 'fixed' as 'fixed' | 'percent', value: 0, minAmount: 0,
validFrom: '', validTo: '',
});
const pendingApps = apps.filter(a => a.status === 'pending');
const publishedOfficialApps = apps.filter(a => a.source === 'official' && a.status !== 'removed');
const handleApprove = (id: string) => {
setApps(prev => prev.map(a => a.id === id ? { ...a, status: 'published' as const } : a));
};
const handleReject = (id: string) => {
setApps(prev => prev.map(a => a.id === id ? { ...a, status: 'removed' as const } : a));
};
const handleToggleOfficial = (id: string) => {
setApps(prev => prev.map(a => {
if (a.id === id) {
const newStatus: MarketApp['status'] = a.status === 'published' ? 'removed' : 'published';
return { ...a, status: newStatus };
}
return a;
}));
};
const handleCreateCoupon = () => {
if (!couponForm.code.trim() || !couponForm.validFrom || !couponForm.validTo) return;
setCoupons(prev => [...prev, {
id: 'cp' + Date.now(),
...couponForm,
status: 'active',
}]);
setShowCouponModal(false);
setCouponForm({ code: '', type: 'fixed', value: 0, minAmount: 0, validFrom: '', validTo: '' });
};
const handleDeleteCoupon = (id: string) => {
setCoupons(prev => prev.filter(c => c.id !== id));
};
const formatDate = (d: string) => {
const dt = new Date(d);
return dt.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
};
const tabs = [
{ key: 'review' as const, label: '待审核应用', count: pendingApps.length },
{ key: 'coupon' as const, label: '优惠券管理', count: coupons.length },
{ key: 'official' as const, label: '官方应用管理', count: publishedOfficialApps.length },
];
return (
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
<h1 className="admin-page-title">应用市场审核管理</h1>
{/* Tabs */}
<div style={{ display: 'flex', gap: 'var(--space-sm)', marginBottom: 'var(--space-2xl)', borderBottom: '1px solid var(--color-border)', paddingBottom: 'var(--space-lg)' }}>
{tabs.map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
style={{
padding: 'var(--space-sm) var(--space-lg)',
borderRadius: 'var(--radius-md)',
fontSize: 'var(--text-sm)', fontWeight: 600,
background: activeTab === tab.key ? 'var(--color-accent-light)' : 'transparent',
color: activeTab === tab.key ? 'var(--color-accent)' : 'var(--color-text-secondary)',
transition: 'all var(--transition-fast)',
display: 'flex', alignItems: 'center', gap: 'var(--space-sm)',
}}
>
{tab.label}
{tab.count > 0 && (
<span style={{
background: activeTab === tab.key ? 'var(--color-accent)' : 'var(--color-text-disabled)',
color: '#FFFFFF', padding: '0 6px', borderRadius: 'var(--radius-full)',
fontSize: 'var(--text-xs)', lineHeight: '18px', minWidth: 18, textAlign: 'center',
}}>
{tab.count}
</span>
)}
</button>
))}
</div>
{/* Pending Review Tab */}
{activeTab === 'review' && (
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">待审核应用</h3>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>应用名</th>
<th>类型</th>
<th>发布者</th>
<th>价格</th>
<th>状态</th>
<th>发布时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{pendingApps.map(app => (
<tr key={app.id}>
<td style={{ fontWeight: 600 }}>{app.name}</td>
<td>
<span className={`admin-badge ${app.type === 'agent' ? 'badge-info' : app.type === 'skill' ? 'badge-success' : 'badge-warning'}`}>
{app.type.toUpperCase()}
</span>
</td>
<td>{app.publisherName}</td>
<td style={{ fontFamily: 'var(--font-mono)', fontWeight: 600 }}>{app.price} 元</td>
<td><span className="admin-badge badge-warning">待审核</span></td>
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{formatDate(app.createdAt)}</td>
<td>
<div style={{ display: 'flex', gap: 'var(--space-sm)' }}>
<button className="admin-btn admin-btn-primary admin-btn-sm" onClick={() => handleApprove(app.id)}>
<SVGIcon size={12}><polyline points="20 6 9 17 4 12"/></SVGIcon>
通过
</button>
<button className="admin-btn admin-btn-danger admin-btn-sm" onClick={() => handleReject(app.id)}>
<SVGIcon size={12}><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></SVGIcon>
拒绝
</button>
</div>
</td>
</tr>
))}
{pendingApps.length === 0 && (
<tr>
<td colSpan={7} style={{ textAlign: 'center', color: 'var(--color-text-disabled)', padding: 'var(--space-3xl)' }}>
暂无待审核应用
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)}
{/* Coupon Management Tab */}
{activeTab === 'coupon' && (
<>
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">优惠券列表</h3>
<button className="admin-btn admin-btn-primary admin-btn-sm" onClick={() => setShowCouponModal(true)}>
<SVGIcon><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></SVGIcon>
创建优惠券
</button>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>兑换码</th>
<th>类型</th>
<th>面值</th>
<th>最低消费</th>
<th>有效期</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{coupons.map(c => (
<tr key={c.id}>
<td style={{ fontFamily: 'var(--font-mono)', fontWeight: 600 }}>{c.code}</td>
<td>
<span className={`admin-badge ${c.type === 'fixed' ? 'badge-info' : 'badge-warning'}`}>
{c.type === 'fixed' ? '固定金额' : '百分比'}
</span>
</td>
<td style={{ fontFamily: 'var(--font-mono)', fontWeight: 600 }}>
{c.type === 'fixed' ? `${c.value} 元` : `${c.value}%`}
</td>
<td>{c.minAmount > 0 ? `${c.minAmount} 元` : '无门槛'}</td>
<td style={{ fontSize: 'var(--text-xs)' }}>{c.validFrom} 至 {c.validTo}</td>
<td>
<span className={`admin-badge ${c.status === 'active' ? 'badge-success' : 'badge-error'}`}>
{c.status === 'active' ? '有效' : '已过期'}
</span>
</td>
<td>
<button className="admin-btn admin-btn-danger admin-btn-sm" onClick={() => handleDeleteCoupon(c.id)}>
删除
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
)}
{/* Official App Management Tab */}
{activeTab === 'official' && (
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">官方应用列表</h3>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>应用名</th>
<th>类型</th>
<th>价格</th>
<th>会员免费</th>
<th>安装量</th>
<th>评分</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{publishedOfficialApps.map(app => (
<tr key={app.id}>
<td style={{ fontWeight: 600 }}>{app.name}</td>
<td>
<span className={`admin-badge ${app.type === 'agent' ? 'badge-info' : app.type === 'skill' ? 'badge-success' : 'badge-warning'}`}>
{app.type.toUpperCase()}
</span>
</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>{app.price} 元</td>
<td>
<span className={`admin-badge ${app.isMemberFree ? 'badge-success' : 'badge-neutral'}`}>
{app.isMemberFree ? '是' : '否'}
</span>
</td>
<td>{app.installCount.toLocaleString()}</td>
<td>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<SVGIcon size={12}><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></SVGIcon>
{app.ratingAvg}
</div>
</td>
<td>
<span className={`admin-badge ${app.status === 'published' ? 'badge-success' : 'badge-neutral'}`}>
{app.status === 'published' ? '已上架' : '已下架'}
</span>
</td>
<td>
<button
className={`admin-btn ${app.status === 'published' ? 'admin-btn-danger' : 'admin-btn-outline'} admin-btn-sm`}
onClick={() => handleToggleOfficial(app.id)}
>
{app.status === 'published' ? '下架' : '上架'}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Create Coupon Modal */}
{showCouponModal && (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.4)', zIndex: 'var(--z-modal)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
animation: 'fadeIn var(--transition-normal)',
}} onClick={() => setShowCouponModal(false)}>
<div style={{
background: '#FFFFFF', borderRadius: 'var(--radius-2xl)', width: 460, maxHeight: '90vh', overflow: 'auto',
padding: 'var(--space-3xl)', boxShadow: 'var(--shadow-xl)',
}} onClick={e => e.stopPropagation()}>
<h3 style={{ fontSize: 'var(--text-lg)', fontWeight: 700, marginBottom: 'var(--space-2xl)' }}>创建优惠券</h3>
<div className="admin-form-group">
<label className="admin-form-label">兑换码</label>
<input className="admin-form-input" value={couponForm.code} onChange={e => setCouponForm({ ...couponForm, code: e.target.value })} placeholder="例如: NEW2026" />
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">类型</label>
<select className="admin-form-input" value={couponForm.type} onChange={e => setCouponForm({ ...couponForm, type: e.target.value as 'fixed' | 'percent' })}>
<option value="fixed">固定金额</option>
<option value="percent">百分比折扣</option>
</select>
</div>
<div className="admin-form-group">
<label className="admin-form-label">面值</label>
<input className="admin-form-input" type="number" value={couponForm.value} onChange={e => setCouponForm({ ...couponForm, value: Number(e.target.value) })} />
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">最低消费(元)</label>
<input className="admin-form-input" type="number" value={couponForm.minAmount} onChange={e => setCouponForm({ ...couponForm, minAmount: Number(e.target.value) })} />
</div>
<div className="admin-form-group">
<label className="admin-form-label">开始日期</label>
<input className="admin-form-input" type="date" value={couponForm.validFrom} onChange={e => setCouponForm({ ...couponForm, validFrom: e.target.value })} />
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">结束日期</label>
<input className="admin-form-input" type="date" value={couponForm.validTo} onChange={e => setCouponForm({ ...couponForm, validTo: e.target.value })} />
</div>
<div className="admin-form-actions">
<button className="admin-btn admin-btn-primary" onClick={handleCreateCoupon}>创建</button>
<button className="admin-btn admin-btn-outline" onClick={() => setShowCouponModal(false)}>取消</button>
</div>
</div>
</div>
)}
</div>
);
};
export default Market;
+483
View File
@@ -0,0 +1,483 @@
import React, { useState } from 'react';
import { mockMembershipPlans, mockApps, mockModels } from '@/mock/data';
import type { MembershipPlan } from '@/types';
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
const levelColors = [
{ bg: 'var(--color-bg-tertiary)', accent: 'var(--color-text-secondary)', label: '免费版' },
{ bg: 'var(--color-accent-light)', accent: 'var(--color-accent)', label: '专业版' },
{ bg: '#FFF7E6', accent: '#E8922C', label: '企业版' },
];
const typeLabels: Record<string, string> = { skill: 'Skill', agent: 'Agent', mcp: 'MCP' };
const typeBadgeColors: Record<string, string> = { skill: 'badge-success', agent: 'badge-info', mcp: 'badge-warning' };
// 所有收费应用 (price > 0, published)
const allPaidApps = mockApps.filter(a => a.price > 0 && a.status === 'published');
// 所有收费模型
const allPaidModels = mockModels.filter(m => m.status === 'paid');
const Membership: React.FC = () => {
const [plans, setPlans] = useState<MembershipPlan[]>(mockMembershipPlans);
const [editingPlan, setEditingPlan] = useState<MembershipPlan | null>(null);
const [showModal, setShowModal] = useState(false);
const [autoDowngrade, setAutoDowngrade] = useState(true);
const [autoRenew, setAutoRenew] = useState(true);
const [form, setForm] = useState({
name: '', level: 0, durationType: 'month' as 'month' | 'quarter' | 'year',
price: 0, dailyFreeTokens: 0, features: [] as string[],
freeAppIds: [] as string[], freeModelIds: [] as string[],
});
const [newFeature, setNewFeature] = useState('');
const handleEdit = (plan: MembershipPlan) => {
setEditingPlan(plan);
setForm({
name: plan.name, level: plan.level, durationType: plan.durationType,
price: plan.price, dailyFreeTokens: plan.dailyFreeTokens, features: [...plan.features],
freeAppIds: [...(plan.freeAppIds || [])], freeModelIds: [...(plan.freeModelIds || [])],
});
setShowModal(true);
};
const handleSave = () => {
if (editingPlan) {
setPlans(prev => prev.map(p => p.id === editingPlan.id ? { ...p, ...form } : p));
}
setShowModal(false);
setEditingPlan(null);
};
const addFeature = () => {
if (newFeature.trim()) {
setForm({ ...form, features: [...form.features, newFeature.trim()] });
setNewFeature('');
}
};
const removeFeature = (idx: number) => {
setForm({ ...form, features: form.features.filter((_, i) => i !== idx) });
};
const toggleAppId = (appId: string) => {
setForm(prev => ({
...prev,
freeAppIds: prev.freeAppIds.includes(appId)
? prev.freeAppIds.filter(id => id !== appId)
: [...prev.freeAppIds, appId],
}));
};
const toggleModelId = (modelId: string) => {
setForm(prev => ({
...prev,
freeModelIds: prev.freeModelIds.includes(modelId)
? prev.freeModelIds.filter(id => id !== modelId)
: [...prev.freeModelIds, modelId],
}));
};
// 根据 freeAppIds 获取应用名称列表(用于卡片展示)
const getFreeAppNames = (plan: MembershipPlan): string[] => {
return (plan.freeAppIds || []).map(id => allPaidApps.find(a => a.id === id)?.name).filter(Boolean) as string[];
};
const getFreeModelNames = (plan: MembershipPlan): string[] => {
return (plan.freeModelIds || []).map(id => allPaidModels.find(m => m.id === id)?.name).filter(Boolean) as string[];
};
return (
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
<h1 className="admin-page-title">会员订阅体系管理</h1>
{/* Plan Cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(340px, 1fr))', gap: 'var(--space-xl)', marginBottom: 'var(--space-2xl)' }}>
{plans.map((plan, idx) => {
const colors = levelColors[idx] || levelColors[0];
const freeAppNames = getFreeAppNames(plan);
const freeModelNames = getFreeModelNames(plan);
return (
<div key={plan.id} className="admin-card" style={{ marginBottom: 0, borderTop: `3px solid ${colors.accent}` }}>
<div className="admin-card-header">
<div>
<span className="admin-badge" style={{ background: colors.bg, color: colors.accent, marginBottom: 'var(--space-sm)' }}>
{colors.label}
</span>
<h3 style={{ fontSize: 'var(--text-lg)', fontWeight: 700, marginTop: 'var(--space-sm)' }}>{plan.name}</h3>
</div>
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={() => handleEdit(plan)}>
<SVGIcon size={12}><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></SVGIcon>
编辑
</button>
</div>
{/* Pricing */}
<div style={{ display: 'flex', gap: 'var(--space-lg)', marginBottom: 'var(--space-xl)' }}>
<div style={{ flex: 1, textAlign: 'center', padding: 'var(--space-md)', background: 'var(--color-bg-tertiary)', borderRadius: 'var(--radius-md)' }}>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>月付</div>
<div style={{ fontSize: 'var(--text-lg)', fontWeight: 700, color: colors.accent }}>{plan.price} 元</div>
</div>
<div style={{ flex: 1, textAlign: 'center', padding: 'var(--space-md)', background: 'var(--color-bg-tertiary)', borderRadius: 'var(--radius-md)' }}>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>季付</div>
<div style={{ fontSize: 'var(--text-lg)', fontWeight: 700, color: colors.accent }}>{Math.round(plan.price * 3 * 0.85)} 元</div>
</div>
<div style={{ flex: 1, textAlign: 'center', padding: 'var(--space-md)', background: 'var(--color-bg-tertiary)', borderRadius: 'var(--radius-md)' }}>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>年付</div>
<div style={{ fontSize: 'var(--text-lg)', fontWeight: 700, color: colors.accent }}>{Math.round(plan.price * 12 * 0.7)} 元</div>
</div>
</div>
{/* Features */}
<div>
<div style={{ fontSize: 'var(--text-sm)', fontWeight: 600, marginBottom: 'var(--space-md)' }}>会员权益</div>
<ul style={{ listStyle: 'none', padding: 0, display: 'flex', flexDirection: 'column', gap: 'var(--space-sm)' }}>
{plan.features.map((f, i) => (
<li key={i} style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)', fontSize: 'var(--text-sm)', color: 'var(--color-text-secondary)' }}>
<SVGIcon size={14}><polyline points="20 6 9 17 4 12"/></SVGIcon>
{f}
</li>
))}
</ul>
</div>
{/* Free tokens */}
<div style={{ marginTop: 'var(--space-lg)', padding: 'var(--space-md)', background: 'var(--color-accent-subtle)', borderRadius: 'var(--radius-md)', fontSize: 'var(--text-sm)', color: 'var(--color-accent)' }}>
每日免费额度: {plan.dailyFreeTokens.toLocaleString()} Token
</div>
{/* Free apps & models summary */}
{plan.level > 0 && (
<div style={{ marginTop: 'var(--space-md)', display: 'flex', flexDirection: 'column', gap: 'var(--space-sm)' }}>
{freeAppNames.length > 0 && (
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)', display: 'flex', alignItems: 'center', gap: 'var(--space-xs)' }}>
<SVGIcon size={12}><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></SVGIcon>
免费应用({freeAppNames.length}): {freeAppNames.join(', ')}
</div>
)}
{freeModelNames.length > 0 && (
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)', display: 'flex', alignItems: 'center', gap: 'var(--space-xs)' }}>
<SVGIcon size={12}><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></SVGIcon>
免费模型({freeModelNames.length}): {freeModelNames.join(', ')}
</div>
)}
</div>
)}
</div>
);
})}
</div>
{/* Automation Rules */}
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">自动化规则配置</h3>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: 'var(--space-xl)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: 'var(--space-xl)', background: 'var(--color-bg-tertiary)', borderRadius: 'var(--radius-lg)' }}>
<div>
<div style={{ fontSize: 'var(--text-sm)', fontWeight: 600, marginBottom: 'var(--space-xs)' }}>到期自动降级</div>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>会员到期后自动降级为免费版</div>
</div>
<button
onClick={() => setAutoDowngrade(!autoDowngrade)}
style={{
width: 48, height: 26, borderRadius: 13, border: 'none',
background: autoDowngrade ? 'var(--color-accent)' : 'var(--color-text-disabled)',
cursor: 'pointer', position: 'relative', transition: 'background var(--transition-fast)',
}}
>
<span style={{
position: 'absolute', top: 3, left: autoDowngrade ? 24 : 3,
width: 20, height: 20, borderRadius: '50%', background: '#FFFFFF',
transition: 'left var(--transition-fast)', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}} />
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: 'var(--space-xl)', background: 'var(--color-bg-tertiary)', borderRadius: 'var(--radius-lg)' }}>
<div>
<div style={{ fontSize: 'var(--text-sm)', fontWeight: 600, marginBottom: 'var(--space-xs)' }}>自动续费默认开启</div>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>新会员订阅默认开启自动续费</div>
</div>
<button
onClick={() => setAutoRenew(!autoRenew)}
style={{
width: 48, height: 26, borderRadius: 13, border: 'none',
background: autoRenew ? 'var(--color-accent)' : 'var(--color-text-disabled)',
cursor: 'pointer', position: 'relative', transition: 'background var(--transition-fast)',
}}
>
<span style={{
position: 'absolute', top: 3, left: autoRenew ? 24 : 3,
width: 20, height: 20, borderRadius: '50%', background: '#FFFFFF',
transition: 'left var(--transition-fast)', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}} />
</button>
</div>
</div>
</div>
{/* Global Free Application List */}
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">当前各等级会员免费应用总览</h3>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>应用名称</th>
<th>类型</th>
<th>来源</th>
<th>价格</th>
<th>免费等级</th>
</tr>
</thead>
<tbody>
{allPaidApps.map(app => {
const freePlans = plans.filter(p => (p.freeAppIds || []).includes(app.id));
return (
<tr key={app.id}>
<td style={{ fontWeight: 600 }}>{app.name}</td>
<td>
<span className={`admin-badge ${typeBadgeColors[app.type] || 'badge-neutral'}`}>
{typeLabels[app.type] || app.type.toUpperCase()}
</span>
</td>
<td><span className="admin-badge badge-neutral">{app.source === 'official' ? '官方' : '用户'}</span></td>
<td style={{ fontFamily: 'var(--font-mono)' }}>{app.price} 元</td>
<td>
{freePlans.length > 0
? freePlans.map(p => (
<span key={p.id} className="admin-badge" style={{ background: 'var(--color-accent-light)', color: 'var(--color-accent)', marginRight: 4, marginBottom: 2 }}>
{p.name}
</span>
))
: <span style={{ color: 'var(--color-text-tertiary)', fontSize: 'var(--text-xs)' }}>无</span>
}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* Global Free Model List */}
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">当前各等级会员免费大模型总览</h3>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>模型名称</th>
<th>提供商</th>
<th>每百万Token价格</th>
<th>免费等级</th>
</tr>
</thead>
<tbody>
{allPaidModels.map(model => {
const freePlans = plans.filter(p => (p.freeModelIds || []).includes(model.id));
return (
<tr key={model.id}>
<td style={{ fontWeight: 600 }}>{model.name}</td>
<td>{model.providerName}</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>{model.pricePerMillionTokens} 元</td>
<td>
{freePlans.length > 0
? freePlans.map(p => (
<span key={p.id} className="admin-badge" style={{ background: 'var(--color-accent-light)', color: 'var(--color-accent)', marginRight: 4, marginBottom: 2 }}>
{p.name}
</span>
))
: <span style={{ color: 'var(--color-text-tertiary)', fontSize: 'var(--text-xs)' }}>无</span>
}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* Edit Modal */}
{showModal && (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.4)', zIndex: 'var(--z-modal)',
display: 'flex', alignItems: 'flex-start', justifyContent: 'center',
paddingTop: '6vh',
animation: 'fadeIn var(--transition-normal)',
}} onClick={() => { setShowModal(false); setEditingPlan(null); }}>
<div style={{
background: '#FFFFFF', borderRadius: 'var(--radius-2xl)', width: 640, maxHeight: '84vh', overflow: 'auto',
padding: 'var(--space-3xl)', boxShadow: 'var(--shadow-xl)',
}} onClick={e => e.stopPropagation()}>
<h3 style={{ fontSize: 'var(--text-lg)', fontWeight: 700, marginBottom: 'var(--space-2xl)' }}>
编辑 {editingPlan?.name}
</h3>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">月价格(元)</label>
<input className="admin-form-input" type="number" value={form.price} onChange={e => setForm({ ...form, price: Number(e.target.value) })} />
</div>
<div className="admin-form-group">
<label className="admin-form-label">每日免费Token</label>
<input className="admin-form-input" type="number" value={form.dailyFreeTokens} onChange={e => setForm({ ...form, dailyFreeTokens: Number(e.target.value) })} />
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">会员权益</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-sm)', marginBottom: 'var(--space-md)' }}>
{form.features.map((f, i) => (
<span key={i} style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 10px', borderRadius: 'var(--radius-full)',
background: 'var(--color-accent-light)', color: 'var(--color-accent)',
fontSize: 'var(--text-xs)', fontWeight: 500,
}}>
{f}
<button onClick={() => removeFeature(i)} style={{ cursor: 'pointer', color: 'inherit', padding: 0, lineHeight: 1 }}>
<SVGIcon size={12}><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></SVGIcon>
</button>
</span>
))}
</div>
<div style={{ display: 'flex', gap: 'var(--space-sm)' }}>
<input
className="admin-form-input"
value={newFeature}
onChange={e => setNewFeature(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addFeature(); } }}
placeholder="输入权益名称,回车添加"
style={{ flex: 1 }}
/>
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={addFeature}>添加</button>
</div>
</div>
{/* Free Applications Selection */}
<div style={{ marginTop: 'var(--space-2xl)' }}>
<label className="admin-form-label" style={{ marginBottom: 'var(--space-md)', display: 'block' }}>
收费应用免费权益
<span style={{ fontWeight: 400, color: 'var(--color-text-tertiary)', marginLeft: 'var(--space-sm)', fontSize: 'var(--text-xs)' }}>
勾选后会员可免费使用
</span>
</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, maxHeight: 200, overflowY: 'auto', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-md)', padding: 'var(--space-sm)' }}>
{allPaidApps.length === 0 && (
<div style={{ padding: 'var(--space-md)', color: 'var(--color-text-tertiary)', fontSize: 'var(--text-sm)', textAlign: 'center' }}>暂无收费应用</div>
)}
{allPaidApps.map(app => {
const checked = form.freeAppIds.includes(app.id);
return (
<label
key={app.id}
style={{
display: 'flex', alignItems: 'center', gap: 'var(--space-sm)',
padding: 'var(--space-sm) var(--space-md)',
borderRadius: 'var(--radius-sm)', cursor: 'pointer',
background: checked ? 'var(--color-accent-subtle)' : 'transparent',
transition: 'background var(--transition-fast)',
userSelect: 'none',
}}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleAppId(app.id)}
style={{
width: 16, height: 16, accentColor: 'var(--color-accent)',
cursor: 'pointer', flexShrink: 0,
}}
/>
<span style={{ fontSize: 'var(--text-sm)', fontWeight: 500, flex: 1 }}>{app.name}</span>
<span className={`admin-badge ${typeBadgeColors[app.type] || 'badge-neutral'}`} style={{ fontSize: 'var(--text-2xs)' }}>
{typeLabels[app.type] || app.type.toUpperCase()}
</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)', minWidth: 60, textAlign: 'right' }}>
{app.price} 元
</span>
</label>
);
})}
</div>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)', marginTop: 'var(--space-xs)' }}>
已选 {form.freeAppIds.length}/{allPaidApps.length} 个应用
</div>
</div>
{/* Free Models Selection */}
<div style={{ marginTop: 'var(--space-2xl)' }}>
<label className="admin-form-label" style={{ marginBottom: 'var(--space-md)', display: 'block' }}>
收费大模型免费权益
<span style={{ fontWeight: 400, color: 'var(--color-text-tertiary)', marginLeft: 'var(--space-sm)', fontSize: 'var(--text-xs)' }}>
勾选后会员可免费调用,不消耗Token
</span>
</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, maxHeight: 200, overflowY: 'auto', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-md)', padding: 'var(--space-sm)' }}>
{allPaidModels.length === 0 && (
<div style={{ padding: 'var(--space-md)', color: 'var(--color-text-tertiary)', fontSize: 'var(--text-sm)', textAlign: 'center' }}>暂无收费模型</div>
)}
{allPaidModels.map(model => {
const checked = form.freeModelIds.includes(model.id);
return (
<label
key={model.id}
style={{
display: 'flex', alignItems: 'center', gap: 'var(--space-sm)',
padding: 'var(--space-sm) var(--space-md)',
borderRadius: 'var(--radius-sm)', cursor: 'pointer',
background: checked ? 'var(--color-accent-subtle)' : 'transparent',
transition: 'background var(--transition-fast)',
userSelect: 'none',
}}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleModelId(model.id)}
style={{
width: 16, height: 16, accentColor: 'var(--color-accent)',
cursor: 'pointer', flexShrink: 0,
}}
/>
<span style={{ fontSize: 'var(--text-sm)', fontWeight: 500, flex: 1 }}>{model.name}</span>
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{model.providerName}</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)', minWidth: 80, textAlign: 'right' }}>
{model.pricePerMillionTokens} 元/百万Token
</span>
</label>
);
})}
</div>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)', marginTop: 'var(--space-xs)' }}>
已选 {form.freeModelIds.length}/{allPaidModels.length} 个模型
</div>
</div>
<div className="admin-form-actions">
<button className="admin-btn admin-btn-primary" onClick={handleSave}>保存</button>
<button className="admin-btn admin-btn-outline" onClick={() => { setShowModal(false); setEditingPlan(null); }}>取消</button>
</div>
</div>
</div>
)}
</div>
);
};
export default Membership;
+864
View File
@@ -0,0 +1,864 @@
import React, { useState, useEffect, useCallback } from 'react';
import { adminAPI } from '@/api';
import type { ModelProvider, AIModel, ProtocolDef, FetchableModel } from '@/types';
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
const formatNumber = (n: number) => {
if (n >= 100000000) return (n / 100000000).toFixed(1) + '亿';
if (n >= 10000) return (n / 10000).toFixed(1) + '万';
return n.toLocaleString();
};
// Capability标签颜色映射
const capabilityColors: Record<string, string> = {
text: '#5B5FED',
vision: '#219B5E',
audio: '#E8922C',
video: '#D9383A',
image: '#D9383A',
thinking: '#8B5CF6',
tool: '#3B82F6',
};
const capabilityLabels: Record<string, string> = {
text: '文本',
vision: '视觉',
audio: '音频',
video: '视频',
image: '图像',
thinking: '思考',
tool: '工具',
};
const Models: React.FC = () => {
// 状态
const [providers, setProviders] = useState<ModelProvider[]>([]);
const [models, setModels] = useState<AIModel[]>([]);
const [protocols, setProtocols] = useState<ProtocolDef[]>([]);
const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
const [fetchedModels, setFetchedModels] = useState<FetchableModel[]>([]);
const [fetchSource, setFetchSource] = useState<string>(''); // 模型来源标识
const [fetchMessage, setFetchMessage] = useState<string>(''); // API错误消息
const [selectedModelIds, setSelectedModelIds] = useState<Set<string>>(new Set());
const [showAddProvider, setShowAddProvider] = useState(false);
const [editingProvider, setEditingProvider] = useState<ModelProvider | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 模型定价编辑状态
const [editingModelPrice, setEditingModelPrice] = useState<string | null>(null);
const [editPriceValue, setEditPriceValue] = useState<string>('');
const [priceSaving, setPriceSaving] = useState(false);
// 供应商表单
const [providerForm, setProviderForm] = useState({
name: '',
protocol: 'openai_compatible',
api_key: '',
api_base_url: '',
});
// 加载数据
const loadData = useCallback(async () => {
try {
setLoading(true);
const [provRes, modelsRes, protoRes] = await Promise.all([
adminAPI.getProviders(),
adminAPI.getAdminModels({ page_size: 100 }),
adminAPI.getProtocols(),
]);
setProviders(Array.isArray(provRes) ? provRes : (provRes.data || []));
setProtocols(Array.isArray(protoRes) ? protoRes : (protoRes.data || []));
// 后端models API返回分页格式: {total, page, page_size, items}
// 需要兼容 {code, data: {items}} 和 {total, items} 两种格式
const modelData = modelsRes.data || modelsRes;
const rawModels = Array.isArray(modelData) ? modelData : (modelData?.items || []);
// 后端返回snake_case,前端接口使用camelCase,需要映射
const mappedModels = (Array.isArray(rawModels) ? rawModels : []).map((m: any) => ({
...m,
providerId: m.providerId || m.provider_id,
providerName: m.providerName || m.provider_name,
apiKey: m.apiKey || m.api_key,
pricePerMillionTokens: m.pricePerMillionTokens ?? m.price_per_million_tokens ?? 0,
isEnabled: m.isEnabled ?? m.is_enabled ?? true,
isDefault: m.isDefault ?? m.is_default ?? false,
totalCalls: m.totalCalls ?? m.total_calls ?? 0,
totalTokens: m.totalTokens ?? m.total_tokens ?? 0,
}));
setModels(mappedModels);
} catch (err: any) {
const msg = typeof err.message === 'string' ? err.message : (err.detail || JSON.stringify(err));
setError('加载数据失败:' + msg);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
// 供应商操作
const handleAddProvider = async (e: React.FormEvent) => {
e.preventDefault();
if (!providerForm.name.trim()) return;
try {
setError(null);
await adminAPI.addProvider({
name: providerForm.name,
protocol: providerForm.protocol,
api_key: providerForm.api_key,
api_base_url: providerForm.api_base_url || undefined,
});
setShowAddProvider(false);
setProviderForm({ name: '', protocol: 'openai_compatible', api_key: '', api_base_url: '' });
await loadData();
} catch (err: any) {
setError(err.message || '添加供应商失败');
}
};
const handleUpdateProvider = async (e: React.FormEvent) => {
e.preventDefault();
if (!editingProvider || !providerForm.name.trim()) return;
try {
setError(null);
await adminAPI.updateProvider(editingProvider.id, {
name: providerForm.name,
protocol: providerForm.protocol,
api_key: providerForm.api_key,
api_base_url: providerForm.api_base_url || undefined,
});
setEditingProvider(null);
setShowAddProvider(false);
await loadData();
} catch (err: any) {
setError(err.message || '更新供应商失败');
}
};
const handleDeleteProvider = async (id: string) => {
if (!window.confirm('确定要删除该供应商吗?若有模型关联则无法删除。')) return;
try {
setError(null);
await adminAPI.deleteProvider(id);
alert('供应商已删除');
await loadData();
} catch (err: any) {
const msg = err?.message || err?.detail || '删除供应商失败';
alert(msg);
setError(msg);
}
};
const handleFetchModels = async (id: string) => {
try {
setError(null);
setFetchedModels([]);
setFetchSource('');
setFetchMessage('');
setSelectedModelIds(new Set());
const res = await adminAPI.fetchModels(id);
// 兼容后端返回数组或 {models: [...]} 两种格式
const raw = res.data !== undefined ? res.data : res;
const models = Array.isArray(raw) ? raw : (raw.models || []);
const source = raw.source || '';
const message = raw.message || '';
setFetchedModels(models);
setFetchSource(source);
setFetchMessage(message);
setExpandedProvider(id);
} catch (err: any) {
setError(err.message || '获取模型列表失败');
}
};
const handleAddModels = async (providerId: string) => {
if (selectedModelIds.size === 0) return;
try {
setError(null);
await adminAPI.addModels(providerId, Array.from(selectedModelIds));
alert('模型添加成功');
setFetchedModels([]);
setSelectedModelIds(new Set());
await loadData();
} catch (err: any) {
const msg = err?.message || err?.detail || '添加模型失败';
alert(msg);
setError(msg);
}
};
const handleToggleModel = async (model: AIModel) => {
try {
setError(null);
await adminAPI.toggleModel(model.id, !model.isEnabled);
await loadData();
} catch (err: any) {
setError(err.message || '切换模型状态失败');
}
};
const handleDeleteModel = async (model: AIModel) => {
if (!window.confirm(`确定要删除模型 "${model.name}" 吗?`)) return;
try {
setError(null);
await adminAPI.deleteModel(model.id);
alert('模型已删除');
await loadData();
} catch (err: any) {
const msg = err?.message || err?.detail || '删除模型失败';
alert(msg);
setError(msg);
}
};
const handleStartEditPrice = (model: AIModel) => {
setEditingModelPrice(model.id);
setEditPriceValue(String(model.pricePerMillionTokens || 0));
};
const handleCancelEditPrice = () => {
setEditingModelPrice(null);
setEditPriceValue('');
};
const handleSavePrice = async (model: AIModel) => {
const newPrice = parseFloat(editPriceValue);
if (isNaN(newPrice) || newPrice < 0) {
setError('请输入有效的价格数值');
return;
}
setPriceSaving(true);
setError(null);
try {
await adminAPI.updateModel(model.id, { price_per_million_tokens: newPrice });
setEditingModelPrice(null);
setEditPriceValue('');
await loadData();
} catch (err: any) {
setError(err.message || '修改价格失败');
} finally {
setPriceSaving(false);
}
};
const handleToggleStatus = async (model: AIModel) => {
const newStatus = model.status === 'free' ? 'paid' : 'free';
setError(null);
try {
await adminAPI.updateModel(model.id, { status: newStatus });
await loadData();
} catch (err: any) {
setError(err.message || '修改计费状态失败');
}
};
// 全选/取消
const toggleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedModelIds(new Set(fetchedModels.map(m => m.id)));
} else {
setSelectedModelIds(new Set());
}
};
const toggleSelectModel = (modelId: string) => {
const next = new Set(selectedModelIds);
if (next.has(modelId)) {
next.delete(modelId);
} else {
next.add(modelId);
}
setSelectedModelIds(next);
};
// 统计
const stats = {
providerCount: providers.length,
enabledCount: models.filter(m => m.isEnabled).length,
totalCalls: models.reduce((sum, m) => sum + (m.totalCalls || 0), 0),
totalTokens: models.reduce((sum, m) => sum + (m.totalTokens || 0), 0),
};
const statCards = [
{ label: '供应商数', value: stats.providerCount },
{ label: '启用模型数', value: stats.enabledCount },
{ label: '总调用量', value: formatNumber(stats.totalCalls) },
{ label: '总Token消耗', value: formatNumber(stats.totalTokens) },
];
// 获取协议名称
const getProtocolName = (protocolId: string) => {
const p = protocols.find(p => p.id === protocolId);
return p ? p.name : protocolId;
};
// 获取provider下的模型
const getProviderModels = (providerId: string) => {
return models.filter(m => m.providerId === providerId);
};
return (
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-2xl)' }}>
<h1 className="admin-page-title" style={{ marginBottom: 0 }}>大模型库管理</h1>
<div style={{ display: 'flex', gap: 'var(--space-sm)' }}>
<button
className="admin-btn admin-btn-outline"
onClick={() => {
const dialog = document.createElement('dialog');
dialog.style.cssText = 'border:none;border-radius:16px;padding:24px;width:420px;max-width:90vw;box-shadow:0 20px 48px rgba(0,0,0,0.15);';
dialog.innerHTML = `
<h3 style="margin:0 0 16px;font-size:18px;font-weight:700;">设置全局默认LLM</h3>
<p style="margin:0 0 12px;font-size:13px;color:#545A65;">选择平台默认使用的大语言模型,新对话将自动使用此模型。</p>
<select id="defaultLLMSelect" style="width:100%;padding:10px 12px;border:1px solid #E3E6EB;border-radius:8px;font-size:14px;margin-bottom:16px;">
${models.filter(m => m.isEnabled).map(m => `<option value="${m.id}" ${(m as any).isDefault ? 'selected' : ''}>${m.name} - ${m.providerName || ''} ${m.status === 'free' ? '(免费)' : '(付费)'}</option>`).join('')}
${models.filter(m => m.isEnabled).length === 0 ? '<option disabled>暂无可用的启用模型</option>' : ''}
</select>
<div style="display:flex;gap:8px;justify-content:flex-end;">
<button id="cancelDefaultLLM" style="padding:8px 16px;border:1px solid #E3E6EB;border-radius:8px;background:#fff;cursor:pointer;font-size:13px;">取消</button>
<button id="confirmDefaultLLM" style="padding:8px 16px;border:none;border-radius:8px;background:#5B5FED;color:#fff;cursor:pointer;font-size:13px;font-weight:600;">确认设置</button>
</div>
`;
document.body.appendChild(dialog);
dialog.showModal();
dialog.querySelector('#cancelDefaultLLM')!.addEventListener('click', () => { dialog.close(); dialog.remove(); });
dialog.querySelector('#confirmDefaultLLM')!.addEventListener('click', async () => {
const select = dialog.querySelector('#defaultLLMSelect') as HTMLSelectElement;
const modelId = select?.value;
if (!modelId) {
alert('请选择模型');
return;
}
try {
await adminAPI.setDefaultModel(modelId);
alert('已设置全局默认模型');
dialog.close();
dialog.remove();
loadData();
} catch (err: any) {
alert('设置失败:' + (err?.message || err?.detail || '未知错误'));
}
});
}}
>
<SVGIcon size={14}><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></SVGIcon>
设置默认LLM
</button>
<button
className="admin-btn admin-btn-primary"
onClick={() => { setShowAddProvider(true); setEditingProvider(null); setProviderForm({ name: '', protocol: 'openai_compatible', api_key: '', api_base_url: '' }); }}
>
<SVGIcon><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></SVGIcon>
添加供应商
</button>
</div>
</div>
{/* 错误提示 */}
{error && (
<div style={{
padding: 'var(--space-md) var(--space-lg)', marginBottom: 'var(--space-lg)',
background: 'var(--color-error-light)', color: 'var(--color-error)',
borderRadius: 'var(--radius-md)', fontSize: 'var(--text-sm)',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}>
<span>{error}</span>
<span style={{ cursor: 'pointer', fontWeight: 700, marginLeft: 'var(--space-lg)' }} onClick={() => setError(null)}>x</span>
</div>
)}
{/* Stats */}
<div className="admin-stats">
{statCards.map((s, i) => (
<div key={i} className="admin-stat-card">
<div className="admin-stat-label">{s.label}</div>
<div className="admin-stat-value" style={{ fontSize: 'var(--text-xl)' }}>{s.value}</div>
</div>
))}
</div>
{/* 加载状态 */}
{loading && (
<div style={{ textAlign: 'center', padding: 'var(--space-4xl)', color: 'var(--color-text-tertiary)' }}>
加载中...
</div>
)}
{/* 供应商列表 */}
{!loading && (
<div style={{ marginBottom: 'var(--space-2xl)' }}>
<h2 style={{ fontSize: 'var(--text-lg)', fontWeight: 600, marginBottom: 'var(--space-xl)' }}>模型供应商</h2>
{providers.length === 0 ? (
<div className="admin-card" style={{ textAlign: 'center', padding: 'var(--space-4xl)', color: 'var(--color-text-tertiary)' }}>
暂无供应商,请点击"添加供应商"开始
</div>
) : (
providers.map(provider => (
<div key={provider.id} className="admin-card" style={{ marginBottom: 'var(--space-lg)' }}>
{/* 供应商卡片头部 */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-lg)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-md)' }}>
<div style={{
width: 40, height: 40, borderRadius: 'var(--radius-lg)',
background: 'var(--color-accent-light)', color: 'var(--color-accent)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<SVGIcon size={20}><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></SVGIcon>
</div>
<div>
<div style={{ fontSize: 'var(--text-base)', fontWeight: 600 }}>{provider.name}</div>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>
协议: {getProtocolName(provider.protocol)}
{provider.apiBaseUrl ? ` | ${provider.apiBaseUrl}` : ''}
</div>
</div>
</div>
<div style={{ display: 'flex', gap: 'var(--space-sm)' }}>
<button
className="admin-btn admin-btn-outline admin-btn-sm"
onClick={() => handleFetchModels(provider.id)}
>
<SVGIcon size={12}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon>
获取模型列表
</button>
<button
className="admin-btn admin-btn-outline admin-btn-sm"
onClick={() => {
setEditingProvider(provider);
setProviderForm({
name: provider.name,
protocol: provider.protocol,
api_key: provider.apiKey || (provider as any).api_key || '',
api_base_url: provider.apiBaseUrl || (provider as any).api_base_url || '',
});
setShowAddProvider(true);
}}
>
<SVGIcon size={12}><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></SVGIcon>
编辑
</button>
<button
className="admin-btn admin-btn-danger admin-btn-sm"
onClick={() => handleDeleteProvider(provider.id)}
>
<SVGIcon size={12}><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></SVGIcon>
删除
</button>
</div>
</div>
{/* 获取到的模型列表 (fetch-models结果) */}
{expandedProvider === provider.id && fetchedModels.length > 0 && (
<div style={{
background: 'var(--color-bg-tertiary)',
borderRadius: 'var(--radius-lg)',
padding: 'var(--space-lg)',
marginBottom: 'var(--space-md)',
}}>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
marginBottom: 'var(--space-md)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)' }}>
<span style={{ fontWeight: 600, fontSize: 'var(--text-sm)' }}>
可选模型 ({fetchedModels.length})
</span>
{fetchSource && (
<span style={{
display: 'inline-block',
padding: '1px 8px',
borderRadius: 'var(--radius-full)',
fontSize: '11px',
fontWeight: 600,
color: '#fff',
background: fetchSource === 'api' ? '#219B5E' : fetchSource === 'error' ? '#D9383A' : '#E8922C',
}}>
{fetchSource === 'api' ? '真实API' : fetchSource === 'error' ? 'API错误' : '默认列表'}
</span>
)}
</div>
<button
className="admin-btn admin-btn-primary admin-btn-sm"
disabled={selectedModelIds.size === 0}
style={{ opacity: selectedModelIds.size === 0 ? 0.5 : 1 }}
onClick={() => handleAddModels(provider.id)}
>
添加选中模型 ({selectedModelIds.size})
</button>
</div>
<table className="admin-table">
<thead>
<tr>
<th style={{ width: 40 }}>
<input
type="checkbox"
checked={selectedModelIds.size === fetchedModels.length && fetchedModels.length > 0}
onChange={e => toggleSelectAll(e.target.checked)}
/>
</th>
<th>模型名称</th>
<th>功能标签</th>
<th>计费类型</th>
</tr>
</thead>
<tbody>
{fetchedModels.map(fm => (
<tr key={fm.id}>
<td>
<input
type="checkbox"
checked={selectedModelIds.has(fm.id)}
onChange={() => toggleSelectModel(fm.id)}
/>
</td>
<td style={{ fontWeight: 600 }}>{fm.name}</td>
<td>
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
{(fm.capabilities || []).map(cap => (
<span key={cap} style={{
display: 'inline-block',
padding: '1px 8px',
borderRadius: 'var(--radius-full)',
fontSize: '11px',
fontWeight: 600,
color: '#fff',
background: capabilityColors[cap] || '#888',
}}>
{capabilityLabels[cap] || cap}
</span>
))}
</div>
</td>
<td>
<span className={`admin-badge ${fm.pricing === 'free' ? 'badge-success' : 'badge-warning'}`}>
{fm.pricing === 'free' ? '免费' : '付费'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* 获取模型列表时的错误/提示信息 */}
{expandedProvider === provider.id && fetchedModels.length === 0 && fetchMessage && (
<div style={{
background: fetchSource === 'error' ? '#FEF2F2' : '#F0F9FA',
border: fetchSource === 'error' ? '1px solid #FECACA' : '1px solid #BBD9DD',
color: fetchSource === 'error' ? '#991B1B' : '#0F5156',
borderRadius: 'var(--radius-lg)',
padding: 'var(--space-xl)',
marginBottom: 'var(--space-md)',
fontSize: 'var(--text-sm)',
display: 'flex', flexDirection: 'column', gap: 'var(--space-md)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)' }}>
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 24, height: 24, borderRadius: '50%',
background: fetchSource === 'error' ? '#D9383A' : '#E8922C',
color: '#fff', fontSize: '13px', fontWeight: 700,
}}>
{fetchSource === 'error' ? '!' : 'i'}
</span>
<span style={{
display: 'inline-block',
padding: '1px 8px',
borderRadius: 'var(--radius-full)',
fontSize: '11px',
fontWeight: 600,
color: '#fff',
background: fetchSource === 'api' ? '#219B5E' : fetchSource === 'error' ? '#D9383A' : '#E8922C',
}}>
{fetchSource === 'api' ? '真实API' : fetchSource === 'error' ? 'API错误' : '默认列表'}
</span>
<span style={{ fontWeight: 600 }}>{fetchSource === 'error' ? '错误详情' : '提示'}</span>
</div>
<span style={{ lineHeight: 1.6 }}>{fetchMessage}</span>
{fetchSource === 'error' && (
<button
className="admin-btn admin-btn-outline admin-btn-sm"
style={{ alignSelf: 'flex-start' }}
onClick={() => handleFetchModels(provider.id)}
>
<SVGIcon size={12}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon>
重试获取
</button>
)}
</div>
)}
{/* 已上架模型 */}
{(() => {
const providerModels = getProviderModels(provider.id);
if (providerModels.length === 0) {
return (
<div style={{
fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)',
padding: 'var(--space-md) 0',
}}>
暂无模型,点击"获取模型列表"获取可用模型
</div>
);
}
return (
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>模型名</th>
<th>功能标签</th>
<th>单价(元/百万Token)</th>
<th>状态</th>
<th>调用量</th>
<th>Token消耗</th>
<th>开关</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{providerModels.map(model => (
<tr key={model.id}>
<td style={{ fontWeight: 600 }}>
{model.name}
{(model as any).isDefault && (
<span style={{
display: 'inline-block', marginLeft: 6,
padding: '0 6px', borderRadius: 'var(--radius-full)',
fontSize: '10px', fontWeight: 600,
background: '#FEF3C7', color: '#D97706',
}}>默认</span>
)}
</td>
<td>
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
{(model.capabilities || []).map((cap: string) => (
<span key={cap} style={{
display: 'inline-block',
padding: '1px 6px',
borderRadius: 'var(--radius-full)',
fontSize: '10px',
fontWeight: 600,
color: '#fff',
background: capabilityColors[cap] || '#888',
}}>
{capabilityLabels[cap] || cap}
</span>
))}
</div>
</td>
<td style={{ fontFamily: 'var(--font-mono)', textAlign: 'center' }}>
{editingModelPrice === model.id ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<input
type="number"
min="0"
step="0.01"
value={editPriceValue}
onChange={e => setEditPriceValue(e.target.value)}
style={{
width: 80, padding: '2px 6px', fontSize: 'var(--text-xs)',
border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)',
}}
autoFocus
onKeyDown={e => {
if (e.key === 'Enter') handleSavePrice(model);
if (e.key === 'Escape') handleCancelEditPrice();
}}
/>
<button
className="admin-btn admin-btn-primary admin-btn-sm"
style={{ padding: '2px 6px', fontSize: '10px' }}
onClick={() => handleSavePrice(model)}
disabled={priceSaving}
>
确定
</button>
<button
className="admin-btn admin-btn-outline admin-btn-sm"
style={{ padding: '2px 6px', fontSize: '10px' }}
onClick={handleCancelEditPrice}
>
取消
</button>
</div>
) : (
<span
style={{
cursor: 'pointer',
padding: '2px 8px',
borderRadius: 'var(--radius-sm)',
transition: 'background var(--transition-fast)',
}}
onMouseEnter={e => (e.currentTarget.style.background = '#F0F4FF')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
onClick={() => handleStartEditPrice(model)}
title="点击编辑单价"
>
{model.pricePerMillionTokens !== undefined && model.pricePerMillionTokens !== null
? '¥' + model.pricePerMillionTokens.toFixed(2)
: '未设置'}
</span>
)}
</td>
<td>
<span
className={`admin-badge ${model.status === 'free' ? 'badge-success' : 'badge-warning'}`}
style={{ cursor: 'pointer' }}
onClick={() => handleToggleStatus(model)}
title="点击切换计费状态"
>
{model.status === 'free' ? '免费' : '付费'}
</span>
</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>{formatNumber(model.totalCalls || 0)}</td>
<td style={{ fontFamily: 'var(--font-mono)' }}>{formatNumber(model.totalTokens || 0)}</td>
<td>
<button
onClick={() => handleToggleModel(model)}
style={{
width: 44, height: 24, borderRadius: 12, border: 'none',
background: model.isEnabled ? 'var(--color-accent)' : 'var(--color-text-disabled)',
cursor: 'pointer', position: 'relative', transition: 'background var(--transition-fast)',
}}
>
<span style={{
position: 'absolute', top: 2, left: model.isEnabled ? 22 : 2,
width: 20, height: 20, borderRadius: '50%', background: '#FFFFFF',
transition: 'left var(--transition-fast)',
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}} />
</button>
</td>
<td>
<button
className="admin-btn admin-btn-danger admin-btn-sm"
onClick={() => handleDeleteModel(model)}
>
<SVGIcon size={12}><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></SVGIcon>
删除
</button>
{(model as any).isDefault ? (
<span className="admin-badge badge-success" style={{ marginLeft: 4, fontSize: '10px' }}>默认</span>
) : (
<button
className="admin-btn admin-btn-outline admin-btn-sm"
style={{ marginLeft: 4, fontSize: '10px' }}
onClick={async () => {
try {
await adminAPI.setDefaultModel(model.id);
loadData();
} catch (err: any) {
alert('设置失败:' + (err?.message || err?.detail || ''));
}
}}
>设为默认</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
})()}
</div>
))
)}
</div>
)}
{/* 添加/编辑供应商弹窗 */}
{showAddProvider && (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.4)', zIndex: 'var(--z-modal)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
animation: 'fadeIn var(--transition-normal)',
}} onClick={() => { setShowAddProvider(false); setEditingProvider(null); }}>
<div style={{
background: '#FFFFFF', borderRadius: 'var(--radius-2xl)', width: 520, maxHeight: '90vh', overflow: 'auto',
padding: 'var(--space-3xl)', boxShadow: 'var(--shadow-xl)',
}} onClick={e => e.stopPropagation()}>
<h3 style={{ fontSize: 'var(--text-lg)', fontWeight: 700, marginBottom: 'var(--space-2xl)' }}>
{editingProvider ? '编辑供应商' : '添加供应商'}
</h3>
<form onSubmit={editingProvider ? handleUpdateProvider : handleAddProvider}>
<div className="admin-form-group">
<label className="admin-form-label">供应商名称</label>
<input
className="admin-form-input"
value={providerForm.name}
onChange={e => setProviderForm({ ...providerForm, name: e.target.value })}
placeholder="例如: DeepSeek"
required
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">协议类型</label>
<select
className="admin-form-input"
value={providerForm.protocol}
onChange={e => setProviderForm({ ...providerForm, protocol: e.target.value })}
>
{protocols.map(p => (
<option key={p.id} value={p.id}>{p.name} - {p.description}</option>
))}
</select>
</div>
<div className="admin-form-group">
<label className="admin-form-label">API Key</label>
<input
className="admin-form-input"
value={providerForm.api_key}
onChange={e => setProviderForm({ ...providerForm, api_key: e.target.value })}
placeholder="sk-..."
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">API Base URL(可选)</label>
<input
className="admin-form-input"
value={providerForm.api_base_url}
onChange={e => setProviderForm({ ...providerForm, api_base_url: e.target.value })}
placeholder="例如: https://api.deepseek.com"
/>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)', marginTop: 'var(--space-xs)' }}>
根域名,不含路径。系统将自动拼接协议对应的请求路径
</div>
</div>
<div className="admin-form-actions">
<button type="submit" className="admin-btn admin-btn-primary">
{editingProvider ? '保存修改' : '创建供应商'}
</button>
<button
type="button"
className="admin-btn admin-btn-outline"
onClick={() => { setShowAddProvider(false); setEditingProvider(null); }}
>
取消
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default Models;
+367
View File
@@ -0,0 +1,367 @@
import React, { useState, useEffect } from 'react';
import { adminAPI } from '@/api';
import type { PaymentConfig, Order } from '@/types';
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
const statusBadge = (status: string) => {
const map: Record<string, { cls: string; text: string }> = {
active: { cls: 'badge-success', text: '已启用' },
inactive: { cls: 'badge-neutral', text: '未启用' },
paid: { cls: 'badge-success', text: '已支付' },
pending: { cls: 'badge-warning', text: '待支付' },
refunded: { cls: 'badge-error', text: '已退款' },
};
const s = map[status] || { cls: 'badge-neutral', text: status };
return <span className={`admin-badge ${s.cls}`}>{s.text}</span>;
};
const paymentMethodBadge = (method: string) => {
return method === 'wechat'
? <span className="admin-badge badge-success">微信支付</span>
: <span className="admin-badge badge-info">支付宝</span>;
};
const Payment: React.FC = () => {
const [configs, setConfigs] = useState<PaymentConfig[]>([]);
const [orders, setOrders] = useState<Order[]>([]);
const [saving, setSaving] = useState<Record<string, boolean>>({});
const [msg, setMsg] = useState<Record<string, { type: string; text: string }>>({});
const wechat = configs.find(c => c.type === 'wechat');
const alipay = configs.find(c => c.type === 'alipay');
const [wechatForm, setWechatForm] = useState({
app_id: '', merchant_id: '', api_key: '', api_v3_key: '',
cert_path: '', cert_serial_no: '', notify_url: '',
});
const [alipayForm, setAlipayForm] = useState({
app_id: '', app_private_key: '', alipay_public_key: '',
gateway_url: 'https://openapi.alipay.com/gateway.do', notify_url: '', return_url: '',
});
useEffect(() => {
loadConfigs();
loadOrders();
}, []);
useEffect(() => {
if (wechat) {
setWechatForm({
app_id: wechat.app_id || '',
merchant_id: wechat.merchant_id || '',
api_key: wechat.api_key || '',
api_v3_key: wechat.api_v3_key || '',
cert_path: wechat.cert_path || '/etc/certs/apiclient_cert.pem',
cert_serial_no: wechat.cert_serial_no || '',
notify_url: wechat.notify_url || '',
});
}
if (alipay) {
setAlipayForm({
app_id: alipay.app_id || '',
app_private_key: alipay.app_private_key || '',
alipay_public_key: alipay.alipay_public_key || '',
gateway_url: alipay.gateway_url || 'https://openapi.alipay.com/gateway.do',
notify_url: alipay.notify_url || '',
return_url: alipay.return_url || '',
});
}
}, [configs]);
const loadConfigs = async () => {
try {
const res = await adminAPI.getPaymentConfigs();
const list = res?.data || res;
if (Array.isArray(list)) {
setConfigs(list);
}
} catch (e) { console.error('加载支付配置失败', e); }
};
const loadOrders = async () => {
try {
const res = await adminAPI.getAdminOrders({ page: 1, page_size: 20 });
const data: any = res?.data || res;
if (data?.items) {
setOrders(data.items);
} else if (Array.isArray(data)) {
setOrders(data);
}
} catch (e) { console.error('加载订单失败', e); }
};
const handleSaveConfig = async (id: string, data: any) => {
setSaving(prev => ({ ...prev, [id]: true }));
setMsg(prev => ({ ...prev, [id]: { type: '', text: '' } }));
try {
await adminAPI.updatePaymentConfig(id, data);
setMsg(prev => ({ ...prev, [id]: { type: 'success', text: '保存成功' } }));
loadConfigs();
} catch (e: any) {
setMsg(prev => ({ ...prev, [id]: { type: 'error', text: e.message || '保存失败' } }));
} finally {
setSaving(prev => ({ ...prev, [id]: false }));
}
};
const handleToggleConfig = async (type: string) => {
const config = type === 'wechat' ? wechat : alipay;
if (!config) return;
const newStatus = config.status === 'active' ? 'inactive' : 'active';
await handleSaveConfig(config.id, { status: newStatus });
};
const handleRefund = async (orderId: string) => {
try {
await adminAPI.refundOrder(orderId);
loadOrders();
} catch (e) { console.error('退款失败', e); }
};
const formatDate = (d: string) => {
const dt = new Date(d);
return dt.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
};
return (
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
<h1 className="admin-page-title">支付渠道配置</h1>
{Object.entries(msg).map(([id, m]) => m.text ? (
<div key={id} style={{
padding: 'var(--space-md) var(--space-lg)',
marginBottom: 'var(--space-lg)',
borderRadius: 'var(--radius-md)',
background: m.type === 'success' ? 'var(--color-success-light)' : 'var(--color-error-light)',
color: m.type === 'success' ? 'var(--color-success)' : 'var(--color-error)',
fontWeight: 500,
}}>
{m.text}
</div>
) : null)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(420px, 1fr))', gap: 'var(--space-xl)', marginBottom: 'var(--space-2xl)' }}>
{/* WeChat Pay Card */}
<div className="admin-card" style={{ marginBottom: 0 }}>
<div className="admin-card-header">
<h3 className="admin-card-title" style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)' }}>
<div style={{ width: 32, height: 32, borderRadius: 'var(--radius-md)', background: '#E6F7EE', color: '#09BB07', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<SVGIcon size={18}><path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2z"/></SVGIcon>
</div>
微信支付
</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)' }}>
{wechat ? statusBadge(wechat.status) : <span className="admin-badge badge-neutral">未配置</span>}
{wechat && (
<button
onClick={() => handleToggleConfig('wechat')}
style={{
width: 44, height: 24, borderRadius: 12, border: 'none',
background: wechat.status === 'active' ? 'var(--color-accent)' : 'var(--color-text-disabled)',
cursor: 'pointer', position: 'relative', transition: 'background var(--transition-fast)',
}}
>
<span style={{
position: 'absolute', top: 2, left: wechat.status === 'active' ? 22 : 2,
width: 20, height: 20, borderRadius: '50%', background: '#FFFFFF',
transition: 'left var(--transition-fast)', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}} />
</button>
)}
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">AppID</label>
<input className="admin-form-input" value={wechatForm.app_id} onChange={e => setWechatForm({ ...wechatForm, app_id: e.target.value })} />
</div>
<div className="admin-form-group">
<label className="admin-form-label">商户号</label>
<input className="admin-form-input" value={wechatForm.merchant_id} onChange={e => setWechatForm({ ...wechatForm, merchant_id: e.target.value })} />
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">API v2 密钥</label>
<input className="admin-form-input" type="password" value={wechatForm.api_key} onChange={e => setWechatForm({ ...wechatForm, api_key: e.target.value })} />
</div>
<div className="admin-form-group">
<label className="admin-form-label">API v3 密钥</label>
<input className="admin-form-input" type="password" value={wechatForm.api_v3_key} onChange={e => setWechatForm({ ...wechatForm, api_v3_key: e.target.value })} />
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">证书路径</label>
<input className="admin-form-input" value={wechatForm.cert_path} onChange={e => setWechatForm({ ...wechatForm, cert_path: e.target.value })} />
</div>
<div className="admin-form-group">
<label className="admin-form-label">回调通知地址</label>
<input className="admin-form-input" value={wechatForm.notify_url} placeholder="https://your-domain.com/api/payment/wechat/notify" onChange={e => setWechatForm({ ...wechatForm, notify_url: e.target.value })} />
</div>
</div>
<div className="admin-form-actions">
<button className="admin-btn admin-btn-primary" disabled={saving[wechat?.id || ''] || !wechat} onClick={() => wechat && handleSaveConfig(wechat.id, {
app_id: wechatForm.app_id,
merchant_id: wechatForm.merchant_id,
api_key: wechatForm.api_key,
api_v3_key: wechatForm.api_v3_key,
cert_path: wechatForm.cert_path,
notify_url: wechatForm.notify_url,
})}>
{saving[wechat?.id || ''] ? '保存中...' : '保存配置'}
</button>
</div>
</div>
{/* Alipay Card */}
<div className="admin-card" style={{ marginBottom: 0 }}>
<div className="admin-card-header">
<h3 className="admin-card-title" style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)' }}>
<div style={{ width: 32, height: 32, borderRadius: 'var(--radius-md)', background: '#EBF2FE', color: '#1677FF', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<SVGIcon size={18}><path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2z"/></SVGIcon>
</div>
支付宝
</h3>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-sm)' }}>
{alipay ? statusBadge(alipay.status) : <span className="admin-badge badge-neutral">未配置</span>}
{alipay && (
<button
onClick={() => handleToggleConfig('alipay')}
style={{
width: 44, height: 24, borderRadius: 12, border: 'none',
background: alipay.status === 'active' ? 'var(--color-accent)' : 'var(--color-text-disabled)',
cursor: 'pointer', position: 'relative', transition: 'background var(--transition-fast)',
}}
>
<span style={{
position: 'absolute', top: 2, left: alipay.status === 'active' ? 22 : 2,
width: 20, height: 20, borderRadius: '50%', background: '#FFFFFF',
transition: 'left var(--transition-fast)', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}} />
</button>
)}
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">AppID</label>
<input className="admin-form-input" value={alipayForm.app_id} onChange={e => setAlipayForm({ ...alipayForm, app_id: e.target.value })} />
</div>
<div className="admin-form-group">
<label className="admin-form-label">接口地址</label>
<input className="admin-form-input" value={alipayForm.gateway_url} onChange={e => setAlipayForm({ ...alipayForm, gateway_url: e.target.value })} />
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">应用私钥</label>
<input className="admin-form-input" type="password" value={alipayForm.app_private_key} placeholder="-----BEGIN RSA PRIVATE KEY-----" onChange={e => setAlipayForm({ ...alipayForm, app_private_key: e.target.value })} />
</div>
<div className="admin-form-group">
<label className="admin-form-label">支付宝公钥</label>
<input className="admin-form-input" type="password" value={alipayForm.alipay_public_key} placeholder="-----BEGIN PUBLIC KEY-----" onChange={e => setAlipayForm({ ...alipayForm, alipay_public_key: e.target.value })} />
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">异步回调地址</label>
<input className="admin-form-input" value={alipayForm.notify_url} placeholder="https://your-domain.com/api/payment/alipay/notify" onChange={e => setAlipayForm({ ...alipayForm, notify_url: e.target.value })} />
</div>
<div className="admin-form-group">
<label className="admin-form-label">同步跳转地址</label>
<input className="admin-form-input" value={alipayForm.return_url} placeholder="https://your-domain.com/#/payment-result" onChange={e => setAlipayForm({ ...alipayForm, return_url: e.target.value })} />
</div>
</div>
<div className="admin-form-actions">
<button className="admin-btn admin-btn-primary" disabled={saving[alipay?.id || ''] || !alipay} onClick={() => alipay && handleSaveConfig(alipay.id, {
app_id: alipayForm.app_id,
app_private_key: alipayForm.app_private_key,
alipay_public_key: alipayForm.alipay_public_key,
gateway_url: alipayForm.gateway_url,
notify_url: alipayForm.notify_url,
return_url: alipayForm.return_url,
})}>
{saving[alipay?.id || ''] ? '保存中...' : '保存配置'}
</button>
</div>
</div>
</div>
{/* Order Management */}
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">订单管理</h3>
<button className="admin-btn admin-btn-outline admin-btn-sm">
<SVGIcon><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></SVGIcon>
导出
</button>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>订单号</th>
<th>用户</th>
<th>类型</th>
<th>项目</th>
<th>金额</th>
<th>支付方式</th>
<th>状态</th>
<th>时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{orders.map(order => (
<tr key={order.id}>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)' }}>{order.id}</td>
<td style={{ fontWeight: 500 }}>{order.userId}</td>
<td>
<span className={`admin-badge ${order.orderType === 'membership' ? 'badge-info' : order.orderType === 'app' ? 'badge-success' : 'badge-warning'}`}>
{order.orderType === 'membership' ? '会员' : order.orderType === 'app' ? '应用' : '服务'}
</span>
</td>
<td style={{ maxWidth: 160, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{order.itemName}</td>
<td style={{ fontFamily: 'var(--font-mono)', fontWeight: 600 }}>{order.amount.toFixed(2)} 元</td>
<td>{paymentMethodBadge(order.paymentMethod)}</td>
<td>{statusBadge(order.status)}</td>
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{formatDate(order.createdAt)}</td>
<td>
{order.status === 'paid' && (
<button className="admin-btn admin-btn-danger admin-btn-sm" onClick={() => handleRefund(order.id)}>
退款
</button>
)}
{order.status === 'refunded' && (
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-disabled)' }}>已退款</span>
)}
{order.status === 'pending' && (
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>等待支付</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
};
export default Payment;
+546
View File
@@ -0,0 +1,546 @@
import React, { useState, useEffect, useCallback } from 'react';
import { adminAPI } from '@/api';
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
const SERVICE_TYPE_MAP: Record<string, { label: string; color: string; bg: string }> = {
app_custom: { label: '应用定制', color: '#4F46E5', bg: '#EEF2FF' },
platform_proxy: { label: '平台代理', color: '#059669', bg: '#ECFDF5' },
data_custom: { label: '数据定制', color: '#D97706', bg: '#FFFBEB' },
private_project: { label: '私有化项目', color: '#7C3AED', bg: '#F5F3FF' },
};
const STATUS_MAP: Record<string, { label: string; cls: string }> = {
active: { label: '上架中', cls: 'badge-success' },
inactive: { label: '已下架', cls: 'badge-neutral' },
};
const REQUEST_STATUS_MAP: Record<string, { label: string; cls: string }> = {
pending: { label: '待处理', cls: 'badge-warning' },
processing: { label: '处理中', cls: 'badge-info' },
completed: { label: '已完成', cls: 'badge-success' },
rejected: { label: '已驳回', cls: 'badge-error' },
};
interface ServiceItem {
id: string;
name: string;
description: string;
price: number;
type: string;
type_name: string;
features: string[];
status: string;
assigned_agent_id: string | null;
created_at: string;
updated_at: string;
}
interface CustomRequestItem {
id: string;
user_id: string;
username: string;
service_id: string;
service_name: string;
request_type: string;
request_type_name: string;
title: string;
description: string;
requirements: Record<string, any>;
status: string;
assigned_agent_id: string | null;
created_at: string;
updated_at: string;
}
interface AgentItem {
id: string;
name: string;
description: string;
publisher_name: string;
}
const Services: React.FC = () => {
const [services, setServices] = useState<ServiceItem[]>([]);
const [requests, setRequests] = useState<CustomRequestItem[]>([]);
const [agents, setAgents] = useState<AgentItem[]>([]);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<'services' | 'requests'>('services');
// Modal states
const [showModal, setShowModal] = useState(false);
const [editingService, setEditingService] = useState<ServiceItem | null>(null);
const [form, setForm] = useState({
name: '', service_type: 'app_custom', description: '', price: 0,
features: '', status: 'active', assigned_agent_id: '',
});
// Agent assignment modal
const [showAgentModal, setShowAgentModal] = useState(false);
const [agentServiceId, setAgentServiceId] = useState('');
const loadData = useCallback(async () => {
try {
setLoading(true);
const [svcRes, reqRes, agentsRes] = await Promise.all([
adminAPI.getServices(),
adminAPI.getCustomRequests(),
adminAPI.getAvailableAgents(),
]);
if (Array.isArray(svcRes)) setServices(svcRes);
else if (svcRes?.data) setServices(svcRes.data);
if (Array.isArray(reqRes)) setRequests(reqRes);
else if (reqRes?.data) setRequests(reqRes.data);
if (Array.isArray(agentsRes)) setAgents(agentsRes);
else if (agentsRes?.data) setAgents(agentsRes.data);
} catch (e) {
console.error('加载增值服务数据失败', e);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
const handleEdit = (s: ServiceItem) => {
setEditingService(s);
setForm({
name: s.name,
service_type: s.type,
description: s.description || '',
price: s.price || 0,
features: (s.features || []).join(', '),
status: s.status,
assigned_agent_id: s.assigned_agent_id || '',
});
setShowModal(true);
};
const handleSave = async () => {
if (!form.name.trim()) return;
try {
const data = {
name: form.name,
service_type: form.service_type,
description: form.description,
price: form.price,
features: form.features,
status: form.status,
assigned_agent_id: form.assigned_agent_id || undefined,
};
if (editingService) {
await adminAPI.updateService(editingService.id, data);
} else {
await adminAPI.addService(data);
}
setShowModal(false);
setEditingService(null);
setForm({ name: '', service_type: 'app_custom', description: '', price: 0, features: '', status: 'active', assigned_agent_id: '' });
await loadData();
} catch (e: any) {
alert('保存失败: ' + (e.message || '未知错误'));
}
};
const handleDelete = async (id: string, name: string) => {
if (!confirm('确定要删除服务: ' + name + '? 删除后该服务将不再对外展示,但不会影响已发生的服务记录。')) return;
try {
console.log('[handleDelete] 开始删除服务:', id, name);
const res = await adminAPI.deleteService(id);
console.log('[handleDelete] 删除响应:', res);
alert(res?.message || '删除成功');
await loadData();
} catch (e: any) {
console.error('[handleDelete] 删除失败:', e);
let msg = '未知错误';
if (typeof e === 'string') {
msg = e;
} else if (e?.response?.data?.detail) {
msg = e.response.data.detail;
} else if (e?.detail) {
msg = e.detail;
} else if (e?.message) {
msg = e.message;
}
alert('删除失败: ' + msg);
}
};
const handleToggleStatus = async (s: ServiceItem) => {
try {
await adminAPI.updateService(s.id, { status: s.status === 'active' ? 'inactive' : 'active' });
await loadData();
} catch (e: any) {
alert('操作失败: ' + (e.message || '未知错误'));
}
};
const handleAssignAgent = async (agentId: string) => {
try {
await adminAPI.assignAgentToService(agentServiceId, agentId);
alert('Agent绑定成功');
setShowAgentModal(false);
await loadData();
} catch (e: any) {
alert('绑定失败: ' + (e.message || '未知错误'));
}
};
const handleUpdateRequest = async (id: string, status: string) => {
try {
await adminAPI.updateCustomRequest(id, { status });
await loadData();
} catch (e: any) {
alert('更新失败: ' + (e.message || '未知错误'));
}
};
const handleAssignAgentToRequest = async (id: string, agentId: string) => {
try {
await adminAPI.updateCustomRequest(id, { assigned_agent_id: agentId, status: 'processing' });
await loadData();
} catch (e: any) {
alert('分配失败: ' + (e.message || '未知错误'));
}
};
const formatDate = (d: string) => {
if (!d) return '--';
const dt = new Date(d);
return dt.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
};
if (loading) return <div style={{ textAlign: 'center', padding: 60 }}>加载中...</div>;
return (
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-2xl)' }}>
<h1 className="admin-page-title" style={{ marginBottom: 0 }}>增值服务管理</h1>
<button className="admin-btn admin-btn-primary" onClick={() => {
setEditingService(null);
setForm({ name: '', service_type: 'app_custom', description: '', price: 0, features: '', status: 'active', assigned_agent_id: '' });
setShowModal(true);
}}>
<SVGIcon><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></SVGIcon>
上架新服务
</button>
</div>
{/* Tabs */}
<div style={{ display: 'flex', gap: 'var(--space-md)', marginBottom: 'var(--space-xl)', borderBottom: '1px solid var(--color-border-light)', paddingBottom: 'var(--space-md)' }}>
<button
className={`pricing-cycle-tab ${activeTab === 'services' ? 'active' : ''}`}
onClick={() => setActiveTab('services')}
style={{ padding: 'var(--space-sm) var(--space-lg)', borderRadius: 'var(--radius-md)', fontSize: 'var(--text-sm)', fontWeight: 600, border: 'none', cursor: 'pointer', background: activeTab === 'services' ? 'var(--color-bg-secondary)' : 'transparent', color: activeTab === 'services' ? 'var(--color-text-primary)' : 'var(--color-text-tertiary)' }}
>
服务列表 ({services.length})
</button>
<button
className={`pricing-cycle-tab ${activeTab === 'requests' ? 'active' : ''}`}
onClick={() => setActiveTab('requests')}
style={{ padding: 'var(--space-sm) var(--space-lg)', borderRadius: 'var(--radius-md)', fontSize: 'var(--text-sm)', fontWeight: 600, border: 'none', cursor: 'pointer', background: activeTab === 'requests' ? 'var(--color-bg-secondary)' : 'transparent', color: activeTab === 'requests' ? 'var(--color-text-primary)' : 'var(--color-text-tertiary)' }}
>
工单管理 ({requests.length})
</button>
</div>
{/* Services List */}
{activeTab === 'services' && (
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">增值服务列表</h3>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>名称</th>
<th>类型</th>
<th>状态</th>
<th>价格</th>
<th>绑定Agent</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{services.map(s => {
const typeConfig = SERVICE_TYPE_MAP[s.type] || SERVICE_TYPE_MAP.app_custom;
const statusConfig = STATUS_MAP[s.status] || STATUS_MAP.active;
const boundAgent = s.assigned_agent_id ? agents.find(a => a.id === s.assigned_agent_id) : null;
return (
<tr key={s.id}>
<td>
<div style={{ fontWeight: 600 }}>{s.name}</div>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)', maxWidth: 280, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.description}</div>
</td>
<td>
<span style={{
display: 'inline-flex', padding: '2px 10px', borderRadius: 'var(--radius-full)',
fontSize: '11px', fontWeight: 600, color: typeConfig.color, background: typeConfig.bg
}}>
{typeConfig.label}
</span>
</td>
<td><span className={`admin-badge ${statusConfig.cls}`}>{statusConfig.label}</span></td>
<td style={{ fontFamily: 'var(--font-mono)', fontWeight: 600 }}>
{s.price > 0 ? s.price.toFixed(2) + ' 元' : '面议'}
</td>
<td>
{boundAgent ? (
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--color-accent)', fontWeight: 500 }}>
{boundAgent.name}
</span>
) : (
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-disabled)' }}>未绑定</span>
)}
</td>
<td>
<div style={{ display: 'flex', gap: 'var(--space-xs)', flexWrap: 'wrap' }}>
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={() => handleEdit(s)}>
<SVGIcon size={12}><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></SVGIcon>
编辑
</button>
{s.type === 'app_custom' && (
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={() => { setAgentServiceId(s.id); setShowAgentModal(true); }}>
<SVGIcon size={12}><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></SVGIcon>
Agent
</button>
)}
<button
className={`admin-btn ${s.status === 'active' ? 'admin-btn-danger' : 'admin-btn-outline'} admin-btn-sm`}
onClick={() => handleToggleStatus(s)}
>
{s.status === 'active' ? '下架' : '上架'}
</button>
<button className="admin-btn admin-btn-danger admin-btn-sm" onClick={() => handleDelete(s.id, s.name)}>
<SVGIcon size={12}><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></SVGIcon>
删除
</button>
</div>
</td>
</tr>
);
})}
{services.length === 0 && (
<tr><td colSpan={6} style={{ textAlign: 'center', color: 'var(--color-text-disabled)', padding: 'var(--space-3xl)' }}>暂无增值服务</td></tr>
)}
</tbody>
</table>
</div>
</div>
)}
{/* CustomRequests List */}
{activeTab === 'requests' && (
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">服务工单列表</h3>
</div>
<div style={{ overflowX: 'auto' }}>
<table className="admin-table">
<thead>
<tr>
<th>工单号</th>
<th>用户</th>
<th>类型</th>
<th>标题</th>
<th>状态</th>
<th>分配Agent</th>
<th>时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{requests.map(req => {
const typeConfig = SERVICE_TYPE_MAP[req.request_type] || SERVICE_TYPE_MAP.app_custom;
const statusConfig = REQUEST_STATUS_MAP[req.status] || REQUEST_STATUS_MAP.pending;
return (
<tr key={req.id}>
<td style={{ fontFamily: 'var(--font-mono)', fontSize: 'var(--text-xs)' }}>{req.id.slice(0, 8)}...</td>
<td style={{ fontWeight: 500 }}>{req.username}</td>
<td>
<span style={{
display: 'inline-flex', padding: '2px 10px', borderRadius: 'var(--radius-full)',
fontSize: '11px', fontWeight: 600, color: typeConfig.color, background: typeConfig.bg
}}>
{typeConfig.label}
</span>
</td>
<td style={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{req.title}</td>
<td><span className={`admin-badge ${statusConfig.cls}`}>{statusConfig.label}</span></td>
<td>
{req.assigned_agent_id ? (
(() => {
const agent = agents.find(a => a.id === req.assigned_agent_id);
return <span style={{ fontSize: 'var(--text-xs)', color: 'var(--color-accent)', fontWeight: 500 }}>{agent ? agent.name : req.assigned_agent_id.slice(0, 8)}</span>;
})()
) : (
<span style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-disabled)' }}>未分配</span>
)}
</td>
<td style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{formatDate(req.created_at)}</td>
<td>
<div style={{ display: 'flex', gap: 'var(--space-xs)', flexWrap: 'wrap' }}>
{req.status === 'pending' && (
<>
<select
style={{ fontSize: 'var(--text-xs)', padding: '4px 8px', borderRadius: 'var(--radius-sm)', border: '1px solid var(--color-border)' }}
defaultValue=""
onChange={e => {
if (e.target.value) handleAssignAgentToRequest(req.id, e.target.value);
}}
>
<option value="">分配Agent</option>
{agents.map(a => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
<button className="admin-btn admin-btn-danger admin-btn-sm" onClick={() => handleUpdateRequest(req.id, 'rejected')}>
驳回
</button>
</>
)}
{req.status === 'processing' && (
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={() => handleUpdateRequest(req.id, 'completed')}>
标记完成
</button>
)}
</div>
</td>
</tr>
);
})}
{requests.length === 0 && (
<tr><td colSpan={8} style={{ textAlign: 'center', color: 'var(--color-text-disabled)', padding: 'var(--space-3xl)' }}>暂无服务工单</td></tr>
)}
</tbody>
</table>
</div>
</div>
)}
{/* Add/Edit Modal */}
{showModal && (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.4)', zIndex: 9999,
display: 'flex', alignItems: 'center', justifyContent: 'center',
animation: 'fadeIn var(--transition-normal)',
}} onClick={() => { setShowModal(false); setEditingService(null); }}>
<div style={{
background: '#FFFFFF', borderRadius: 'var(--radius-2xl)', width: 520, maxHeight: '90vh', overflow: 'auto',
padding: 'var(--space-3xl)', boxShadow: 'var(--shadow-xl)',
}} onClick={e => e.stopPropagation()}>
<h3 style={{ fontSize: 'var(--text-lg)', fontWeight: 700, marginBottom: 'var(--space-2xl)' }}>
{editingService ? '编辑服务' : '上架新服务'}
</h3>
<div className="admin-form-group">
<label className="admin-form-label">服务名称</label>
<input className="admin-form-input" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} placeholder="输入服务名称" />
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">服务类型</label>
<select className="admin-form-input" value={form.service_type} onChange={e => setForm({ ...form, service_type: e.target.value })}>
{Object.entries(SERVICE_TYPE_MAP).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
</select>
</div>
<div className="admin-form-group">
<label className="admin-form-label">状态</label>
<select className="admin-form-input" value={form.status} onChange={e => setForm({ ...form, status: e.target.value })}>
<option value="active">上架</option>
<option value="inactive">下架</option>
</select>
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">服务描述</label>
<textarea
className="admin-form-input admin-form-textarea"
value={form.description}
onChange={e => setForm({ ...form, description: e.target.value })}
placeholder="输入服务描述"
style={{ minHeight: 80 }}
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">服务特性(逗号分隔)</label>
<input className="admin-form-input" value={form.features} onChange={e => setForm({ ...form, features: e.target.value })} placeholder="特性1, 特性2, 特性3" />
</div>
<div className="admin-form-group">
<label className="admin-form-label">价格(元, 0表示面议)</label>
<input className="admin-form-input" type="number" value={form.price} onChange={e => setForm({ ...form, price: Number(e.target.value) })} />
</div>
<div className="admin-form-actions">
<button className="admin-btn admin-btn-primary" onClick={handleSave}>保存</button>
<button className="admin-btn admin-btn-outline" onClick={() => { setShowModal(false); setEditingService(null); }}>取消</button>
</div>
</div>
</div>
)}
{/* Agent Assignment Modal */}
{showAgentModal && (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.4)', zIndex: 9999,
display: 'flex', alignItems: 'center', justifyContent: 'center',
animation: 'fadeIn var(--transition-normal)',
}} onClick={() => setShowAgentModal(false)}>
<div style={{
background: '#FFFFFF', borderRadius: 'var(--radius-2xl)', width: 480, maxHeight: '70vh', overflow: 'auto',
padding: 'var(--space-3xl)', boxShadow: 'var(--shadow-xl)',
}} onClick={e => e.stopPropagation()}>
<h3 style={{ fontSize: 'var(--text-lg)', fontWeight: 700, marginBottom: 'var(--space-lg)' }}>
选择要绑定的Agent
</h3>
<p style={{ fontSize: 'var(--text-sm)', color: 'var(--color-text-tertiary)', marginBottom: 'var(--space-xl)' }}>
从已发布的市场应用中选择一个Agent绑定到该应用定制服务
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-md)', maxHeight: 300, overflowY: 'auto' }}>
{agents.map(agent => (
<div
key={agent.id}
style={{
padding: 'var(--space-lg)', border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-lg)',
cursor: 'pointer', transition: 'all var(--transition-fast)',
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
}}
onClick={() => handleAssignAgent(agent.id)}
onMouseEnter={e => (e.currentTarget.style.borderColor = 'var(--color-accent)')}
onMouseLeave={e => (e.currentTarget.style.borderColor = 'var(--color-border-light)')}
>
<div>
<div style={{ fontWeight: 600, fontSize: 'var(--text-sm)', color: 'var(--color-text-primary)' }}>{agent.name}</div>
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)', marginTop: 4 }}>{agent.description}</div>
<div style={{ fontSize: '11px', color: 'var(--color-text-disabled)', marginTop: 4 }}>发布者: {agent.publisher_name}</div>
</div>
<button className="admin-btn admin-btn-primary admin-btn-sm">选择</button>
</div>
))}
{agents.length === 0 && (
<p style={{ textAlign: 'center', color: 'var(--color-text-disabled)', padding: 'var(--space-xl)' }}>暂无可用Agent</p>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default Services;
+454
View File
@@ -0,0 +1,454 @@
import React, { useState, useEffect } from 'react';
import { adminAPI } from '@/api';
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
const SiteSettings: React.FC = () => {
const [brand, setBrand] = useState<Record<string, string>>({
logo: '', siteName: '', announcement: '', icpNumber: '', contactPhone: '',
});
const [mail, setMail] = useState({
smtp_host: '', smtp_port: 587, username: '', password: '', from_address: '', use_tls: true,
});
const [sms, setSms] = useState({
provider: '', api_key: '', api_secret: '', sign_name: '', template_code: '',
});
const [savedMsg, setSavedMsg] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [testResult, setTestResult] = useState<string | null>(null);
const [showEmailTest, setShowEmailTest] = useState(false);
const [emailTestTo, setEmailTestTo] = useState('');
const [emailTestSubject, setEmailTestSubject] = useState('');
const [emailTestBody, setEmailTestBody] = useState('');
const [emailGenerating, setEmailGenerating] = useState(false);
// AI生成测试邮件内容
const handleAIGenerateContent = async () => {
setEmailGenerating(true);
try {
const res = await adminAPI.generateEmailContent('测试邮件');
const data = (res as any).data || res;
setEmailTestSubject(data.subject || '');
setEmailTestBody(data.body || '');
} catch (e) {
// 降级:使用客户端预设内容
const subjects = [
'【测试】AIFlow邮件服务配置验证',
'AIFlow平台 - 邮件投递测试',
'测试邮件:您的SMTP服务已就绪',
];
const bodies = [
'您好!\n\n这是一封来自 AIFlow 智能自动化平台的测试邮件。\n\n如果您收到此邮件,说明SMTP邮件服务已配置成功。\n\n此致\nAIFlow 平台团队',
'Hi,\n\nThis is a test email from AIFlow Platform.\n\nIf you received this, the SMTP service works fine.\n\nBest regards,\nAIFlow Team',
'【系统通知】\n邮件服务连通性测试通过。\n\n发件服务器配置正确。\n\n—— AIFlow',
];
setEmailTestSubject(subjects[Math.floor(Math.random() * subjects.length)]);
setEmailTestBody(bodies[Math.floor(Math.random() * bodies.length)]);
} finally {
setEmailGenerating(false);
}
};
// 加载真实配置
useEffect(() => {
async function loadConfig() {
try {
const [siteRes, emailRes, smsRes] = await Promise.all([
adminAPI.getSiteSettings(),
adminAPI.getEmailConfig(),
adminAPI.getSmsConfig(),
]);
if (siteRes?.data) {
setBrand({
logo: siteRes.data.logo || '',
siteName: siteRes.data.site_name || siteRes.data.siteName || '',
announcement: siteRes.data.announcement || '',
icpNumber: siteRes.data.icp_number || siteRes.data.icpNumber || '',
contactPhone: siteRes.data.contact_phone || siteRes.data.contactPhone || '',
});
}
if (emailRes?.data) {
setMail({
smtp_host: emailRes.data.smtp_host || '',
smtp_port: emailRes.data.smtp_port || 587,
username: emailRes.data.username || '',
password: emailRes.data.password || '',
from_address: emailRes.data.from_address || '',
use_tls: emailRes.data.use_tls !== false,
});
}
if (smsRes?.data) {
setSms({
provider: smsRes.data.provider || '',
api_key: smsRes.data.api_key || '',
api_secret: smsRes.data.api_secret || '',
sign_name: smsRes.data.sign_name || '',
template_code: smsRes.data.template_code || '',
});
}
} catch (e) {
console.error('加载配置失败:', e);
} finally {
setLoading(false);
}
}
loadConfig();
}, []);
const showSaved = (block: string) => {
setSavedMsg(block + ' 保存成功');
setTimeout(() => setSavedMsg(null), 2000);
};
const showTestResult = (result: string) => {
setTestResult(result);
setTimeout(() => setTestResult(null), 5000);
};
// 保存品牌设置
const saveSiteSettings = async () => {
try {
await adminAPI.updateSiteSettings({
logo: brand.logo,
site_name: brand.siteName,
announcement: brand.announcement,
icp_number: brand.icpNumber,
contact_phone: brand.contactPhone,
});
showSaved('品牌设置');
} catch (e: any) {
alert('保存失败: ' + (e.detail || e.message));
}
};
// 保存邮件配置
const saveEmailConfig = async () => {
try {
await adminAPI.updateEmailConfig({
smtp_host: mail.smtp_host,
smtp_port: mail.smtp_port,
username: mail.username,
password: mail.password,
from_address: mail.from_address,
use_tls: mail.use_tls,
});
showSaved('邮件配置');
} catch (e: any) {
alert('保存失败: ' + (e.detail || e.message));
}
};
// 保存短信配置
const saveSmsConfig = async () => {
try {
await adminAPI.updateSmsConfig({
provider: sms.provider,
api_key: sms.api_key,
api_secret: sms.api_secret,
sign_name: sms.sign_name,
template_code: sms.template_code,
});
showSaved('短信配置');
} catch (e: any) {
alert('保存失败: ' + (e.detail || e.message));
}
};
// 发送测试邮件 - 先保存配置再发送
const sendTestEmail = async () => {
if (!emailTestTo.trim()) {
showTestResult('请输入收件邮箱地址');
return;
}
try {
// 测试前先保存邮件配置到数据库,确保使用最新配置
await adminAPI.updateEmailConfig({
smtp_host: mail.smtp_host,
smtp_port: mail.smtp_port,
username: mail.username,
password: mail.password,
from_address: mail.from_address,
use_tls: mail.use_tls,
});
const res = await adminAPI.testEmail({
test_to: emailTestTo.trim(),
test_subject: emailTestSubject || undefined,
test_body: emailTestBody || undefined,
});
showTestResult('测试邮件已发送至 ' + emailTestTo + ': ' + ((res as any)?.message || 'OK'));
setShowEmailTest(false);
} catch (e: any) {
showTestResult('测试邮件发送失败: ' + (e.detail || e.message));
}
};
const openEmailTest = () => {
setEmailTestTo('');
setEmailTestSubject('');
setEmailTestBody('');
setShowEmailTest(true);
};
// 发送测试短信
const sendTestSms = async () => {
try {
const res = await adminAPI.testSms();
showTestResult('测试短信发送成功: ' + (res?.message || 'OK'));
} catch (e: any) {
showTestResult('测试短信发送失败: ' + (e.detail || e.message));
}
};
if (loading) {
return <div style={{ padding: 40, textAlign: 'center' }}>加载中...</div>;
}
return (
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-2xl)' }}>
<h1 className="admin-page-title" style={{ marginBottom: 0 }}>网站基础设置</h1>
{savedMsg && (
<span className="admin-badge badge-success" style={{ padding: '6px 14px', fontSize: 'var(--text-sm)' }}>
<SVGIcon><polyline points="20 6 9 17 4 12"/></SVGIcon>
{' '}{savedMsg}
</span>
)}
</div>
{/* Brand Settings */}
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">
<SVGIcon size={18}><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></SVGIcon>
{' '}品牌设置
</h3>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">LOGO URL</label>
<input className="admin-form-input" value={brand.logo} onChange={e => setBrand({ ...brand, logo: e.target.value })} />
</div>
<div className="admin-form-group">
<label className="admin-form-label">网站名称</label>
<input className="admin-form-input" value={brand.siteName} onChange={e => setBrand({ ...brand, siteName: e.target.value })} />
</div>
</div>
<div className="admin-form-group">
<label className="admin-form-label">首页公告</label>
<textarea
className="admin-form-input admin-form-textarea"
value={brand.announcement}
onChange={e => setBrand({ ...brand, announcement: e.target.value })}
style={{ minHeight: 60 }}
/>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">备案号</label>
<input className="admin-form-input" value={brand.icpNumber} onChange={e => setBrand({ ...brand, icpNumber: e.target.value })} />
</div>
<div className="admin-form-group">
<label className="admin-form-label">客服联系方式</label>
<input className="admin-form-input" value={brand.contactPhone} onChange={e => setBrand({ ...brand, contactPhone: e.target.value })} />
</div>
</div>
<div className="admin-form-actions">
<button className="admin-btn admin-btn-primary" onClick={saveSiteSettings}>
<SVGIcon><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></SVGIcon>
保存设置
</button>
</div>
</div>
{/* Mail Config */}
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">
<SVGIcon size={18}><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></SVGIcon>
{' '}邮件配置
</h3>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">SMTP服务器</label>
<input className="admin-form-input" value={mail.smtp_host} onChange={e => setMail({ ...mail, smtp_host: e.target.value })} />
</div>
<div className="admin-form-group">
<label className="admin-form-label">端口</label>
<input className="admin-form-input" type="number" value={mail.smtp_port} onChange={e => setMail({ ...mail, smtp_port: Number(e.target.value) })} />
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">用户名</label>
<input className="admin-form-input" value={mail.username} onChange={e => setMail({ ...mail, username: e.target.value })} />
</div>
<div className="admin-form-group">
<label className="admin-form-label">密码</label>
<input className="admin-form-input" type="password" value={mail.password} onChange={e => setMail({ ...mail, password: e.target.value })} />
</div>
</div>
<div className="admin-form-group" style={{ maxWidth: '50%' }}>
<label className="admin-form-label">发件地址</label>
<input className="admin-form-input" value={mail.from_address} onChange={e => setMail({ ...mail, from_address: e.target.value })} />
</div>
<div className="admin-form-actions">
<button className="admin-btn admin-btn-primary" onClick={saveEmailConfig}>
<SVGIcon><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></SVGIcon>
保存设置
</button>
<button className="admin-btn admin-btn-outline" onClick={openEmailTest}>
<SVGIcon><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></SVGIcon>
发送测试邮件
</button>
</div>
</div>
{/* SMS Config */}
<div className="admin-card">
<div className="admin-card-header">
<h3 className="admin-card-title">
<SVGIcon size={18}><rect x="5" y="2" width="14" height="20" rx="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></SVGIcon>
{' '}短信配置
</h3>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">短信服务商</label>
<select className="admin-form-input" value={sms.provider} onChange={e => setSms({ ...sms, provider: e.target.value })}>
<option value="">请选择</option>
<option value="阿里云短信">阿里云短信</option>
<option value="腾讯云短信">腾讯云短信</option>
<option value="华为云短信">华为云短信</option>
</select>
</div>
<div className="admin-form-group">
<label className="admin-form-label">短信签名</label>
<input className="admin-form-input" value={sms.sign_name} onChange={e => setSms({ ...sms, sign_name: e.target.value })} />
</div>
</div>
<div className="admin-form-row">
<div className="admin-form-group">
<label className="admin-form-label">API Key</label>
<input className="admin-form-input" value={sms.api_key} onChange={e => setSms({ ...sms, api_key: e.target.value })} />
</div>
<div className="admin-form-group">
<label className="admin-form-label">API Secret</label>
<input className="admin-form-input" type="password" value={sms.api_secret} onChange={e => setSms({ ...sms, api_secret: e.target.value })} />
</div>
</div>
<div className="admin-form-actions">
<button className="admin-btn admin-btn-primary" onClick={saveSmsConfig}>
<SVGIcon><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></SVGIcon>
保存设置
</button>
<button className="admin-btn admin-btn-outline" onClick={sendTestSms}>
<SVGIcon><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></SVGIcon>
发送测试短信
</button>
</div>
</div>
{/* Email Test Modal */}
{showEmailTest && (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.4)', zIndex: 'var(--z-modal)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}} onClick={() => setShowEmailTest(false)}>
<div style={{
background: '#fff', borderRadius: 'var(--radius-2xl)', width: 520, maxHeight: '90vh', overflow: 'auto',
padding: 'var(--space-3xl)', boxShadow: 'var(--shadow-xl)',
}} onClick={e => e.stopPropagation()}>
<h3 style={{ fontSize: 'var(--text-lg)', fontWeight: 700, marginBottom: 'var(--space-2xl)' }}>
<SVGIcon size={20}><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></SVGIcon>
{' '}发送测试邮件
</h3>
<div className="admin-form-group">
<label className="admin-form-label">收件邮箱 <span style={{ color: 'var(--color-error)' }}>*</span></label>
<input
className="admin-form-input"
type="email"
value={emailTestTo}
onChange={e => setEmailTestTo(e.target.value)}
placeholder="例如: admin@example.com"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">邮件主题</label>
<input
className="admin-form-input"
value={emailTestSubject}
onChange={e => setEmailTestSubject(e.target.value)}
placeholder="测试邮件标题"
/>
</div>
<div className="admin-form-group">
<label className="admin-form-label">邮件内容</label>
<textarea
className="admin-form-input admin-form-textarea"
value={emailTestBody}
onChange={e => setEmailTestBody(e.target.value)}
style={{ minHeight: 120 }}
placeholder="测试邮件内容..."
/>
</div>
<div style={{ display: 'flex', gap: 'var(--space-sm)', justifyContent: 'space-between', marginBottom: 'var(--space-lg)' }}>
<button
className="admin-btn admin-btn-outline admin-btn-sm"
onClick={handleAIGenerateContent}
disabled={emailGenerating}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}
>
<SVGIcon size={12}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></SVGIcon>
{emailGenerating ? '生成中...' : 'AI生成测试内容'}
</button>
</div>
<div className="admin-form-actions">
<button
className="admin-btn admin-btn-primary"
onClick={sendTestEmail}
disabled={!emailTestTo.trim()}
>
<SVGIcon><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></SVGIcon>
发送测试邮件
</button>
<button className="admin-btn admin-btn-outline" onClick={() => setShowEmailTest(false)}>
取消
</button>
</div>
</div>
</div>
)}
{/* Test Result Toast */}
{testResult && (
<div style={{
position: 'fixed', bottom: 24, right: 24, padding: '12px 20px',
background: testResult.includes('失败') ? 'var(--color-error)' : 'var(--color-success)',
color: '#fff', borderRadius: 8, boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
zIndex: 9999, fontSize: 'var(--text-sm)',
}}>
{testResult}
</div>
)}
</div>
);
};
export default SiteSettings;
+584
View File
@@ -0,0 +1,584 @@
/* ===== Admin Users Page ===== */
.admin-users-page {
display: flex;
flex-direction: column;
gap: var(--space-2xl);
animation: fadeIn var(--transition-normal);
}
.admin-page-title {
font-size: var(--text-xl);
font-weight: 700;
color: var(--color-text-primary);
letter-spacing: -0.02em;
}
/* ===== Card ===== */
.admin-card {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-xl);
padding: var(--space-2xl);
}
.admin-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-xl);
padding-bottom: var(--space-lg);
border-bottom: 1px solid var(--color-border-light);
}
.admin-card-title {
font-size: var(--text-lg);
font-weight: 600;
color: var(--color-text-primary);
}
.admin-card-count {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
background: var(--color-bg-tertiary);
padding: 4px 12px;
border-radius: var(--radius-full);
}
.admin-card-subtitle {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
}
/* ===== Toolbar ===== */
.users-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-lg);
margin-bottom: var(--space-xl);
flex-wrap: wrap;
}
.users-search {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
min-width: 300px;
}
.users-search svg {
color: var(--color-text-tertiary);
flex-shrink: 0;
}
.users-search-input {
border: none;
outline: none;
background: transparent;
font-size: var(--text-sm);
color: var(--color-text-primary);
width: 100%;
}
.users-search-input::placeholder {
color: var(--color-text-disabled);
}
.users-filters {
display: flex;
gap: var(--space-sm);
}
.users-filter-select {
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: var(--text-xs);
color: var(--color-text-secondary);
background: var(--color-bg-tertiary);
outline: none;
cursor: pointer;
transition: border-color var(--transition-fast);
}
.users-filter-select:focus {
border-color: var(--color-accent);
}
/* ===== Table ===== */
.users-table-wrap {
overflow-x: auto;
}
.users-table {
width: 100%;
border-collapse: collapse;
}
.users-table thead {
background: var(--color-bg-tertiary);
}
.users-table th {
padding: var(--space-md) var(--space-lg);
text-align: left;
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-tertiary);
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
}
.users-table td {
padding: var(--space-md) var(--space-lg);
font-size: var(--text-sm);
color: var(--color-text-secondary);
border-bottom: 1px solid var(--color-border-light);
white-space: nowrap;
}
.users-table tbody tr {
transition: background var(--transition-fast);
}
.users-table tbody tr:hover {
background: var(--color-bg-tertiary);
}
.users-cell-user {
display: flex;
align-items: center;
gap: var(--space-sm);
color: var(--color-text-primary);
font-weight: 500;
}
.users-cell-avatar {
width: 32px;
height: 32px;
border-radius: var(--radius-full);
background: linear-gradient(135deg, var(--color-accent), #8B8DFF);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--text-xs);
font-weight: 700;
}
.users-cell-mono {
font-family: var(--font-mono);
font-size: var(--text-xs) !important;
}
.users-cell-date {
font-size: var(--text-xs) !important;
color: var(--color-text-tertiary) !important;
}
.users-cell-actions {
display: flex;
gap: var(--space-xs);
}
.users-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
background: var(--color-bg-secondary);
color: var(--color-text-tertiary);
cursor: pointer;
transition: all var(--transition-fast);
}
.users-action-btn:hover {
border-color: var(--color-accent);
color: var(--color-accent);
}
.users-action-btn.danger:hover {
border-color: var(--color-error);
color: var(--color-error);
}
.users-action-btn.success:hover {
border-color: var(--color-success);
color: var(--color-success);
}
/* Badges */
.users-badge {
display: inline-flex;
padding: 2px 10px;
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: 500;
}
.users-level-badge {
display: inline-flex;
padding: 2px 10px;
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: 500;
background: var(--color-accent-subtle);
color: var(--color-accent);
}
.users-empty {
text-align: center;
padding: var(--space-3xl);
color: var(--color-text-disabled);
font-size: var(--text-sm);
}
/* ===== User Detail Modal ===== */
.admin-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: var(--z-modal);
animation: fadeIn var(--transition-fast);
}
.admin-modal {
background: var(--color-bg-secondary);
border-radius: var(--radius-xl);
width: 100%;
max-width: 560px;
max-height: 85vh;
overflow-y: auto;
box-shadow: var(--shadow-xl);
animation: slideUp var(--transition-normal);
}
.admin-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-xl) var(--space-2xl);
border-bottom: 1px solid var(--color-border-light);
}
.admin-modal-header h2 {
font-size: var(--text-lg);
font-weight: 600;
color: var(--color-text-primary);
}
.admin-modal-close {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--radius-md);
border: none;
background: transparent;
color: var(--color-text-tertiary);
cursor: pointer;
transition: all var(--transition-fast);
}
.admin-modal-close:hover {
background: var(--color-bg-hover);
color: var(--color-text-primary);
}
.admin-modal-body {
padding: var(--space-2xl);
}
.user-detail-top {
display: flex;
align-items: center;
gap: var(--space-lg);
margin-bottom: var(--space-2xl);
}
.user-detail-avatar {
width: 56px;
height: 56px;
border-radius: var(--radius-xl);
background: linear-gradient(135deg, var(--color-accent), #8B8DFF);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--text-xl);
font-weight: 700;
flex-shrink: 0;
}
.user-detail-basic h3 {
font-size: var(--text-lg);
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: var(--space-sm);
}
.user-detail-tags {
display: flex;
gap: var(--space-sm);
flex-wrap: wrap;
}
.user-detail-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 10px;
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: 500;
}
.user-detail-tag.role-tag {
background: var(--color-accent-subtle);
color: var(--color-accent);
}
.user-detail-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-lg);
}
.user-detail-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.detail-label {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
}
.detail-value {
font-size: var(--text-sm);
color: var(--color-text-primary);
font-weight: 500;
}
/* ===== Register Settings ===== */
.register-settings {
display: flex;
flex-direction: column;
gap: var(--space-2xl);
}
.register-settings-section {
padding: var(--space-xl);
background: var(--color-bg-tertiary);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-light);
}
.register-section-title {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: var(--text-md);
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: var(--space-lg);
padding-bottom: var(--space-md);
border-bottom: 1px solid var(--color-border-light);
}
.register-section-title svg {
color: var(--color-accent);
}
.register-setting-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-md) 0;
}
.register-setting-row + .register-setting-row {
border-top: 1px solid var(--color-border-light);
}
.register-setting-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.register-setting-info strong {
font-size: var(--text-sm);
color: var(--color-text-primary);
}
.register-setting-info span {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
}
/* Toggle Switch */
.toggle-switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
cursor: pointer;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--color-border);
border-radius: 24px;
transition: background var(--transition-fast);
}
.toggle-slider::before {
content: '';
position: absolute;
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background: white;
border-radius: 50%;
transition: transform var(--transition-fast);
}
.toggle-switch input:checked + .toggle-slider {
background: var(--color-accent);
}
.toggle-switch input:checked + .toggle-slider::before {
transform: translateX(20px);
}
.toggle-switch input:disabled + .toggle-slider {
opacity: 0.4;
cursor: not-allowed;
}
/* Aliyun Config */
.aliyun-config {
margin-top: var(--space-lg);
padding: var(--space-lg);
background: var(--color-bg-primary);
border-radius: var(--radius-md);
border: 1px dashed var(--color-border);
}
.aliyun-config-header {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-text-secondary);
margin-bottom: var(--space-lg);
}
.aliyun-config-header svg {
color: var(--color-accent);
}
.aliyun-config-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-md);
}
.aliyun-config-grid .form-group {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.aliyun-config-grid .form-group label {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
font-weight: 500;
}
.users-form-input {
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: var(--text-sm);
color: var(--color-text-primary);
background: var(--color-bg-secondary);
outline: none;
transition: border-color var(--transition-fast);
}
.users-form-input:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-light);
}
.users-form-input::placeholder {
color: var(--color-text-disabled);
font-size: var(--text-xs);
}
/* Save Button */
.users-save-btn {
align-self: flex-end;
padding: var(--space-md) var(--space-2xl);
background: var(--color-accent);
color: white;
border: none;
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
transition: all var(--transition-fast);
}
.users-save-btn:hover {
background: var(--color-accent-hover);
}
@media (max-width: 768px) {
.users-toolbar {
flex-direction: column;
align-items: stretch;
}
.users-search {
min-width: auto;
}
.users-filters {
flex-wrap: wrap;
}
.user-detail-grid {
grid-template-columns: 1fr;
}
.aliyun-config-grid {
grid-template-columns: 1fr;
}
}
+561
View File
@@ -0,0 +1,561 @@
import React, { useState, useMemo, useEffect } from 'react';
import { adminAPI } from '@/api';
import { mockUsers, mockMembershipPlans } from '@/mock/data';
import type { User, RegisterSettings } from '@/types';
import './Users.css';
// ===== SVG Icons =====
const IconSearch = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
);
const IconEye = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
</svg>
);
const IconCheck = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
);
const IconX = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
);
const IconShield = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
);
const IconPhone = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/>
</svg>
);
const IconMail = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/>
</svg>
);
const IconServer = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>
</svg>
);
const IconClose = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
);
// ===== Helpers =====
const realNameStatusMap: Record<string, { label: string; color: string; bg: string }> = {
verified: { label: '已实名', color: 'var(--color-success)', bg: 'var(--color-success-light)' },
pending: { label: '审核中', color: 'var(--color-warning)', bg: 'var(--color-warning-light)' },
unverified: { label: '未实名', color: 'var(--color-text-tertiary)', bg: 'var(--color-bg-tertiary)' },
};
const statusMap: Record<string, { label: string; color: string; bg: string }> = {
active: { label: '正常', color: 'var(--color-success)', bg: 'var(--color-success-light)' },
banned: { label: '已封禁', color: 'var(--color-error)', bg: 'var(--color-error-light)' },
};
// ===== User Detail Modal =====
interface UserDetailModalProps {
user: User;
onClose: () => void;
}
const UserDetailModal: React.FC<UserDetailModalProps> = ({ user, onClose }) => {
const plan = mockMembershipPlans.find(p => p.id === user.membership?.planId);
const rn = realNameStatusMap[user.realNameStatus || 'unverified'];
return (
<div className="admin-modal-overlay" onClick={onClose}>
<div className="admin-modal users-detail-modal" onClick={e => e.stopPropagation()}>
<div className="admin-modal-header">
<h2>用户详情</h2>
<button className="admin-modal-close" onClick={onClose}><IconClose /></button>
</div>
<div className="admin-modal-body">
<div className="user-detail-top">
<div className="user-detail-avatar">
{user.username.charAt(0).toUpperCase()}
</div>
<div className="user-detail-basic">
<h3>{user.username}</h3>
<div className="user-detail-tags">
<span className="user-detail-tag" style={{ color: rn.color, background: rn.bg }}>
<IconShield />
{rn.label}
</span>
<span className="user-detail-tag" style={{ color: statusMap[user.status].color, background: statusMap[user.status].bg }}>
{statusMap[user.status].label}
</span>
<span className="user-detail-tag role-tag">
{user.role === 'admin' ? '管理员' : '普通用户'}
</span>
</div>
</div>
</div>
<div className="user-detail-grid">
<div className="user-detail-item">
<span className="detail-label">用户ID</span>
<span className="detail-value">{user.id}</span>
</div>
<div className="user-detail-item">
<span className="detail-label">邮箱</span>
<span className="detail-value">{user.email}</span>
</div>
<div className="user-detail-item">
<span className="detail-label">手机号</span>
<span className="detail-value">{user.phone || '未绑定'}</span>
</div>
<div className="user-detail-item">
<span className="detail-label">实名姓名</span>
<span className="detail-value">{user.realName || '--'}</span>
</div>
<div className="user-detail-item">
<span className="detail-label">实名状态</span>
<span className="detail-value" style={{ color: rn.color, fontWeight: 500 }}>{rn.label}</span>
</div>
<div className="user-detail-item">
<span className="detail-label">会员等级</span>
<span className="detail-value">{user.membership?.planName || '免费版'}</span>
</div>
<div className="user-detail-item">
<span className="detail-label">会员到期</span>
<span className="detail-value">{user.membership?.expireAt ? new Date(user.membership.expireAt).toLocaleDateString('zh-CN') : '--'}</span>
</div>
<div className="user-detail-item">
<span className="detail-label">注册时间</span>
<span className="detail-value">{new Date(user.createdAt).toLocaleString('zh-CN')}</span>
</div>
</div>
</div>
</div>
</div>
);
};
// ===== Main Component =====
const UsersPage: React.FC = () => {
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [levelFilter, setLevelFilter] = useState<string>('all');
const [realNameFilter, setRealNameFilter] = useState<string>('all');
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [localUsers, setLocalUsers] = useState<User[]>(mockUsers);
// Register settings state
const [registerSettings, setRegisterSettings] = useState<RegisterSettings>({
phoneRequired: true,
phoneSmsRequired: true,
emailRequired: true,
emailVerifyRequired: true,
realNameRequired: false,
aliyunAccessKey: '',
aliyunSecretKey: '',
aliyunEndpoint: 'https://cloudauth.aliyuncs.com',
});
const [settingsSaving, setSettingsSaving] = useState(false);
const [settingsMsg, setSettingsMsg] = useState<{ type: string; text: string } | null>(null);
// 加载注册设置
useEffect(() => {
loadRegisterSettings();
}, []);
const loadRegisterSettings = async () => {
try {
const res = await adminAPI.getRegisterSettings();
const data = res?.data || res;
if (data && typeof data === 'object') {
setRegisterSettings({
phoneRequired: data.phone_required !== undefined ? data.phone_required : true,
phoneSmsRequired: data.phone_sms_required !== undefined ? data.phone_sms_required : true,
emailRequired: data.email_required !== undefined ? data.email_required : true,
emailVerifyRequired: data.email_verify_required !== undefined ? data.email_verify_required : true,
realNameRequired: data.real_name_required || false,
aliyunAccessKey: data.aliyun_access_key || '',
aliyunSecretKey: data.aliyun_secret_key || '',
aliyunEndpoint: data.aliyun_endpoint || 'https://dysmsapi.aliyuncs.com',
smsSignName: data.sms_sign_name || '',
smsTemplateCode: data.sms_template_code || '',
});
}
} catch (e) {
console.error('加载注册设置失败', e);
}
};
const filteredUsers = useMemo(() => {
let result = localUsers.filter(u => u.role === 'user');
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
result = result.filter(u =>
u.username.toLowerCase().includes(q) ||
u.email.toLowerCase().includes(q) ||
(u.phone && u.phone.includes(q)) ||
(u.realName && u.realName.includes(q))
);
}
if (statusFilter !== 'all') result = result.filter(u => u.status === statusFilter);
if (levelFilter !== 'all') result = result.filter(u => (u.membership?.level ?? 0).toString() === levelFilter);
if (realNameFilter !== 'all') result = result.filter(u => u.realNameStatus === realNameFilter);
return result;
}, [localUsers, searchQuery, statusFilter, levelFilter, realNameFilter]);
const handleToggleSetting = (key: keyof RegisterSettings) => {
setRegisterSettings(prev => ({
...prev,
[key]: !prev[key as keyof RegisterSettings],
}));
};
const handleSettingChange = (key: keyof RegisterSettings, value: string) => {
setRegisterSettings(prev => ({ ...prev, [key]: value }));
};
const handleSaveRegisterSettings = async () => {
setSettingsSaving(true);
setSettingsMsg(null);
try {
await adminAPI.updateRegisterSettings({
phone_required: registerSettings.phoneRequired,
phone_sms_required: registerSettings.phoneSmsRequired,
email_required: registerSettings.emailRequired,
email_verify_required: registerSettings.emailVerifyRequired,
real_name_required: registerSettings.realNameRequired,
aliyun_access_key: registerSettings.aliyunAccessKey,
aliyun_secret_key: registerSettings.aliyunSecretKey,
aliyun_endpoint: registerSettings.aliyunEndpoint,
sms_sign_name: registerSettings.smsSignName,
sms_template_code: registerSettings.smsTemplateCode,
});
setSettingsMsg({ type: 'success', text: '注册设置已保存' });
setTimeout(() => setSettingsMsg(null), 3000);
} catch (e: any) {
setSettingsMsg({ type: 'error', text: e?.message || e?.detail || '保存失败' });
} finally {
setSettingsSaving(false);
}
};
const handleToggleUserStatus = (userId: string) => {
setLocalUsers(prev =>
prev.map(u => u.id === userId ? {
...u,
status: u.status === 'active' ? 'banned' as const : 'active' as const,
} : u)
);
};
return (
<div className="admin-users-page">
<h1 className="admin-page-title">用户管理</h1>
{/* ===== 用户列表 ===== */}
<div className="admin-card">
<div className="admin-card-header">
<h2 className="admin-card-title">用户列表</h2>
<span className="admin-card-count">共 {filteredUsers.length} 个用户</span>
</div>
{/* 搜索和筛选 */}
<div className="users-toolbar">
<div className="users-search">
<IconSearch />
<input
type="text"
className="users-search-input"
placeholder="搜索用户名、邮箱、手机号..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
/>
</div>
<div className="users-filters">
<select className="users-filter-select" value={statusFilter} onChange={e => setStatusFilter(e.target.value)}>
<option value="all">全部状态</option>
<option value="active">正常</option>
<option value="banned">已封禁</option>
</select>
<select className="users-filter-select" value={levelFilter} onChange={e => setLevelFilter(e.target.value)}>
<option value="all">全部等级</option>
<option value="0">免费版</option>
<option value="2">专业版</option>
<option value="3">企业版</option>
</select>
<select className="users-filter-select" value={realNameFilter} onChange={e => setRealNameFilter(e.target.value)}>
<option value="all">全部实名</option>
<option value="verified">已实名</option>
<option value="pending">审核中</option>
<option value="unverified">未实名</option>
</select>
</div>
</div>
{/* 用户表格 */}
<div className="users-table-wrap">
<table className="users-table">
<thead>
<tr>
<th>用户名</th>
<th>邮箱</th>
<th>手机号</th>
<th>实名状态</th>
<th>会员等级</th>
<th>注册时间</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{filteredUsers.map(u => {
const rn = realNameStatusMap[u.realNameStatus || 'unverified'];
const st = statusMap[u.status || 'active'];
return (
<tr key={u.id}>
<td>
<div className="users-cell-user">
<div className="users-cell-avatar">{u.username.charAt(0).toUpperCase()}</div>
<span>{u.username}</span>
</div>
</td>
<td className="users-cell-mono">{u.email}</td>
<td>{u.phone || '--'}</td>
<td>
<span className="users-badge" style={{ color: rn.color, background: rn.bg }}>
{rn.label}
</span>
</td>
<td>
<span className="users-level-badge">
{u.membership?.planName || '免费版'}
</span>
</td>
<td className="users-cell-date">{new Date(u.createdAt).toLocaleDateString('zh-CN')}</td>
<td>
<span className="users-badge" style={{ color: st.color, background: st.bg }}>
{st.label}
</span>
</td>
<td>
<div className="users-cell-actions">
<button className="users-action-btn" title="查看详情" onClick={() => setSelectedUser(u)}>
<IconEye />
</button>
<button
className={`users-action-btn ${u.status === 'active' ? 'danger' : 'success'}`}
title={u.status === 'active' ? '封禁' : '解封'}
onClick={() => handleToggleUserStatus(u.id)}
>
{u.status === 'active' ? <IconX /> : <IconCheck />}
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
{filteredUsers.length === 0 && (
<div className="users-empty">无匹配的用户记录</div>
)}
</div>
</div>
{/* ===== 注册用户设置 ===== */}
<div className="admin-card" style={{ marginTop: 'var(--space-2xl)' }}>
<div className="admin-card-header">
<h2 className="admin-card-title">注册用户设置</h2>
<span className="admin-card-subtitle">配置用户注册方式与认证策略</span>
</div>
<div className="register-settings">
{/* 手机号注册 */}
<div className="register-settings-section">
<h3 className="register-section-title">
<IconPhone />
手机号注册
</h3>
<div className="register-setting-row">
<div className="register-setting-info">
<strong>手机号必填</strong>
<span>要求用户注册时填写手机号码</span>
</div>
<label className="toggle-switch">
<input
type="checkbox"
checked={registerSettings.phoneRequired}
onChange={() => handleToggleSetting('phoneRequired')}
/>
<span className="toggle-slider" />
</label>
</div>
<div className="register-setting-row">
<div className="register-setting-info">
<strong>短信验证码激活</strong>
<span>注册时发送短信验证码验证手机号</span>
</div>
<label className="toggle-switch">
<input
type="checkbox"
checked={registerSettings.phoneSmsRequired}
onChange={() => handleToggleSetting('phoneSmsRequired')}
disabled={!registerSettings.phoneRequired}
/>
<span className="toggle-slider" />
</label>
</div>
</div>
{/* 邮箱注册 */}
<div className="register-settings-section">
<h3 className="register-section-title">
<IconMail />
邮箱注册
</h3>
<div className="register-setting-row">
<div className="register-setting-info">
<strong>邮箱必填</strong>
<span>要求用户注册时填写邮箱地址</span>
</div>
<label className="toggle-switch">
<input
type="checkbox"
checked={registerSettings.emailRequired}
onChange={() => handleToggleSetting('emailRequired')}
/>
<span className="toggle-slider" />
</label>
</div>
<div className="register-setting-row">
<div className="register-setting-info">
<strong>邮箱认证激活</strong>
<span>发送验证邮件到用户邮箱完成激活</span>
</div>
<label className="toggle-switch">
<input
type="checkbox"
checked={registerSettings.emailVerifyRequired}
onChange={() => handleToggleSetting('emailVerifyRequired')}
disabled={!registerSettings.emailRequired}
/>
<span className="toggle-slider" />
</label>
</div>
</div>
{/* 实名认证 */}
<div className="register-settings-section">
<h3 className="register-section-title">
<IconShield />
实名认证
</h3>
<div className="register-setting-row">
<div className="register-setting-info">
<strong>强制实名认证</strong>
<span>要求用户完成实名认证后方可使用全部功能</span>
</div>
<label className="toggle-switch">
<input
type="checkbox"
checked={registerSettings.realNameRequired}
onChange={() => handleToggleSetting('realNameRequired')}
/>
<span className="toggle-slider" />
</label>
</div>
<div className="aliyun-config">
<div className="aliyun-config-header">
<IconServer />
<span>阿里云短信API配置(用于实名认证短信验证)</span>
</div>
<div className="aliyun-config-grid">
<div className="form-group">
<label>AccessKey ID</label>
<input
type="text"
className="users-form-input"
placeholder="请输入阿里云AccessKey ID"
value={registerSettings.aliyunAccessKey || ''}
onChange={e => handleSettingChange('aliyunAccessKey', e.target.value)}
/>
</div>
<div className="form-group">
<label>AccessKey Secret</label>
<input
type="password"
className="users-form-input"
placeholder="请输入阿里云AccessKey Secret"
value={registerSettings.aliyunSecretKey || ''}
onChange={e => handleSettingChange('aliyunSecretKey', e.target.value)}
/>
</div>
<div className="form-group">
<label>短信签名 (SignName)</label>
<input
type="text"
className="users-form-input"
placeholder="阿里云短信控制台中审核通过的签名"
value={registerSettings.smsSignName || ''}
onChange={e => handleSettingChange('smsSignName', e.target.value)}
/>
</div>
<div className="form-group">
<label>短信模板 (TemplateCode)</label>
<input
type="text"
className="users-form-input"
placeholder="SMS_XXXXXXXXXX"
value={registerSettings.smsTemplateCode || ''}
onChange={e => handleSettingChange('smsTemplateCode', e.target.value)}
/>
</div>
</div>
</div>
</div>
{settingsMsg && (
<div style={{
padding: 'var(--space-md) var(--space-lg)',
marginBottom: 'var(--space-lg)',
borderRadius: 'var(--radius-md)',
background: settingsMsg.type === 'success' ? 'var(--color-success-light)' : 'var(--color-error-light)',
color: settingsMsg.type === 'success' ? 'var(--color-success)' : 'var(--color-error)',
fontWeight: 500,
}}>
{settingsMsg.text}
</div>
)}
<button className="users-save-btn" onClick={handleSaveRegisterSettings} disabled={settingsSaving}>
{settingsSaving ? '保存中...' : '保存设置'}
</button>
</div>
</div>
{/* User Detail Modal */}
{selectedUser && (
<UserDetailModal
user={selectedUser}
onClose={() => setSelectedUser(null)}
/>
)}
</div>
);
};
export default UsersPage;
+666
View File
@@ -0,0 +1,666 @@
/* ===== Assets Page ===== */
.assets-page {
height: 100%;
overflow-y: auto;
padding: var(--space-3xl);
display: flex;
flex-direction: column;
gap: var(--space-xl);
}
.assets-header {
display: flex;
align-items: baseline;
gap: var(--space-md);
}
.assets-title {
font-size: var(--text-xl);
font-weight: 700;
color: var(--color-text-primary);
letter-spacing: -0.02em;
}
.assets-count {
font-size: var(--text-sm);
color: var(--color-text-tertiary);
}
.assets-create-btn {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 6px;
padding: var(--space-sm) var(--space-lg);
background: var(--color-accent);
color: #FFFFFF;
border: none;
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
transition: all var(--transition-fast);
}
.assets-create-btn:hover {
background: var(--color-accent-hover);
}
/* Tabs */
.assets-tabs {
display: flex;
gap: var(--space-sm);
flex-wrap: wrap;
}
.assets-tab {
display: inline-flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-lg);
border-radius: var(--radius-lg);
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-text-secondary);
background: var(--color-bg-tertiary);
transition: all var(--transition-fast);
border: 1px solid transparent;
}
.assets-tab:hover {
background: var(--color-bg-hover);
color: var(--color-text-primary);
}
.assets-tab.active {
background: var(--tab-bg, var(--color-accent-light));
color: var(--tab-color, var(--color-accent));
border-color: var(--tab-color, var(--color-accent));
font-weight: 600;
}
/* Sub-tabs */
.assets-subtabs {
display: flex;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-lg);
background: var(--color-bg-secondary);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-light);
}
.assets-subtab {
padding: 4px 12px;
border-radius: var(--radius-sm);
font-size: var(--text-xs);
font-weight: 500;
color: var(--color-text-tertiary);
transition: all var(--transition-fast);
}
.assets-subtab:hover { color: var(--color-text-primary); }
.assets-subtab.active {
background: var(--color-accent);
color: #FFFFFF;
}
/* Assets List */
.assets-list {
flex: 1;
}
.assets-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-lg);
}
.novel-grid {
grid-template-columns: repeat(2, 1fr);
}
/* File Card */
.asset-card {
display: flex;
gap: var(--space-lg);
padding: var(--space-xl);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-xl);
transition: all var(--transition-fast);
cursor: pointer;
}
.asset-card:hover {
box-shadow: var(--shadow-md);
border-color: var(--color-border-hover);
}
.asset-card-icon {
flex-shrink: 0;
}
.asset-file-type {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 56px;
height: 28px;
padding: 0 10px;
border-radius: var(--radius-sm);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.03em;
}
.asset-card-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.asset-card-name {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.asset-card-desc {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.asset-card-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: auto;
padding-top: var(--space-sm);
}
.asset-card-size, .asset-card-date {
font-size: 11px;
color: var(--color-text-disabled);
}
/* Novel Card */
.asset-novel-card {
display: flex;
gap: var(--space-xl);
padding: var(--space-2xl);
background: linear-gradient(135deg, #FFFBEB 0%, #FFF7ED 50%, #FFFBF0 100%);
border: 1px solid #FDE68A;
border-radius: var(--radius-xl);
cursor: pointer;
transition: all var(--transition-normal);
}
.asset-novel-card:hover {
border-color: #F59E0B;
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.novel-card-cover {
width: 80px;
height: 110px;
background: linear-gradient(135deg, #FFF7ED, #FFEDD5);
border: 1px solid #FED7AA;
border-radius: var(--radius-md);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-sm);
flex-shrink: 0;
}
.novel-card-badge {
font-size: 10px;
font-weight: 600;
color: #D97706;
background: #FEF3C7;
padding: 1px 8px;
border-radius: var(--radius-full);
}
.novel-card-info {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.novel-card-title {
font-size: var(--text-lg);
font-weight: 700;
color: #92400E;
}
.novel-card-desc {
font-size: var(--text-sm);
color: #A16207;
line-height: 1.5;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.novel-card-meta {
font-size: var(--text-xs);
color: #B45309;
display: flex;
gap: var(--space-sm);
margin-top: auto;
}
/* Empty */
.assets-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-5xl);
color: var(--color-text-disabled);
font-size: var(--text-sm);
gap: var(--space-md);
}
/* Loading */
.assets-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-5xl);
color: var(--color-text-tertiary);
font-size: var(--text-sm);
gap: var(--space-lg);
}
.audit-spinner {
width: 40px;
height: 40px;
border: 3px solid var(--color-border-light);
border-top-color: var(--color-accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ===== Assets Modal ===== */
.assets-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: var(--z-modal);
animation: fadeIn var(--transition-fast);
padding: var(--space-2xl);
}
.assets-modal {
background: var(--color-bg-secondary);
border-radius: var(--radius-2xl);
width: 100%;
max-width: 680px;
max-height: 85vh;
overflow-y: auto;
position: relative;
animation: slideUp var(--transition-normal);
box-shadow: var(--shadow-xl);
}
.assets-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-xl) var(--space-2xl);
border-bottom: 1px solid var(--color-border-light);
}
.assets-modal-title {
font-size: var(--text-lg);
font-weight: 600;
color: var(--color-text-primary);
}
.assets-modal-close {
width: 32px;
height: 32px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-tertiary);
transition: all var(--transition-fast);
}
.assets-modal-close:hover {
background: var(--color-bg-hover);
color: var(--color-text-primary);
}
.assets-modal-body {
padding: var(--space-2xl);
}
.assets-modal-meta {
display: flex;
align-items: center;
gap: var(--space-md);
margin-bottom: var(--space-xl);
padding: var(--space-md);
background: var(--color-bg-tertiary);
border-radius: var(--radius-md);
}
.assets-modal-type {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.assets-modal-cat {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
}
.assets-modal-size, .assets-modal-date {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
}
/* Text Preview */
.assets-preview-text {
margin-bottom: var(--space-xl);
max-height: 400px;
overflow-y: auto;
}
.assets-preview-code {
padding: var(--space-lg);
background: #1e1e2e;
color: #cdd6f4;
border-radius: var(--radius-md);
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-all;
}
/* Image Preview */
.assets-preview-image {
margin-bottom: var(--space-xl);
text-align: center;
}
.assets-preview-image img {
max-width: 100%;
max-height: 400px;
border-radius: var(--radius-md);
object-fit: contain;
}
/* Video Preview */
.assets-preview-video {
margin-bottom: var(--space-xl);
}
.assets-preview-video video {
width: 100%;
max-height: 400px;
border-radius: var(--radius-md);
background: #000;
}
/* Audio Preview */
.assets-preview-audio {
margin-bottom: var(--space-xl);
padding: var(--space-xl);
background: var(--color-bg-tertiary);
border-radius: var(--radius-md);
text-align: center;
}
.assets-preview-audio audio {
width: 100%;
max-width: 500px;
}
/* Edit Section */
.assets-edit-section {
display: flex;
flex-direction: column;
gap: var(--space-md);
margin-bottom: var(--space-xl);
}
.assets-edit-textarea {
padding: var(--space-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.6;
color: var(--color-text-primary);
background: var(--color-bg-primary);
resize: vertical;
min-height: 200px;
outline: none;
transition: border-color var(--transition-fast);
}
.assets-edit-textarea:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-light);
}
.assets-edit-actions {
display: flex;
gap: var(--space-sm);
justify-content: flex-end;
}
/* Modal Actions */
.assets-modal-actions {
display: flex;
gap: var(--space-sm);
flex-wrap: wrap;
padding-top: var(--space-lg);
border-top: 1px solid var(--color-border-light);
}
/* Assets Buttons */
.assets-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: var(--space-sm) var(--space-lg);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 500;
transition: all var(--transition-fast);
cursor: pointer;
}
.assets-btn-primary {
background: var(--color-accent);
color: #FFFFFF;
border: none;
}
.assets-btn-primary:hover {
background: var(--color-accent-hover);
}
.assets-btn-secondary {
background: var(--color-bg-primary);
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
}
.assets-btn-secondary:hover {
border-color: var(--color-accent);
color: var(--color-accent);
}
.assets-btn-cancel {
background: transparent;
color: var(--color-text-tertiary);
border: 1px solid var(--color-border);
}
.assets-btn-cancel:hover {
color: var(--color-text-primary);
}
.assets-btn-danger {
background: transparent;
color: var(--color-error);
border: 1px solid var(--color-error);
}
.assets-btn-danger:hover {
background: var(--color-error-light);
}
/* Create Asset Modal */
.assets-create-modal {
max-width: 500px;
}
.assets-create-modal .form-section {
display: flex;
flex-direction: column;
gap: var(--space-sm);
margin-bottom: var(--space-lg);
}
.form-label {
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-text-primary);
}
.form-label .required {
color: var(--color-error);
}
.form-input,
.form-textarea {
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: var(--text-sm);
color: var(--color-text-primary);
background: var(--color-bg-primary);
outline: none;
transition: border-color var(--transition-fast);
}
.form-input:focus,
.form-textarea:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-light);
}
.form-textarea {
resize: vertical;
font-family: var(--font-mono);
font-size: 13px;
}
.form-submit-btn {
width: 100%;
padding: var(--space-md) var(--space-xl);
background: var(--color-accent);
color: white;
border: none;
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
transition: all var(--transition-fast);
}
.form-submit-btn:hover:not(:disabled) {
background: var(--color-accent-hover);
}
.form-submit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Online Preview */
.assets-preview-online {
margin: var(--space-md) 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
}
.assets-preview-online-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-sm) var(--space-md);
background: var(--color-bg-tertiary);
border-bottom: 1px solid var(--color-border);
font-size: var(--text-xs);
color: var(--color-text-secondary);
}
.assets-preview-reader {
background: var(--color-bg-secondary);
max-height: 500px;
overflow: auto;
border-radius: 0 0 var(--radius-md) var(--radius-md);
}
.assets-kb-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
background: #EEEDFF;
color: #6C63FF;
border-radius: var(--radius-xs);
font-size: 11px;
font-weight: 600;
margin-left: var(--space-sm);
}
/* Responsive */
@media (max-width: 768px) {
.assets-page { padding: var(--space-lg); }
.novel-grid { grid-template-columns: 1fr; }
.assets-grid { grid-template-columns: 1fr; }
.asset-novel-card { flex-direction: column; }
.novel-card-cover { width: 100%; height: 60px; flex-direction: row; }
.assets-modal { max-width: 100%; max-height: 90vh; border-radius: var(--radius-xl); }
.assets-modal-body { padding: var(--space-xl); }
}
+650
View File
@@ -0,0 +1,650 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import type { Asset, AssetCategory, AssetCategoryDef, AssetFileType } from '@/types';
import { assetsAPI } from '@/api';
import './AssetsPage.css';
const fileTypeLabels: Record<string, { label: string; bg: string; color: string }> = {
word: { label: 'Word', bg: '#E8F1FF', color: '#2563EB' },
excel: { label: 'Excel', bg: '#E8F8EE', color: '#059669' },
ppt: { label: 'PPT', bg: '#FEF2E2', color: '#D97706' },
pdf: { label: 'PDF', bg: '#FDEBEB', color: '#DC2626' },
txt: { label: 'TXT', bg: '#F1F3F7', color: '#64748B' },
md: { label: 'MD', bg: '#E8EEF8', color: '#3B82F6' },
doc: { label: 'DOC', bg: '#E6F4EA', color: '#0D9488' },
other: { label: '其他', bg: '#F1F3F7', color: '#64748B' },
};
// SVG Icons
const CloseIcon = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
);
const DownloadIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
);
const TrashIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
);
const getFileTypeInfo = (ft?: string) => {
return fileTypeLabels[ft || 'other'] || fileTypeLabels.other;
};
function formatSize(bytes?: number): string {
if (!bytes) return '--';
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
}
// 将文件扩展名或API返回的file_type映射到前端fileType标签
function mapFileTypeToLabel(ft: string): string {
const mapping: Record<string, string> = {
pdf: 'pdf', docx: 'word', doc: 'word', xlsx: 'excel', xls: 'excel',
pptx: 'ppt', ppt: 'ppt', csv: 'txt', md: 'md', txt: 'txt',
};
return mapping[ft] || 'other';
}
// Preview Modal Component
interface PreviewModalProps {
asset: Asset;
onClose: () => void;
onDelete: (id: string) => void;
}
const PreviewModal: React.FC<PreviewModalProps> = ({ asset, onClose, onDelete }) => {
const [editing, setEditing] = useState(false);
const [editContent, setEditContent] = useState('');
const [showOnlinePreview, setShowOnlinePreview] = useState(false);
const navigate = useNavigate();
const isTextFile = asset.category === 'file' && ['txt', 'other', 'md', 'doc'].includes(asset.fileType || '');
const isImage = asset.category === 'image';
const isVideo = asset.category === 'video';
const isAudio = asset.category === 'audio';
const isTextEditable = asset.category === 'file' && (asset.fileType === 'txt' || asset.fileType === 'other' || asset.fileType === 'md' || asset.fileType === 'doc');
const isOfficeDoc = asset.category === 'file' && ['word', 'excel', 'ppt'].includes(asset.fileType || '');
const isPdf = asset.category === 'file' && asset.fileType === 'pdf';
const handleDownload = () => {
// Simulate download - in production would use asset.url
const a = document.createElement('a');
a.href = '#' + asset.id;
a.download = asset.name;
a.click();
};
const handleStartEdit = () => {
setEditContent(asset.description || '');
setEditing(true);
};
const handleSaveEdit = () => {
// In production, would call assetsAPI.updateAsset
setEditing(false);
};
// 在线预览内容渲染
const renderOnlinePreview = () => {
if (!showOnlinePreview) return null;
const ft = asset.fileType || 'other';
// PDF 使用浏览器原生预览
if (ft === 'pdf' && asset.url) {
return (
<div className="assets-preview-online">
<div className="assets-preview-online-toolbar">
<span>在线预览:{asset.name}</span>
<button className="assets-btn assets-btn-cancel" onClick={() => setShowOnlinePreview(false)} style={{fontSize:'var(--text-xs)'}}>
关闭预览
</button>
</div>
<iframe
src={asset.url}
style={{width:'100%',height:'600px',border:'1px solid var(--color-border)',borderRadius:'0 0 var(--radius-md) var(--radius-md)'}}
title={`预览 ${asset.name}`}
/>
</div>
);
}
// Office文档(docx/xlsx/pptx) 使用微软Office Online查看器
if (asset.url && ['word', 'excel', 'ppt'].includes(ft)) {
const encodedUrl = encodeURIComponent(asset.url);
const officeViewerUrl = `https://view.officeapps.live.com/op/view.aspx?src=${encodedUrl}`;
return (
<div className="assets-preview-online">
<div className="assets-preview-online-toolbar">
<span>在线预览:{asset.name}</span>
<button className="assets-btn assets-btn-cancel" onClick={() => setShowOnlinePreview(false)} style={{fontSize:'var(--text-xs)'}}>
关闭预览
</button>
</div>
<iframe
src={officeViewerUrl}
style={{width:'100%',height:'600px',border:'1px solid var(--color-border)',borderRadius:'0 0 var(--radius-md) var(--radius-md)'}}
title={`预览 ${asset.name}`}
/>
</div>
);
}
// 文本类文件的在线预览
if (['txt', 'word', 'other', 'md', 'doc'].includes(ft)) {
const previewContent = (asset as any).content || asset.description || '(文件内容预览)\n\n该文件暂无内容预览。\n\n--- 在线预览模式 ---\n您正在查看文件的只读预览。';
return (
<div className="assets-preview-online">
<div className="assets-preview-online-toolbar">
<span>在线预览:{asset.name}</span>
<button className="assets-btn assets-btn-cancel" onClick={() => setShowOnlinePreview(false)} style={{fontSize:'var(--text-xs)'}}>
关闭预览
</button>
</div>
<div className="assets-preview-reader">
<pre style={{whiteSpace:'pre-wrap',fontFamily:'var(--font-mono)',fontSize:'var(--text-sm)',lineHeight:1.8,padding:'var(--space-xl)',color:'var(--color-text-primary)'}}>
{previewContent}
</pre>
</div>
</div>
);
}
return null;
};
return (
<div className="assets-modal-overlay" onClick={onClose}>
<div className="assets-modal" onClick={e => e.stopPropagation()}>
<div className="assets-modal-header">
<h3 className="assets-modal-title">{asset.name}</h3>
<button className="assets-modal-close" onClick={onClose}><CloseIcon /></button>
</div>
<div className="assets-modal-body">
<div className="assets-modal-meta">
<span className="assets-modal-type">
{asset.category === 'file' && (
<span className="asset-file-type" style={{ background: getFileTypeInfo(asset.fileType).bg, color: getFileTypeInfo(asset.fileType).color }}>
{getFileTypeInfo(asset.fileType).label}
</span>
)}
<span className="assets-modal-cat">{asset.category === 'image' ? '图片' : asset.category === 'video' ? '视频' : asset.category === 'audio' ? '音频' : asset.category === 'novel' ? '小说' : '文件'}</span>
</span>
{asset.size && <span className="assets-modal-size">{formatSize(asset.size)}</span>}
<span className="assets-modal-date">{formatDate(asset.updatedAt)}</span>
</div>
{/* Text preview */}
{isTextFile && !editing && !showOnlinePreview && (
<div className="assets-preview-text">
<pre className="assets-preview-code">
{(asset as any).content || asset.description || '(文件内容预览)\n\n这是一个文本类型的文件。\n内容由后端API提供。'}
</pre>
</div>
)}
{/* Text editing */}
{isTextEditable && editing && (
<div className="assets-edit-section">
<textarea
className="assets-edit-textarea"
value={editContent}
onChange={e => setEditContent(e.target.value)}
rows={12}
/>
<div className="assets-edit-actions">
<button className="assets-btn assets-btn-primary" onClick={handleSaveEdit}>保存</button>
<button className="assets-btn assets-btn-cancel" onClick={() => setEditing(false)}>取消</button>
</div>
</div>
)}
{/* Image preview */}
{isImage && !showOnlinePreview && (
<div className="assets-preview-image">
<img src={asset.url || ''} alt={asset.name} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
{!asset.url && (
<div style={{ padding: 40, textAlign: 'center', color: 'var(--color-text-tertiary)' }}>
无法加载图片预览
</div>
)}
</div>
)}
{/* Image online preview */}
{isImage && showOnlinePreview && (
<div className="assets-preview-online">
<div className="assets-preview-online-toolbar">
<span>在线预览:{asset.name}</span>
<button className="assets-btn assets-btn-cancel" onClick={() => setShowOnlinePreview(false)} style={{fontSize:'var(--text-xs)'}}>
关闭预览
</button>
</div>
<img src={asset.url || ''} alt={asset.name} style={{maxWidth:'100%',maxHeight:'500px',objectFit:'contain'}}
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} />
</div>
)}
{/* Video preview */}
{isVideo && !showOnlinePreview && (
<div className="assets-preview-video">
<video controls src={asset.url || ''}>
您的浏览器不支持视频播放
</video>
</div>
)}
{/* Audio preview */}
{isAudio && !showOnlinePreview && (
<div className="assets-preview-audio">
<audio controls src={asset.url || ''}>
您的浏览器不支持音频播放
</audio>
</div>
)}
{/* Online Preview */}
{renderOnlinePreview()}
{/* Action buttons */}
<div className="assets-modal-actions">
{isTextFile && !editing && isTextEditable && (
<button className="assets-btn assets-btn-secondary" onClick={handleStartEdit}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
编辑
</button>
)}
{!editing && (
<button className="assets-btn assets-btn-secondary" onClick={handleDownload}>
<DownloadIcon />
下载
</button>
)}
{(isTextFile || isImage || isOfficeDoc || isPdf) && !editing && (
<button
className="assets-btn assets-btn-secondary"
onClick={() => setShowOnlinePreview(!showOnlinePreview)}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
{showOnlinePreview ? '关闭预览' : '在线预览'}
</button>
)}
{asset.category === 'novel' && (
<button className="assets-btn assets-btn-primary" onClick={() => navigate(`/assets/novel/${asset.id}`)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>
阅读
</button>
)}
<button className="assets-btn assets-btn-danger" onClick={() => { if (window.confirm('确定删除该资产?')) { onDelete(asset.id); onClose(); } }}>
<TrashIcon />
删除
</button>
</div>
</div>
</div>
</div>
);
};
// Create Asset Modal
interface CreateAssetModalProps {
onClose: () => void;
onCreated: () => void;
}
const CreateAssetModal: React.FC<CreateAssetModalProps> = ({ onClose, onCreated }) => {
const [name, setName] = useState('');
const [fileType, setFileType] = useState('txt');
const [content, setContent] = useState('');
const [creating, setCreating] = useState(false);
const handleCreate = async () => {
if (!name.trim()) return;
setCreating(true);
try {
await assetsAPI.createAsset({
name: name.trim(),
category: 'file',
file_type: fileType,
content: content,
description: content.slice(0, 100),
});
onCreated();
onClose();
} catch (err) {
console.error('创建失败', err);
} finally {
setCreating(false);
}
};
return (
<div className="assets-modal-overlay" onClick={onClose}>
<div className="assets-modal assets-create-modal" onClick={e => e.stopPropagation()}>
<div className="assets-modal-header">
<h3 className="assets-modal-title">创建资产</h3>
<button className="assets-modal-close" onClick={onClose}><CloseIcon /></button>
</div>
<div className="assets-modal-body">
<div className="form-section">
<label className="form-label">名称 <span className="required">*</span></label>
<input
type="text"
className="form-input"
placeholder="请输入资产名称"
value={name}
onChange={e => setName(e.target.value)}
/>
</div>
<div className="form-section">
<label className="form-label">文件类型</label>
<select className="form-input" value={fileType} onChange={e => setFileType(e.target.value)}>
<option value="txt">TXT</option>
<option value="word">Word</option>
<option value="excel">Excel</option>
<option value="ppt">PPT</option>
<option value="pdf">PDF</option>
<option value="other">其他</option>
</select>
</div>
<div className="form-section">
<label className="form-label">内容</label>
<textarea
className="form-textarea"
placeholder="输入文件内容..."
value={content}
onChange={e => setContent(e.target.value)}
rows={6}
/>
</div>
<button
className="form-submit-btn"
onClick={handleCreate}
disabled={!name.trim() || creating}
>
{creating ? '创建中...' : '创建'}
</button>
</div>
</div>
</div>
);
};
// ===== Main AssetsPage Component =====
const AssetsPage: React.FC = () => {
const [activeCategory, setActiveCategory] = useState<string>('all');
const [subCategory, setSubCategory] = useState<string>('all');
const [assets, setAssets] = useState<Asset[]>([]);
const [categories, setCategories] = useState<AssetCategoryDef[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedAsset, setSelectedAsset] = useState<Asset | null>(null);
const [showCreate, setShowCreate] = useState(false);
const navigate = useNavigate();
const fetchAssets = async (category?: string) => {
setLoading(true);
setError(null);
try {
const catParam = category && category !== 'all' ? category : undefined;
const res = await assetsAPI.getAssets(catParam);
// getAssets 返回分页格式 {total, page, page_size, items}
const rawItems = (res.data as any)?.items || res.data || [];
// 标准化资产数据:如果category为空但有fileType,则自动归类为file
const normalizedAssets: Asset[] = rawItems.map((item: any) => ({
...item,
category: item.category || (item.fileType || item.file_type ? 'file' : 'other'),
fileType: item.fileType || item.file_type || 'other',
}));
// 按id去重
const seen = new Set<string>();
const deduped = normalizedAssets.filter(a => {
if (seen.has(a.id)) return false;
seen.add(a.id);
return true;
});
setAssets(deduped);
} catch (err: any) {
setError(err.message || '加载失败');
} finally {
setLoading(false);
}
};
const DEFAULT_CATEGORIES: AssetCategoryDef[] = [
{ id: 'file', name: '文件', type: 'file', color: '#5B5FED', icon: 'file', isDefault: true },
{ id: 'image', name: '图片', type: 'image', color: '#10B981', icon: 'image', isDefault: true },
{ id: 'video', name: '视频', type: 'video', color: '#EF4444', icon: 'video', isDefault: true },
{ id: 'audio', name: '音频', type: 'audio', color: '#8B5CF6', icon: 'audio', isDefault: true },
{ id: 'novel', name: '小说', type: 'novel', color: '#F59E0B', icon: 'novel', isDefault: true },
{ id: 'other', name: '其他', type: 'other', color: '#64748B', icon: 'other', isDefault: true },
{ id: 'custom', name: '自定义', type: 'custom', color: '#EC4899', icon: 'custom', isDefault: true },
];
const fetchCategories = async () => {
try {
const res = await assetsAPI.getCategories();
const apiCats = res.data || [];
const merged: AssetCategoryDef[] = [...apiCats];
for (const def of DEFAULT_CATEGORIES) {
if (!merged.some((c: any) => c.type === def.type)) {
merged.push(def);
}
}
setCategories(merged);
} catch (err) {
console.error('分类加载失败', err);
setCategories(DEFAULT_CATEGORIES);
}
};
useEffect(() => {
fetchCategories();
fetchAssets();
}, []);
useEffect(() => {
fetchAssets(activeCategory === 'all' ? undefined : activeCategory);
}, [activeCategory]);
// Filter by sub-category for files
const filtered = (() => {
// 如果是文件分类,先确保只显示文件类资产
let base = activeCategory === 'all' ? assets : assets.filter(a => a.category === activeCategory);
// 在文件分类下,按子分类再筛选
if (activeCategory === 'file' && subCategory !== 'all') {
base = base.filter(a => a.fileType === subCategory);
}
return base;
})();
const handleDelete = async (id: string) => {
try {
await assetsAPI.deleteAsset(id);
setAssets(prev => prev.filter(a => a.id !== id));
} catch (err) {
console.error('删除失败', err);
}
};
const showFileSubTabs = activeCategory === 'file';
return (
<div className="assets-page">
<div className="assets-header">
<h1 className="assets-title">我的资产</h1>
<span className="assets-count">{filtered.length} 项</span>
<button className="assets-create-btn" onClick={() => setShowCreate(true)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
创建
</button>
</div>
{/* Category Tabs */}
<div className="assets-tabs">
<button className={`assets-tab ${activeCategory === 'all' ? 'active' : ''}`} onClick={() => { setActiveCategory('all'); setSubCategory('all'); }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
全部
</button>
{categories.length > 0 ? categories.map(cat => (
<button
key={cat.id}
className={`assets-tab ${activeCategory === cat.type ? 'active' : ''}`}
onClick={() => { setActiveCategory(cat.type); setSubCategory('all'); }}
style={activeCategory === cat.type ? { '--tab-color': cat.color, '--tab-bg': cat.color + '18' } as React.CSSProperties : undefined}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
{cat.type === 'file' && <><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></>}
{cat.type === 'novel' && <><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></>}
{cat.type === 'image' && <><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></>}
{cat.type === 'video' && <><polygon points="23 7 16 12 7 12 7 19 23 19 23 7"/></>}
{cat.type === 'audio' && <><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></>}
{cat.type === 'other' && <><circle cx="12" cy="12" r="10"/><line x1="8" y1="12" x2="16" y2="12"/></>}
{cat.type === 'custom' && <><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></>}
</svg>
{cat.name}
</button>
)) : (
<>
<button className={`assets-tab ${activeCategory === 'file' ? 'active' : ''}`} onClick={() => { setActiveCategory('file'); setSubCategory('all'); }} style={activeCategory === 'file' ? { '--tab-color': '#5B5FED', '--tab-bg': '#5B5FED18' } as React.CSSProperties : undefined}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
文件
</button>
<button className={`assets-tab ${activeCategory === 'image' ? 'active' : ''}`} onClick={() => { setActiveCategory('image'); setSubCategory('all'); }} style={activeCategory === 'image' ? { '--tab-color': '#10B981', '--tab-bg': '#10B98118' } as React.CSSProperties : undefined}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
图片
</button>
<button className={`assets-tab ${activeCategory === 'video' ? 'active' : ''}`} onClick={() => { setActiveCategory('video'); setSubCategory('all'); }} style={activeCategory === 'video' ? { '--tab-color': '#EF4444', '--tab-bg': '#EF444418' } as React.CSSProperties : undefined}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polygon points="23 7 16 12 7 12 7 19 23 19 23 7"/></svg>
视频
</button>
<button className={`assets-tab ${activeCategory === 'audio' ? 'active' : ''}`} onClick={() => { setActiveCategory('audio'); setSubCategory('all'); }} style={activeCategory === 'audio' ? { '--tab-color': '#8B5CF6', '--tab-bg': '#8B5CF618' } as React.CSSProperties : undefined}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
音频
</button>
<button className={`assets-tab ${activeCategory === 'novel' ? 'active' : ''}`} onClick={() => { setActiveCategory('novel'); setSubCategory('all'); }} style={activeCategory === 'novel' ? { '--tab-color': '#F59E0B', '--tab-bg': '#F59E0B18' } as React.CSSProperties : undefined}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>
小说
</button>
</>
)}
</div>
{/* File sub-tabs */}
{showFileSubTabs && (
<div className="assets-subtabs">
<button className={`assets-subtab ${subCategory === 'all' ? 'active' : ''}`} onClick={() => setSubCategory('all')}>全部文件</button>
{(['word', 'excel', 'ppt', 'pdf', 'txt', 'md', 'doc', 'other'] as AssetFileType[]).map(ft => (
<button key={ft} className={`assets-subtab ${subCategory === ft ? 'active' : ''}`} onClick={() => setSubCategory(ft)}>
{fileTypeLabels[ft].label}
</button>
))}
</div>
)}
{/* Asset list */}
<div className="assets-list">
{loading ? (
<div className="assets-loading">
<div className="audit-spinner" />
<span>加载中...</span>
</div>
) : error ? (
<div className="assets-empty">
<p>加载失败:{error}</p>
</div>
) : filtered.length === 0 ? (
<div className="assets-empty">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" style={{ color: 'var(--color-text-disabled)' }}><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
<p>暂无资产</p>
</div>
) : (
<div className={`assets-grid ${activeCategory === 'novel' ? 'novel-grid' : ''}`}>
{filtered.map(asset => {
if (asset.category === 'novel') {
return (
<div key={asset.id} className="asset-novel-card" onClick={() => setSelectedAsset(asset)}>
<div className="novel-card-cover">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" style={{ color: '#F59E0B', opacity: 0.6 }}><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>
<span className="novel-card-badge">小说</span>
</div>
<div className="novel-card-info">
<h3 className="novel-card-title">{asset.name}</h3>
<p className="novel-card-desc">{asset.description}</p>
<div className="novel-card-meta">
<span>{asset.chapters?.length || 0} 章</span>
<span>·</span>
<span>{asset.chapters?.reduce((s, c) => s + c.wordCount, 0).toLocaleString() || 0} 字</span>
<span>·</span>
<span>{formatDate(asset.updatedAt)}</span>
</div>
</div>
</div>
);
}
return (
<div key={asset.id} className="asset-card" onClick={() => setSelectedAsset(asset)}>
<div className="asset-card-icon">
{asset.category === 'file' && (
<span className="asset-file-type" style={{ background: getFileTypeInfo(asset.fileType).bg, color: getFileTypeInfo(asset.fileType).color }}>
{getFileTypeInfo(asset.fileType).label}
</span>
)}
{asset.category === 'image' && (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#10B981" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
)}
{asset.category === 'video' && (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#EF4444" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><polygon points="23 7 16 12 7 12 7 19 23 19 23 7"/></svg>
)}
{asset.category === 'audio' && (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#8B5CF6" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
)}
</div>
<div className="asset-card-body">
<h4 className="asset-card-name">{asset.name}</h4>
{asset.description && <p className="asset-card-desc">{asset.description}</p>}
<div className="asset-card-footer">
{asset.size && <span className="asset-card-size">{formatSize(asset.size)}</span>}
<span className="asset-card-date">{formatDate(asset.updatedAt)}</span>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Preview Modal */}
{selectedAsset && (
<PreviewModal
asset={selectedAsset}
onClose={() => setSelectedAsset(null)}
onDelete={handleDelete}
/>
)}
{/* Create Modal */}
{showCreate && (
<CreateAssetModal
onClose={() => setShowCreate(false)}
onCreated={() => fetchAssets(activeCategory === 'all' ? undefined : activeCategory)}
/>
)}
</div>
);
};
export default AssetsPage;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+704
View File
@@ -0,0 +1,704 @@
/* ===== Landing Page Styles ===== */
.landing-page {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow-y: auto;
overflow-x: hidden;
background: var(--color-bg-primary);
z-index: var(--z-base);
-webkit-overflow-scrolling: touch;
}
/* Toast notification */
.landing-toast {
position: fixed;
top: 76px;
left: 50%;
transform: translateX(-50%);
z-index: var(--z-toast);
padding: 10px 24px;
background: var(--color-text-primary);
color: #FFFFFF;
border-radius: var(--radius-lg);
font-size: var(--text-sm);
font-weight: 500;
box-shadow: var(--shadow-lg);
animation: slideDown var(--transition-normal);
white-space: nowrap;
}
.landing-section-inner {
max-width: 1120px;
margin: 0 auto;
padding: 0 var(--space-2xl);
}
/* ===== Header ===== */
.landing-header {
position: sticky;
top: 0;
z-index: 100;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--color-border);
}
.landing-header-inner {
max-width: 1120px;
margin: 0 auto;
padding: 0 var(--space-2xl);
height: 60px;
display: flex;
align-items: center;
gap: var(--space-2xl);
}
.landing-brand {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.brand-name {
font-size: var(--text-lg);
font-weight: 700;
color: var(--color-text-primary);
letter-spacing: -0.02em;
}
.landing-nav-links {
display: flex;
gap: var(--space-xl);
}
.landing-nav-links a {
font-size: var(--text-sm);
color: var(--color-text-secondary);
text-decoration: none;
transition: color var(--transition-fast);
cursor: pointer;
}
.landing-nav-links a:hover {
color: var(--color-accent);
}
.landing-header-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: var(--space-md);
}
/* ===== Buttons ===== */
.landing-btn {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: 8px 20px;
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
border: none;
transition: all var(--transition-fast);
font-family: inherit;
text-decoration: none;
}
.landing-btn-primary {
background: var(--color-accent);
color: #FFFFFF;
}
.landing-btn-primary:hover {
background: var(--color-accent-hover);
box-shadow: 0 2px 12px rgba(91, 95, 237, 0.35);
}
.landing-btn-outline {
background: transparent;
color: var(--color-accent);
border: 1.5px solid var(--color-accent);
}
.landing-btn-outline:hover {
background: var(--color-accent-subtle);
}
.landing-btn-lg {
padding: 12px 28px;
font-size: var(--text-base);
}
/* ===== Hero ===== */
.landing-hero {
padding: 80px 0 72px;
background: linear-gradient(180deg, #F8F7FF 0%, #FFFFFF 100%);
}
.landing-hero-content {
max-width: 1120px;
margin: 0 auto;
padding: 0 var(--space-2xl);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.hero-title {
font-size: 48px;
font-weight: 800;
color: var(--color-text-primary);
line-height: 1.2;
letter-spacing: -0.03em;
margin: 0 0 var(--space-xl);
}
.hero-accent {
background: linear-gradient(135deg, #5B5FED 0%, #A78BFA 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero-desc {
font-size: var(--text-lg);
color: var(--color-text-secondary);
max-width: 640px;
line-height: 1.7;
margin: 0 0 var(--space-2xl);
}
.hero-actions {
display: flex;
gap: var(--space-lg);
margin-bottom: var(--space-3xl);
}
.hero-stats {
display: flex;
gap: var(--space-3xl);
}
.hero-stat {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-xs);
}
.stat-number {
font-size: 28px;
font-weight: 700;
color: var(--color-accent);
}
.stat-label {
font-size: var(--text-sm);
color: var(--color-text-tertiary);
}
/* ===== Carousel ===== */
.landing-banners {
padding: var(--space-3xl) 0;
}
.section-title {
font-size: var(--text-xl);
font-weight: 700;
color: var(--color-text-primary);
margin: 0 0 var(--space-xl);
}
.section-header {
text-align: center;
margin-bottom: var(--space-2xl);
}
.section-desc {
font-size: var(--text-base);
color: var(--color-text-tertiary);
margin: var(--space-sm) 0 0;
}
.carousel-container {
position: relative;
overflow: hidden;
border-radius: var(--radius-xl);
}
.carousel-track {
display: flex;
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
.carousel-slide {
min-width: 100%;
box-sizing: border-box;
}
.carousel-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3xl) var(--space-3xl);
border-radius: var(--radius-xl);
min-height: 200px;
color: #FFFFFF;
overflow: hidden;
position: relative;
}
.carousel-card-content {
z-index: 1;
}
.carousel-badge {
display: inline-block;
padding: 4px 12px;
background: rgba(255,255,255,0.25);
backdrop-filter: blur(4px);
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: 600;
margin-bottom: var(--space-md);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.carousel-title {
font-size: 28px;
font-weight: 700;
margin: 0 0 var(--space-sm);
line-height: 1.3;
}
.carousel-subtitle {
font-size: var(--text-base);
opacity: 0.9;
margin: 0 0 var(--space-xl);
}
.carousel-cta {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: 8px 20px;
background: rgba(255,255,255,0.2);
border: 1px solid rgba(255,255,255,0.4);
border-radius: var(--radius-md);
color: #FFFFFF;
font-size: var(--text-sm);
font-weight: 600;
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast);
font-family: inherit;
}
.carousel-cta:hover {
background: rgba(255,255,255,0.35);
border-color: rgba(255,255,255,0.6);
}
.carousel-card-visual {
position: relative;
width: 160px;
height: 160px;
flex-shrink: 0;
}
.visual-circle {
position: absolute;
border-radius: 50%;
background: rgba(255,255,255,0.15);
}
.visual-circle.c1 {
width: 140px;
height: 140px;
top: 10px;
right: 0;
}
.visual-circle.c2 {
width: 90px;
height: 90px;
top: 50px;
right: 50px;
background: rgba(255,255,255,0.2);
}
.visual-circle.c3 {
width: 50px;
height: 50px;
top: 20px;
right: 20px;
background: rgba(255,255,255,0.25);
}
/* Dots */
.carousel-dots {
position: absolute;
bottom: var(--space-lg);
left: 50%;
transform: translateX(-50%);
display: flex;
gap: var(--space-sm);
z-index: 2;
}
.carousel-dot {
width: 8px;
height: 8px;
border-radius: 50%;
border: none;
background: rgba(255,255,255,0.4);
cursor: pointer;
transition: all var(--transition-fast);
padding: 0;
}
.carousel-dot.active {
background: #FFFFFF;
width: 24px;
border-radius: 4px;
}
/* Arrows */
.carousel-arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background: rgba(255,255,255,0.9);
color: var(--color-text-primary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--shadow-md);
transition: all var(--transition-fast);
z-index: 2;
}
.carousel-arrow:hover {
background: #FFFFFF;
box-shadow: var(--shadow-lg);
}
.carousel-arrow-left {
left: var(--space-md);
}
.carousel-arrow-right {
right: var(--space-md);
}
/* ===== Market ===== */
.landing-market {
padding: var(--space-3xl) 0 var(--space-4xl);
background: var(--color-bg-secondary);
}
.market-filter-tabs {
display: flex;
justify-content: center;
gap: var(--space-sm);
margin-bottom: var(--space-2xl);
}
.filter-tab {
padding: 6px 16px;
border-radius: var(--radius-full);
border: 1px solid var(--color-border);
background: #FFFFFF;
color: var(--color-text-secondary);
font-size: var(--text-sm);
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
font-family: inherit;
}
.filter-tab:hover {
border-color: var(--color-accent);
color: var(--color-accent);
}
.filter-tab.active {
background: var(--color-accent);
border-color: var(--color-accent);
color: #FFFFFF;
}
.market-app-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: var(--space-xl);
}
.market-app-card {
background: #FFFFFF;
border-radius: var(--radius-lg);
overflow: hidden;
border: 1px solid var(--color-border);
cursor: pointer;
transition: all var(--transition-fast);
}
.market-app-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
border-color: var(--color-accent-light);
}
.app-card-cover {
height: 120px;
position: relative;
display: flex;
align-items: flex-start;
justify-content: flex-end;
padding: var(--space-md);
gap: var(--space-sm);
}
.app-card-type-badge {
font-size: 11px;
font-weight: 600;
color: #FFFFFF;
padding: 2px 8px;
border-radius: var(--radius-full);
letter-spacing: 0.03em;
}
.app-card-price {
position: absolute;
top: var(--space-md);
left: var(--space-md);
font-size: var(--text-sm);
font-weight: 700;
color: #FFFFFF;
background: rgba(0,0,0,0.3);
padding: 2px 10px;
border-radius: var(--radius-full);
}
.app-card-body {
padding: var(--space-lg);
}
.app-card-name {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-primary);
margin: 0 0 var(--space-xs);
}
.app-card-desc {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
margin: 0 0 var(--space-md);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.5;
}
.app-card-meta {
display: flex;
align-items: center;
justify-content: space-between;
font-size: var(--text-xs);
color: var(--color-text-tertiary);
}
.app-card-rating {
display: flex;
align-items: center;
gap: 2px;
color: #F59E0B;
}
.app-card-rating svg {
fill: #F59E0B;
}
.market-more {
display: flex;
justify-content: center;
margin-top: var(--space-2xl);
}
/* ===== Features ===== */
.landing-features {
padding: var(--space-4xl) 0;
}
.features-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: var(--space-xl);
}
.feature-card {
padding: var(--space-2xl);
background: #FFFFFF;
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
transition: all var(--transition-fast);
}
.feature-card:hover {
border-color: var(--color-accent-light);
box-shadow: var(--shadow-md);
}
.feature-icon {
width: 56px;
height: 56px;
border-radius: var(--radius-lg);
background: var(--color-accent-subtle);
color: var(--color-accent);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: var(--space-lg);
}
.feature-title {
font-size: var(--text-base);
font-weight: 600;
color: var(--color-text-primary);
margin: 0 0 var(--space-sm);
}
.feature-desc {
font-size: var(--text-sm);
color: var(--color-text-tertiary);
line-height: 1.6;
margin: 0;
}
/* ===== CTA ===== */
.landing-cta {
padding: var(--space-4xl) 0;
background: linear-gradient(135deg, #5B5FED 0%, #A78BFA 100%);
text-align: center;
}
.cta-title {
font-size: 32px;
font-weight: 700;
color: #FFFFFF;
margin: 0 0 var(--space-md);
}
.cta-desc {
font-size: var(--text-lg);
color: rgba(255,255,255,0.85);
margin: 0 0 var(--space-2xl);
}
.landing-cta .landing-btn-primary {
background: #FFFFFF;
color: var(--color-accent);
}
.landing-cta .landing-btn-primary:hover {
background: #F0EEFF;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
}
/* ===== Footer ===== */
.landing-footer {
padding: var(--space-3xl) 0 var(--space-xl);
background: var(--color-bg-tertiary);
border-top: 1px solid var(--color-border);
}
.footer-content {
display: flex;
justify-content: space-between;
gap: var(--space-2xl);
margin-bottom: var(--space-2xl);
}
.footer-links {
display: flex;
gap: var(--space-4xl);
}
.footer-col h4 {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-primary);
margin: 0 0 var(--space-md);
}
.footer-col a {
display: block;
font-size: var(--text-sm);
color: var(--color-text-tertiary);
margin-bottom: var(--space-sm);
cursor: pointer;
transition: color var(--transition-fast);
}
.footer-col a:hover {
color: var(--color-accent);
}
.footer-bottom {
padding-top: var(--space-xl);
border-top: 1px solid var(--color-border);
font-size: var(--text-xs);
color: var(--color-text-tertiary);
}
/* ===== Responsive ===== */
@media (max-width: 768px) {
.landing-nav-links {
display: none;
}
.hero-title {
font-size: 32px;
}
.hero-stats {
flex-direction: column;
gap: var(--space-lg);
}
.carousel-card {
flex-direction: column;
text-align: center;
min-height: 240px;
}
.carousel-card-visual {
display: none;
}
.features-grid {
grid-template-columns: 1fr;
}
.market-app-grid {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
}
.footer-content {
flex-direction: column;
}
}
+370
View File
@@ -0,0 +1,370 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { mockAdBanners, mockApps } from '@/mock/data';
import type { AdBanner, MarketApp } from '@/types';
import './LandingPage.css';
const SVGIcon: React.FC<{ children: React.ReactNode; size?: number }> = ({ children, size = 16 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
const typeLabels: Record<string, string> = { skill: 'Skill', agent: 'Agent', mcp: 'MCP' };
const typeColors: Record<string, string> = { skill: '#10B981', agent: '#5B5FED', mcp: '#F59E0B' };
const LandingPage: React.FC = () => {
const navigate = useNavigate();
const [currentSlide, setCurrentSlide] = useState(0);
const [isPaused, setIsPaused] = useState(false);
const banners = mockAdBanners.filter(b => b.status === 'active');
const publishedApps = mockApps.filter(a => a.status === 'published');
const [appTypeFilter, setAppTypeFilter] = useState<'' | 'skill' | 'agent' | 'mcp'>('');
const [toastMsg, setToastMsg] = useState('');
const filteredApps = appTypeFilter
? publishedApps.filter(a => a.type === appTypeFilter)
: publishedApps;
const showToast = (msg: string) => {
setToastMsg(msg);
setTimeout(() => setToastMsg(''), 2000);
};
const handleAppClick = (appName: string) => {
showToast(`"${appName}" 需登录后使用,即将跳转登录页`);
setTimeout(() => navigate('/login'), 800);
};
// Auto rotate carousel
const nextSlide = useCallback(() => {
if (banners.length === 0) return;
setCurrentSlide(prev => (prev + 1) % banners.length);
}, [banners.length]);
useEffect(() => {
if (banners.length <= 1 || isPaused) return;
const timer = setInterval(nextSlide, 4000);
return () => clearInterval(timer);
}, [banners.length, isPaused, nextSlide]);
const goToSlide = (idx: number) => setCurrentSlide(idx);
const getGradient = (app: MarketApp): string => {
const gradients: Record<string, string> = {
a1: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
a2: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
a3: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
a4: 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)',
a5: 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)',
a6: 'linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%)',
a8: 'linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%)',
};
return gradients[app.id] || 'linear-gradient(135deg, #5B5FED 0%, #A78BFA 100%)';
};
return (
<div className="landing-page">
{/* Toast notification */}
{toastMsg && (
<div className="landing-toast">{toastMsg}</div>
)}
{/* Hero / Nav bar */}
<header className="landing-header">
<div className="landing-header-inner">
<div className="landing-brand">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#5B5FED"/>
<path d="M8 20V12l4 4 4-4v8" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
<path d="M6 22h20" stroke="white" strokeWidth="1.5" strokeLinecap="round" opacity="0.5"/>
</svg>
<span className="brand-name">Qihu-AIFlow</span>
</div>
<nav className="landing-nav-links">
<a href="#market">应用市场</a>
<a href="#features">平台能力</a>
</nav>
<div className="landing-header-actions">
<button className="landing-btn landing-btn-outline" onClick={() => navigate('/login')}>登录</button>
<button className="landing-btn landing-btn-primary" onClick={() => navigate('/login')}>免费注册</button>
</div>
</div>
</header>
{/* Hero Section */}
<section className="landing-hero">
<div className="landing-hero-content">
<h1 className="hero-title">
用 <span className="hero-accent">AI</span> 构建你的<br/>智能自动化工作流
</h1>
<p className="hero-desc">
Qihu-AIFlow 是一站式AI智能自动化PaaS平台,集成多模态大模型、知识库管理、应用市场,
让每个人都能轻松搭建属于自己的AI自动化SOP。
</p>
<div className="hero-actions">
<button className="landing-btn landing-btn-primary landing-btn-lg" onClick={() => navigate('/login')}>
免费开始使用
</button>
<button className="landing-btn landing-btn-outline landing-btn-lg" onClick={() => { document.getElementById('market')?.scrollIntoView({ behavior: 'smooth' }); }}>
浏览应用市场
</button>
</div>
<div className="hero-stats">
<div className="hero-stat">
<span className="stat-number">3,500+</span>
<span className="stat-label">活跃用户</span>
</div>
<div className="hero-stat">
<span className="stat-number">120+</span>
<span className="stat-label">应用/Skills</span>
</div>
<div className="hero-stat">
<span className="stat-number">99.8%</span>
<span className="stat-label">服务可用率</span>
</div>
</div>
</div>
</section>
{/* Carousel Section */}
{banners.length > 0 && (
<section className="landing-banners">
<div className="landing-section-inner">
<h2 className="section-title">推荐应用</h2>
<div
className="carousel-container"
onMouseEnter={() => setIsPaused(true)}
onMouseLeave={() => setIsPaused(false)}
>
<div className="carousel-track" style={{ transform: `translateX(-${currentSlide * 100}%)` }}>
{banners.map(banner => (
<div key={banner.id} className="carousel-slide">
<div className="carousel-card" style={{ background: banner.linkAppId ? getGradient({ id: banner.linkAppId } as MarketApp) : 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' }}>
<div className="carousel-card-content">
<div className="carousel-badge">推荐</div>
<h3 className="carousel-title">{banner.title}</h3>
{banner.linkAppName && (
<p className="carousel-subtitle">立即体验 {banner.linkAppName}</p>
)}
<button
className="carousel-cta"
onClick={() => navigate('/login')}
>
查看详情
<SVGIcon size={14}><polyline points="9 18 15 12 9 6"/></SVGIcon>
</button>
</div>
<div className="carousel-card-visual">
<div className="visual-circle c1"/>
<div className="visual-circle c2"/>
<div className="visual-circle c3"/>
</div>
</div>
</div>
))}
</div>
{/* Dots */}
{banners.length > 1 && (
<div className="carousel-dots">
{banners.map((_, idx) => (
<button
key={idx}
className={`carousel-dot ${idx === currentSlide ? 'active' : ''}`}
onClick={() => goToSlide(idx)}
/>
))}
</div>
)}
{/* Arrows */}
{banners.length > 1 && (
<>
<button
className="carousel-arrow carousel-arrow-left"
onClick={() => setCurrentSlide(prev => (prev - 1 + banners.length) % banners.length)}
>
<SVGIcon size={20}><polyline points="15 18 9 12 15 6"/></SVGIcon>
</button>
<button
className="carousel-arrow carousel-arrow-right"
onClick={() => nextSlide()}
>
<SVGIcon size={20}><polyline points="9 18 15 12 9 6"/></SVGIcon>
</button>
</>
)}
</div>
</div>
</section>
)}
{/* Market Preview Section */}
<section className="landing-market" id="market">
<div className="landing-section-inner">
<div className="section-header">
<h2 className="section-title">应用市场</h2>
<p className="section-desc">丰富的 Skills、Agent、MCP 应用,一键接入你的工作流</p>
</div>
{/* Type Filter */}
<div className="market-filter-tabs">
<button
className={`filter-tab ${appTypeFilter === '' ? 'active' : ''}`}
onClick={() => setAppTypeFilter('')}
>全部</button>
<button
className={`filter-tab ${appTypeFilter === 'skill' ? 'active' : ''}`}
onClick={() => setAppTypeFilter('skill')}
>Skills</button>
<button
className={`filter-tab ${appTypeFilter === 'agent' ? 'active' : ''}`}
onClick={() => setAppTypeFilter('agent')}
>Agent</button>
<button
className={`filter-tab ${appTypeFilter === 'mcp' ? 'active' : ''}`}
onClick={() => setAppTypeFilter('mcp')}
>MCP</button>
</div>
{/* App Grid */}
<div className="market-app-grid">
{filteredApps.map(app => (
<div key={app.id} className="market-app-card" onClick={() => handleAppClick(app.name)}>
<div className="app-card-cover" style={{ background: getGradient(app) }}>
<span className="app-card-type-badge" style={{ background: typeColors[app.type] }}>
{typeLabels[app.type]}
</span>
{app.price > 0 && (
<span className="app-card-price">{app.price} 元</span>
)}
</div>
<div className="app-card-body">
<h4 className="app-card-name">{app.name}</h4>
<p className="app-card-desc">{app.description}</p>
<div className="app-card-meta">
<span className="app-card-rating">
<SVGIcon size={12}><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></SVGIcon>
{app.ratingAvg > 0 ? app.ratingAvg : '-'}
</span>
<span className="app-card-installs">{app.installCount.toLocaleString()} 次安装</span>
</div>
</div>
</div>
))}
</div>
<div className="market-more">
<button className="landing-btn landing-btn-outline" onClick={() => navigate('/login')}>
登录查看更多应用
<SVGIcon size={14}><polyline points="9 18 15 12 9 6"/></SVGIcon>
</button>
</div>
</div>
</section>
{/* Features Section */}
<section className="landing-features" id="features">
<div className="landing-section-inner">
<div className="section-header">
<h2 className="section-title">平台核心能力</h2>
<p className="section-desc">从对话到自动化,Qihu-AIFlow 为你提供完整的 AI 工作流解决方案</p>
</div>
<div className="features-grid">
<div className="feature-card">
<div className="feature-icon">
<SVGIcon size={28}><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></SVGIcon>
</div>
<h3 className="feature-title">多模型AI对话</h3>
<p className="feature-desc">支持GPT-4o、DeepSeek-R1、Qwen-Max等多种大模型自由切换,按需使用最优模型</p>
</div>
<div className="feature-card">
<div className="feature-icon">
<SVGIcon size={28}><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></SVGIcon>
</div>
<h3 className="feature-title">私有知识库</h3>
<p className="feature-desc">上传文档构建专属知识库,AI回答基于你的内容,更精准可靠</p>
</div>
<div className="feature-card">
<div className="feature-icon">
<SVGIcon size={28}><rect x="3" y="3" width="7" height="7" rx="1.2"/><rect x="14" y="3" width="7" height="7" rx="1.2"/><rect x="3" y="14" width="7" height="7" rx="1.2"/><rect x="14" y="14" width="7" height="7" rx="1.2"/></SVGIcon>
</div>
<h3 className="feature-title">应用市场</h3>
<p className="feature-desc">丰富的Skills、Agent、MCP应用,一站式接入工作流,即装即用</p>
</div>
<div className="feature-card">
<div className="feature-icon">
<SVGIcon size={28}><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></SVGIcon>
</div>
<h3 className="feature-title">Token驾驶舱</h3>
<p className="feature-desc">实时监控Token消耗,费用透明可控,每一笔都有据可查</p>
</div>
<div className="feature-card">
<div className="feature-icon">
<SVGIcon size={28}><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></SVGIcon>
</div>
<h3 className="feature-title">IM频道绑定</h3>
<p className="feature-desc">支持飞书、钉钉、微信等多平台绑定,AI助手融入日常工作流</p>
</div>
<div className="feature-card">
<div className="feature-icon">
<SVGIcon size={28}><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></SVGIcon>
</div>
<h3 className="feature-title">自动化任务编排</h3>
<p className="feature-desc">创建关键词触发、定时执行等自动化任务,让AI按你的节奏工作</p>
</div>
</div>
</div>
</section>
{/* CTA Section */}
<section className="landing-cta">
<div className="landing-section-inner">
<h2 className="cta-title">准备好开始了吗?</h2>
<p className="cta-desc">免费注册即可体验全部功能,无需任何技术背景</p>
<button className="landing-btn landing-btn-primary landing-btn-lg" onClick={() => navigate('/login')}>
免费注册,立即体验
</button>
</div>
</section>
{/* Footer */}
<footer className="landing-footer">
<div className="landing-section-inner">
<div className="footer-content">
<div className="footer-brand">
<div className="landing-brand">
<svg width="28" height="28" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#5B5FED"/>
<path d="M8 20V12l4 4 4-4v8" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
</svg>
<span className="brand-name" style={{ color: 'var(--color-text-primary)' }}>Qihu-AIFlow</span>
</div>
<p style={{ color: 'var(--color-text-tertiary)', fontSize: 'var(--text-sm)', marginTop: 'var(--space-md)' }}>
AI智能自动化PaaS平台
</p>
</div>
<div className="footer-links">
<div className="footer-col">
<h4>产品</h4>
<a>AI聊天</a>
<a>知识库</a>
<a>应用市场</a>
</div>
<div className="footer-col">
<h4>支持</h4>
<a>帮助文档</a>
<a>API参考</a>
<a>联系我们</a>
</div>
</div>
</div>
<div className="footer-bottom">
<span>Qihu-AIFlow 2026. All rights reserved.</span>
</div>
</div>
</footer>
</div>
);
};
export default LandingPage;
+342
View File
@@ -0,0 +1,342 @@
/* ===== Login Page ===== */
.login-page {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
background: var(--color-bg-primary);
position: relative;
overflow: hidden;
}
.login-bg-decor {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
}
.bg-circle {
position: absolute;
border-radius: 50%;
opacity: 0.08;
}
.bg-circle.c1 {
width: 600px; height: 600px;
background: var(--color-accent);
top: -200px; right: -100px;
}
.bg-circle.c2 {
width: 400px; height: 400px;
background: #8B8DFF;
bottom: -100px; left: -50px;
}
.bg-circle.c3 {
width: 300px; height: 300px;
background: var(--color-accent);
top: 50%; left: 50%;
transform: translate(-50%, -50%);
opacity: 0.04;
}
.bg-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(0,0,0,0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,0,0,0.02) 1px, transparent 1px);
background-size: 60px 60px;
}
/* Container */
.login-container {
display: flex;
width: 900px;
min-height: 560px;
background: #FFFFFF;
border-radius: var(--radius-2xl);
box-shadow: var(--shadow-xl);
overflow: hidden;
z-index: var(--z-base);
position: relative;
}
/* Brand Panel */
.login-brand-panel {
width: 400px;
background: linear-gradient(135deg, #2D2B55 0%, #1A1A2E 50%, #2D2B55 100%);
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.login-brand-panel::before {
content: '';
position: absolute;
width: 300px; height: 300px;
border-radius: 50%;
background: radial-gradient(circle, rgba(91,95,237,0.3) 0%, transparent 70%);
top: -50px; right: -50px;
}
.login-brand-panel::after {
content: '';
position: absolute;
width: 200px; height: 200px;
border-radius: 50%;
background: radial-gradient(circle, rgba(139,141,255,0.2) 0%, transparent 70%);
bottom: -30px; left: -30px;
}
.brand-content {
text-align: center;
color: white;
z-index: 1;
}
.brand-icon {
margin-bottom: var(--space-xl);
}
.brand-title {
font-size: 36px;
font-weight: 800;
letter-spacing: -0.03em;
margin-bottom: var(--space-sm);
}
.brand-desc {
font-size: var(--text-base);
color: rgba(255,255,255,0.6);
margin-bottom: var(--space-3xl);
}
.brand-features {
display: flex;
flex-direction: column;
gap: var(--space-md);
text-align: left;
}
.brand-feature {
display: flex;
align-items: center;
gap: var(--space-md);
font-size: var(--text-sm);
color: rgba(255,255,255,0.75);
}
.bf-icon {
color: #6C63FF;
font-weight: 700;
font-size: var(--text-base);
}
/* Form Panel */
.login-form-panel {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-4xl);
}
.login-form-inner {
width: 100%;
max-width: 340px;
}
.login-tabs {
display: flex;
gap: var(--space-2xl);
margin-bottom: var(--space-3xl);
}
.login-tab {
font-size: var(--text-lg);
font-weight: 700;
color: var(--color-text-tertiary);
padding-bottom: var(--space-sm);
border-bottom: 2px solid transparent;
transition: all var(--transition-fast);
}
.login-tab.active {
color: var(--color-text-primary);
border-bottom-color: var(--color-accent);
}
.login-tab:hover {
color: var(--color-text-primary);
}
.login-form {
display: flex;
flex-direction: column;
gap: var(--space-xl);
}
.form-group {
display: flex;
flex-direction: column;
gap: var(--space-sm);
margin-bottom: 16px;
}
.form-label {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: 6px;
}
.form-input {
width: 100%;
padding: 12px 16px;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
font-size: var(--text-base);
color: var(--color-text-primary);
background: var(--color-bg-primary);
transition: all var(--transition-fast);
outline: none;
}
.form-input:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-light);
background: #FFFFFF;
}
.form-input::placeholder {
color: var(--color-text-disabled);
}
.login-submit-btn {
height: 46px;
background: linear-gradient(135deg, #5B5FED, #6C63FF);
color: white;
border-radius: var(--radius-lg);
font-size: var(--text-base);
font-weight: 700;
letter-spacing: 0.15em;
transition: all var(--transition-fast);
}
.login-submit-btn:hover {
background: linear-gradient(135deg, #4A4ECC, #5B5FED);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.login-submit-btn:active {
transform: translateY(0);
}
.login-submit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.login-submit-btn:disabled:hover {
transform: none;
box-shadow: none;
}
/* Register Method Tabs */
.register-method-tabs {
display: flex;
gap: var(--space-sm);
margin-bottom: var(--space-lg);
}
.register-method-tab {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
background: var(--color-bg-primary);
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
}
.register-method-tab:hover {
border-color: var(--color-accent-light);
}
.register-method-tab.active {
border-color: var(--color-accent);
background: var(--color-accent-subtle);
color: var(--color-accent);
font-weight: 600;
}
/* SMS Row */
.sms-row {
display: flex;
gap: var(--space-sm);
}
.sms-input {
flex: 1;
}
.sms-btn {
padding: var(--space-sm) var(--space-lg);
border-radius: var(--radius-md);
border: 1px solid var(--color-accent);
background: transparent;
color: var(--color-accent);
font-size: var(--text-sm);
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: all var(--transition-fast);
}
.sms-btn:hover:not(:disabled) {
background: var(--color-accent);
color: white;
}
.sms-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
border-color: var(--color-border);
color: var(--color-text-tertiary);
}
/* Required Marker */
.required {
color: var(--color-error);
}
@media (max-width: 768px) {
.login-container {
flex-direction: column;
width: 90vw;
min-height: auto;
}
.login-brand-panel {
width: 100%;
padding: var(--space-3xl) var(--space-2xl);
}
.login-form-panel {
padding: var(--space-2xl);
}
.brand-features {
display: none;
}
}
+267
View File
@@ -0,0 +1,267 @@
import React, { useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import { authAPI } from '@/api';
import type { User } from '@/types';
import './LoginPage.css';
const LoginPage: React.FC = () => {
const { login } = useAuth();
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<'login' | 'register'>('login');
const [errorMsg, setErrorMsg] = useState('');
const [loading, setLoading] = useState(false);
// Register state
const [registerMethod, setRegisterMethod] = useState<'phone' | 'email'>('phone');
const [regPhone, setRegPhone] = useState('');
const [regEmail, setRegEmail] = useState('');
const [regUsername, setRegUsername] = useState('');
const [regPassword, setRegPassword] = useState('');
const [smsCode, setSmsCode] = useState('');
const [smsSent, setSmsSent] = useState(false);
const [smsCountdown, setSmsCountdown] = useState(0);
const [emailCode, setEmailCode] = useState('');
const [emailSent, setEmailSent] = useState(false);
const [emailCountdown, setEmailCountdown] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Login state
const [loginAccount, setLoginAccount] = useState('');
const [loginPassword, setLoginPassword] = useState('');
const startCountdown = (setter: (v: number) => void) => {
let count = 60;
setter(count);
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = setInterval(() => {
count--;
setter(count);
if (count <= 0 && timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, 1000);
};
const handleSendSms = () => {
if (!regPhone.trim() || smsCountdown > 0) return;
setSmsSent(true);
startCountdown(setSmsCountdown);
};
const handleSendEmailCode = () => {
if (!regEmail.trim() || emailCountdown > 0) return;
setEmailSent(true);
startCountdown(setEmailCountdown);
};
const handleLogin = async () => {
setErrorMsg('');
if (!loginAccount.trim() || !loginPassword.trim()) {
setErrorMsg('请输入用户名和密码');
return;
}
setLoading(true);
try {
const res = await authAPI.login(loginAccount, loginPassword);
const { access_token, user: userData } = res.data;
localStorage.setItem('aiflow_token', access_token);
const userObj: User = {
id: userData.id,
username: userData.username,
email: userData.email || '',
phone: userData.phone || '',
avatar: userData.avatar || '',
role: userData.role,
status: userData.status,
realNameStatus: userData.real_name_status || 'unverified',
createdAt: userData.created_at || new Date().toISOString(),
membership: userData.membership_level > 0 ? {
planId: userData.membership_level >= 3 ? 'mp3' : (userData.membership_level >= 2 ? 'mp2' : 'mp1'),
planName: userData.role === 'admin' ? '管理员' : (userData.membership_level >= 3 ? '企业版' : (userData.membership_level >= 2 ? '专业版' : '免费版')),
level: userData.membership_level,
expireAt: userData.membership_expire_at || '',
autoRenew: userData.auto_renew || false,
} : undefined,
};
login(userObj);
navigate(userData.role === 'admin' ? '/admin' : '/chat');
} catch (e: any) {
// 处理后端返回的精确错误信息,区分用户不存在和密码错误
const status = e.status || e.response?.status || 0;
const msg = e.detail || e.message || '';
if (status === 403) {
setErrorMsg('账号已被禁用,请联系管理员');
} else if (status === 401) {
// 后端的detail精确指明了原因:"用户名不存在" 或 "密码错误"
setErrorMsg(msg || '用户名或密码错误');
} else if (status === 0 || msg.includes('NetworkError') || msg.includes('Failed to fetch')) {
setErrorMsg('网络连接失败,请检查网络');
} else {
setErrorMsg(msg || '登录失败,请稍后重试');
}
} finally {
setLoading(false);
}
};
const handleRegister = async () => {
setErrorMsg('');
if (!regUsername.trim() || !regPassword.trim() || regPassword.length < 6) {
setErrorMsg('用户名和密码为必填(密码至少6位)');
return;
}
setLoading(true);
try {
const data: any = { username: regUsername, password: regPassword };
if (registerMethod === 'phone') data.phone = regPhone;
else data.email = regEmail;
const res = await authAPI.register(data);
const { access_token, user: userData } = res.data;
localStorage.setItem('aiflow_token', access_token);
const userObj: User = {
id: userData.id,
username: userData.username,
email: userData.email || '',
phone: userData.phone || '',
avatar: userData.avatar || '',
role: 'user',
status: 'active',
realNameStatus: 'unverified',
createdAt: new Date().toISOString(),
};
login(userObj);
navigate('/chat');
} catch (e: any) {
setErrorMsg(e.message || '注册失败');
} finally {
setLoading(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && activeTab === 'login') handleLogin();
};
return (
<div className="login-page">
<div className="login-bg-decor">
<div className="bg-circle c1" />
<div className="bg-circle c2" />
<div className="bg-circle c3" />
<div className="bg-grid" />
</div>
<div className="login-container">
<div className="login-brand-panel">
<div className="brand-content">
<div className="brand-icon">
<svg width="48" height="48" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="white"/>
<path d="M8 20V12l4 4 4-4v8" stroke="#5B5FED" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
<path d="M6 22h20" stroke="#5B5FED" strokeWidth="1.5" strokeLinecap="round" opacity="0.5"/>
</svg>
</div>
<h1 className="brand-title">AIFlow</h1>
<p className="brand-desc">智能自动化PaaS平台</p>
<div className="brand-features">
<div className="brand-feature"><span className="bf-icon">&#x2713;</span><span>一站式AI自动化SOP</span></div>
<div className="brand-feature"><span className="bf-icon">&#x2713;</span><span>多模型自由切换</span></div>
<div className="brand-feature"><span className="bf-icon">&#x2713;</span><span>知识库 + 应用市场</span></div>
<div className="brand-feature"><span className="bf-icon">&#x2713;</span><span>Token消耗实时掌控</span></div>
</div>
</div>
</div>
<div className="login-form-panel">
<div className="login-form-inner">
<div className="login-tabs">
<button className={`login-tab ${activeTab === 'login' ? 'active' : ''}`} onClick={() => setActiveTab('login')}>登录</button>
<button className={`login-tab ${activeTab === 'register' ? 'active' : ''}`} onClick={() => setActiveTab('register')}>注册</button>
</div>
{errorMsg && <div className="form-error">{errorMsg}</div>}
{activeTab === 'login' ? (
<div className="login-form" onKeyDown={handleKeyDown}>
<div className="form-group">
<label className="form-label">用户名</label>
<input type="text" className="form-input" placeholder="请输入用户名" value={loginAccount} onChange={e => setLoginAccount(e.target.value)} />
</div>
<div className="form-group">
<label className="form-label">密码</label>
<input type="password" className="form-input" placeholder="请输入密码" value={loginPassword} onChange={e => setLoginPassword(e.target.value)} />
</div>
<button className="login-submit-btn" onClick={handleLogin} disabled={loading}>
{loading ? '登录中...' : '登 录'}
</button>
</div>
) : (
<div className="login-form">
<div className="register-method-tabs">
<button className={`register-method-tab ${registerMethod === 'phone' ? 'active' : ''}`} onClick={() => setRegisterMethod('phone')}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg>
手机号注册
</button>
<button className={`register-method-tab ${registerMethod === 'email' ? 'active' : ''}`} onClick={() => setRegisterMethod('email')}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
邮箱注册
</button>
</div>
{registerMethod === 'phone' ? (
<>
<div className="form-group">
<label className="form-label">手机号</label>
<input type="tel" className="form-input" placeholder="请输入手机号码" value={regPhone} onChange={e => setRegPhone(e.target.value)} />
</div>
<div className="form-group">
<label className="form-label">短信验证码</label>
<div className="sms-row">
<input type="text" className="form-input sms-input" placeholder="请输入验证码" value={smsCode} onChange={e => setSmsCode(e.target.value)} />
<button className="sms-btn" onClick={handleSendSms} disabled={!regPhone.trim() || smsCountdown > 0}>
{smsCountdown > 0 ? `${smsCountdown}秒后重发` : smsSent ? '重新发送' : '获取验证码'}
</button>
</div>
</div>
</>
) : (
<>
<div className="form-group">
<label className="form-label">邮箱</label>
<input type="email" className="form-input" placeholder="请输入邮箱地址" value={regEmail} onChange={e => setRegEmail(e.target.value)} />
</div>
<div className="form-group">
<label className="form-label">邮箱验证码</label>
<div className="sms-row">
<input type="text" className="form-input sms-input" placeholder="请输入邮箱验证码" value={emailCode} onChange={e => setEmailCode(e.target.value)} />
<button className="sms-btn" onClick={handleSendEmailCode} disabled={!regEmail.trim() || emailCountdown > 0}>
{emailCountdown > 0 ? `${emailCountdown}秒后重发` : emailSent ? '重新发送' : '发送验证码'}
</button>
</div>
</div>
</>
)}
<div className="form-group">
<label className="form-label">用户名 <span className="required">*</span></label>
<input type="text" className="form-input" placeholder="请输入用户名" value={regUsername} onChange={e => setRegUsername(e.target.value)} />
</div>
<div className="form-group">
<label className="form-label">密码 <span className="required">*</span></label>
<input type="password" className="form-input" placeholder="请设置密码(至少6位)" value={regPassword} onChange={e => setRegPassword(e.target.value)} />
</div>
<button className="login-submit-btn" onClick={handleRegister} disabled={loading}>
{loading ? '注册中...' : '注 册'}
</button>
</div>
)}
</div>
</div>
</div>
</div>
);
};
export default LoginPage;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,303 @@
/* ===== NotificationPage Styles ===== */
.notification-page {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--color-bg-primary);
}
/* ----- Header ----- */
.notification-page-header {
padding: var(--space-3xl) var(--space-4xl) var(--space-xl);
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border-light);
display: flex;
flex-direction: column;
gap: var(--space-xl);
flex-shrink: 0;
}
.notification-title-row {
display: flex;
align-items: center;
gap: var(--space-md);
}
.notification-page-title {
font-size: var(--text-xl);
font-weight: 700;
color: var(--color-text-primary);
line-height: 1.3;
}
.notification-unread-badge {
display: inline-flex;
align-items: center;
padding: 2px 12px;
background: var(--color-accent-light);
color: var(--color-accent);
border-radius: var(--radius-full);
font-size: var(--text-xs);
font-weight: 600;
}
.notification-header-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-lg);
flex-wrap: wrap;
}
/* ----- Filter Tabs ----- */
.notification-filter-tabs {
display: flex;
gap: var(--space-xs);
background: var(--color-bg-primary);
padding: var(--space-xs);
border-radius: var(--radius-md);
border: 1px solid var(--color-border-light);
}
.notif-filter-tab {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-lg);
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-text-secondary);
background: none;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
white-space: nowrap;
}
.notif-filter-tab:hover {
color: var(--color-text-primary);
background: var(--color-bg-hover);
}
.notif-filter-tab.active {
color: var(--color-accent);
background: var(--color-accent-subtle);
font-weight: 600;
}
.notif-filter-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: var(--radius-full);
font-size: 11px;
font-weight: 600;
background: var(--color-bg-active);
color: var(--color-text-tertiary);
line-height: 1;
}
.notif-filter-tab.active .notif-filter-count {
background: var(--color-accent);
color: white;
}
/* ----- Mark All Read Button ----- */
.mark-all-read-btn {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-lg);
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-accent);
background: var(--color-accent-subtle);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
white-space: nowrap;
}
.mark-all-read-btn:hover {
background: var(--color-accent-light);
color: var(--color-accent-hover);
}
/* ----- Notification List ----- */
.notification-list {
flex: 1;
overflow-y: auto;
padding: var(--space-lg) var(--space-4xl);
display: flex;
flex-direction: column;
gap: 2px;
}
/* ----- Notification Item ----- */
.notification-item {
position: relative;
display: flex;
align-items: flex-start;
gap: var(--space-lg);
padding: var(--space-xl) var(--space-lg);
background: var(--color-bg-secondary);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
border: 1px solid transparent;
}
.notification-item:hover {
background: var(--color-bg-hover);
border-color: var(--color-border-light);
}
.notification-item.unread {
background: var(--color-accent-subtle);
}
.notification-item.unread:hover {
background: var(--color-accent-light);
}
/* Unread dot */
.notification-unread-dot {
position: absolute;
top: var(--space-lg);
left: var(--space-sm);
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-accent);
flex-shrink: 0;
}
/* Type Icon */
.notification-type-icon {
width: 40px;
height: 40px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
/* Content */
.notification-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.notification-content-header {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.notification-type-label {
display: inline-block;
font-size: var(--text-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
flex-shrink: 0;
}
.notification-title {
font-size: var(--text-base);
font-weight: 600;
color: var(--color-text-primary);
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.notification-body {
font-size: var(--text-sm);
color: var(--color-text-secondary);
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Time */
.notification-time {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
white-space: nowrap;
flex-shrink: 0;
margin-top: 2px;
}
/* ----- Empty State ----- */
.notification-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-5xl);
gap: var(--space-md);
flex: 1;
}
.notification-empty-icon {
color: var(--color-text-disabled);
}
.notification-empty-text {
font-size: var(--text-base);
color: var(--color-text-tertiary);
}
/* ----- Responsive ----- */
@media (max-width: 768px) {
.notification-page-header {
padding: var(--space-xl) var(--space-lg);
}
.notification-header-actions {
flex-direction: column;
align-items: flex-start;
}
.notification-filter-tabs {
flex-wrap: wrap;
width: 100%;
}
.notif-filter-tab {
padding: var(--space-sm) var(--space-md);
font-size: var(--text-xs);
}
.notification-list {
padding: var(--space-md) var(--space-lg);
}
.notification-item {
padding: var(--space-md);
gap: var(--space-md);
}
.notification-time {
display: none;
}
}
@media (max-width: 480px) {
.notification-title-row {
flex-direction: column;
align-items: flex-start;
gap: var(--space-sm);
}
.notification-type-icon {
width: 32px;
height: 32px;
}
}
@@ -0,0 +1,218 @@
import React, { useState, useMemo } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { mockNotifications } from '@/mock/data';
import type { Notification } from '@/types';
import './NotificationPage.css';
// ===== SVG Icons for notification types =====
const typeIconMap: Record<Notification['type'], { icon: JSX.Element; color: string; bg: string }> = {
announcement: {
color: '#5B5FED',
bg: '#EEEDFF',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
),
},
reminder: {
color: '#E8922C',
bg: '#FEF4E8',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
),
},
renewal: {
color: '#3B82F6',
bg: '#EBF2FE',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
),
},
order: {
color: '#219B5E',
bg: '#E6F7EE',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>
</svg>
),
},
update: {
color: '#8B5CF6',
bg: '#F3F0FF',
icon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/>
</svg>
),
},
};
const typeLabelMap: Record<Notification['type'] | 'all', string> = {
all: '全部',
announcement: '公告',
reminder: '提醒',
renewal: '续费',
order: '订单',
update: '更新',
};
// ===== Helper: format relative time =====
function formatRelativeTime(dateStr: string): string {
const now = Date.now();
const date = new Date(dateStr).getTime();
const diff = now - date;
if (diff < 0) return '刚刚';
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 60) return '刚刚';
if (minutes < 60) return `${minutes}分钟前`;
if (hours < 24) return `${hours}小时前`;
if (days < 7) return `${days}天前`;
if (days < 30) return `${Math.floor(days / 7)}周前`;
const d = new Date(dateStr);
return d.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' });
}
// ===== NotificationPage =====
const NotificationPage: React.FC = () => {
const { user } = useAuth();
const [activeFilter, setActiveFilter] = useState<Notification['type'] | 'all'>('all');
const [notifications, setNotifications] = useState<Notification[]>(() =>
mockNotifications.filter(n => n.userId === 'u1')
);
const unreadCount = useMemo(() => notifications.filter(n => !n.isRead).length, [notifications]);
const filteredNotifications = useMemo(() => {
if (activeFilter === 'all') return notifications;
return notifications.filter(n => n.type === activeFilter);
}, [notifications, activeFilter]);
const typeCounts = useMemo(() => {
const counts: Record<string, number> = { all: notifications.length };
notifications.forEach(n => {
counts[n.type] = (counts[n.type] || 0) + 1;
});
return counts;
}, [notifications]);
const handleMarkAsRead = (id: string) => {
setNotifications(prev =>
prev.map(n => (n.id === id ? { ...n, isRead: true } : n))
);
};
const handleMarkAllAsRead = () => {
setNotifications(prev => prev.map(n => ({ ...n, isRead: true })));
};
if (!user) return null;
const filterKeys = ['all', 'announcement', 'reminder', 'renewal', 'order', 'update'] as const;
return (
<div className="notification-page">
{/* Header */}
<div className="notification-page-header">
<div className="notification-title-row">
<h1 className="notification-page-title">消息通知</h1>
{unreadCount > 0 && (
<span className="notification-unread-badge">{unreadCount}条未读</span>
)}
</div>
<div className="notification-header-actions">
{/* Filter Tabs */}
<div className="notification-filter-tabs">
{filterKeys.map(key => (
<button
key={key}
className={`notif-filter-tab ${activeFilter === key ? 'active' : ''}`}
onClick={() => setActiveFilter(key)}
>
<span>{typeLabelMap[key]}</span>
{typeCounts[key] !== undefined && (
<span className="notif-filter-count">{typeCounts[key]}</span>
)}
</button>
))}
</div>
{/* Mark All Read */}
{unreadCount > 0 && (
<button className="mark-all-read-btn" onClick={handleMarkAllAsRead}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
全部标为已读
</button>
)}
</div>
</div>
{/* Notification List */}
<div className="notification-list">
{filteredNotifications.length > 0 ? (
filteredNotifications.map(notif => {
const typeConfig = typeIconMap[notif.type];
return (
<div
key={notif.id}
className={`notification-item ${!notif.isRead ? 'unread' : ''}`}
onClick={() => handleMarkAsRead(notif.id)}
>
{/* Unread dot */}
{!notif.isRead && <span className="notification-unread-dot" />}
{/* Type Icon */}
<div
className="notification-type-icon"
style={{ backgroundColor: typeConfig.bg, color: typeConfig.color }}
>
{typeConfig.icon}
</div>
{/* Content */}
<div className="notification-content">
<div className="notification-content-header">
<span className="notification-type-label" style={{ color: typeConfig.color }}>
{typeLabelMap[notif.type]}
</span>
<h4 className="notification-title">{notif.title}</h4>
</div>
<p className="notification-body">{notif.content}</p>
</div>
{/* Time */}
<span className="notification-time">{formatRelativeTime(notif.createdAt)}</span>
</div>
);
})
) : (
<div className="notification-empty">
<div className="notification-empty-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
</div>
<p className="notification-empty-text">暂无{activeFilter === 'all' ? '' : typeLabelMap[activeFilter]}通知</p>
</div>
)}
</div>
</div>
);
};
export default NotificationPage;
@@ -0,0 +1,469 @@
/* ===== Novel Preview Page ===== */
.novel-preview-page {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--color-bg-primary);
animation: fadeIn var(--transition-normal);
}
.novel-preview-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: var(--space-lg);
color: var(--color-text-tertiary);
font-size: var(--text-sm);
}
/* Top Bar */
.novel-topbar {
display: flex;
align-items: flex-start;
gap: var(--space-lg);
padding: var(--space-lg) var(--space-2xl);
background: linear-gradient(135deg, #FFFBEB, #FFF7ED);
border-bottom: 1px solid #FDE68A;
flex-shrink: 0;
}
.novel-back-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: var(--radius-md);
color: #92400E;
transition: all var(--transition-fast);
flex-shrink: 0;
margin-top: 2px;
}
.novel-back-btn:hover {
background: #FEF3C7;
}
.novel-intro {
flex: 1;
min-width: 0;
}
.novel-intro-title {
font-size: var(--text-xl);
font-weight: 700;
color: #78350F;
margin-bottom: var(--space-xs);
}
.novel-intro-desc {
font-size: var(--text-sm);
color: #A16207;
line-height: 1.6;
margin-bottom: var(--space-sm);
}
.novel-intro-stats {
font-size: var(--text-xs);
color: #B45309;
display: flex;
gap: var(--space-sm);
}
/* Body Layout */
.novel-body {
flex: 1;
display: flex;
overflow: hidden;
}
/* Left Sidebar */
.novel-sidebar-left {
width: 200px;
min-width: 200px;
border-right: 1px solid var(--color-border-light);
overflow-y: auto;
background: #FAFAFA;
flex-shrink: 0;
}
.novel-sidebar-title {
padding: var(--space-lg);
font-size: var(--text-sm);
font-weight: 700;
color: var(--color-text-secondary);
border-bottom: 1px solid var(--color-border-light);
}
.novel-chapter-list {
display: flex;
flex-direction: column;
gap: 1px;
padding: var(--space-sm);
}
.novel-chapter-item {
display: flex;
gap: var(--space-md);
padding: var(--space-md) var(--space-md);
border-radius: var(--radius-md);
text-align: left;
transition: all var(--transition-fast);
width: 100%;
}
.novel-chapter-item:hover {
background: var(--color-bg-hover);
}
.novel-chapter-item.active {
background: #FEF3C7;
}
.chapter-item-num {
font-size: 11px;
font-weight: 700;
color: var(--color-text-disabled);
font-family: var(--font-mono);
flex-shrink: 0;
width: 20px;
}
.novel-chapter-item.active .chapter-item-num {
color: #D97706;
}
.chapter-item-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.chapter-item-title {
font-size: var(--text-xs);
font-weight: 500;
color: var(--color-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chapter-item-count {
font-size: 10px;
color: var(--color-text-disabled);
}
/* Center Content */
.novel-content {
flex: 1;
overflow-y: auto;
padding: var(--space-3xl) var(--space-4xl);
background: #FFFFFF;
min-width: 0;
}
.novel-content-header {
margin-bottom: var(--space-3xl);
}
.novel-chapter-title {
font-size: var(--text-2xl);
font-weight: 700;
color: var(--color-text-primary);
}
.novel-content-body {
display: flex;
flex-direction: column;
gap: var(--space-xl);
}
.novel-paragraph {
font-size: var(--text-md);
line-height: 2;
color: var(--color-text-primary);
text-indent: 2em;
letter-spacing: 0.01em;
}
.novel-content-footer {
margin-top: var(--space-3xl);
padding-top: var(--space-xl);
border-top: 1px solid var(--color-border-light);
font-size: var(--text-xs);
color: var(--color-text-tertiary);
}
.novel-content-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--color-text-disabled);
font-size: var(--text-sm);
}
/* Right Sidebar */
.novel-sidebar-right {
width: 280px;
min-width: 280px;
border-left: 1px solid var(--color-border-light);
overflow-y: auto;
background: #FAFAFA;
flex-shrink: 0;
}
.novel-right-tabs {
display: flex;
border-bottom: 1px solid var(--color-border-light);
}
.novel-right-tab {
flex: 1;
padding: var(--space-md) var(--space-sm);
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-tertiary);
text-align: center;
transition: all var(--transition-fast);
border-bottom: 2px solid transparent;
}
.novel-right-tab:hover {
color: var(--color-text-primary);
}
.novel-right-tab.active {
color: #D97706;
border-bottom-color: #D97706;
}
.novel-right-content {
padding: var(--space-lg);
}
/* World View */
.novel-worldview {
display: flex;
flex-direction: column;
gap: var(--space-xl);
}
.worldview-summary {
font-size: var(--text-xs);
color: var(--color-text-secondary);
line-height: 1.7;
}
.worldview-section h4 {
font-size: 11px;
font-weight: 700;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--space-sm);
}
.worldview-section ul {
list-style: none;
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.worldview-section li {
font-size: var(--text-xs);
color: var(--color-text-secondary);
padding-left: var(--space-md);
position: relative;
line-height: 1.5;
}
.worldview-section li::before {
content: '';
position: absolute;
left: 0;
top: 6px;
width: 5px;
height: 5px;
border-radius: 50%;
background: #D97706;
}
.worldview-timeline {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.timeline-item {
display: flex;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: #FFFFFF;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border-light);
}
.timeline-era {
font-size: 10px;
font-weight: 700;
color: #D97706;
font-family: var(--font-mono);
flex-shrink: 0;
}
.timeline-event {
font-size: var(--text-xs);
color: var(--color-text-secondary);
line-height: 1.4;
}
/* Characters */
.novel-characters {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.character-card {
display: flex;
gap: var(--space-md);
padding: var(--space-md);
background: #FFFFFF;
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-light);
}
.character-avatar {
width: 36px;
height: 36px;
border-radius: var(--radius-full);
background: linear-gradient(135deg, #F59E0B, #FBBF24);
color: #FFFFFF;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--text-sm);
font-weight: 700;
flex-shrink: 0;
}
.character-info {
flex: 1;
min-width: 0;
}
.character-name-row {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-bottom: 4px;
}
.character-name {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-primary);
}
.character-role {
font-size: 10px;
font-weight: 600;
padding: 1px 6px;
border-radius: var(--radius-full);
background: var(--color-bg-tertiary);
color: var(--color-text-secondary);
}
.character-role.main {
background: #FEF3C7;
color: #92400E;
}
.character-role.villain {
background: #FEE2E2;
color: #991B1B;
}
.character-desc {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
line-height: 1.5;
margin-bottom: var(--space-xs);
}
.character-rels {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.character-rel-tag {
font-size: 10px;
padding: 1px 8px;
border-radius: var(--radius-full);
background: var(--color-accent-subtle);
color: var(--color-accent);
font-weight: 500;
}
/* Hooks & Anchors */
.novel-hooks {
display: flex;
flex-direction: column;
gap: var(--space-2xl);
}
.hooks-section h4 {
font-size: 11px;
font-weight: 700;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--space-sm);
}
.hook-item, .anchor-item {
display: flex;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: #FFFFFF;
border-radius: var(--radius-md);
border: 1px solid var(--color-border-light);
margin-bottom: var(--space-sm);
font-size: var(--text-xs);
color: var(--color-text-secondary);
line-height: 1.5;
}
.hook-item svg {
color: #F59E0B;
flex-shrink: 0;
margin-top: 2px;
}
.anchor-item svg {
color: var(--color-accent);
flex-shrink: 0;
margin-top: 2px;
}
/* Responsive */
@media (max-width: 1024px) {
.novel-sidebar-right { width: 240px; min-width: 240px; }
.novel-sidebar-left { width: 160px; min-width: 160px; }
.novel-content { padding: var(--space-2xl); }
}
@media (max-width: 768px) {
.novel-body { flex-direction: column; }
.novel-sidebar-left { width: 100%; min-width: 0; max-height: 200px; border-right: 0; border-bottom: 1px solid var(--color-border-light); }
.novel-sidebar-right { width: 100%; min-width: 0; max-height: 300px; border-left: 0; border-top: 1px solid var(--color-border-light); }
.novel-content { flex: 1; padding: var(--space-lg); }
.novel-topbar { padding: var(--space-md); }
}
@@ -0,0 +1,186 @@
import React, { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import type { Asset, NovelChapter } from '@/types';
import { mockAssets } from '@/mock/data';
import './NovelPreviewPage.css';
const NovelPreviewPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [activeChapter, setActiveChapter] = useState<string>('');
const [rightTab, setRightTab] = useState<'worldview' | 'characters' | 'hooks'>('worldview');
const novel: Asset | undefined = mockAssets.find(a => a.id === id && a.category === 'novel');
const chapters = novel?.chapters || [];
const meta = novel?.novelMeta;
const currentChapter: NovelChapter | undefined = chapters.find(c => c.id === (activeChapter || chapters[0]?.id));
if (!novel) {
return (
<div className="novel-preview-page">
<div className="novel-preview-empty">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" style={{ color: 'var(--color-text-disabled)' }}><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<p>未找到该小说</p>
<button className="novel-back-btn" onClick={() => navigate('/assets')}>返回资产</button>
</div>
</div>
);
}
return (
<div className="novel-preview-page">
{/* Top: Novel Intro */}
<div className="novel-topbar">
<button className="novel-back-btn" onClick={() => navigate('/assets')}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
</button>
<div className="novel-intro">
<h1 className="novel-intro-title">{novel.name}</h1>
<p className="novel-intro-desc">{novel.description}</p>
<div className="novel-intro-stats">
<span>{chapters.length} 章</span>
<span>·</span>
<span>{chapters.reduce((s, c) => s + c.wordCount, 0).toLocaleString()} 字</span>
</div>
</div>
</div>
<div className="novel-body">
{/* Left: Chapter List */}
<aside className="novel-sidebar-left">
<h3 className="novel-sidebar-title">章节目录</h3>
<div className="novel-chapter-list">
{chapters.map(ch => (
<button
key={ch.id}
className={`novel-chapter-item ${(activeChapter || chapters[0]?.id) === ch.id ? 'active' : ''}`}
onClick={() => setActiveChapter(ch.id)}
>
<span className="chapter-item-num">{String(ch.sortOrder).padStart(2, '0')}</span>
<div className="chapter-item-info">
<span className="chapter-item-title">{ch.title}</span>
<span className="chapter-item-count">{ch.wordCount.toLocaleString()} 字</span>
</div>
</button>
))}
</div>
</aside>
{/* Center: Content */}
<main className="novel-content">
{currentChapter ? (
<>
<div className="novel-content-header">
<h2 className="novel-chapter-title">{currentChapter.title}</h2>
</div>
<div className="novel-content-body">
{currentChapter.content.split('\n\n').map((para, i) => (
<p key={i} className="novel-paragraph">{para}</p>
))}
</div>
<div className="novel-content-footer">
<span>本章字数:{currentChapter.wordCount.toLocaleString()} 字</span>
</div>
</>
) : (
<div className="novel-content-empty">
<p>暂无章节内容</p>
</div>
)}
</main>
{/* Right: World View, Characters, Hooks & Anchors */}
<aside className="novel-sidebar-right">
<div className="novel-right-tabs">
<button
className={`novel-right-tab ${rightTab === 'worldview' ? 'active' : ''}`}
onClick={() => setRightTab('worldview')}
>世界观</button>
<button
className={`novel-right-tab ${rightTab === 'characters' ? 'active' : ''}`}
onClick={() => setRightTab('characters')}
>人物</button>
<button
className={`novel-right-tab ${rightTab === 'hooks' ? 'active' : ''}`}
onClick={() => setRightTab('hooks')}
>钩子&锚点</button>
</div>
<div className="novel-right-content">
{rightTab === 'worldview' && meta?.worldView && (
<div className="novel-worldview">
<p className="worldview-summary">{meta.worldView.summary}</p>
<div className="worldview-section">
<h4>世界规则</h4>
<ul>
{meta.worldView.rules.map((r, i) => <li key={i}>{r}</li>)}
</ul>
</div>
<div className="worldview-section">
<h4>时间线</h4>
<div className="worldview-timeline">
{meta.worldView.timeline.map((t, i) => (
<div key={i} className="timeline-item">
<span className="timeline-era">{t.era}</span>
<span className="timeline-event">{t.events}</span>
</div>
))}
</div>
</div>
</div>
)}
{rightTab === 'characters' && meta?.characters && (
<div className="novel-characters">
{meta.characters.map(char => (
<div key={char.id} className="character-card">
<div className="character-avatar">{char.name.charAt(0)}</div>
<div className="character-info">
<div className="character-name-row">
<span className="character-name">{char.name}</span>
<span className={`character-role ${char.role === '主角' ? 'main' : char.role === '反派' ? 'villain' : ''}`}>{char.role}</span>
</div>
<p className="character-desc">{char.description}</p>
{char.relationships.length > 0 && (
<div className="character-rels">
{char.relationships.map((r, i) => (
<span key={i} className="character-rel-tag">{r}</span>
))}
</div>
)}
</div>
</div>
))}
</div>
)}
{rightTab === 'hooks' && meta && (
<div className="novel-hooks">
<div className="hooks-section">
<h4>故事钩子</h4>
{meta.hooks.map((h, i) => (
<div key={i} className="hook-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 01-2.83 0L2 12V2h10l8.59 8.59a2 2 0 010 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>
<span>{h}</span>
</div>
))}
</div>
<div className="hooks-section">
<h4>故事锚点</h4>
{meta.anchors.map((a, i) => (
<div key={i} className="anchor-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/></svg>
<span>{a}</span>
</div>
))}
</div>
</div>
)}
</div>
</aside>
</div>
</div>
);
};
export default NovelPreviewPage;
+995
View File
@@ -0,0 +1,995 @@
/* ===== Pricing Page ===== */
.pricing-page {
padding: var(--space-3xl) var(--space-4xl);
max-width: 1200px;
width: 100%;
margin: 0 auto;
height: 100%;
overflow-y: auto;
animation: fadeIn var(--transition-normal);
scrollbar-width: thin;
scrollbar-color: transparent transparent;
box-sizing: border-box;
}
.pricing-page::-webkit-scrollbar {
width: 0;
}
.pricing-page:hover::-webkit-scrollbar {
width: 5px;
}
.pricing-page:hover::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: var(--radius-full);
}
.pricing-page:hover::-webkit-scrollbar-track {
background: transparent;
}
/* ===== Header ===== */
.pricing-header {
text-align: center;
margin-bottom: var(--space-3xl);
}
.pricing-title {
font-size: var(--text-2xl);
font-weight: 800;
color: var(--color-text-primary);
letter-spacing: -0.02em;
margin-bottom: var(--space-sm);
}
.pricing-subtitle {
font-size: var(--text-base);
color: var(--color-text-tertiary);
}
/* ===== Billing Cycle Tabs ===== */
.pricing-cycle-tabs {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
margin-bottom: var(--space-3xl);
background: var(--color-bg-tertiary);
border-radius: var(--radius-lg);
padding: 4px;
max-width: 320px;
margin-left: auto;
margin-right: auto;
}
.pricing-cycle-tab {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-lg);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-tertiary);
transition: all var(--transition-fast);
position: relative;
}
.pricing-cycle-tab.active {
background: var(--color-bg-secondary);
color: var(--color-text-primary);
box-shadow: var(--shadow-sm);
}
.pricing-cycle-tab:hover:not(.active) {
color: var(--color-text-secondary);
}
.pricing-savings-badge {
display: inline-flex;
align-items: center;
padding: 1px 6px;
border-radius: var(--radius-sm);
background: var(--color-success-light);
color: var(--color-success);
font-size: 10px;
font-weight: 700;
}
/* ===== Plans Grid ===== */
.pricing-plans-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: var(--space-xl);
margin-bottom: var(--space-4xl);
max-width: 100%;
justify-content: center;
}
/* Single card centers naturally with auto-fit */
@media (min-width: 1025px) {
.pricing-plans-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 1024px) and (min-width: 769px) {
.pricing-plans-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.pricing-plans-grid {
grid-template-columns: 1fr;
max-width: 400px;
margin-left: auto;
margin-right: auto;
}
}
/* ===== Plan Card ===== */
.pricing-plan-card {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-2xl);
padding: var(--space-3xl) var(--space-2xl);
display: flex;
flex-direction: column;
gap: var(--space-2xl);
position: relative;
overflow: hidden;
transition: transform var(--transition-fast), box-shadow var(--transition-fast), border-color var(--transition-fast);
}
.pricing-plan-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
}
.pricing-plan--current {
border-color: var(--color-accent);
box-shadow: 0 0 0 1px var(--color-accent);
}
.pricing-plan--popular {
border-color: #F59E0B;
}
/* Popular Ribbon */
.pricing-popular-ribbon {
position: absolute;
top: 16px;
right: -32px;
background: linear-gradient(135deg, #F59E0B, #EF4444);
color: #FFFFFF;
font-size: 11px;
font-weight: 700;
padding: 4px 40px;
transform: rotate(45deg);
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.pricing-popular-ribbon svg {
color: #FFFFFF;
flex-shrink: 0;
}
.pricing-current-badge {
position: absolute;
top: var(--space-lg);
left: var(--space-lg);
padding: 4px 12px;
border-radius: var(--radius-full);
background: var(--color-accent);
color: #FFFFFF;
font-size: 11px;
font-weight: 600;
}
/* Plan Head */
.pricing-plan-head {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: var(--space-md);
}
.pricing-plan-name {
font-size: var(--text-lg);
font-weight: 700;
color: var(--color-text-primary);
}
.pricing-plan-price-row {
display: flex;
align-items: baseline;
gap: 2px;
}
.pricing-plan-price {
font-size: var(--text-3xl);
font-weight: 800;
color: var(--color-text-primary);
font-family: var(--font-mono);
letter-spacing: -0.02em;
}
.pricing-currency {
font-size: var(--text-lg);
font-weight: 600;
color: var(--color-text-tertiary);
}
.pricing-plan-cycle {
font-size: var(--text-sm);
color: var(--color-text-tertiary);
}
.pricing-plan-savings {
font-size: var(--text-xs);
color: var(--color-success);
font-weight: 600;
padding: 2px 10px;
background: var(--color-success-light);
border-radius: var(--radius-full);
}
.pricing-plan-tokens {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
margin-top: var(--space-xs);
}
/* Features */
.pricing-plan-features {
display: flex;
flex-direction: column;
gap: var(--space-md);
padding-top: var(--space-lg);
border-top: 1px solid var(--color-border-light);
}
.pricing-feature-item {
display: flex;
align-items: center;
gap: var(--space-md);
font-size: var(--text-sm);
color: var(--color-text-secondary);
}
.pricing-feature-check {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: var(--radius-full);
background: var(--color-success-light);
color: var(--color-success);
flex-shrink: 0;
}
/* Subscribe Button */
.pricing-subscribe-btn {
width: 100%;
padding: var(--space-md);
border-radius: var(--radius-lg);
font-size: var(--text-sm);
font-weight: 700;
background: var(--color-accent);
color: #FFFFFF;
transition: all var(--transition-fast);
height: 48px;
}
.pricing-subscribe-btn:hover {
background: var(--color-accent-hover);
box-shadow: var(--shadow-md);
}
.pricing-btn--current {
background: var(--color-accent-light);
color: var(--color-accent);
cursor: default;
}
.pricing-btn--current:hover {
background: var(--color-accent-light);
box-shadow: none;
}
/* ===== Section ===== */
.pricing-section {
margin-bottom: var(--space-4xl);
}
.pricing-section-header {
display: flex;
align-items: center;
gap: var(--space-md);
margin-bottom: var(--space-xl);
}
.pricing-section-header svg {
color: var(--color-accent);
}
.pricing-section-title {
font-size: var(--text-lg);
font-weight: 700;
color: var(--color-text-primary);
}
/* ===== Services Grid ===== */
.pricing-services-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-lg);
}
.pricing-service-card {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: var(--space-xl);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-xl);
gap: var(--space-lg);
transition: box-shadow var(--transition-fast);
}
.pricing-service-card:hover {
box-shadow: var(--shadow-md);
}
.pricing-service-info {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.pricing-service-name {
font-size: var(--text-base);
font-weight: 700;
color: var(--color-text-primary);
}
.pricing-service-desc {
font-size: var(--text-sm);
color: var(--color-text-tertiary);
line-height: 1.5;
}
.pricing-service-action {
display: flex;
align-items: center;
justify-content: space-between;
}
.pricing-service-price {
font-size: var(--text-lg);
font-weight: 700;
color: var(--color-accent);
font-family: var(--font-mono);
}
.pricing-buy-btn {
padding: var(--space-sm) var(--space-xl);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-accent);
border: 1.5px solid var(--color-accent);
transition: all var(--transition-fast);
}
.pricing-buy-btn:hover {
background: var(--color-accent);
color: #FFFFFF;
}
/* ===== Task Management (expanded service) ===== */
.pricing-service-main {
display: flex;
flex-direction: column;
gap: var(--space-lg);
cursor: default;
}
.pricing-service-card.expanded {
grid-column: 1 / -1;
flex-direction: column;
}
.pricing-service-card.expanded .pricing-service-main {
flex-direction: row;
align-items: center;
justify-content: space-between;
cursor: pointer;
}
.pricing-service-card.expanded .pricing-service-info {
flex: 1;
}
.pricing-task-detail {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-2xl);
padding-top: var(--space-xl);
border-top: 1px solid var(--color-border-light);
animation: slideUp var(--transition-normal);
}
.task-detail-section {
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.task-detail-title {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: var(--text-sm);
font-weight: 700;
color: var(--color-text-primary);
}
/* Task Creation Options */
.task-options-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-md);
}
.task-option-card {
padding: var(--space-lg);
background: var(--color-bg-tertiary);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-light);
transition: all var(--transition-fast);
}
.task-option-card:hover {
background: var(--color-accent-subtle);
border-color: var(--color-accent);
}
.task-option-header {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-primary);
margin-bottom: var(--space-sm);
}
.task-option-desc {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
line-height: 1.5;
}
/* Task Triggers */
.task-triggers-list {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.task-trigger-card {
display: flex;
gap: var(--space-md);
padding: var(--space-lg);
background: var(--color-bg-tertiary);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-light);
transition: all var(--transition-fast);
}
.task-trigger-card:hover {
background: var(--color-accent-subtle);
border-color: var(--color-accent);
}
.task-trigger-icon {
display: flex;
align-items: flex-start;
padding-top: 2px;
color: var(--color-accent);
flex-shrink: 0;
}
.task-trigger-content {
display: flex;
flex-direction: column;
gap: 4px;
}
.task-trigger-label {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-primary);
}
.task-trigger-desc {
font-size: var(--text-xs);
color: var(--color-text-secondary);
line-height: 1.5;
}
.task-trigger-example {
font-size: 11px;
color: var(--color-text-tertiary);
font-style: italic;
padding-top: 2px;
}
@media (max-width: 768px) {
.pricing-task-detail {
grid-template-columns: 1fr;
}
.task-options-grid {
grid-template-columns: 1fr;
}
}
/* ===== Coupons ===== */
.pricing-coupons-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-lg);
}
.pricing-coupon-card {
display: flex;
border-radius: var(--radius-xl);
overflow: hidden;
border: 1px solid var(--color-border-light);
background: var(--color-bg-secondary);
transition: box-shadow var(--transition-fast);
}
.pricing-coupon-card:hover {
box-shadow: var(--shadow-md);
}
.pricing-coupon--used {
opacity: 0.5;
}
.pricing-coupon-left {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-2xl) var(--space-xl);
background: linear-gradient(135deg, #FEF3C7 0%, #FDE68A 100%);
min-width: 120px;
border-right: 2px dashed var(--color-border);
}
.pricing-coupon-value {
font-size: var(--text-xl);
font-weight: 800;
color: #D97706;
font-family: var(--font-mono);
}
.pricing-coupon-type {
font-size: var(--text-xs);
color: #B45309;
margin-top: var(--space-xs);
font-weight: 500;
}
.pricing-coupon-right {
flex: 1;
padding: var(--space-lg) var(--space-xl);
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.pricing-coupon-code {
font-size: var(--text-base);
font-weight: 800;
color: var(--color-text-primary);
font-family: var(--font-mono);
letter-spacing: 0.05em;
}
.pricing-coupon-desc {
font-size: var(--text-sm);
color: var(--color-text-secondary);
}
.pricing-coupon-valid {
display: flex;
align-items: center;
gap: 4px;
font-size: var(--text-xs);
color: var(--color-text-tertiary);
margin-top: var(--space-xs);
}
.pricing-coupon-valid svg {
color: var(--color-text-tertiary);
}
.pricing-coupon-status {
display: inline-flex;
align-items: center;
align-self: flex-start;
padding: 2px 10px;
border-radius: var(--radius-full);
font-size: 11px;
font-weight: 600;
background: var(--color-success-light);
color: var(--color-success);
margin-top: var(--space-sm);
}
.pricing-coupon--used .pricing-coupon-status {
background: var(--color-bg-tertiary);
color: var(--color-text-disabled);
}
.pricing-empty {
text-align: center;
color: var(--color-text-disabled);
grid-column: 1 / -1;
padding: var(--space-3xl);
}
/* ===== Payment Methods ===== */
.pricing-payment-row {
display: flex;
gap: var(--space-lg);
}
.pricing-payment-method {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-lg) var(--space-2xl);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-lg);
background: var(--color-bg-secondary);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-secondary);
transition: all var(--transition-fast);
}
.pricing-payment-method:hover {
border-color: var(--color-accent);
color: var(--color-accent);
}
.pricing-payment-method svg {
color: var(--color-accent);
}
/* ===== Responsive ===== */
@media (max-width: 1024px) {
.pricing-page {
padding: var(--space-xl) var(--space-lg);
}
.pricing-plans-grid {
grid-template-columns: 1fr;
max-width: 480px;
margin-left: auto;
margin-right: auto;
margin-bottom: var(--space-4xl);
}
.pricing-services-grid {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 768px) {
.pricing-services-grid,
.pricing-coupons-row {
grid-template-columns: 1fr;
}
.pricing-payment-row {
flex-direction: column;
}
.pricing-plan-card {
padding: var(--space-xl);
}
.pricing-services-grid-v2 {
grid-template-columns: 1fr;
}
}
/* ===== V2 增值服务卡片(四大类) ===== */
.pricing-services-grid-v2 {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-xl);
}
.pricing-service-card-v2 {
display: flex;
flex-direction: column;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-xl);
padding: var(--space-2xl);
gap: var(--space-lg);
transition: transform var(--transition-fast), box-shadow var(--transition-fast), border-color var(--transition-fast);
}
.pricing-service-card-v2:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
border-color: var(--color-accent);
}
.pricing-service-card-v2-icon {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: var(--radius-lg);
flex-shrink: 0;
}
.pricing-service-card-v2-body {
display: flex;
flex-direction: column;
gap: var(--space-sm);
flex: 1;
}
.pricing-service-card-v2-tag {
display: inline-flex;
align-items: center;
align-self: flex-start;
padding: 2px 10px;
border-radius: var(--radius-full);
font-size: 11px;
font-weight: 600;
}
.pricing-service-card-v2-name {
font-size: var(--text-lg);
font-weight: 700;
color: var(--color-text-primary);
margin: 0;
}
.pricing-service-card-v2-desc {
font-size: var(--text-sm);
color: var(--color-text-tertiary);
line-height: 1.6;
margin: 0;
}
.pricing-service-card-v2-features {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: var(--space-xs);
}
.pricing-service-card-v2-feature-tag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: var(--radius-sm);
background: var(--color-bg-tertiary);
color: var(--color-text-secondary);
font-size: 11px;
font-weight: 500;
}
.pricing-service-card-v2-action {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: var(--space-md);
border-top: 1px solid var(--color-border-light);
}
.pricing-service-card-v2-price {
font-size: var(--text-base);
font-weight: 700;
color: var(--color-accent);
font-family: var(--font-mono);
}
.pricing-service-card-v2-btn {
padding: var(--space-sm) var(--space-xl);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 600;
color: #FFFFFF;
background: var(--color-accent);
transition: all var(--transition-fast);
}
.pricing-service-card-v2-btn:hover {
background: var(--color-accent-hover);
box-shadow: var(--shadow-md);
}
/* ===== Modal ===== */
.pricing-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn var(--transition-normal);
}
.pricing-modal {
background: #FFFFFF;
border-radius: var(--radius-2xl);
width: 500px;
max-width: 90vw;
max-height: 85vh;
overflow-y: auto;
padding: var(--space-3xl);
box-shadow: var(--shadow-xl);
}
.pricing-modal-title {
font-size: var(--text-xl);
font-weight: 700;
color: var(--color-text-primary);
margin: 0 0 var(--space-xs) 0;
}
.pricing-modal-subtitle {
font-size: var(--text-sm);
color: var(--color-text-tertiary);
margin: 0 0 var(--space-2xl) 0;
}
.pricing-modal-app-type-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-md);
margin-bottom: var(--space-2xl);
}
.pricing-modal-app-type-card {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-sm);
padding: var(--space-xl) var(--space-md);
border: 2px solid var(--color-border-light);
border-radius: var(--radius-lg);
cursor: pointer;
transition: all var(--transition-fast);
text-align: center;
}
.pricing-modal-app-type-card:hover {
border-color: var(--color-accent);
background: var(--color-accent-subtle);
}
.pricing-modal-app-type-card.active {
border-color: var(--color-accent);
background: var(--color-accent-subtle);
box-shadow: 0 0 0 1px var(--color-accent);
}
.pricing-modal-app-type-label {
font-size: var(--text-base);
font-weight: 700;
color: var(--color-text-primary);
}
.pricing-modal-app-type-desc {
font-size: 11px;
color: var(--color-text-tertiary);
line-height: 1.4;
}
.pricing-modal-form {
display: flex;
flex-direction: column;
gap: var(--space-lg);
margin-bottom: var(--space-2xl);
}
.pricing-modal-form-group {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.pricing-modal-form-group label {
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-primary);
}
.pricing-modal-form-group label .required {
color: #EF4444;
}
.pricing-modal-form-group input,
.pricing-modal-form-group textarea {
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: var(--text-sm);
color: var(--color-text-primary);
background: var(--color-bg-secondary);
outline: none;
transition: border-color var(--transition-fast);
font-family: inherit;
}
.pricing-modal-form-group input:focus,
.pricing-modal-form-group textarea:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-subtle);
}
.pricing-modal-actions {
display: flex;
gap: var(--space-md);
justify-content: flex-end;
}
.pricing-modal-btn-cancel {
padding: var(--space-sm) var(--space-xl);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 600;
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
background: transparent;
transition: all var(--transition-fast);
}
.pricing-modal-btn-cancel:hover {
background: var(--color-bg-tertiary);
}
.pricing-modal-btn-confirm {
padding: var(--space-sm) var(--space-xl);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 600;
color: #FFFFFF;
background: var(--color-accent);
border: none;
transition: all var(--transition-fast);
}
.pricing-modal-btn-confirm:hover:not(:disabled) {
background: var(--color-accent-hover);
box-shadow: var(--shadow-md);
}
.pricing-modal-btn-confirm:disabled {
opacity: 0.6;
cursor: not-allowed;
}
+662
View File
@@ -0,0 +1,662 @@
import React, { useState, useEffect, useCallback } from 'react';
import { paymentAPI } from '@/api';
import { useAuth } from '@/contexts/AuthContext';
import './PricingPage.css';
// ---- SVG Icons ----
const IconCheck = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<polyline points="20 6 9 17 4 12" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
const IconService = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<rect x="3" y="3" width="18" height="18" rx="3" stroke="currentColor" strokeWidth="1.8" />
<line x1="9" y1="9" x2="15" y2="9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<line x1="9" y1="13" x2="13" y2="13" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<line x1="9" y1="17" x2="11" y2="17" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
const IconTag = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M12 2L2 7l10 5 10-5-10-5z" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" />
<path d="M2 17l10 5 10-5" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" />
<path d="M2 12l10 5 10-5" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" />
</svg>
);
const IconCoupon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<rect x="2" y="5" width="20" height="14" rx="2" stroke="currentColor" strokeWidth="1.6" />
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" strokeWidth="1.6" strokeDasharray="2 2" />
<circle cx="6" cy="12" r="3" stroke="currentColor" strokeWidth="1.2" />
<text x="18" y="13.5" textAnchor="middle" fontSize="5" fontWeight="800" fill="currentColor">OFF</text>
</svg>
);
const IconClock = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="1.6" />
<polyline points="12 6 12 12 16 14" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
const IconSparkles = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M12 2l1.5 5h5l-4 3 1.5 5-4-3-4 3 1.5-5-4-3h5L12 2z" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
</svg>
);
const IconAppCustom = () => (
<svg width="32" height="32" viewBox="0 0 24 24" fill="none">
<rect x="3" y="3" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="1.6" />
<rect x="14" y="3" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="1.6" />
<rect x="3" y="14" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="1.6" />
<rect x="14" y="14" width="7" height="7" rx="1.5" stroke="currentColor" strokeWidth="1.6" />
</svg>
);
const IconPlatform = () => (
<svg width="32" height="32" viewBox="0 0 24 24" fill="none">
<rect x="2" y="9" width="20" height="13" rx="2" stroke="currentColor" strokeWidth="1.6" />
<path d="M6 9V7a6 6 0 0112 0v2" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<circle cx="12" cy="16" r="2" stroke="currentColor" strokeWidth="1.4" />
</svg>
);
const IconDataCustom = () => (
<svg width="32" height="32" viewBox="0 0 24 24" fill="none">
<ellipse cx="12" cy="5" rx="9" ry="3" stroke="currentColor" strokeWidth="1.6" />
<path d="M3 5v14c0 1.66 4.03 3 9 3s9-1.34 9-3V5" stroke="currentColor" strokeWidth="1.6" />
<path d="M3 12c0 1.66 4.03 3 9 3s9-1.34 9-3" stroke="currentColor" strokeWidth="1.2" strokeDasharray="2 2" />
</svg>
);
const IconPrivateProject = () => (
<svg width="32" height="32" viewBox="0 0 24 24" fill="none">
<path d="M3 21h18" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<path d="M5 21V7l7-4 7 4v14" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
<path d="M9 14v7" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
<path d="M15 11v10" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
</svg>
);
const IconWechat = () => (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
<path d="M8 12a1.5 1.5 0 100-3 1.5 1.5 0 000 3z" fill="currentColor" />
<path d="M15 10.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3z" fill="currentColor" />
<path d="M21 13.5c0 3.6-3.7 6.5-8.3 6.5-.8 0-1.5-.1-2.2-.2L7 21l1.3-3.2C6.5 16.5 5 15.1 5 13.5 5 9.9 8.7 7 13.3 7S21 9.9 21 13.5z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
</svg>
);
const IconAlipay = () => (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
<rect x="2" y="4" width="20" height="16" rx="3" stroke="currentColor" strokeWidth="1.8" />
<path d="M8 14.5c1.5-2.5 3.5-4 5.5-4.5" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
<path d="M7 12l4-3.5" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
<circle cx="14" cy="14" r="1" fill="currentColor" />
<text x="12" y="17" textAnchor="middle" fontSize="5" fontWeight="700" fill="currentColor">Alipay</text>
</svg>
);
function formatYuan(n: number): string {
return n.toLocaleString('en-US');
}
interface ServiceItem {
id: string;
name: string;
description: string;
type: string;
price: number;
features?: string[];
assigned_agent_id?: string;
}
interface CouponItem {
id: string;
code: string;
type: string;
value: number;
min_amount: number;
expire_at: string;
status: string;
}
const SERVICE_TYPE_CONFIG: Record<string, { label: string; icon: React.ReactNode; color: string; bg: string }> = {
app_custom: { label: '应用定制', icon: <IconAppCustom />, color: '#4F46E5', bg: '#EEF2FF' },
platform_proxy: { label: '平台代理', icon: <IconPlatform />, color: '#059669', bg: '#ECFDF5' },
data_custom: { label: '数据定制', icon: <IconDataCustom />, color: '#D97706', bg: '#FFFBEB' },
private_project: { label: '私有化项目', icon: <IconPrivateProject />, color: '#7C3AED', bg: '#F5F3FF' },
};
const PricingPage: React.FC = () => {
const { user } = useAuth();
const [billingCycle, setBillingCycle] = useState<'month' | 'quarter' | 'year'>('month');
const [plans, setPlans] = useState<any[]>([]);
const [services, setServices] = useState<ServiceItem[]>([]);
const [coupons, setCoupons] = useState<CouponItem[]>([]);
const [loading, setLoading] = useState(true);
const [subscribeMsg, setSubscribeMsg] = useState('');
// Modal states
const [showModal, setShowModal] = useState<string | null>(null); // service type or null
const [appCustomType, setAppCustomType] = useState<string>('skill'); // skill/agent/mcp
const [platformForm, setPlatformForm] = useState({ companyName: '', contactName: '', usage: '' });
const [dataRequestForm, setDataRequestForm] = useState({ title: '', description: '', requirements: '' });
const [privateProjectForm, setPrivateProjectForm] = useState({ title: '', description: '', requirements: '' });
const [submitLoading, setSubmitLoading] = useState(false);
const currentPlanLevel = user?.membership_level ?? 0;
const loadData = useCallback(async () => {
try {
setLoading(true);
const [plansRes, servicesRes, couponsRes] = await Promise.all([
paymentAPI.getPlans(),
paymentAPI.getServices(),
paymentAPI.getCoupons(),
]);
if (plansRes.code === 0) setPlans(plansRes.data);
if (servicesRes.code === 0) setServices(servicesRes.data);
if (couponsRes.code === 0) setCoupons(couponsRes.data);
} catch (e) {
console.error('加载付费数据失败', e);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
const getPrice = (plan: any, cycle: typeof billingCycle) => {
const base = Number(plan.price) || 0;
switch (cycle) {
case 'quarter': return Math.round(base * 2.7);
case 'year': return Math.round(base * 10);
default: return base;
}
};
const getCycleLabel = (cycle: typeof billingCycle) => {
switch (cycle) { case 'month': return '/月'; case 'quarter': return '/季'; case 'year': return '/年'; }
};
const handleSubscribe = async (plan: any) => {
try {
setSubscribeMsg('');
const res = await paymentAPI.createOrder({
item_id: plan.id,
order_type: 'membership',
});
if (res.code === 0) {
const payRes = await paymentAPI.payOrder(res.data.id);
if (payRes.code === 0) {
setSubscribeMsg('成功订阅 ' + plan.name + '!');
await loadData();
}
}
} catch (e: any) {
setSubscribeMsg('订阅失败:' + (e.message || '未知错误'));
}
};
// 应用定制服务 - 提交
const handleAppCustomSubmit = async (serviceId: string) => {
try {
setSubmitLoading(true);
const res = await paymentAPI.createCustomOrder({
service_id: serviceId,
description: '应用定制服务 - ' + (appCustomType === 'skill' ? 'Skills' : appCustomType === 'agent' ? 'Agent' : 'MCP') + ' 类型',
budget: 0,
contact_info: '',
});
if (res.code === 0) {
setSubscribeMsg('应用定制服务已提交!即将跳转到定制对话窗口...');
setShowModal(null);
// 跳转到聊天页面,带定制服务ID
setTimeout(() => {
window.location.hash = '#/chat?service_id=' + res.data.id;
}, 1500);
}
} catch (e: any) {
setSubscribeMsg('提交失败:' + (e.message || '未知错误'));
} finally {
setSubmitLoading(false);
}
};
// 平台代理申请 - 提交
const handlePlatformProxySubmit = async (serviceId: string) => {
if (!platformForm.companyName.trim()) {
setSubscribeMsg('请填写公司名称');
return;
}
try {
setSubmitLoading(true);
const res = await paymentAPI.createServiceRequest({
service_id: serviceId,
title: '平台代理申请 - ' + platformForm.companyName,
description: '公司名称: ' + platformForm.companyName + '\n联系人: ' + platformForm.contactName + '\n用途说明: ' + platformForm.usage,
requirements: {
company_name: platformForm.companyName,
contact_name: platformForm.contactName,
usage: platformForm.usage,
},
});
if (res.code === 0) {
setSubscribeMsg('平台代理申请已成功提交!工单号: ' + res.data.id);
setShowModal(null);
setPlatformForm({ companyName: '', contactName: '', usage: '' });
await loadData();
}
} catch (e: any) {
setSubscribeMsg('提交失败:' + (e.message || '未知错误'));
} finally {
setSubmitLoading(false);
}
};
// 数据定制服务 - 提交
const handleDataCustomSubmit = async (serviceId: string) => {
if (!dataRequestForm.title.trim()) {
setSubscribeMsg('请填写需求标题');
return;
}
try {
setSubmitLoading(true);
const res = await paymentAPI.createServiceRequest({
service_id: serviceId,
title: dataRequestForm.title,
description: dataRequestForm.description,
requirements: { detail: dataRequestForm.requirements },
});
if (res.code === 0) {
setSubscribeMsg('数据定制需求已成功提交!工单号: ' + res.data.id);
setShowModal(null);
setDataRequestForm({ title: '', description: '', requirements: '' });
await loadData();
}
} catch (e: any) {
setSubscribeMsg('提交失败:' + (e.message || '未知错误'));
} finally {
setSubmitLoading(false);
}
};
// 私有化项目服务 - 提交
const handlePrivateProjectSubmit = async (serviceId: string) => {
if (!privateProjectForm.title.trim()) {
setSubscribeMsg('请填写咨询标题');
return;
}
try {
setSubmitLoading(true);
const res = await paymentAPI.createServiceRequest({
service_id: serviceId,
title: privateProjectForm.title,
description: privateProjectForm.description,
requirements: { detail: privateProjectForm.requirements },
});
if (res.code === 0) {
setSubscribeMsg('私有化项目咨询已成功提交!工单号: ' + res.data.id);
setShowModal(null);
setPrivateProjectForm({ title: '', description: '', requirements: '' });
await loadData();
}
} catch (e: any) {
setSubscribeMsg('提交失败:' + (e.message || '未知错误'));
} finally {
setSubmitLoading(false);
}
};
const getServiceAction = (svc: ServiceItem) => {
switch (svc.type) {
case 'app_custom':
return { label: '前往定制', action: () => setShowModal('app_custom_' + svc.id) };
case 'platform_proxy':
return { label: '立即申请', action: () => setShowModal('platform_proxy_' + svc.id) };
case 'data_custom':
return { label: '提交需求', action: () => setShowModal('data_custom_' + svc.id) };
case 'private_project':
return { label: '咨询方案', action: () => setShowModal('private_project_' + svc.id) };
default:
return { label: '了解更多', action: () => {} };
}
};
if (loading) return <div className="pricing-page"><p style={{textAlign:'center',marginTop:60}}>加载中...</p></div>;
return (
<div className="pricing-page">
<div className="pricing-header">
<h1 className="pricing-title">订阅付费中心</h1>
<p className="pricing-subtitle">选择最适合你的方案,解锁 AI 无限可能</p>
</div>
<div className="pricing-cycle-tabs">
{(['month', 'quarter', 'year'] as const).map((cycle) => (
<button
key={cycle}
className={`pricing-cycle-tab ${billingCycle === cycle ? 'active' : ''}`}
onClick={() => setBillingCycle(cycle)}
>
{cycle === 'month' ? '月付' : cycle === 'quarter' ? '季付' : '年付'}
{cycle === 'year' && <span className="pricing-savings-badge">84折</span>}
</button>
))}
</div>
{subscribeMsg && <div className="pricing-subscribe-msg">{subscribeMsg}</div>}
<div className="pricing-plans-grid">
{plans.map((plan: any, idx: number) => {
const isCurrent = currentPlanLevel === plan.level;
const price = getPrice(plan, billingCycle);
const isPopular = idx === 1;
return (
<div
key={plan.id}
className={`pricing-plan-card ${isCurrent ? 'pricing-plan--current' : ''} ${isPopular ? 'pricing-plan--popular' : ''}`}
>
{isPopular && (
<div className="pricing-popular-ribbon"><IconSparkles /><span>最受欢迎</span></div>
)}
{isCurrent && <div className="pricing-current-badge">当前方案</div>}
<div className="pricing-plan-head">
<span className="pricing-plan-name">{plan.name}</span>
<div className="pricing-plan-price-row">
<span className="pricing-plan-price">
<span className="pricing-currency">$</span>{price === 0 ? '0' : formatYuan(price)}
</span>
<span className="pricing-plan-cycle">{getCycleLabel(billingCycle)}</span>
</div>
<p className="pricing-plan-tokens">日赠 {formatYuan(plan.daily_free_tokens)} Tokens</p>
</div>
<div className="pricing-plan-features">
{(plan.features || []).map((feat: string, i: number) => (
<div key={i} className="pricing-feature-item">
<span className="pricing-feature-check"><IconCheck /></span>
<span>{feat}</span>
</div>
))}
</div>
<button
className={`pricing-subscribe-btn ${isCurrent ? 'pricing-btn--current' : ''}`}
onClick={() => !isCurrent && handleSubscribe(plan)}
>
{isCurrent ? '当前方案' : '立即订阅'}
</button>
</div>
);
})}
</div>
{/* 增值服务 - 四大类 */}
<div className="pricing-section">
<div className="pricing-section-header">
<IconService />
<h2 className="pricing-section-title">增值服务</h2>
</div>
<div className="pricing-services-grid-v2">
{services.map((svc) => {
const config = SERVICE_TYPE_CONFIG[svc.type] || SERVICE_TYPE_CONFIG.app_custom;
const actionInfo = getServiceAction(svc);
return (
<div key={svc.id} className="pricing-service-card-v2">
<div className="pricing-service-card-v2-icon" style={{ background: config.bg, color: config.color }}>
{config.icon}
</div>
<div className="pricing-service-card-v2-body">
<span className="pricing-service-card-v2-tag" style={{ color: config.color, background: config.bg }}>
{config.label}
</span>
<h4 className="pricing-service-card-v2-name">{svc.name}</h4>
<p className="pricing-service-card-v2-desc">{svc.description}</p>
{(svc.features && svc.features.length > 0) && (
<div className="pricing-service-card-v2-features">
{svc.features.map((feat: string, i: number) => (
<span key={i} className="pricing-service-card-v2-feature-tag">{feat}</span>
))}
</div>
)}
</div>
<div className="pricing-service-card-v2-action">
{svc.price > 0 ? (
<span className="pricing-service-card-v2-price">{formatYuan(svc.price)} 元起</span>
) : (
<span className="pricing-service-card-v2-price">免费咨询</span>
)}
<button
className="pricing-service-card-v2-btn"
onClick={actionInfo.action}
>
{actionInfo.label}
</button>
</div>
</div>
);
})}
{services.length === 0 && <p className="pricing-empty">暂无增值服务</p>}
</div>
</div>
{/* 优惠券 */}
<div className="pricing-section">
<div className="pricing-section-header">
<IconCoupon />
<h2 className="pricing-section-title">优惠券</h2>
</div>
<div className="pricing-coupons-row">
{coupons.map((cp) => {
const isPercent = cp.type === 'percent';
return (
<div key={cp.id} className="pricing-coupon-card">
<div className="pricing-coupon-left">
<span className="pricing-coupon-value">{isPercent ? `${cp.value}%` : `$${cp.value}`}</span>
<span className="pricing-coupon-type">{isPercent ? '折扣券' : '立减券'}</span>
</div>
<div className="pricing-coupon-right">
<div className="pricing-coupon-code">{cp.code}</div>
<div className="pricing-coupon-desc">满 {formatYuan(cp.min_amount || 0)} 元可用</div>
<div className="pricing-coupon-valid"><IconClock /><span>有效期至 {cp.expire_at || '--'}</span></div>
<span className="pricing-coupon-status">{cp.status === 'active' ? '可使用' : cp.status}</span>
</div>
</div>
);
})}
{coupons.length === 0 && <p className="pricing-empty">暂无可用优惠券</p>}
</div>
</div>
{/* 支付方式 */}
<div className="pricing-section">
<div className="pricing-section-header">
<IconTag />
<h2 className="pricing-section-title">支付方式</h2>
</div>
<div className="pricing-payment-row">
<div className="pricing-payment-method"><IconWechat /><span>微信支付</span></div>
<div className="pricing-payment-method"><IconAlipay /><span>支付宝</span></div>
</div>
</div>
{/* ===== Modal - 应用定制服务 ===== */}
{showModal && showModal.startsWith('app_custom_') && (
<div className="pricing-modal-overlay" onClick={() => setShowModal(null)}>
<div className="pricing-modal" onClick={e => e.stopPropagation()}>
<h3 className="pricing-modal-title">应用定制服务</h3>
<p className="pricing-modal-subtitle">请选择您需要的定制类型</p>
<div className="pricing-modal-app-type-grid">
{[
{ type: 'skill', label: 'Skills', desc: '自定义技能开发,扩展AI能力边界' },
{ type: 'agent', label: 'Agent', desc: '智能体定制,构建专属AI助手' },
{ type: 'mcp', label: 'MCP', desc: 'MCP协议开发,连接外部数据源' },
].map((item) => (
<div
key={item.type}
className={`pricing-modal-app-type-card ${appCustomType === item.type ? 'active' : ''}`}
onClick={() => setAppCustomType(item.type)}
>
<div className="pricing-modal-app-type-label">{item.label}</div>
<div className="pricing-modal-app-type-desc">{item.desc}</div>
</div>
))}
</div>
<div className="pricing-modal-actions">
<button
className="pricing-modal-btn-cancel"
onClick={() => setShowModal(null)}
>取消</button>
<button
className="pricing-modal-btn-confirm"
disabled={submitLoading}
onClick={() => handleAppCustomSubmit(showModal.replace('app_custom_', ''))}
>
{submitLoading ? '提交中...' : '确认并前往定制'}
</button>
</div>
</div>
</div>
)}
{/* ===== Modal - 平台代理申请 ===== */}
{showModal && showModal.startsWith('platform_proxy_') && (
<div className="pricing-modal-overlay" onClick={() => setShowModal(null)}>
<div className="pricing-modal" onClick={e => e.stopPropagation()}>
<h3 className="pricing-modal-title">平台代理申请</h3>
<p className="pricing-modal-subtitle">填写以下信息,提交代理申请</p>
<div className="pricing-modal-form">
<div className="pricing-modal-form-group">
<label>公司名称 <span className="required">*</span></label>
<input
type="text"
placeholder="请输入公司名称"
value={platformForm.companyName}
onChange={e => setPlatformForm({ ...platformForm, companyName: e.target.value })}
/>
</div>
<div className="pricing-modal-form-group">
<label>联系人</label>
<input
type="text"
placeholder="请输入联系人姓名"
value={platformForm.contactName}
onChange={e => setPlatformForm({ ...platformForm, contactName: e.target.value })}
/>
</div>
<div className="pricing-modal-form-group">
<label>用途说明</label>
<textarea
placeholder="请简要说明代理用途"
rows={3}
value={platformForm.usage}
onChange={e => setPlatformForm({ ...platformForm, usage: e.target.value })}
/>
</div>
</div>
<div className="pricing-modal-actions">
<button className="pricing-modal-btn-cancel" onClick={() => setShowModal(null)}>取消</button>
<button
className="pricing-modal-btn-confirm"
disabled={submitLoading}
onClick={() => handlePlatformProxySubmit(showModal.replace('platform_proxy_', ''))}
>{submitLoading ? '提交中...' : '提交申请'}</button>
</div>
</div>
</div>
)}
{/* ===== Modal - 数据定制服务 ===== */}
{showModal && showModal.startsWith('data_custom_') && (
<div className="pricing-modal-overlay" onClick={() => setShowModal(null)}>
<div className="pricing-modal" onClick={e => e.stopPropagation()}>
<h3 className="pricing-modal-title">数据定制服务</h3>
<p className="pricing-modal-subtitle">请描述您的数据定制需求</p>
<div className="pricing-modal-form">
<div className="pricing-modal-form-group">
<label>需求标题 <span className="required">*</span></label>
<input
type="text"
placeholder="请输入需求标题"
value={dataRequestForm.title}
onChange={e => setDataRequestForm({ ...dataRequestForm, title: e.target.value })}
/>
</div>
<div className="pricing-modal-form-group">
<label>需求描述</label>
<textarea
placeholder="请描述您的数据定制需求详情"
rows={3}
value={dataRequestForm.description}
onChange={e => setDataRequestForm({ ...dataRequestForm, description: e.target.value })}
/>
</div>
<div className="pricing-modal-form-group">
<label>具体要求</label>
<textarea
placeholder="请说明数据格式、数量、交付时间等具体要求"
rows={2}
value={dataRequestForm.requirements}
onChange={e => setDataRequestForm({ ...dataRequestForm, requirements: e.target.value })}
/>
</div>
</div>
<div className="pricing-modal-actions">
<button className="pricing-modal-btn-cancel" onClick={() => setShowModal(null)}>取消</button>
<button
className="pricing-modal-btn-confirm"
disabled={submitLoading}
onClick={() => handleDataCustomSubmit(showModal.replace('data_custom_', ''))}
>{submitLoading ? '提交中...' : '提交需求'}</button>
</div>
</div>
</div>
)}
{/* ===== Modal - 私有化项目服务 ===== */}
{showModal && showModal.startsWith('private_project_') && (
<div className="pricing-modal-overlay" onClick={() => setShowModal(null)}>
<div className="pricing-modal" onClick={e => e.stopPropagation()}>
<h3 className="pricing-modal-title">私有化项目服务</h3>
<p className="pricing-modal-subtitle">请描述您的私有化部署需求,我们将为您提供专业方案</p>
<div className="pricing-modal-form">
<div className="pricing-modal-form-group">
<label>项目标题 <span className="required">*</span></label>
<input
type="text"
placeholder="请输入项目标题"
value={privateProjectForm.title}
onChange={e => setPrivateProjectForm({ ...privateProjectForm, title: e.target.value })}
/>
</div>
<div className="pricing-modal-form-group">
<label>项目描述</label>
<textarea
placeholder="请描述您的私有化部署需求"
rows={3}
value={privateProjectForm.description}
onChange={e => setPrivateProjectForm({ ...privateProjectForm, description: e.target.value })}
/>
</div>
<div className="pricing-modal-form-group">
<label>具体要求</label>
<textarea
placeholder="请说明部署规模、安全要求、时间计划等"
rows={2}
value={privateProjectForm.requirements}
onChange={e => setPrivateProjectForm({ ...privateProjectForm, requirements: e.target.value })}
/>
</div>
</div>
<div className="pricing-modal-actions">
<button className="pricing-modal-btn-cancel" onClick={() => setShowModal(null)}>取消</button>
<button
className="pricing-modal-btn-confirm"
disabled={submitLoading}
onClick={() => handlePrivateProjectSubmit(showModal.replace('private_project_', ''))}
>{submitLoading ? '提交中...' : '提交咨询'}</button>
</div>
</div>
</div>
)}
</div>
);
};
export default PricingPage;
File diff suppressed because it is too large. Load diff
+920
View File
@@ -0,0 +1,920 @@
import React, { useState, useMemo } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { profileAPI } from '@/api';
import { mockOrders, mockMembershipPlans, mockUserCoupons, mockApps, mockOperationLogs } from '@/mock/data';
import type { Order, UserCoupon, MarketApp, OperationLog } from '@/types';
import './ProfilePage.css';
// ===== Mock API Keys =====
interface ApiKey {
id: string;
prefix: string;
fullKey: string;
createdAt: string;
status: 'active' | 'disabled';
}
const mockApiKeys: ApiKey[] = [
{ id: 'ak1', prefix: 'sk-aiflow-****a1b2', fullKey: 'sk-aiflow-u1-x8j3a1b2c3d4e5f6g7h8i9j0k1l2', createdAt: '2026-03-15T08:00:00Z', status: 'active' },
{ id: 'ak2', prefix: 'sk-aiflow-****c3d4', fullKey: 'sk-aiflow-u1-y9k4c3d4e5f6g7h8i9j0k1l2m3n4', createdAt: '2026-04-20T10:00:00Z', status: 'active' },
{ id: 'ak3', prefix: 'sk-aiflow-****e5f6', fullKey: 'sk-aiflow-u1-z0l5e5f6g7h8i9j0k1l2m3n4o5p6', createdAt: '2026-05-10T14:00:00Z', status: 'disabled' },
];
// ===== SVG Icons =====
const SvgIcons = {
// Password
lock: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
),
phone: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="5" y="2" width="14" height="20" rx="2" ry="2"/><line x1="12" y1="18" x2="12.01" y2="18"/>
</svg>
),
mail: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/>
</svg>
),
log: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>
</svg>
),
order: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="12" y1="17" x2="8" y2="17"/>
</svg>
),
member: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>
),
coupon: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 12V20H4V12"/><path d="M22 7H2v5h20V7z"/><circle cx="12" cy="7" r="2"/><circle cx="12" cy="17" r="2"/><line x1="12" y1="9" x2="12" y2="15"/>
</svg>
),
apps: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1.2"/><rect x="14" y="3" width="7" height="7" rx="1.2"/><rect x="3" y="14" width="7" height="7" rx="1.2"/><rect x="14" y="14" width="7" height="7" rx="1.2"/>
</svg>
),
key: (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>
</svg>
),
copy: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
),
check: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
),
eye: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
</svg>
),
calendar: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
</svg>
),
diamond: (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="4" transform="rotate(45 12 12)"/>
</svg>
),
close: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
),
checkCircle: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/>
</svg>
),
};
// ===== Tabs =====
type ProfileTab = 'security' | 'orders' | 'membership' | 'coupons' | 'apps' | 'apikeys';
interface TabDef {
key: ProfileTab;
label: string;
icon: JSX.Element;
}
const tabs: TabDef[] = [
{ key: 'security', label: '账号安全', icon: SvgIcons.lock },
{ key: 'orders', label: '我的订单', icon: SvgIcons.order },
{ key: 'membership', label: '我的会员', icon: SvgIcons.member },
{ key: 'coupons', label: '我的优惠券', icon: SvgIcons.coupon },
{ key: 'apps', label: '我的已购应用', icon: SvgIcons.apps },
{ key: 'apikeys', label: 'API密钥管理', icon: SvgIcons.key },
];
// ===== Helper =====
function formatDate(dateStr: string): string {
const d = new Date(dateStr);
return d.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
}
function formatShortDate(dateStr: string): string {
const d = new Date(dateStr);
return d.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' });
}
// ===== ProfilePage =====
const ProfilePage: React.FC = () => {
const { user } = useAuth();
const [activeTab, setActiveTab] = useState<ProfileTab>('security');
// Security form states
const [passwordForm, setPasswordForm] = useState({ old: '', newPass: '', confirm: '' });
const [bindForm, setBindForm] = useState({ type: 'phone' as 'phone' | 'email', value: '' });
// Order filter
const [orderFilter, setOrderFilter] = useState<Order['status'] | 'all'>('all');
// Coupon filter
const [couponFilter, setCouponFilter] = useState<UserCoupon['status'] | 'all'>('all');
// API Key
const [apiKeys, setApiKeys] = useState<ApiKey[]>(mockApiKeys);
const [showKeyId, setShowKeyId] = useState<string | null>(null);
const [copiedId, setCopiedId] = useState<string | null>(null);
if (!user) return null;
const memberPlan = mockMembershipPlans.find(p => p.id === user.membership?.planId);
// User's purchased apps (via mockOrders -> app type orders)
const purchasedAppOrders = useMemo(() => mockOrders.filter(o => o.userId === 'u1' && o.orderType === 'app' && o.status === 'paid'), []);
const purchasedApps: MarketApp[] = useMemo(() => {
return purchasedAppOrders
.map(o => mockApps.find(a => a.name === o.itemName))
.filter((a): a is MarketApp => !!a);
}, [purchasedAppOrders]);
// Login logs (recent 5)
const loginLogs = useMemo(() =>
mockOperationLogs.filter(l => l.userId === 'u1').slice(0, 5),
[]);
// Filter orders
const filteredOrders = useMemo(() => {
const userOrders = mockOrders.filter(o => o.userId === 'u1');
return orderFilter === 'all' ? userOrders : userOrders.filter(o => o.status === orderFilter);
}, [orderFilter]);
// Filter coupons
const filteredCoupons = useMemo(() => {
return couponFilter === 'all' ? mockUserCoupons : mockUserCoupons.filter(c => c.status === couponFilter);
}, [couponFilter]);
const handleCopyKey = (key: ApiKey) => {
navigator.clipboard.writeText(key.fullKey);
setCopiedId(key.id);
setTimeout(() => setCopiedId(null), 2000);
};
const handleGenerateKey = () => {
const newKey: ApiKey = {
id: `ak${Date.now()}`,
prefix: `sk-aiflow-****${Math.random().toString(36).slice(2, 6)}`,
fullKey: `sk-aiflow-u1-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`,
createdAt: new Date().toISOString(),
status: 'active',
};
setApiKeys(prev => [newKey, ...prev]);
};
const handleCopyPrefix = (key: ApiKey) => {
navigator.clipboard.writeText(key.prefix);
setCopiedId(key.id);
setTimeout(() => setCopiedId(null), 2000);
};
const handleToggleKeyStatus = (keyId: string) => {
setApiKeys(prev => prev.map(k =>
k.id === keyId ? { ...k, status: k.status === 'active' ? 'disabled' as const : 'active' as const } : k
));
};
const orderStatusLabels: Record<Order['status'] | 'all', string> = {
all: '全部',
pending: '待支付',
paid: '已支付',
refunded: '已退款',
};
const orderStatusColors: Record<Order['status'], string> = {
pending: 'var(--color-warning)',
paid: 'var(--color-success)',
refunded: 'var(--color-text-tertiary)',
};
const orderTypeLabels: Record<Order['orderType'], string> = {
membership: '会员',
app: '应用',
service: '服务',
};
const couponStatusConfig: Record<UserCoupon['status'], { label: string; color: string; bg: string }> = {
unused: { label: '未使用', color: 'var(--color-success)', bg: 'var(--color-success-light)' },
used: { label: '已使用', color: 'var(--color-text-tertiary)', bg: 'var(--color-bg-tertiary)' },
expired: { label: '已过期', color: 'var(--color-error)', bg: 'var(--color-error-light)' },
};
const getCouponDescription = (coupon: UserCoupon['coupon']) => {
if (coupon.type === 'fixed') return `满${coupon.minAmount}减${coupon.value}`;
return `${coupon.value}折券,满${coupon.minAmount}可用`;
};
const getLevelGradient = (level: number) => {
const gradients: Record<number, string> = {
0: 'linear-gradient(135deg, #8B919E, #BFC4CE)',
2: 'linear-gradient(135deg, #5B5FED, #8B8DFF)',
3: 'linear-gradient(135deg, #F59E0B, #FBBF24)',
};
return gradients[level] || gradients[0];
};
return (
<div className="profile-page">
{/* Header */}
<div className="profile-header">
<div className="profile-user-card">
<div
className="profile-avatar"
style={{ background: getLevelGradient(user.membership?.level ?? 0) }}
>
<span>{user.username.charAt(0).toUpperCase()}</span>
</div>
<div className="profile-user-info">
<div className="profile-username-row">
<h1 className="profile-username">{user.username}</h1>
{user.membership && (
<span
className="profile-level-badge"
style={{ background: getLevelGradient(user.membership.level) }}
>
{user.membership.planName}
</span>
)}
</div>
{user.membership && (
<p className="profile-expire">
<span className="profile-expire-icon">{SvgIcons.calendar}</span>
会员到期:{formatShortDate(user.membership.expireAt)}
</p>
)}
</div>
</div>
</div>
{/* Body */}
<div className="profile-body">
{/* Left Tabs */}
<nav className="profile-tabs">
{tabs.map(tab => (
<button
key={tab.key}
className={`profile-tab ${activeTab === tab.key ? 'active' : ''}`}
onClick={() => setActiveTab(tab.key)}
>
<span className="profile-tab-icon">{tab.icon}</span>
<span className="profile-tab-label">{tab.label}</span>
</button>
))}
</nav>
{/* Right Content */}
<div className="profile-content">
{activeTab === 'security' && (
<SecurityContent
passwordForm={passwordForm}
setPasswordForm={setPasswordForm}
bindForm={bindForm}
setBindForm={setBindForm}
loginLogs={loginLogs}
/>
)}
{activeTab === 'orders' && (
<OrdersContent
orders={filteredOrders}
filter={orderFilter}
setFilter={setOrderFilter}
statusLabels={orderStatusLabels}
statusColors={orderStatusColors}
typeLabels={orderTypeLabels}
/>
)}
{activeTab === 'membership' && (
<MembershipContent user={user} memberPlan={memberPlan} />
)}
{activeTab === 'coupons' && (
<CouponsContent
coupons={filteredCoupons}
filter={couponFilter}
setFilter={setCouponFilter}
getDescription={getCouponDescription}
statusConfig={couponStatusConfig}
/>
)}
{activeTab === 'apps' && (
<AppsContent apps={purchasedApps} purchasedOrders={purchasedAppOrders} />
)}
{activeTab === 'apikeys' && (
<ApiKeysContent
apiKeys={apiKeys}
showKeyId={showKeyId}
setShowKeyId={setShowKeyId}
copiedId={copiedId}
onCopy={handleCopyKey}
onCopyPrefix={handleCopyPrefix}
onGenerate={handleGenerateKey}
onToggleStatus={handleToggleKeyStatus}
/>
)}
</div>
</div>
</div>
);
};
// ===== Security Content =====
interface SecurityContentProps {
passwordForm: { old: string; newPass: string; confirm: string };
setPasswordForm: React.Dispatch<React.SetStateAction<{ old: string; newPass: string; confirm: string }>>;
bindForm: { type: 'phone' | 'email'; value: string };
setBindForm: React.Dispatch<React.SetStateAction<{ type: 'phone' | 'email'; value: string }>>;
loginLogs: OperationLog[];
}
const SecurityContent: React.FC<SecurityContentProps> = ({ passwordForm, setPasswordForm, bindForm, setBindForm, loginLogs }) => {
const [pwdMsg, setPwdMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [pwdLoading, setPwdLoading] = useState(false);
const handleChangePassword = async () => {
setPwdMsg(null);
if (!passwordForm.old.trim()) {
setPwdMsg({ type: 'error', text: '请输入旧密码' });
return;
}
if (!passwordForm.newPass.trim() || passwordForm.newPass.length < 6) {
setPwdMsg({ type: 'error', text: '新密码长度至少为6位' });
return;
}
if (passwordForm.newPass !== passwordForm.confirm) {
setPwdMsg({ type: 'error', text: '两次输入的新密码不一致' });
return;
}
setPwdLoading(true);
try {
await profileAPI.updatePassword({
old_password: passwordForm.old,
new_password: passwordForm.newPass,
});
setPwdMsg({ type: 'success', text: '密码修改成功' });
setPasswordForm({ old: '', newPass: '', confirm: '' });
} catch (e: any) {
setPwdMsg({ type: 'error', text: e.message || e.detail || '密码修改失败' });
} finally {
setPwdLoading(false);
}
};
return (
<div className="security-content">
{/* Change Password */}
<section className="profile-section">
<h3 className="section-title">
<span className="section-icon">{SvgIcons.lock}</span>
修改密码
</h3>
{pwdMsg && (
<div className={`form-message ${pwdMsg.type}`}>
{pwdMsg.text}
</div>
)}
<div className="form-grid">
<div className="form-item">
<label className="form-label-small">旧密码</label>
<input
type="password"
className="form-input"
placeholder="请输入旧密码"
value={passwordForm.old}
onChange={e => setPasswordForm(p => ({ ...p, old: e.target.value }))}
/>
</div>
<div className="form-item">
<label className="form-label-small">新密码</label>
<input
type="password"
className="form-input"
placeholder="请输入新密码(至少6位)"
value={passwordForm.newPass}
onChange={e => setPasswordForm(p => ({ ...p, newPass: e.target.value }))}
/>
</div>
<div className="form-item">
<label className="form-label-small">确认新密码</label>
<input
type="password"
className="form-input"
placeholder="请再次输入新密码"
value={passwordForm.confirm}
onChange={e => setPasswordForm(p => ({ ...p, confirm: e.target.value }))}
/>
</div>
<div className="form-item form-item-btn">
<button className="btn-primary" onClick={handleChangePassword} disabled={pwdLoading}>
{pwdLoading ? '修改中...' : '修改密码'}
</button>
</div>
</div>
</section>
{/* Bind Phone/Email */}
<section className="profile-section">
<h3 className="section-title">
<span className="section-icon">{SvgIcons.phone}</span>
绑定手机 / 邮箱
</h3>
<div className="bind-container">
<div className="bind-type-toggle">
<button
className={`bind-type-btn ${bindForm.type === 'phone' ? 'active' : ''}`}
onClick={() => setBindForm(f => ({ ...f, type: 'phone' }))}
>
{SvgIcons.phone}
<span>手机</span>
</button>
<button
className={`bind-type-btn ${bindForm.type === 'email' ? 'active' : ''}`}
onClick={() => setBindForm(f => ({ ...f, type: 'email' }))}
>
{SvgIcons.mail}
<span>邮箱</span>
</button>
</div>
<div className="bind-input-row">
<div className="form-item" style={{ flex: 1 }}>
<label className="form-label-small">
{bindForm.type === 'phone' ? '手机号' : '邮箱地址'}
</label>
<input
type={bindForm.type === 'phone' ? 'tel' : 'email'}
className="form-input"
placeholder={bindForm.type === 'phone' ? '请输入手机号' : '请输入邮箱地址'}
value={bindForm.value}
onChange={e => setBindForm(f => ({ ...f, value: e.target.value }))}
/>
</div>
<div className="form-item form-item-btn">
<label className="form-label-small">&nbsp;</label>
<button className="btn-secondary">获取验证码</button>
</div>
</div>
<div className="form-item form-item-btn">
<button className="btn-primary">确认绑定</button>
</div>
</div>
</section>
{/* Login Logs */}
<section className="profile-section">
<h3 className="section-title">
<span className="section-icon">{SvgIcons.log}</span>
最近登录日志
</h3>
<div className="simple-table-wrapper">
<table className="simple-table">
<thead>
<tr>
<th>操作</th>
<th>目标</th>
<th>IP地址</th>
<th>时间</th>
</tr>
</thead>
<tbody>
{loginLogs.map(log => (
<tr key={log.id}>
<td>{log.action}</td>
<td className="td-muted">{log.target}</td>
<td className="td-mono">{log.ip}</td>
<td className="td-muted">{formatDate(log.createdAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</div>
);
};
// ===== Orders Content =====
interface OrdersContentProps {
orders: Order[];
filter: Order['status'] | 'all';
setFilter: React.Dispatch<React.SetStateAction<Order['status'] | 'all'>>;
statusLabels: Record<Order['status'] | 'all', string>;
statusColors: Record<Order['status'], string>;
typeLabels: Record<Order['orderType'], string>;
}
const OrdersContent: React.FC<OrdersContentProps> = ({ orders, filter, setFilter, statusLabels, statusColors, typeLabels }) => {
return (
<div className="tab-panel-content">
{/* Filter Tabs */}
<div className="filter-tabs">
{(Object.keys(statusLabels) as (Order['status'] | 'all')[]).map(key => (
<button
key={key}
className={`filter-tab ${filter === key ? 'active' : ''}`}
onClick={() => setFilter(key)}
>
{statusLabels[key]}
</button>
))}
</div>
{/* Table */}
<div className="simple-table-wrapper">
<table className="simple-table">
<thead>
<tr>
<th>订单号</th>
<th>类型</th>
<th>商品名</th>
<th>金额</th>
<th>支付方式</th>
<th>状态</th>
<th>时间</th>
</tr>
</thead>
<tbody>
{orders.map(order => (
<tr key={order.id}>
<td className="td-mono">{order.id}</td>
<td>
<span className="order-type-tag">{typeLabels[order.orderType]}</span>
</td>
<td>{order.itemName}</td>
<td className="td-price">¥{order.amount.toFixed(2)}</td>
<td className="td-muted">{order.paymentMethod === 'wechat' ? '微信支付' : '支付宝'}</td>
<td>
<span className="status-dot" style={{ backgroundColor: statusColors[order.status] }} />
{statusLabels[order.status]}
</td>
<td className="td-muted">{formatDate(order.createdAt)}</td>
</tr>
))}
{orders.length === 0 && (
<tr><td colSpan={7} className="td-empty">暂无订单</td></tr>
)}
</tbody>
</table>
</div>
</div>
);
};
// ===== Membership Content =====
interface MembershipContentProps {
user: NonNullable<ReturnType<typeof useAuth>['user']>;
memberPlan: typeof mockMembershipPlans[0] | undefined;
}
const MembershipContent: React.FC<MembershipContentProps> = ({ user, memberPlan }) => {
const [autoRenew, setAutoRenew] = useState(user.membership?.autoRenew ?? false);
// 使用 memberPlan 或从 user.membership 获取 level,判断是否付费会员
const effectiveLevel = memberPlan?.level ?? user.membership?.level ?? 0;
const effectiveName = memberPlan?.name || user.membership?.planName || '免费版';
const hasPaidMembership = effectiveLevel > 0;
return (
<div className="tab-panel-content">
<div className="membership-card">
<div className="membership-card-header">
<div className="membership-card-icon">
{SvgIcons.diamond}
</div>
<div className="membership-card-title">
<h2>{effectiveName}</h2>
{hasPaidMembership && (
<span className="membership-level-tag">Lv.{effectiveLevel}</span>
)}
</div>
</div>
{hasPaidMembership && user.membership && (
<>
{memberPlan && (
<div className="membership-stats">
<div className="membership-stat">
<span className="stat-label">每日可用Token</span>
<span className="stat-value">{memberPlan.dailyFreeTokens.toLocaleString()}</span>
</div>
<div className="membership-stat">
<span className="stat-label">会员到期</span>
<span className="stat-value">{formatShortDate(user.membership.expireAt)}</span>
</div>
<div className="membership-stat">
<span className="stat-label">月费</span>
<span className="stat-value">¥{memberPlan.price}/月</span>
</div>
</div>
)}
{!memberPlan && user.membership && (
<div className="membership-stats">
<div className="membership-stat">
<span className="stat-label">会员到期</span>
<span className="stat-value">{formatShortDate(user.membership.expireAt)}</span>
</div>
</div>
)}
{memberPlan && (
<div className="membership-features">
<h4 className="features-title">会员权益</h4>
<ul className="features-list">
{memberPlan.features.map((f, i) => (
<li key={i} className="feature-item">
<span className="feature-check">{SvgIcons.checkCircle}</span>
{f}
</li>
))}
</ul>
</div>
)}
<div className="membership-auto-renew">
<span className="auto-renew-label">自动续费</span>
<label className="toggle-switch">
<input
type="checkbox"
checked={autoRenew}
onChange={e => setAutoRenew(e.target.checked)}
/>
<span className="toggle-slider" />
</label>
</div>
<div className="membership-actions">
<button className="btn-primary" onClick={() => { window.location.hash = '#/pricing'; }}>续费会员</button>
<button className="btn-secondary" onClick={() => { window.location.hash = '#/pricing'; }}>升级会员</button>
</div>
</>
)}
{!hasPaidMembership && (
<div className="membership-empty">
<p>您当前使用的是免费版,升级会员享受更多权益</p>
<button
className="btn-primary"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
window.location.hash = '#/pricing';
}}
>
开通会员
</button>
</div>
)}
</div>
</div>
);
};
// ===== Coupons Content =====
interface CouponsContentProps {
coupons: UserCoupon[];
filter: UserCoupon['status'] | 'all';
setFilter: React.Dispatch<React.SetStateAction<UserCoupon['status'] | 'all'>>;
getDescription: (coupon: UserCoupon['coupon']) => string;
statusConfig: Record<UserCoupon['status'], { label: string; color: string; bg: string }>;
}
const CouponsContent: React.FC<CouponsContentProps> = ({ coupons, filter, setFilter, getDescription, statusConfig }) => {
const filterLabels: Record<UserCoupon['status'] | 'all', string> = {
all: '全部',
unused: '未使用',
used: '已使用',
expired: '已过期',
};
return (
<div className="tab-panel-content">
<div className="filter-tabs">
{(Object.keys(filterLabels) as (UserCoupon['status'] | 'all')[]).map(key => (
<button
key={key}
className={`filter-tab ${filter === key ? 'active' : ''}`}
onClick={() => setFilter(key)}
>
{filterLabels[key]}
</button>
))}
</div>
<div className="coupon-list">
{coupons.map(uc => {
const cfg = statusConfig[uc.status];
return (
<div key={uc.id} className={`coupon-item ${uc.status !== 'unused' ? 'coupon-disabled' : ''}`}>
<div className="coupon-left">
<div className="coupon-code">{uc.coupon.code}</div>
<div className="coupon-desc">{getDescription(uc.coupon)}</div>
<div className="coupon-date">
有效期:{formatShortDate(uc.coupon.validFrom)} ~ {formatShortDate(uc.coupon.validTo)}
</div>
</div>
<div className="coupon-right">
<span
className="coupon-status"
style={{ color: cfg.color, backgroundColor: cfg.bg }}
>
{cfg.label}
</span>
{uc.status === 'unused' && (
<button className="btn-coupon-use">去使用</button>
)}
</div>
</div>
);
})}
{coupons.length === 0 && (
<div className="content-empty">暂无优惠券</div>
)}
</div>
</div>
);
};
// ===== Apps Content =====
interface AppsContentProps {
apps: MarketApp[];
purchasedOrders: Order[];
}
const AppsContent: React.FC<AppsContentProps> = ({ apps, purchasedOrders }) => {
const appTypeLabels: Record<string, string> = {
skill: '技能',
agent: '智能体',
mcp: 'MCP',
};
const findOrderDate = (appName: string) => {
const order = purchasedOrders.find(o => o.itemName === appName);
return order ? formatDate(order.createdAt) : '';
};
return (
<div className="tab-panel-content">
<div className="apps-grid">
{apps.map(app => (
<div key={app.id} className="app-card">
<div className="app-card-header">
<div className="app-card-icon">
<span>{app.icon || app.name.charAt(0)}</span>
</div>
<div className="app-card-info">
<h4 className="app-card-name">{app.name}</h4>
<div className="app-card-meta">
<span className="app-type-tag">{appTypeLabels[app.type] || app.type}</span>
<span className="app-source-tag">{app.source === 'official' ? '官方' : '用户'}</span>
</div>
</div>
</div>
<p className="app-card-desc">{app.description}</p>
<div className="app-card-footer">
<span className="app-purchase-date">
{SvgIcons.calendar}
{findOrderDate(app.name)}
</span>
</div>
</div>
))}
{apps.length === 0 && (
<div className="content-empty">暂未购买任何应用</div>
)}
</div>
</div>
);
};
// ===== API Keys Content =====
interface ApiKeysContentProps {
apiKeys: ApiKey[];
showKeyId: string | null;
setShowKeyId: React.Dispatch<React.SetStateAction<string | null>>;
copiedId: string | null;
onCopy: (key: ApiKey) => void;
onCopyPrefix: (key: ApiKey) => void;
onGenerate: () => void;
onToggleStatus: (keyId: string) => void;
}
const ApiKeysContent: React.FC<ApiKeysContentProps> = ({
apiKeys, showKeyId, setShowKeyId, copiedId, onCopy, onCopyPrefix, onGenerate, onToggleStatus,
}) => {
return (
<div className="tab-panel-content">
<div className="apikey-header">
<div className="apikey-info">
<h3>API密钥</h3>
<p className="apikey-desc">使用API密钥访问AIFlow接口,请妥善保管您的密钥</p>
</div>
<button className="btn-primary" onClick={onGenerate}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
生成新API Key
</button>
</div>
<div className="apikey-list">
<div className="apikey-list-header">
<span>密钥前缀</span>
<span>创建时间</span>
<span>状态</span>
<span>操作</span>
</div>
{apiKeys.map(key => (
<div key={key.id} className="apikey-row">
<div className="apikey-prefix-col">
<code className="apikey-prefix">{key.prefix}</code>
{showKeyId === key.id && (
<div className="apikey-full-reveal">
<code>{key.fullKey}</code>
</div>
)}
</div>
<span className="apikey-date">{formatDate(key.createdAt)}</span>
<span>
<span className={`apikey-status ${key.status}`}>
{key.status === 'active' ? '活跃' : '已禁用'}
</span>
</span>
<div className="apikey-actions">
<button
className="apikey-action-btn"
onClick={() => {
if (showKeyId === key.id) {
setShowKeyId(null);
} else {
setShowKeyId(key.id);
onCopyPrefix(key);
}
}}
title={showKeyId === key.id ? '隐藏密钥' : '显示密钥'}
>
{SvgIcons.eye}
</button>
<button
className="apikey-action-btn"
onClick={() => {
if (showKeyId === key.id || showKeyId === null) {
onCopy(key);
} else {
onCopyPrefix(key);
}
}}
title="复制密钥"
>
{copiedId === key.id ? SvgIcons.check : SvgIcons.copy}
</button>
<button
className={`apikey-action-btn ${key.status === 'active' ? 'danger' : ''}`}
onClick={() => onToggleStatus(key.id)}
title={key.status === 'active' ? '禁用' : '启用'}
>
{key.status === 'active' ? '禁用' : '启用'}
</button>
</div>
</div>
))}
</div>
</div>
);
};
export default ProfilePage;
@@ -0,0 +1,480 @@
/* ===== Token Dashboard Page ===== */
.tkd-page {
padding: var(--space-3xl) var(--space-4xl);
max-width: 1280px;
margin: 0 auto;
animation: fadeIn var(--transition-normal);
}
/* ===== Header ===== */
.tkd-header {
margin-bottom: var(--space-3xl);
}
.tkd-title {
font-size: var(--text-2xl);
font-weight: 800;
color: var(--color-text-primary);
letter-spacing: -0.02em;
margin-bottom: var(--space-xs);
}
.tkd-subtitle {
font-size: var(--text-base);
color: var(--color-text-tertiary);
}
/* ===== Warning Banner ===== */
.tkd-warning-banner {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-md) var(--space-lg);
background: var(--color-warning-light);
border: 1px solid rgba(232, 146, 44, 0.2);
border-left: 4px solid var(--color-warning);
border-radius: var(--radius-lg);
margin-bottom: var(--space-2xl);
font-size: var(--text-sm);
color: var(--color-text-secondary);
}
.tkd-warning-banner svg {
color: var(--color-warning);
flex-shrink: 0;
}
.tkd-warning-banner strong {
color: var(--color-warning);
font-weight: 700;
}
/* ===== KPI Cards ===== */
.tkd-kpi-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-xl);
margin-bottom: var(--space-2xl);
}
.tkd-kpi-card {
display: flex;
align-items: center;
gap: var(--space-xl);
padding: var(--space-2xl);
border-radius: var(--radius-xl);
position: relative;
overflow: hidden;
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
}
.tkd-kpi-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.tkd-kpi--blue {
background: linear-gradient(135deg, #5B5FED 0%, #8B8DFF 100%);
}
.tkd-kpi--green {
background: linear-gradient(135deg, #219B5E 0%, #34C97A 100%);
}
.tkd-kpi--orange {
background: linear-gradient(135deg, #E8922C 0%, #F5A84C 100%);
}
.tkd-kpi-card::after {
content: '';
position: absolute;
width: 120px;
height: 120px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
top: -30px;
right: -30px;
}
.tkd-kpi-card::before {
content: '';
position: absolute;
width: 80px;
height: 80px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.06);
bottom: -20px;
left: -20px;
}
.tkd-kpi-icon-wrapper {
width: 52px;
height: 52px;
border-radius: var(--radius-lg);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
z-index: 1;
}
.tkd-kpi-icon--blue { background: rgba(255, 255, 255, 0.18); color: #FFFFFF; }
.tkd-kpi-icon--green { background: rgba(255, 255, 255, 0.18); color: #FFFFFF; }
.tkd-kpi-icon--orange { background: rgba(255, 255, 255, 0.18); color: #FFFFFF; }
.tkd-kpi-content {
display: flex;
flex-direction: column;
gap: 2px;
z-index: 1;
}
.tkd-kpi-label {
font-size: var(--text-sm);
color: rgba(255, 255, 255, 0.75);
font-weight: 500;
}
.tkd-kpi-value {
font-size: clamp(1.25rem, 5vw, var(--text-3xl));
font-weight: 800;
color: #FFFFFF;
font-family: var(--font-mono);
letter-spacing: -0.02em;
line-height: 1.1;
word-break: break-all;
overflow-wrap: break-word;
}
.tkd-kpi-sub {
font-size: var(--text-xs);
color: rgba(255, 255, 255, 0.6);
font-weight: 400;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ===== Middle Row ===== */
.tkd-middle-row {
display: grid;
grid-template-columns: 1fr 1fr 220px;
gap: var(--space-xl);
margin-bottom: var(--space-2xl);
}
/* ===== Rank Card ===== */
.tkd-rank-card {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-xl);
padding: var(--space-xl);
box-shadow: var(--shadow-xs);
}
.tkd-rank-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-lg);
}
.tkd-rank-header svg {
color: var(--color-text-tertiary);
}
.tkd-rank-title {
font-size: var(--text-sm);
font-weight: 700;
color: var(--color-text-primary);
}
.tkd-rank-model-body {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.tkd-rank-model-name {
font-size: var(--text-lg);
font-weight: 700;
color: var(--color-accent);
}
.tkd-rank-model-tokens {
font-size: var(--text-sm);
color: var(--color-text-tertiary);
font-family: var(--font-mono);
}
/* Rank List */
.tkd-rank-list {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.tkd-rank-item {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-md);
background: var(--color-bg-primary);
transition: background var(--transition-fast);
}
.tkd-rank-item:hover {
background: var(--color-bg-hover);
}
.tkd-rank-item-idx {
width: 24px;
height: 24px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--text-xs);
font-weight: 700;
color: var(--color-accent);
background: var(--color-accent-light);
}
.tkd-rank-item:nth-child(1) .tkd-rank-item-idx { background: #FEF3C7; color: #D97706; }
.tkd-rank-item:nth-child(2) .tkd-rank-item-idx { background: #E5E7EB; color: #6B7280; }
.tkd-rank-item:nth-child(3) .tkd-rank-item-idx { background: #FDE8CD; color: #B45309; }
.tkd-rank-item-name {
flex: 1;
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-text-primary);
}
.tkd-rank-item-val {
font-size: var(--text-xs);
color: var(--color-text-tertiary);
font-family: var(--font-mono);
}
.tkd-rank-empty {
text-align: center;
color: var(--color-text-disabled);
font-size: var(--text-sm);
padding: var(--space-xl);
}
/* ===== Actions Card ===== */
.tkd-actions-card {
display: flex;
flex-direction: column;
gap: var(--space-md);
justify-content: center;
}
.tkd-btn {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
padding: var(--space-md) var(--space-xl);
border-radius: var(--radius-lg);
font-size: var(--text-sm);
font-weight: 600;
transition: all var(--transition-fast);
height: 48px;
}
.tkd-btn svg {
flex-shrink: 0;
}
.tkd-btn-primary {
background: var(--color-accent);
color: #FFFFFF;
}
.tkd-btn-primary:hover {
background: var(--color-accent-hover);
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
.tkd-btn-secondary {
background: var(--color-bg-secondary);
color: var(--color-accent);
border: 1.5px solid var(--color-accent);
}
.tkd-btn-secondary:hover {
background: var(--color-accent-subtle);
transform: translateY(-1px);
}
/* ===== Table ===== */
.tkd-table-wrapper {
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-xs);
}
.tkd-table-title {
font-size: var(--text-base);
font-weight: 700;
color: var(--color-text-primary);
padding: var(--space-xl) var(--space-xl) 0;
}
.tkd-table-scroll {
overflow-x: auto;
padding: var(--space-lg) var(--space-xl) var(--space-sm);
}
.tkd-table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
}
.tkd-table th {
text-align: left;
padding: var(--space-md) var(--space-md);
font-size: var(--text-xs);
font-weight: 600;
color: var(--color-text-tertiary);
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid var(--color-border-light);
white-space: nowrap;
}
.tkd-table td {
padding: var(--space-md);
color: var(--color-text-secondary);
border-bottom: 1px solid var(--color-border-light);
white-space: nowrap;
}
.tkd-table tbody tr:hover {
background: var(--color-bg-tertiary);
}
.tkd-table tbody tr:last-child td {
border-bottom: none;
}
.tkd-col-num {
text-align: right;
font-family: var(--font-mono);
}
.tkd-col-bold {
font-weight: 600;
color: var(--color-text-primary);
}
.tkd-col-cost {
color: var(--color-accent);
font-weight: 600;
}
.tkd-col-time {
color: var(--color-text-tertiary);
font-size: var(--text-xs);
}
.tkd-model-badge {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: var(--radius-full);
background: var(--color-accent-light);
color: var(--color-accent);
font-size: var(--text-xs);
font-weight: 600;
}
/* ===== Pagination ===== */
.tkd-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-lg);
padding: var(--space-lg) var(--space-xl);
border-top: 1px solid var(--color-border-light);
}
.tkd-page-btn {
padding: var(--space-sm) var(--space-lg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-size: var(--text-sm);
color: var(--color-text-secondary);
background: var(--color-bg-secondary);
transition: all var(--transition-fast);
}
.tkd-page-btn:hover:not(:disabled) {
border-color: var(--color-accent);
color: var(--color-accent);
}
.tkd-page-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.tkd-page-info {
font-size: var(--text-sm);
color: var(--color-text-tertiary);
font-family: var(--font-mono);
}
/* ===== Loading ===== */
.tkd-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-lg);
padding: var(--space-5xl);
color: var(--color-text-tertiary);
font-size: var(--text-sm);
}
.tkd-loading .audit-spinner {
width: 40px;
height: 40px;
border: 3px solid var(--color-border-light);
border-top-color: var(--color-accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ===== Responsive ===== */
@media (max-width: 1024px) {
.tkd-page {
padding: var(--space-xl) var(--space-lg);
}
.tkd-kpi-grid {
grid-template-columns: 1fr;
}
.tkd-middle-row {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.tkd-kpi-card {
padding: var(--space-lg);
}
.tkd-kpi-value {
font-size: var(--text-xl);
}
}
@@ -0,0 +1,479 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { tokenAPI } from '@/api';
import type { TokenUsageLog } from '@/types';
import './TokenDashboardPage.css';
// ---- SVG Icons ----
const IconToken = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="1.8" />
<circle cx="12" cy="12" r="5" stroke="currentColor" strokeWidth="1.4" />
<circle cx="12" cy="12" r="1.5" fill="currentColor" />
<line x1="12" y1="2" x2="12" y2="5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
<line x1="12" y1="19" x2="12" y2="22" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
const IconWallet = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<rect x="2" y="5" width="20" height="14" rx="2.5" stroke="currentColor" strokeWidth="1.8" />
<line x1="2" y1="9" x2="22" y2="9" stroke="currentColor" strokeWidth="1.4" />
<circle cx="17" cy="12" r="1.8" fill="currentColor" />
</svg>
);
const IconApp = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<rect x="2" y="2" width="8" height="8" rx="1.8" stroke="currentColor" strokeWidth="1.8" />
<rect x="14" y="2" width="8" height="8" rx="1.8" stroke="currentColor" strokeWidth="1.8" />
<rect x="2" y="14" width="8" height="8" rx="1.8" stroke="currentColor" strokeWidth="1.8" />
<rect x="14" y="14" width="8" height="8" rx="1.8" stroke="currentColor" strokeWidth="1.8" />
</svg>
);
const IconModel = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M12 2L2 7l10 5 10-5-10-5z" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
<path d="M2 17l10 5 10-5" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
<path d="M2 12l10 5 10-5" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
</svg>
);
const IconTrophy = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M6 4h12v4a6 6 0 01-12 0V4z" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" />
<path d="M6 4H4a2 2 0 00-2 2v2a4 4 0 004 4" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" />
<path d="M18 4h2a2 2 0 012 2v2a4 4 0 01-4 4" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" />
<line x1="12" y1="14" x2="12" y2="20" stroke="currentColor" strokeWidth="1.8" />
<line x1="8" y1="20" x2="16" y2="20" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
const IconWarning = () => (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
<path d="M12 2L2 22h20L12 2z" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
<line x1="12" y1="10" x2="12" y2="15" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<circle cx="12" cy="18.5" r="0.8" fill="currentColor" />
</svg>
);
const IconDownload = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
<polyline points="7 10 12 15 17 10" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
<line x1="12" y1="15" x2="12" y2="3" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
const IconRecharge = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="1.6" />
<polyline points="12 6 12 12 16 14" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
const IconArrowUp = () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none">
<polyline points="18 15 12 9 6 15" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
// ---- Formatting helpers ----
function formatNumber(n: number | undefined | null): string {
if (n == null) return '0';
if (n >= 1000000000) {
return (n / 1000000000).toFixed(2).replace(/\.?0+$/, '') + 'B';
}
if (n >= 1000000) {
return (n / 1000000).toFixed(2).replace(/\.?0+$/, '') + 'M';
}
if (n >= 10000) {
return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K';
}
return n.toLocaleString('en-US');
}
// ---- Data analysis ----
function getTopModel(logs: TokenUsageLog[]): { name: string; total: number } {
const map = new Map<string, number>();
logs.forEach((l) => {
map.set(l.modelName, (map.get(l.modelName) || 0) + l.totalTokens);
});
let max = 0;
let name = '';
map.forEach((v, k) => { if (v > max) { max = v; name = k; } });
return { name, total: max };
}
function getTopApps(logs: TokenUsageLog[]): { name: string; total: number }[] {
const map = new Map<string, number>();
logs.forEach((l) => {
if (l.appName) {
map.set(l.appName, (map.get(l.appName) || 0) + l.totalTokens);
}
});
return Array.from(map.entries())
.map(([name, total]) => ({ name, total }))
.sort((a, b) => b.total - a.total)
.slice(0, 3);
}
function getTodayAppCount(logs: TokenUsageLog[], serverTime?: string): number {
// 优先使用服务器时间,否则使用本地时间
const today = (serverTime || new Date().toISOString()).slice(0, 10);
const apps = new Set<string>();
logs.forEach((l) => {
if (l.appName && l.createdAt && String(l.createdAt).startsWith(today)) {
apps.add(l.appName);
}
});
return apps.size;
}
const TokenDashboardPage: React.FC = () => {
const navigate = useNavigate();
const [totalConsumed, setTotalConsumed] = useState(0);
const [balance, setBalance] = useState(0);
const [logs, setLogs] = useState<TokenUsageLog[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [exporting, setExporting] = useState(false);
const [recharging, setRecharging] = useState(false);
// 从后端API获取服务器时间(用于时间同步)
const [serverTime, setServerTime] = useState<string>('');
useEffect(() => {
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const [overviewRes, balanceRes, logsRes] = await Promise.all([
tokenAPI.getOverview(),
tokenAPI.getBalance(),
tokenAPI.getLogs(),
]);
setTotalConsumed(overviewRes.data?.total_used || overviewRes.data?.total_consumed || overviewRes.data?.totalConsumed || 0);
setBalance(balanceRes.data?.balance || 0);
// 从overview响应中获取服务器时间
const serverTimeStr = overviewRes.data?.server_time || overviewRes.data?.serverTime || overviewRes.data?.current_time || overviewRes.data?.time || '';
setServerTime(serverTimeStr);
// 兼容分页格式和直接数组两种返回格式
const rawLogs = (logsRes.data as any)?.items || logsRes.data || [];
// 兼容后端返回的 snake_case 字段名
setLogs(rawLogs.map((item: any) => ({
...item,
promptTokens: item.promptTokens ?? item.prompt_tokens ?? 0,
completionTokens: item.completionTokens ?? item.completion_tokens ?? 0,
totalTokens: item.totalTokens ?? item.total_tokens ?? 0,
modelName: item.modelName ?? item.model_name ?? '',
cost: item.cost ?? 0,
createdAt: item.createdAt ?? item.created_at ?? item.createdAt,
})));
} catch (err: any) {
setError(err.message || '数据加载失败');
} finally {
setLoading(false);
}
};
fetchData();
}, []);
// 导出CSV功能
const handleExportCSV = async () => {
setExporting(true);
try {
// 分页获取所有token日志(后端限制page_size <= 100)
const allLogs: TokenUsageLog[] = [];
let page = 1;
let hasMore = true;
while (hasMore) {
const res = await tokenAPI.getLogs({ page_size: 100, page });
const rawLogs = (res.data as any)?.items || res.data || [];
if (!Array.isArray(rawLogs) || rawLogs.length === 0) {
hasMore = false;
break;
}
allLogs.push(...rawLogs.map((item: any) => ({
...item,
promptTokens: item.promptTokens ?? item.prompt_tokens ?? 0,
completionTokens: item.completionTokens ?? item.completion_tokens ?? 0,
totalTokens: item.totalTokens ?? item.total_tokens ?? 0,
modelName: item.modelName ?? item.model_name ?? '',
cost: item.cost ?? 0,
createdAt: item.createdAt ?? item.created_at ?? item.createdAt,
appName: item.appName ?? item.app_name ?? '',
})));
if (rawLogs.length < 100) hasMore = false;
else page++;
}
if (allLogs.length === 0) {
alert('暂无数据可导出');
return;
}
// 生成CSV内容
const headers = ['时间', '模型', '应用', 'Prompt Tokens', 'Completion Tokens', '总Tokens', '费用'];
const csvRows = [headers.join(',')];
allLogs.forEach(log => {
const row = [
formatTime(log.createdAt),
`"${(log.modelName || '').replace(/"/g, '""')}"`,
`"${(log.appName || '').replace(/"/g, '""')}"`,
log.promptTokens || 0,
log.completionTokens || 0,
log.totalTokens || 0,
(log.cost ?? 0).toFixed(4),
];
csvRows.push(row.join(','));
});
const csvContent = '\uFEFF' + csvRows.join('\n');
// 触发浏览器下载
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `token-usage-${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (err: any) {
const msg = err?.detail || err?.message || (typeof err === 'string' ? err : JSON.stringify(err));
alert('导出失败: ' + msg);
} finally {
setExporting(false);
}
};
// 一键充值功能 - 跳转到付费中心
const handleRecharge = () => {
navigate('/pricing');
};
const topModel = getTopModel(logs);
const topApps = getTopApps(logs);
const todayAppCount = getTodayAppCount(logs, serverTime);
const lowBalanceThreshold = 200000;
const isLowBalance = balance < lowBalanceThreshold;
// Pagination
const [page, setPage] = useState(1);
const pageSize = 5;
const totalPages = Math.ceil(logs.length / pageSize);
const pagedLogs = logs.slice((page - 1) * pageSize, page * pageSize);
if (loading) {
return (
<div className="tkd-page">
<div className="tkd-header">
<h1 className="tkd-title">Token 消耗驾驶舱</h1>
<p className="tkd-subtitle">实时监控Token使用情况,优化AI成本支出</p>
</div>
<div className="tkd-loading">
<div className="audit-spinner" />
<span>加载中...</span>
</div>
</div>
);
}
if (error) {
return (
<div className="tkd-page">
<div className="tkd-header">
<h1 className="tkd-title">Token 消耗驾驶舱</h1>
<p className="tkd-subtitle">实时监控Token使用情况,优化AI成本支出</p>
</div>
<div className="tkd-warning-banner">
<IconWarning />
<span>数据加载失败:{error}</span>
</div>
</div>
);
}
return (
<div className="tkd-page">
{/* Header */}
<div className="tkd-header">
<h1 className="tkd-title">Token 消耗驾驶舱</h1>
<p className="tkd-subtitle">实时监控Token使用情况,优化AI成本支出</p>
</div>
{/* Balance Warning Banner */}
{isLowBalance && (
<div className="tkd-warning-banner">
<IconWarning />
<span>Token余额不足</span>
<strong>{formatNumber(balance)}</strong>
<span>,建议尽快充值以确保服务连续性</span>
</div>
)}
{/* KPI Cards */}
<div className="tkd-kpi-grid">
<div className="tkd-kpi-card tkd-kpi--blue">
<div className="tkd-kpi-icon-wrapper tkd-kpi-icon--blue">
<IconToken />
</div>
<div className="tkd-kpi-content">
<span className="tkd-kpi-label">累计总消耗 Token</span>
<span className="tkd-kpi-value">{formatNumber(totalConsumed)}</span>
<span className="tkd-kpi-sub">历史总计</span>
</div>
</div>
<div className="tkd-kpi-card tkd-kpi--green">
<div className="tkd-kpi-icon-wrapper tkd-kpi-icon--green">
<IconWallet />
</div>
<div className="tkd-kpi-content">
<span className="tkd-kpi-label">当前余额</span>
<span className="tkd-kpi-value">{formatNumber(balance)}</span>
<span className="tkd-kpi-sub">
{isLowBalance ? '余额偏低' : '余量充足'}
</span>
</div>
</div>
<div className="tkd-kpi-card tkd-kpi--orange">
<div className="tkd-kpi-icon-wrapper tkd-kpi-icon--orange">
<IconApp />
</div>
<div className="tkd-kpi-content">
<span className="tkd-kpi-label">当日使用中应用</span>
<span className="tkd-kpi-value">{todayAppCount}</span>
<span className="tkd-kpi-sub">个应用活跃</span>
</div>
</div>
</div>
{/* Rankings + Actions */}
<div className="tkd-middle-row">
{/* Top Model */}
<div className="tkd-rank-card">
<div className="tkd-rank-header">
<h3 className="tkd-rank-title">消耗量第一的大模型</h3>
<IconModel />
</div>
<div className="tkd-rank-model-body">
<span className="tkd-rank-model-name">{topModel.name || '-'}</span>
<span className="tkd-rank-model-tokens">{formatNumber(topModel.total)} Tokens</span>
</div>
</div>
{/* Top Apps */}
<div className="tkd-rank-card">
<div className="tkd-rank-header">
<h3 className="tkd-rank-title">消耗量 TOP3 应用</h3>
<IconTrophy />
</div>
<div className="tkd-rank-list">
{topApps.map((app, idx) => (
<div key={app.name} className="tkd-rank-item">
<span className="tkd-rank-item-idx">{idx + 1}</span>
<span className="tkd-rank-item-name">{app.name}</span>
<span className="tkd-rank-item-val">{formatNumber(app.total)}</span>
</div>
))}
{topApps.length === 0 && (
<div className="tkd-rank-empty">暂无数据</div>
)}
</div>
</div>
{/* Actions */}
<div className="tkd-actions-card">
<button className="tkd-btn tkd-btn-primary" onClick={handleExportCSV} disabled={exporting}>
<IconDownload />
<span>{exporting ? '导出中...' : '导出数据'}</span>
</button>
<button className="tkd-btn tkd-btn-secondary" onClick={handleRecharge} disabled={recharging}>
<IconRecharge />
<span>{recharging ? '充值中...' : '一键充值'}</span>
</button>
</div>
</div>
{/* Detail Table */}
<div className="tkd-table-wrapper">
<h3 className="tkd-table-title">Token 消耗明细</h3>
<div className="tkd-table-scroll">
<table className="tkd-table">
<thead>
<tr>
<th>时间</th>
<th>模型</th>
<th>应用</th>
<th className="tkd-col-num">Prompt Tokens</th>
<th className="tkd-col-num">Completion Tokens</th>
<th className="tkd-col-num">总 Tokens</th>
<th className="tkd-col-num">费用</th>
</tr>
</thead>
<tbody>
{pagedLogs.map((log) => (
<tr key={log.id}>
<td className="tkd-col-time">{formatTime(log.createdAt)}</td>
<td>
<span className="tkd-model-badge">{log.modelName}</span>
</td>
<td>{log.appName || '-'}</td>
<td className="tkd-col-num">{formatNumber(log.promptTokens)}</td>
<td className="tkd-col-num">{formatNumber(log.completionTokens)}</td>
<td className="tkd-col-num tkd-col-bold">{formatNumber(log.totalTokens)}</td>
<td className="tkd-col-num tkd-col-cost">${(log.cost ?? 0).toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="tkd-pagination">
<button
className="tkd-page-btn"
disabled={page <= 1}
onClick={() => setPage(page - 1)}
>
上一页
</button>
<span className="tkd-page-info">{page} / {totalPages}</span>
<button
className="tkd-page-btn"
disabled={page >= totalPages}
onClick={() => setPage(page + 1)}
>
下一页
</button>
</div>
)}
</div>
</div>
);
};
function formatTime(iso: string | number | undefined | null): string {
if (iso == null) return '-';
let d: Date;
// Handle Unix timestamp (seconds or milliseconds) or ISO string
if (typeof iso === 'number') {
d = new Date(iso < 10000000000 ? iso * 1000 : iso);
} else if (/^\d+$/.test(iso)) {
const ts = parseInt(iso, 10);
d = new Date(ts < 10000000000 ? ts * 1000 : ts);
} else {
d = new Date(iso);
}
// Check validity
if (isNaN(d.getTime())) return String(iso).slice(0, 19) || '-';
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export default TokenDashboardPage;