Files
qihuaiflow/frontend/src/pages/admin/Membership.tsx
T

681 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;