初始提交: 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;