初始提交: Qihu-AIFlow v2.0.1 完整项目
- 前端 React+TypeScript (源码+构建产物) - 后端 Python FastAPI - 数据库迁移 001-015 - Docker部署配置 - 微信ClawBot API频道绑定 - 项目文档 README.md
This commit is contained in:
commit
78ff261b5a
199 files changed
+69126
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user