681 lines
38 KiB
TypeScript
681 lines
38 KiB
TypeScript
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 levelColors: Record<number, { bg: string; accent: string; label: string }> = {
|
||
0: { bg: 'var(--color-bg-tertiary)', accent: 'var(--color-text-secondary)', label: '免费版' },
|
||
1: { bg: 'rgba(99,102,241,0.1)', accent: '#818cf8', label: '基础版' },
|
||
2: { bg: 'rgba(34,197,94,0.1)', accent: '#22c55e', label: '专业版' },
|
||
3: { bg: 'rgba(245,158,11,0.1)', accent: '#f59e0b', label: '企业版' },
|
||
};
|
||
|
||
const Toggle: React.FC<{ checked: boolean; onChange: (v: boolean) => void }> = ({ checked, onChange }) => (
|
||
<button
|
||
onClick={() => onChange(!checked)}
|
||
style={{
|
||
width: 44, height: 24, borderRadius: 12, border: 'none',
|
||
background: checked ? 'var(--color-accent)' : 'var(--color-text-disabled)',
|
||
cursor: 'pointer', position: 'relative', transition: 'background 0.2s', flexShrink: 0,
|
||
}}
|
||
>
|
||
<span style={{
|
||
position: 'absolute', top: 3, left: checked ? 22 : 3,
|
||
width: 18, height: 18, borderRadius: '50%', background: '#fff',
|
||
transition: 'left 0.2s', boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
|
||
}} />
|
||
</button>
|
||
);
|
||
|
||
interface Plan {
|
||
id: string; name: string; level: number; duration_type: string; price: number;
|
||
daily_free_tokens: number; features: string[]; free_app_ids: string[]; free_model_ids: string[];
|
||
is_active: boolean;
|
||
max_agents: number; max_workflows: number; max_knowledge_bases: number; max_storage_mb: number;
|
||
api_calls_per_day: number; api_calls_per_min: number;
|
||
can_publish_apps: boolean; can_create_api_keys: boolean; can_use_workflow: boolean;
|
||
can_use_mcp: boolean; can_use_code_exec: boolean; can_use_privacy_mode: boolean; can_use_dag: boolean;
|
||
allowed_model_tiers: string[]; permissions: Record<string, any>;
|
||
}
|
||
|
||
interface UserMembership {
|
||
user_id: string; username: string; email: string;
|
||
membership_plan_id: string; membership_plan_name: string; membership_level: number;
|
||
membership_expire_at: string; auto_renew: boolean; member_status: string;
|
||
}
|
||
|
||
interface GlobalConfig {
|
||
id: string; key: string; value: any; type: string; description: string;
|
||
}
|
||
|
||
const Membership: React.FC = () => {
|
||
const [activeTab, setActiveTab] = useState<'plans' | 'benefits' | 'users' | 'configs' | 'stats'>('plans');
|
||
const [plans, setPlans] = useState<Plan[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [editingPlan, setEditingPlan] = useState<Plan | null>(null);
|
||
const [showModal, setShowModal] = useState(false);
|
||
const [showAssignModal, setShowAssignModal] = useState(false);
|
||
const [assignUserId, setAssignUserId] = useState('');
|
||
const [assignPlanId, setAssignPlanId] = useState('');
|
||
const [assignDays, setAssignDays] = useState(30);
|
||
|
||
// User memberships
|
||
const [userMemberships, setUserMemberships] = useState<UserMembership[]>([]);
|
||
const [userTotal, setUserTotal] = useState(0);
|
||
const [userPage, setUserPage] = useState(1);
|
||
|
||
// Configs
|
||
const [configs, setConfigs] = useState<GlobalConfig[]>([]);
|
||
|
||
// Stats
|
||
const [stats, setStats] = useState<any>(null);
|
||
|
||
const fetchPlans = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
const res = await adminAPI.getPlans();
|
||
setPlans(Array.isArray(res.data) ? res.data : (Array.isArray(res) ? res : []));
|
||
} catch (e) { console.error('fetchPlans:', e); }
|
||
setLoading(false);
|
||
}, []);
|
||
|
||
const fetchUserMemberships = useCallback(async () => {
|
||
try {
|
||
const res = await adminAPI.getUserMemberships(userPage);
|
||
const d = res.data || res;
|
||
setUserMemberships(d?.items || []);
|
||
setUserTotal(d?.total || 0);
|
||
} catch (e) { console.error('fetchUserMemberships:', e); }
|
||
}, [userPage]);
|
||
|
||
const fetchConfigs = useCallback(async () => {
|
||
try {
|
||
const res = await adminAPI.getMembershipConfigs();
|
||
setConfigs(Array.isArray(res.data) ? res.data : (Array.isArray(res) ? res : []));
|
||
} catch (e) { console.error('fetchConfigs:', e); }
|
||
}, []);
|
||
|
||
const fetchStats = useCallback(async () => {
|
||
try {
|
||
const res = await adminAPI.getMembershipStats();
|
||
setStats(res.data || res);
|
||
} catch (e) { console.error('fetchStats:', e); }
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
fetchPlans();
|
||
}, [fetchPlans]);
|
||
|
||
useEffect(() => {
|
||
if (activeTab === 'users') fetchUserMemberships();
|
||
if (activeTab === 'configs') fetchConfigs();
|
||
if (activeTab === 'stats') fetchStats();
|
||
}, [activeTab, fetchUserMemberships, fetchConfigs, fetchStats]);
|
||
|
||
// ===================== Plan Edit Form =====================
|
||
const emptyForm: Plan = {
|
||
id: '', name: '', level: 0, duration_type: 'month', price: 0, daily_free_tokens: 0,
|
||
features: [], free_app_ids: [], free_model_ids: [], is_active: true,
|
||
max_agents: 5, max_workflows: 3, max_knowledge_bases: 2, max_storage_mb: 1024,
|
||
api_calls_per_day: 100, api_calls_per_min: 10,
|
||
can_publish_apps: false, can_create_api_keys: false, can_use_workflow: false,
|
||
can_use_mcp: false, can_use_code_exec: false, can_use_privacy_mode: false, can_use_dag: false,
|
||
allowed_model_tiers: ['free'], permissions: {},
|
||
};
|
||
const [form, setForm] = useState<Plan>(emptyForm);
|
||
const [newFeature, setNewFeature] = useState('');
|
||
|
||
const handleEdit = (plan: Plan) => {
|
||
setEditingPlan(plan);
|
||
setForm({ ...plan });
|
||
setShowModal(true);
|
||
};
|
||
|
||
const handleCreate = () => {
|
||
setEditingPlan(null);
|
||
setForm({ ...emptyForm });
|
||
setShowModal(true);
|
||
};
|
||
|
||
const handleSave = async () => {
|
||
try {
|
||
const params = new URLSearchParams();
|
||
Object.keys(form).forEach(k => {
|
||
if (k === 'id' || k === 'created_at' || k === 'updated_at') return;
|
||
const val = (form as any)[k];
|
||
if (val === null || val === undefined) return;
|
||
if (typeof val === 'boolean') params.append(k, String(val));
|
||
else if (Array.isArray(val)) params.append(k, JSON.stringify(val));
|
||
else if (typeof val === 'object') params.append(k, JSON.stringify(val));
|
||
else params.append(k, String(val));
|
||
});
|
||
if (editingPlan) {
|
||
await adminAPI.updatePlan(editingPlan.id, `?${params.toString()}`);
|
||
} else {
|
||
await adminAPI.addPlan(`?${params.toString()}`);
|
||
}
|
||
setShowModal(false);
|
||
fetchPlans();
|
||
} catch (e) { console.error('handleSave:', e); alert('保存失败: ' + (e as any)?.message); }
|
||
};
|
||
|
||
const handleDelete = async (id: string) => {
|
||
if (!confirm('确定删除此会员计划?')) return;
|
||
try {
|
||
await adminAPI.deletePlan(id);
|
||
fetchPlans();
|
||
} catch (e) { alert('删除失败: ' + (e as any)?.message); }
|
||
};
|
||
|
||
const addFeature = () => {
|
||
if (newFeature.trim()) {
|
||
setForm({ ...form, features: [...form.features, newFeature.trim()] });
|
||
setNewFeature('');
|
||
}
|
||
};
|
||
|
||
const toggleModelTier = (tier: string) => {
|
||
setForm(prev => ({
|
||
...prev,
|
||
allowed_model_tiers: prev.allowed_model_tiers.includes(tier)
|
||
? prev.allowed_model_tiers.filter(t => t !== tier)
|
||
: [...prev.allowed_model_tiers, tier],
|
||
}));
|
||
};
|
||
|
||
// ===================== Assign Plan =====================
|
||
const handleAssignPlan = async () => {
|
||
if (!assignUserId || !assignPlanId) { alert('请填写用户ID和选择计划'); return; }
|
||
try {
|
||
await adminAPI.assignPlan(assignUserId, assignPlanId, assignDays);
|
||
setShowAssignModal(false);
|
||
setAssignUserId(''); setAssignPlanId(''); setAssignDays(30);
|
||
fetchUserMemberships();
|
||
} catch (e) { alert('分配失败: ' + (e as any)?.message); }
|
||
};
|
||
|
||
const handleRevoke = async (userId: string, username: string) => {
|
||
if (!confirm(`确定撤销 ${username} 的会员?`)) return;
|
||
try {
|
||
await adminAPI.revokeMembership(userId);
|
||
fetchUserMemberships();
|
||
} catch (e) { alert('撤销失败: ' + (e as any)?.message); }
|
||
};
|
||
|
||
const handleUpdateConfig = async (key: string, value: any) => {
|
||
try {
|
||
const params = new URLSearchParams();
|
||
params.append('value', String(value));
|
||
await adminAPI.updateMembershipConfig(key, `?${params.toString()}`);
|
||
fetchConfigs();
|
||
} catch (e) { alert('更新失败: ' + (e as any)?.message); }
|
||
};
|
||
|
||
// ===================== Render =====================
|
||
const tabs = [
|
||
{ key: 'plans', label: '会员计划', icon: <><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"/></> },
|
||
{ key: 'benefits', label: '权益对比', icon: <><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="3" y1="9" x2="21" y2="9"/></> },
|
||
{ key: 'users', label: '用户会员', icon: <><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"/></> },
|
||
{ key: 'configs', label: '全局配置', icon: <><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 .33-1.82 1.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 1.82.33H9a1.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-.33 1.82V9a1.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"/></> },
|
||
{ key: 'stats', label: '统计概览', icon: <><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></> },
|
||
];
|
||
|
||
const inputStyle: React.CSSProperties = {
|
||
background: 'var(--color-bg-secondary)', border: '1px solid var(--color-border)',
|
||
borderRadius: 'var(--radius-md)', padding: '8px 12px', color: 'var(--color-text)',
|
||
fontSize: 'var(--text-sm)', width: '100%', outline: 'none',
|
||
};
|
||
const labelStyle: React.CSSProperties = {
|
||
fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)', fontWeight: 600, marginBottom: 4, display: 'block',
|
||
};
|
||
const sectionStyle: React.CSSProperties = {
|
||
padding: 'var(--space-lg)', borderBottom: '1px solid var(--color-border)',
|
||
};
|
||
|
||
return (
|
||
<div style={{ animation: 'fadeIn var(--transition-normal)' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 'var(--space-xl)' }}>
|
||
<h1 className="admin-page-title">会员订阅体系管理</h1>
|
||
{activeTab === 'plans' && (
|
||
<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>
|
||
|
||
{/* Tabs */}
|
||
<div style={{ display: 'flex', gap: 2, marginBottom: 'var(--space-xl)', borderBottom: '1px solid var(--color-border)' }}>
|
||
{tabs.map(tab => (
|
||
<button
|
||
key={tab.key}
|
||
onClick={() => setActiveTab(tab.key as any)}
|
||
style={{
|
||
padding: '10px 20px', border: 'none', cursor: 'pointer',
|
||
background: activeTab === tab.key ? 'var(--color-bg-secondary)' : 'transparent',
|
||
color: activeTab === tab.key ? 'var(--color-accent)' : 'var(--color-text-secondary)',
|
||
fontSize: 'var(--text-sm)', fontWeight: activeTab === tab.key ? 600 : 400,
|
||
borderBottom: activeTab === tab.key ? '2px solid var(--color-accent)' : '2px solid transparent',
|
||
transition: 'all 0.2s', display: 'flex', alignItems: 'center', gap: 6,
|
||
}}
|
||
>
|
||
<SVGIcon size={14}>{tab.icon}</SVGIcon>
|
||
{tab.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* ===================== Plans Tab ===================== */}
|
||
{activeTab === 'plans' && (
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(360px, 1fr))', gap: 'var(--space-xl)' }}>
|
||
{plans.map((plan) => {
|
||
const colors = levelColors[plan.level] || levelColors[0];
|
||
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: 4 }}>
|
||
{colors.label} · Level {plan.level}
|
||
</span>
|
||
<h3 style={{ fontSize: 'var(--text-lg)', fontWeight: 700, marginTop: 4 }}>{plan.name}</h3>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 4 }}>
|
||
<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>
|
||
<button className="admin-btn admin-btn-outline admin-btn-sm" style={{ color: '#ef4444' }} onClick={() => handleDelete(plan.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>
|
||
|
||
{/* Price */}
|
||
<div style={{ display: 'flex', gap: 'var(--space-md)', marginBottom: 'var(--space-lg)' }}>
|
||
<div style={{ flex: 1, textAlign: 'center', padding: 'var(--space-sm)', 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-sm)', background: 'var(--color-bg-tertiary)', borderRadius: 'var(--radius-md)' }}>
|
||
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>每日Token</div>
|
||
<div style={{ fontSize: 'var(--text-lg)', fontWeight: 700, color: colors.accent }}>{plan.daily_free_tokens?.toLocaleString()}</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Quotas */}
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 6, marginBottom: 'var(--space-md)' }}>
|
||
{[
|
||
{ label: 'Agent', value: plan.max_agents },
|
||
{ label: '工作流', value: plan.max_workflows },
|
||
{ label: '知识库', value: plan.max_knowledge_bases },
|
||
{ label: '存储(MB)', value: plan.max_storage_mb },
|
||
{ label: 'API/日', value: plan.api_calls_per_day },
|
||
{ label: 'API/分', value: plan.api_calls_per_min },
|
||
].map((item, i) => (
|
||
<div key={i} style={{ padding: '6px 8px', background: 'var(--color-bg-tertiary)', borderRadius: 'var(--radius-sm)', textAlign: 'center' }}>
|
||
<div style={{ fontSize: 'var(--text-2xs)', color: 'var(--color-text-tertiary)' }}>{item.label}</div>
|
||
<div style={{ fontSize: 'var(--text-sm)', fontWeight: 600 }}>{item.value >= 999 ? '∞' : item.value?.toLocaleString()}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Permissions */}
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 'var(--space-md)' }}>
|
||
{[
|
||
{ key: 'can_publish_apps', label: '发布应用' },
|
||
{ key: 'can_create_api_keys', label: 'API密钥' },
|
||
{ key: 'can_use_workflow', label: '工作流' },
|
||
{ key: 'can_use_mcp', label: 'MCP' },
|
||
{ key: 'can_use_code_exec', label: '代码执行' },
|
||
{ key: 'can_use_privacy_mode', label: '隐私模式' },
|
||
{ key: 'can_use_dag', label: 'DAG' },
|
||
].map(perm => {
|
||
const enabled = (plan as any)[perm.key];
|
||
return enabled ? (
|
||
<span key={perm.key} className="admin-badge" style={{ background: 'rgba(34,197,94,0.1)', color: '#22c55e', fontSize: 'var(--text-2xs)' }}>
|
||
✓ {perm.label}
|
||
</span>
|
||
) : (
|
||
<span key={perm.key} className="admin-badge" style={{ background: 'var(--color-bg-tertiary)', color: 'var(--color-text-tertiary)', fontSize: 'var(--text-2xs)' }}>
|
||
✗ {perm.label}
|
||
</span>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Features */}
|
||
{plan.features && plan.features.length > 0 && (
|
||
<div>
|
||
<div style={{ fontSize: 'var(--text-xs)', fontWeight: 600, marginBottom: 4, color: 'var(--color-text-secondary)' }}>会员权益</div>
|
||
<ul style={{ listStyle: 'none', padding: 0, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||
{plan.features.map((f, i) => (
|
||
<li key={i} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 'var(--text-xs)', color: 'var(--color-text-secondary)' }}>
|
||
<SVGIcon size={12}><polyline points="20 6 9 17 4 12"/></SVGIcon>
|
||
{f}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* ===================== Benefits Comparison Tab ===================== */}
|
||
{activeTab === 'benefits' && (
|
||
<div className="admin-card" style={{ overflowX: 'auto' }}>
|
||
<table className="admin-table">
|
||
<thead>
|
||
<tr>
|
||
<th style={{ minWidth: 160 }}>权益项</th>
|
||
{plans.map(p => {
|
||
const c = levelColors[p.level] || levelColors[0];
|
||
return <th key={p.id} style={{ textAlign: 'center', minWidth: 120 }}>
|
||
<span className="admin-badge" style={{ background: c.bg, color: c.accent }}>{p.name}</span>
|
||
</th>;
|
||
})}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{[
|
||
{ key: 'price', label: '月价格(元)', type: 'number' },
|
||
{ key: 'daily_free_tokens', label: '每日免费Token', type: 'number' },
|
||
{ key: 'max_agents', label: '最大Agent数', type: 'number' },
|
||
{ key: 'max_workflows', label: '最大工作流数', type: 'number' },
|
||
{ key: 'max_knowledge_bases', label: '最大知识库数', type: 'number' },
|
||
{ key: 'max_storage_mb', label: '存储配额(MB)', type: 'number' },
|
||
{ key: 'api_calls_per_day', label: '每日API限额', type: 'number' },
|
||
{ key: 'api_calls_per_min', label: '每分钟API限额', type: 'number' },
|
||
{ key: 'can_publish_apps', label: '发布应用到市场', type: 'boolean' },
|
||
{ key: 'can_create_api_keys', label: '创建开发者API密钥', type: 'boolean' },
|
||
{ key: 'can_use_workflow', label: '工作流编排', type: 'boolean' },
|
||
{ key: 'can_use_mcp', label: 'MCP工具', type: 'boolean' },
|
||
{ key: 'can_use_code_exec', label: '代码执行', type: 'boolean' },
|
||
{ key: 'can_use_privacy_mode', label: '隐私模式', type: 'boolean' },
|
||
{ key: 'can_use_dag', label: 'DAG多Agent', type: 'boolean' },
|
||
{ key: 'allowed_model_tiers', label: '可用模型层级', type: 'list' },
|
||
].map(row => (
|
||
<tr key={row.key}>
|
||
<td style={{ fontWeight: 600 }}>{row.label}</td>
|
||
{plans.map(p => {
|
||
const val = (p as any)[row.key];
|
||
let display;
|
||
if (row.type === 'boolean') {
|
||
display = val ? <span style={{ color: '#22c55e' }}>✓</span> : <span style={{ color: 'var(--color-text-tertiary)' }}>✗</span>;
|
||
} else if (row.type === 'number') {
|
||
display = val >= 999 ? '不限' : (val?.toLocaleString() || '0');
|
||
} else if (row.type === 'list') {
|
||
display = Array.isArray(val) ? val.join(', ') : '-';
|
||
} else {
|
||
display = String(val ?? '-');
|
||
}
|
||
return <td key={p.id} style={{ textAlign: 'center', fontFamily: row.type === 'number' ? 'var(--font-mono)' : 'inherit' }}>{display}</td>;
|
||
})}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
{/* ===================== Users Tab ===================== */}
|
||
{activeTab === 'users' && (
|
||
<div>
|
||
<div style={{ marginBottom: 'var(--space-lg)', display: 'flex', justifyContent: 'flex-end' }}>
|
||
<button className="admin-btn admin-btn-primary" onClick={() => setShowAssignModal(true)}>
|
||
<SVGIcon size={14}><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></SVGIcon>
|
||
分配会员
|
||
</button>
|
||
</div>
|
||
<div className="admin-card" 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>
|
||
{userMemberships.length === 0 ? (
|
||
<tr><td colSpan={8} style={{ textAlign: 'center', padding: 'var(--space-2xl)', color: 'var(--color-text-tertiary)' }}>暂无数据</td></tr>
|
||
) : userMemberships.map(u => (
|
||
<tr key={u.user_id}>
|
||
<td style={{ fontWeight: 600 }}>{u.username}</td>
|
||
<td>{u.email || '-'}</td>
|
||
<td>{u.membership_plan_name}</td>
|
||
<td>Level {u.membership_level}</td>
|
||
<td>{u.membership_expire_at ? new Date(u.membership_expire_at).toLocaleString('zh-CN') : '-'}</td>
|
||
<td>{u.auto_renew ? '✓' : '✗'}</td>
|
||
<td>
|
||
<span className={`admin-badge ${u.member_status === 'active' ? 'badge-success' : u.member_status === 'expired' ? 'badge-danger' : 'badge-neutral'}`}>
|
||
{u.member_status === 'active' ? '活跃' : u.member_status === 'expired' ? '已过期' : '免费'}
|
||
</span>
|
||
</td>
|
||
<td>
|
||
{u.membership_level > 0 && (
|
||
<button className="admin-btn admin-btn-outline admin-btn-sm" style={{ color: '#ef4444' }} onClick={() => handleRevoke(u.user_id, u.username)}>
|
||
撤销
|
||
</button>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{userTotal > 20 && (
|
||
<div style={{ display: 'flex', justifyContent: 'center', gap: 'var(--space-md)', marginTop: 'var(--space-lg)' }}>
|
||
<button className="admin-btn admin-btn-outline admin-btn-sm" disabled={userPage <= 1} onClick={() => setUserPage(p => p - 1)}>上一页</button>
|
||
<span style={{ lineHeight: '32px', fontSize: 'var(--text-sm)' }}>第 {userPage} 页</span>
|
||
<button className="admin-btn admin-btn-outline admin-btn-sm" disabled={userPage * 20 >= userTotal} onClick={() => setUserPage(p => p + 1)}>下一页</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ===================== Configs Tab ===================== */}
|
||
{activeTab === 'configs' && (
|
||
<div className="admin-card">
|
||
<div className="admin-card-header"><h3 className="admin-card-title">全局会员配置</h3></div>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-md)' }}>
|
||
{configs.map(cfg => (
|
||
<div key={cfg.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: 'var(--space-md)', background: 'var(--color-bg-tertiary)', borderRadius: 'var(--radius-md)' }}>
|
||
<div>
|
||
<div style={{ fontSize: 'var(--text-sm)', fontWeight: 600 }}>{cfg.description}</div>
|
||
<div style={{ fontSize: 'var(--text-xs)', color: 'var(--color-text-tertiary)' }}>{cfg.key}</div>
|
||
</div>
|
||
{cfg.type === 'boolean' ? (
|
||
<Toggle checked={!!cfg.value} onChange={(v) => handleUpdateConfig(cfg.key, v)} />
|
||
) : cfg.type === 'number' ? (
|
||
<input type="number" value={cfg.value} onChange={e => handleUpdateConfig(cfg.key, e.target.value)} style={{ ...inputStyle, width: 80 }} />
|
||
) : (
|
||
<input value={cfg.value} onChange={e => handleUpdateConfig(cfg.key, e.target.value)} style={{ ...inputStyle, width: 200 }} />
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ===================== Stats Tab ===================== */}
|
||
{activeTab === 'stats' && stats && (
|
||
<div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 'var(--space-lg)', marginBottom: 'var(--space-xl)' }}>
|
||
{[
|
||
{ label: '总用户数', value: stats.total_users, color: '#818cf8' },
|
||
{ label: '活跃会员', value: stats.active_members, color: '#22c55e' },
|
||
{ label: '已过期', value: stats.expired_members, color: '#ef4444' },
|
||
{ label: '免费用户', value: stats.free_users, color: 'var(--color-text-secondary)' },
|
||
{ label: '自动续费', value: stats.auto_renew_count, color: '#f59e0b' },
|
||
{ label: '今日新增', value: stats.today_new_subs, color: '#06b6d4' },
|
||
].map((s, i) => (
|
||
<div key={i} className="admin-card" style={{ marginBottom: 0, textAlign: 'center' }}>
|
||
<div style={{ fontSize: 'var(--text-2xl)', fontWeight: 800, color: s.color }}>{s.value?.toLocaleString()}</div>
|
||
<div style={{ fontSize: 'var(--text-sm)', color: 'var(--color-text-tertiary)', marginTop: 4 }}>{s.label}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="admin-card">
|
||
<div className="admin-card-header"><h3 className="admin-card-title">各计划订阅统计</h3></div>
|
||
<table className="admin-table">
|
||
<thead><tr><th>计划名称</th><th>等级</th><th>用户数</th><th>状态</th></tr></thead>
|
||
<tbody>
|
||
{stats.plan_stats?.map((ps: any) => (
|
||
<tr key={ps.plan_id}>
|
||
<td style={{ fontWeight: 600 }}>{ps.plan_name}</td>
|
||
<td>Level {ps.level}</td>
|
||
<td style={{ fontFamily: 'var(--font-mono)' }}>{ps.user_count}</td>
|
||
<td>{ps.is_active ? <span className="admin-badge badge-success">启用</span> : <span className="admin-badge badge-neutral">停用</span>}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ===================== Edit/Create Plan Modal ===================== */}
|
||
{showModal && (
|
||
<div style={{
|
||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||
background: 'rgba(0,0,0,0.5)', zIndex: 999,
|
||
display: 'flex', alignItems: 'flex-start', justifyContent: 'center',
|
||
paddingTop: '4vh', animation: 'fadeIn 0.2s',
|
||
}} onClick={() => setShowModal(false)}>
|
||
<div style={{
|
||
background: 'var(--color-bg-primary)', borderRadius: 'var(--radius-xl)',
|
||
width: 720, maxHeight: '90vh', overflow: 'auto',
|
||
boxShadow: 'var(--shadow-xl)',
|
||
}} onClick={e => e.stopPropagation()}>
|
||
<div style={{ padding: 'var(--space-xl) var(--space-2xl)', borderBottom: '1px solid var(--color-border)' }}>
|
||
<h3 style={{ fontSize: 'var(--text-lg)', fontWeight: 700 }}>
|
||
{editingPlan ? `编辑 ${editingPlan.name}` : '新建会员计划'}
|
||
</h3>
|
||
</div>
|
||
|
||
{/* Basic Info */}
|
||
<div style={sectionStyle}>
|
||
<div style={{ fontSize: 'var(--text-sm)', fontWeight: 700, marginBottom: 'var(--space-md)' }}>基础信息</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 'var(--space-md)' }}>
|
||
<div><label style={labelStyle}>计划名称</label><input style={inputStyle} value={form.name} onChange={e => setForm({ ...form, name: e.target.value })} /></div>
|
||
<div><label style={labelStyle}>等级 (0-3)</label><input type="number" style={inputStyle} value={form.level} onChange={e => setForm({ ...form, level: Number(e.target.value) })} /></div>
|
||
<div><label style={labelStyle}>周期</label><select style={inputStyle} value={form.duration_type} onChange={e => setForm({ ...form, duration_type: e.target.value })}><option value="month">月</option><option value="quarter">季</option><option value="year">年</option></select></div>
|
||
<div><label style={labelStyle}>价格(元)</label><input type="number" style={inputStyle} value={form.price} onChange={e => setForm({ ...form, price: Number(e.target.value) })} /></div>
|
||
<div><label style={labelStyle}>每日免费Token</label><input type="number" style={inputStyle} value={form.daily_free_tokens} onChange={e => setForm({ ...form, daily_free_tokens: Number(e.target.value) })} /></div>
|
||
<div style={{ display: 'flex', alignItems: 'flex-end' }}>
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
||
<Toggle checked={form.is_active} onChange={v => setForm({ ...form, is_active: v })} />
|
||
<span style={{ fontSize: 'var(--text-sm)' }}>启用</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Quotas */}
|
||
<div style={sectionStyle}>
|
||
<div style={{ fontSize: 'var(--text-sm)', fontWeight: 700, marginBottom: 'var(--space-md)' }}>配额限制</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 'var(--space-md)' }}>
|
||
<div><label style={labelStyle}>最大Agent数</label><input type="number" style={inputStyle} value={form.max_agents} onChange={e => setForm({ ...form, max_agents: Number(e.target.value) })} /></div>
|
||
<div><label style={labelStyle}>最大工作流数</label><input type="number" style={inputStyle} value={form.max_workflows} onChange={e => setForm({ ...form, max_workflows: Number(e.target.value) })} /></div>
|
||
<div><label style={labelStyle}>最大知识库数</label><input type="number" style={inputStyle} value={form.max_knowledge_bases} onChange={e => setForm({ ...form, max_knowledge_bases: Number(e.target.value) })} /></div>
|
||
<div><label style={labelStyle}>存储配额(MB)</label><input type="number" style={inputStyle} value={form.max_storage_mb} onChange={e => setForm({ ...form, max_storage_mb: Number(e.target.value) })} /></div>
|
||
<div><label style={labelStyle}>每日API限额</label><input type="number" style={inputStyle} value={form.api_calls_per_day} onChange={e => setForm({ ...form, api_calls_per_day: Number(e.target.value) })} /></div>
|
||
<div><label style={labelStyle}>每分钟API限额</label><input type="number" style={inputStyle} value={form.api_calls_per_min} onChange={e => setForm({ ...form, api_calls_per_min: Number(e.target.value) })} /></div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Permissions */}
|
||
<div style={sectionStyle}>
|
||
<div style={{ fontSize: 'var(--text-sm)', fontWeight: 700, marginBottom: 'var(--space-md)' }}>功能权限</div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-md)' }}>
|
||
{[
|
||
{ key: 'can_publish_apps', label: '发布应用到市场' },
|
||
{ key: 'can_create_api_keys', label: '创建开发者API密钥' },
|
||
{ key: 'can_use_workflow', label: '使用工作流编排' },
|
||
{ key: 'can_use_mcp', label: '使用MCP工具' },
|
||
{ key: 'can_use_code_exec', label: '使用代码执行' },
|
||
{ key: 'can_use_privacy_mode', label: '使用隐私模式' },
|
||
{ key: 'can_use_dag', label: '使用DAG多Agent' },
|
||
].map(perm => (
|
||
<label key={perm.key} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '8px 12px', background: 'var(--color-bg-tertiary)', borderRadius: 'var(--radius-md)', cursor: 'pointer' }}>
|
||
<span style={{ fontSize: 'var(--text-sm)' }}>{perm.label}</span>
|
||
<Toggle checked={(form as any)[perm.key]} onChange={v => setForm({ ...form, [perm.key]: v })} />
|
||
</label>
|
||
))}
|
||
</div>
|
||
<div style={{ marginTop: 'var(--space-md)' }}>
|
||
<label style={labelStyle}>可用模型层级</label>
|
||
<div style={{ display: 'flex', gap: 'var(--space-sm)' }}>
|
||
{['free', 'standard', 'premium'].map(tier => (
|
||
<label key={tier} style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer', padding: '4px 12px', background: form.allowed_model_tiers.includes(tier) ? 'var(--color-accent-subtle)' : 'var(--color-bg-tertiary)', borderRadius: 'var(--radius-full)', fontSize: 'var(--text-sm)' }}>
|
||
<input type="checkbox" checked={form.allowed_model_tiers.includes(tier)} onChange={() => toggleModelTier(tier)} style={{ accentColor: 'var(--color-accent)' }} />
|
||
{tier}
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Features */}
|
||
<div style={sectionStyle}>
|
||
<div style={{ fontSize: 'var(--text-sm)', fontWeight: 700, marginBottom: 'var(--space-md)' }}>会员权益标签</div>
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 'var(--space-sm)' }}>
|
||
{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-subtle)', color: 'var(--color-accent)', fontSize: 'var(--text-xs)' }}>
|
||
{f}
|
||
<button onClick={() => setForm({ ...form, features: form.features.filter((_, j) => j !== i) })} style={{ cursor: 'pointer', color: 'inherit', padding: 0, lineHeight: 1, border: 'none', background: 'none' }}>×</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 'var(--space-sm)' }}>
|
||
<input style={{ ...inputStyle, flex: 1 }} value={newFeature} onChange={e => setNewFeature(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addFeature(); } }} placeholder="输入权益名称,回车添加" />
|
||
<button className="admin-btn admin-btn-outline admin-btn-sm" onClick={addFeature}>添加</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Actions */}
|
||
<div style={{ padding: 'var(--space-lg) var(--space-2xl)', display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-md)' }}>
|
||
<button className="admin-btn admin-btn-outline" onClick={() => setShowModal(false)}>取消</button>
|
||
<button className="admin-btn admin-btn-primary" onClick={handleSave}>保存</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* ===================== Assign Plan Modal ===================== */}
|
||
{showAssignModal && (
|
||
<div style={{
|
||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||
background: 'rgba(0,0,0,0.5)', zIndex: 999,
|
||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
}} onClick={() => setShowAssignModal(false)}>
|
||
<div style={{ background: 'var(--color-bg-primary)', borderRadius: 'var(--radius-xl)', width: 480, padding: 'var(--space-2xl)' }} onClick={e => e.stopPropagation()}>
|
||
<h3 style={{ fontSize: 'var(--text-lg)', fontWeight: 700, marginBottom: 'var(--space-xl)' }}>分配会员计划</h3>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-md)' }}>
|
||
<div><label style={labelStyle}>用户ID</label><input style={inputStyle} value={assignUserId} onChange={e => setAssignUserId(e.target.value)} placeholder="输入用户ID" /></div>
|
||
<div><label style={labelStyle}>选择计划</label>
|
||
<select style={inputStyle} value={assignPlanId} onChange={e => setAssignPlanId(e.target.value)}>
|
||
<option value="">请选择...</option>
|
||
{plans.filter(p => p.is_active).map(p => <option key={p.id} value={p.id}>{p.name} (Level {p.level})</option>)}
|
||
</select>
|
||
</div>
|
||
<div><label style={labelStyle}>订阅时长(天)</label><input type="number" style={inputStyle} value={assignDays} onChange={e => setAssignDays(Number(e.target.value))} /></div>
|
||
</div>
|
||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 'var(--space-md)', marginTop: 'var(--space-xl)' }}>
|
||
<button className="admin-btn admin-btn-outline" onClick={() => setShowAssignModal(false)}>取消</button>
|
||
<button className="admin-btn admin-btn-primary" onClick={handleAssignPlan}>确认分配</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default Membership;
|