platform-assistant/apps/web/src/components/AdminPage.tsx
Sisyphus afac18e843 feat(F002): W26 + W29.x + W30 + W31 平台助手全部改动首次 commit
- W26: NestJS API + Next.js Web + Prisma + SQLite scaffold
- W26: Lead 模型 + Crawler + 询价发布助手 (线索池 + 一键发布)
- W29.x: MCP 集成 (Integration 模型 + connectMcp + publish_rfq 5 工具端到端跑通)
- W30: 3 字段补充 (notes / quotationType / quotationDemand)
- W31 P1: 自动填 enterpriseId / userId (Prisma companyUserId 字段 + get_app_identity tool + lead.service.ts publishArgs)
- W31: detail 详情页 + admin UI 集成管理 (OpenAppListView + AuditLogListView)

关联: ~/Documents/02-平台开发/产品开发/F002-W31-交接文档.md
GIT-AUDIT-REPORT-2026-06-24.md
2026-06-24 11:07:11 +08:00

1599 lines
92 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.

'use client'
import React, { useEffect, useState, useCallback } from 'react'
import { API_BASE } from '@/lib/apiBase'
// ---- Types ----
interface Tenant {
id: string; companyName: string; contactName: string | null; contactEmail: string | null
plan: string; status: string; validFrom: string; validUntil: string
platformType: string | null; platformUrl: string | null
features: string | null; usageQuotas: string | null
createdAt: string; updatedAt: string; _daysUntilExpiry?: number; _userCount?: number
}
interface User {
id: string; email: string | null; username: string | null; name: string | null; role: string; isActive: boolean
tenantId: string | null; tenant: { companyName: string } | null; createdAt: string
}
interface SystemConfig {
id: string; key: string; value: string; group: string; description: string | null
createdAt: string; updatedAt: string
}
interface Stats {
overview: { tenantCount: number; userCount: number; productCount: number; leadCount: number; imageCount: number; inquiryCount: number }
perTenant: {
id: string; companyName: string; plan: string; status: string; validUntil: string
userCount: number
quotas: { monthlyLeads: number; monthlyImages: number; monthlyProducts: number; monthlyComparisons: number; maxUsers: number }
usage: { leads: number; images: number; products: number; comparisons: number; users: number }
}[]
}
interface Features { inquiry: boolean; imageGen: boolean; product: boolean; comparison: boolean }
interface UsageQuotas { monthlyLeads: number; monthlyImages: number; monthlyProducts: number; monthlyComparisons: number; maxUsers: number }
const DEFAULT_FEATURES: Features = { inquiry: true, imageGen: true, product: true, comparison: true }
const DEFAULT_QUOTAS: UsageQuotas = { monthlyLeads: 100, monthlyImages: 50, monthlyProducts: 100, monthlyComparisons: 50, maxUsers: 5 }
type Tab = 'tenants' | 'users' | 'stats' | 'configs' | 'plans'
const PLAN_LABELS: Record<string, string> = { basic: '基础版', pro: '专业版', enterprise: '旗舰版' }
const PLAN_COLORS: Record<string, string> = { basic: 'bg-gray-100 text-gray-700', pro: 'bg-blue-50 text-blue-700', enterprise: 'bg-purple-50 text-purple-700' }
const STATUS_LABELS: Record<string, string> = { active: '正常', inactive: '已停用', expired: '已过期' }
const STATUS_COLORS: Record<string, string> = { active: 'bg-green-50 text-green-700', inactive: 'bg-red-50 text-red-700', expired: 'bg-yellow-50 text-yellow-700' }
const PLATFORM_LABELS: Record<string, string> = { jubian: '巨变平台', custom: '自建平台' }
const FEATURE_LABELS: Record<keyof Features, string> = { inquiry: '询价发布', imageGen: '商品生图', product: '商品上架', comparison: '智能比价' }
const CONFIG_GROUPS: Record<string, string> = { ai: 'AI 模型', platform: '平台对接', system: '系统参数' }
const API = `${API_BASE}/api/admin`
const authHeaders = (): Record<string, string> => {
const auth = localStorage.getItem('auth')
return { 'Content-Type': 'application/json', Authorization: `Bearer ${auth ? JSON.parse(auth).token : ''}` }
}
function toastManager() {
const [toast, setToast] = useState('')
const show = (msg: string) => { setToast(msg); setTimeout(() => setToast(''), 2500) }
return { toast, show }
}
export default function AdminPage({ section }: { section: Tab }) {
const { toast, show: showToast } = toastManager()
return (
<div>
{toast && <div className="fixed top-4 right-4 bg-gray-800 text-white px-4 py-2 rounded-lg text-sm z-50 shadow-lg">{toast}</div>}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold">运营后台</h1>
<p className="text-sm text-on-surface-variant mt-1">平台管理控制台</p>
</div>
</div>
{section === 'tenants' && <TenantTab showToast={showToast} />}
{section === 'users' && <UserTab showToast={showToast} />}
{section === 'stats' && <StatsTab />}
{section === 'configs' && <ConfigTab showToast={showToast} />}
{section === 'plans' && <PlanConfigTab showToast={showToast} />}
</div>
)
}
// ====================== Tenant Tab ======================
function TenantTab({ showToast }: { showToast: (m: string) => void }) {
const [tenants, setTenants] = useState<Tenant[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [showForm, setShowForm] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [form, setForm] = useState({ companyName: '', contactName: '', contactEmail: '', plan: 'basic', status: 'active', validFrom: '', validUntil: '', platformType: 'jubian', platformUrl: '', platformApiKey: '' })
const [features, setFeatures] = useState<Features>(DEFAULT_FEATURES)
const [quotas, setQuotas] = useState<UsageQuotas>(DEFAULT_QUOTAS)
const [adminUsername, setAdminUsername] = useState('')
const [adminPassword, setAdminPassword] = useState('')
const [adminName, setAdminName] = useState('')
const [saving, setSaving] = useState(false)
const [planConfig, setPlanConfig] = useState<Record<string, any> | null>(null)
const [userManager, setUserManager] = useState<{ tenantId: string; companyName: string } | null>(null)
const loadTenants = useCallback(async () => {
try {
const res = await fetch(`${API}/tenants`, { headers: authHeaders() })
if (res.ok) setTenants(await res.json())
} catch { showToast('加载失败')
} finally { setLoading(false) }
}, [showToast])
useEffect(() => { loadTenants() }, [loadTenants])
useEffect(() => {
fetch(`${API}/plan-config`, { headers: authHeaders() })
.then((r) => r.ok && r.json())
.then((cfg) => setPlanConfig(cfg))
.catch(() => {})
}, [])
const filtered = tenants.filter((t) =>
[t.companyName, t.contactName, t.contactEmail].some((v) => v?.toLowerCase().includes(search.toLowerCase()))
)
const openCreate = () => {
setForm({ companyName: '', contactName: '', contactEmail: '', plan: 'basic', status: 'active', validFrom: '', validUntil: '', platformType: 'jubian', platformUrl: '', platformApiKey: '' })
const q = planConfig?.basic?.quotas || DEFAULT_QUOTAS
setFeatures(planConfig?.basic?.features || DEFAULT_FEATURES); setQuotas(q); setEditingId(null); setShowForm(true)
setAdminUsername(''); setAdminPassword(''); setAdminName('')
}
const openEdit = (t: Tenant) => {
setForm({
companyName: t.companyName, contactName: t.contactName || '', contactEmail: t.contactEmail || '',
plan: t.plan, status: t.status, validFrom: t.validFrom?.slice(0, 10) || '', validUntil: t.validUntil?.slice(0, 10) || '',
platformType: t.platformType || 'jubian', platformUrl: t.platformUrl || '', platformApiKey: '',
})
try { setFeatures(t.features ? JSON.parse(t.features) : DEFAULT_FEATURES) } catch { setFeatures(DEFAULT_FEATURES) }
try { setQuotas(t.usageQuotas ? JSON.parse(t.usageQuotas) : DEFAULT_QUOTAS) } catch { setQuotas(DEFAULT_QUOTAS) }
setEditingId(t.id); setShowForm(true)
}
const handleSave = async () => {
if (!form.companyName || !form.validUntil) { showToast('请填写公司名和有效期'); return }
setSaving(true)
try {
const body = {
companyName: form.companyName, contactName: form.contactName || undefined, contactEmail: form.contactEmail || undefined,
plan: form.plan, status: form.status, validFrom: form.validFrom || undefined, validUntil: form.validUntil,
features: JSON.stringify(features), usageQuotas: JSON.stringify(quotas),
}
if (!editingId) {
Object.assign(body, { platformApiKey: form.platformApiKey || '', adminUsername: adminUsername || undefined, adminPassword: adminPassword || undefined, adminName: adminName || undefined })
} else {
Object.assign(body, { platformType: form.platformType, platformUrl: form.platformUrl, platformApiKey: form.platformApiKey })
}
const res = await fetch(editingId ? `${API}/tenants/${editingId}` : `${API}/tenants`, {
method: editingId ? 'PATCH' : 'POST', headers: authHeaders(),
body: JSON.stringify(body),
})
if (!res.ok) { showToast('保存失败'); return }
showToast(editingId ? '更新成功' : '创建成功'); setShowForm(false); loadTenants()
} catch { showToast('保存失败')
} finally { setSaving(false) }
}
const handleDelete = async (id: string) => {
if (!confirm('确定删除该租户?')) return
const res = await fetch(`${API}/tenants/${id}`, { method: 'DELETE', headers: authHeaders() })
if (res.ok) { showToast('删除成功'); loadTenants() } else showToast('删除失败')
}
const expiryBadge = (days: number | undefined) => {
if (days === undefined) return null
if (days < 0) return <span className="text-xs text-error-red font-semibold">已过期 {Math.abs(days)}天</span>
if (days <= 7) return <span className="text-xs text-red-600 font-semibold">即将到期 {days}天</span>
if (days <= 30) return <span className="text-xs text-yellow-600 font-semibold">{days}天后到期</span>
return <span className="text-xs text-green-600">{days}天后到期</span>
}
const fmt = (d: string) => d ? new Date(d).toLocaleDateString('zh-CN') : '-'
return (
<div>
<div className="flex items-center justify-between mb-4">
<input type="text" placeholder="搜索公司名、联系人、邮箱..." value={search} onChange={(e) => setSearch(e.target.value)}
className="w-full max-w-md px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
<button onClick={openCreate} className="ml-4 bg-secondary text-white px-5 py-2.5 rounded-lg text-sm font-semibold hover:opacity-90 transition-opacity whitespace-nowrap">
+ 新建租户
</button>
</div>
<div className="bg-white rounded-xl border border-border-subtle overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-surface-container-low text-left text-on-surface-variant">
<th className="px-4 py-3 font-semibold">公司名</th>
<th className="px-4 py-3 font-semibold">订阅版本</th>
<th className="px-4 py-3 font-semibold">状态</th>
<th className="px-4 py-3 font-semibold">有效期 / 到期提醒</th>
<th className="px-4 py-3 font-semibold">联系人</th>
<th className="px-4 py-3 font-semibold">功能</th>
<th className="px-4 py-3 font-semibold">对接平台</th>
<th className="px-4 py-3 font-semibold">操作</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={8} className="px-4 py-12 text-center text-on-surface-variant">加载中...</td></tr>
) : filtered.length === 0 ? (
<tr><td colSpan={8} className="px-4 py-12 text-center text-on-surface-variant">暂无租户数据</td></tr>
) : filtered.map((t) => {
const feat = (() => { try { return JSON.parse(t.features || '{}') as Features } catch { return {} as Features } })()
return (
<tr key={t.id} className="border-t border-border-subtle hover:bg-surface-container-low/50 transition-colors">
<td className="px-4 py-3 font-semibold">{t.companyName}</td>
<td className="px-4 py-3">
<span className={`inline-block px-2 py-0.5 rounded text-xs font-semibold ${PLAN_COLORS[t.plan] || ''}`}>
{PLAN_LABELS[t.plan] || t.plan}
</span>
</td>
<td className="px-4 py-3">
<span className={`inline-block px-2 py-0.5 rounded text-xs font-semibold ${STATUS_COLORS[t.status] || ''}`}>
{STATUS_LABELS[t.status] || t.status}
</span>
</td>
<td className="px-4 py-3">
<div className="text-xs">{fmt(t.validFrom)} ~ {fmt(t.validUntil)}</div>
<div>{expiryBadge(t._daysUntilExpiry)}</div>
</td>
<td className="px-4 py-3">
<div>{t.contactName || '-'}</div>
{t.contactEmail && <div className="text-xs text-on-surface-variant">{t.contactEmail}</div>}
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{(Object.entries(FEATURE_LABELS) as [keyof Features, string][]).map(([k, label]) => (
<span key={k} className={`inline-block px-1.5 py-0.5 rounded text-xs ${feat[k] ? 'bg-green-50 text-green-700' : 'bg-gray-100 text-gray-400'}`}>
{label}
</span>
))}
</div>
</td>
<td className="px-4 py-3">
<span className="inline-block px-2 py-0.5 rounded text-xs font-semibold bg-gray-100 text-gray-700">
{PLATFORM_LABELS[t.platformType || ''] || t.platformType || '-'}
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<button onClick={() => openEdit(t)} className="text-xs text-secondary hover:underline">编辑</button>
<button onClick={() => setUserManager({ tenantId: t.id, companyName: t.companyName })} className="text-xs text-secondary hover:underline">管理用户</button>
<button onClick={() => handleDelete(t.id)} className="text-xs text-error-red hover:underline">删除</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
{showForm && (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50" onClick={() => setShowForm(false)}>
<div className="bg-white rounded-xl shadow-xl w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
<div className="p-6 border-b border-border-subtle">
<h2 className="text-lg font-bold">{editingId ? '编辑租户' : '新建租户'}</h2>
</div>
<div className="p-6 space-y-6">
{/* 基本信息 */}
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label className="block text-sm font-semibold mb-1">公司名 *</label>
<input type="text" value={form.companyName} onChange={(e) => setForm({ ...form, companyName: e.target.value })}
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
</div>
<div>
<label className="block text-sm font-semibold mb-1">联系人</label>
<input type="text" value={form.contactName} onChange={(e) => setForm({ ...form, contactName: e.target.value })}
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
</div>
<div>
<label className="block text-sm font-semibold mb-1">联系邮箱</label>
<input type="email" value={form.contactEmail} onChange={(e) => setForm({ ...form, contactEmail: e.target.value })}
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
</div>
</div>
{/* 订阅版本 & 状态 */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold mb-1">订阅版本</label>
<select value={form.plan} onChange={(e) => {
const p = e.target.value
setForm({ ...form, plan: p })
const cfg = planConfig?.[p]
if (cfg) { setFeatures(cfg.features || DEFAULT_FEATURES); setQuotas(cfg.quotas || DEFAULT_QUOTAS) }
else { setQuotas(DEFAULT_QUOTAS) }
}}
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors bg-white">
{['basic', 'pro', 'enterprise'].map((k) => {
const cfg = planConfig?.[k]
return <option key={k} value={k}>{cfg ? `${cfg.label} (¥${cfg.price.toLocaleString()}/年)` : PLAN_LABELS[k] || k}</option>
})}
</select>
</div>
<div>
<label className="block text-sm font-semibold mb-1">状态</label>
<select value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value })}
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors bg-white">
<option value="active">正常</option>
<option value="inactive">已停用</option>
<option value="expired">已过期</option>
</select>
</div>
<div>
<label className="block text-sm font-semibold mb-1">有效期起</label>
<input type="date" value={form.validFrom} onChange={(e) => setForm({ ...form, validFrom: e.target.value })}
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
</div>
<div>
<label className="block text-sm font-semibold mb-1">有效期止 *</label>
<input type="date" value={form.validUntil} onChange={(e) => setForm({ ...form, validUntil: e.target.value })}
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
</div>
</div>
{/* 平台对接配置 */}
<div className="border-t border-border-subtle pt-4">
<h3 className="text-sm font-semibold mb-3">平台对接配置</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold mb-1">平台类型</label>
<select value={form.platformType} onChange={(e) => setForm({ ...form, platformType: e.target.value })}
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors bg-white">
<option value="jubian">巨变平台</option>
<option value="custom">自建平台</option>
</select>
</div>
<div>
<label className="block text-sm font-semibold mb-1">平台接口地址</label>
<input type="text" value={form.platformUrl} onChange={(e) => setForm({ ...form, platformUrl: e.target.value })} placeholder="https://api.example.com"
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
</div>
<div className="col-span-2">
<label className="block text-sm font-semibold mb-1">平台 API 密钥</label>
<input type="password" value={form.platformApiKey} onChange={(e) => setForm({ ...form, platformApiKey: e.target.value })} placeholder="用于平台对接的 API 密钥"
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
</div>
</div>
</div>
{!editingId && (
<div className="border-t border-border-subtle pt-4">
<h3 className="text-sm font-semibold mb-3">管理员账号(创建时可选)</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold mb-1">邮箱</label>
<input type="text" value={adminUsername} onChange={(e) => setAdminUsername(e.target.value)} placeholder="admin"
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
</div>
<div>
<label className="block text-sm font-semibold mb-1">密码</label>
<input type="password" value={adminPassword} onChange={(e) => setAdminPassword(e.target.value)} placeholder="至少6位"
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
</div>
<div className="col-span-2">
<label className="block text-sm font-semibold mb-1">姓名</label>
<input type="text" value={adminName} onChange={(e) => setAdminName(e.target.value)} placeholder="可选,默认显示'公司名管理员'"
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
</div>
</div>
</div>
)}
{/* 功能权限 */}
<div className="border-t border-border-subtle pt-4">
<h3 className="text-sm font-semibold mb-3">功能权限</h3>
<div className="grid grid-cols-2 gap-3">
{(Object.entries(FEATURE_LABELS) as [keyof Features, string][]).map(([k, label]) => (
<label key={k} className="flex items-center gap-3 p-3 border border-border-subtle rounded-lg cursor-pointer hover:bg-surface-container-low transition-colors">
<input type="checkbox" checked={features[k]} onChange={() => setFeatures({ ...features, [k]: !features[k] })}
className="w-4 h-4 rounded border-gray-300 text-secondary focus:ring-secondary" />
<span className="text-sm">{label}</span>
</label>
))}
</div>
</div>
{/* 用量配额 */}
<div className="border-t border-border-subtle pt-4">
<h3 className="text-sm font-semibold mb-3">用量配额</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold mb-1">月线索数上限</label>
<input type="number" min={0} value={quotas.monthlyLeads} onChange={(e) => setQuotas({ ...quotas, monthlyLeads: +e.target.value })}
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
</div>
<div>
<label className="block text-sm font-semibold mb-1">月生图数上限</label>
<input type="number" min={0} value={quotas.monthlyImages} onChange={(e) => setQuotas({ ...quotas, monthlyImages: +e.target.value })}
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
</div>
<div>
<label className="block text-sm font-semibold mb-1">月商品上架上限</label>
<input type="number" min={0} value={quotas.monthlyProducts} onChange={(e) => setQuotas({ ...quotas, monthlyProducts: +e.target.value })}
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
</div>
<div>
<label className="block text-sm font-semibold mb-1">月比价次数上限</label>
<input type="number" min={0} value={quotas.monthlyComparisons} onChange={(e) => setQuotas({ ...quotas, monthlyComparisons: +e.target.value })}
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
</div>
<div>
<label className="block text-sm font-semibold mb-1">最大用户数</label>
<input type="number" min={1} value={quotas.maxUsers} onChange={(e) => setQuotas({ ...quotas, maxUsers: +e.target.value })}
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
</div>
</div>
</div>
</div>
<div className="p-6 border-t border-border-subtle flex items-center justify-end gap-3">
<button onClick={() => setShowForm(false)} className="px-4 py-2 text-sm text-on-surface-variant hover:text-gray-900 transition-colors" disabled={saving}>取消</button>
<button onClick={handleSave} disabled={saving}
className="bg-secondary text-white px-5 py-2 rounded-lg text-sm font-semibold hover:opacity-90 transition-opacity disabled:opacity-50">
{saving ? '保存中...' : (editingId ? '更新' : '创建')}
</button>
</div>
</div>
</div>
)}
{/* User Manager Modal */}
{userManager && <TenantUserManager
tenantId={userManager.tenantId}
companyName={userManager.companyName}
onClose={() => setUserManager(null)}
showToast={showToast}
/>}
</div>
)
}
// ====================== Tenant User Manager ======================
function TenantUserManager({ tenantId, companyName, onClose, showToast }: { tenantId: string; companyName: string; onClose: () => void; showToast: (m: string) => void }) {
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [form, setForm] = useState({ username: '', password: '', name: '' })
const [saving, setSaving] = useState(false)
const [resetPwd, setResetPwd] = useState<{ id: string; name: string } | null>(null)
const [newPwd, setNewPwd] = useState('')
const loadUsers = useCallback(async () => {
try {
const res = await fetch(`${API}/users`, { headers: authHeaders() })
if (res.ok) {
const all: User[] = await res.json()
setUsers(all.filter((u) => u.tenantId === tenantId))
}
} catch { showToast('加载失败')
} finally { setLoading(false) }
}, [tenantId, showToast])
useEffect(() => { loadUsers() }, [loadUsers])
const handleCreate = async () => {
if (!form.username || !form.password) { showToast('请填写用户名和密码'); return }
if (form.password.length < 6) { showToast('密码至少6位'); return }
setSaving(true)
try {
const res = await fetch(`${API}/users`, {
method: 'POST', headers: authHeaders(),
body: JSON.stringify({ ...form, tenantId }),
})
if (res.ok) { showToast('创建成功'); setShowForm(false); setForm({ username: '', password: '', name: '' }); loadUsers() }
else { const err = await res.json(); showToast(err.message || '创建失败') }
} catch { showToast('创建失败')
} finally { setSaving(false) }
}
const handleResetPwd = async () => {
if (!resetPwd || !newPwd || newPwd.length < 6) { showToast('密码至少6位'); return }
setSaving(true)
try {
const res = await fetch(`${API}/users/${resetPwd.id}/reset-password`, {
method: 'POST', headers: authHeaders(), body: JSON.stringify({ password: newPwd }),
})
if (res.ok) { showToast('密码重置成功'); setResetPwd(null); setNewPwd(''); loadUsers() } else showToast('重置失败')
} catch { showToast('重置失败')
} finally { setSaving(false) }
}
const handleToggleActive = async (id: string) => {
const res = await fetch(`${API}/users/${id}/toggle-active`, { method: 'POST', headers: authHeaders() })
if (res.ok) loadUsers(); else showToast('操作失败')
}
return (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-white rounded-xl shadow-xl w-full max-w-3xl mx-4 max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
<div className="p-6 border-b border-border-subtle flex items-center justify-between">
<div>
<h2 className="text-lg font-bold">用户管理</h2>
<p className="text-sm text-on-surface-variant mt-0.5">{companyName}</p>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">&times;</button>
</div>
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<span className="text-sm text-on-surface-variant">共 {users.length} 个用户</span>
<button onClick={() => setShowForm(true)} className="bg-secondary text-white px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90 transition-opacity">+ 新增用户</button>
</div>
{loading ? (
<div className="text-center py-8 text-on-surface-variant text-sm">加载中...</div>
) : users.length === 0 ? (
<div className="text-center py-8 text-on-surface-variant text-sm">暂无用户</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="bg-surface-container-low text-left text-on-surface-variant">
<th className="px-4 py-3 font-semibold">姓名</th>
<th className="px-4 py-3 font-semibold">用户名</th>
<th className="px-4 py-3 font-semibold">角色</th>
<th className="px-4 py-3 font-semibold">状态</th>
<th className="px-4 py-3 font-semibold">创建时间</th>
<th className="px-4 py-3 font-semibold">操作</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className="border-t border-border-subtle hover:bg-surface-container-low/50 transition-colors">
<td className="px-4 py-3 font-semibold">{u.name || '-'}</td>
<td className="px-4 py-3 text-on-surface-variant">{u.username || u.email}</td>
<td className="px-4 py-3">
<span className={`inline-block px-2 py-0.5 rounded text-xs font-semibold ${u.role === 'admin' ? 'bg-purple-50 text-purple-700' : 'bg-gray-100 text-gray-700'}`}>
{u.role === 'admin' ? '管理员' : '普通用户'}
</span>
</td>
<td className="px-4 py-3">
<span className={`inline-block px-2 py-0.5 rounded text-xs font-semibold ${u.isActive ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'}`}>
{u.isActive ? '正常' : '已禁用'}
</span>
</td>
<td className="px-4 py-3 text-xs text-on-surface-variant">{u.createdAt ? new Date(u.createdAt).toLocaleDateString('zh-CN') : '-'}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<button onClick={() => setResetPwd({ id: u.id, name: u.name || u.username || u.email || '' })} className="text-xs text-secondary hover:underline">重置密码</button>
<button onClick={() => handleToggleActive(u.id)} className={`text-xs hover:underline ${u.isActive ? 'text-red-600' : 'text-green-600'}`}>
{u.isActive ? '禁用' : '启用'}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Create user form */}
{showForm && (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-[60]" onClick={() => setShowForm(false)}>
<div className="bg-white rounded-xl shadow-xl w-full max-w-md mx-4" onClick={(e) => e.stopPropagation()}>
<div className="p-6 border-b border-border-subtle">
<h2 className="text-lg font-bold">新增用户 — {companyName}</h2>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-semibold mb-1">用户名 *</label>
<input type="text" value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} placeholder="请输入用户名"
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
</div>
<div>
<label className="block text-sm font-semibold mb-1">密码 *</label>
<input type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} placeholder="至少6位"
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
</div>
<div>
<label className="block text-sm font-semibold mb-1">姓名</label>
<input type="text" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="可选"
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
</div>
</div>
<div className="p-6 border-t border-border-subtle flex items-center justify-end gap-3">
<button onClick={() => setShowForm(false)} className="px-4 py-2 text-sm text-on-surface-variant hover:text-gray-900 transition-colors" disabled={saving}>取消</button>
<button onClick={handleCreate} disabled={saving}
className="bg-secondary text-white px-5 py-2 rounded-lg text-sm font-semibold hover:opacity-90 transition-opacity disabled:opacity-50">
{saving ? '创建中...' : '创建'}
</button>
</div>
</div>
</div>
)}
{/* Reset password */}
{resetPwd && (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-[60]" onClick={() => setResetPwd(null)}>
<div className="bg-white rounded-xl shadow-xl w-full max-w-sm mx-4" onClick={(e) => e.stopPropagation()}>
<div className="p-6 border-b border-border-subtle">
<h2 className="text-lg font-bold">重置密码</h2>
<p className="text-sm text-on-surface-variant mt-1">用户:{resetPwd.name}</p>
</div>
<div className="p-6">
<label className="block text-sm font-semibold mb-1">新密码</label>
<input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} placeholder="至少6位"
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
</div>
<div className="p-6 border-t border-border-subtle flex items-center justify-end gap-3">
<button onClick={() => setResetPwd(null)} className="px-4 py-2 text-sm text-on-surface-variant hover:text-gray-900 transition-colors">取消</button>
<button onClick={handleResetPwd} disabled={saving}
className="bg-secondary text-white px-5 py-2 rounded-lg text-sm font-semibold hover:opacity-90 transition-opacity disabled:opacity-50">
{saving ? '重置中...' : '确认重置'}
</button>
</div>
</div>
</div>
)}
</div>
</div>
)
}
// ====================== User Tab ======================
function UserTab({ showToast }: { showToast: (m: string) => void }) {
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [resetPwd, setResetPwd] = useState<{ id: string; name: string } | null>(null)
const [newPwd, setNewPwd] = useState('')
const [saving, setSaving] = useState(false)
const [showUserForm, setShowUserForm] = useState(false)
const [userForm, setUserForm] = useState({ username: '', password: '', name: '', tenantId: '' })
const [tenants, setTenants] = useState<Tenant[]>([])
const [userSaving, setUserSaving] = useState(false)
const loadUsers = useCallback(async () => {
try {
const res = await fetch(`${API}/users`, { headers: authHeaders() })
if (res.ok) setUsers(await res.json())
} catch { showToast('加载失败')
} finally { setLoading(false) }
}, [showToast])
useEffect(() => { loadUsers() }, [loadUsers])
useEffect(() => {
fetch(`${API}/tenants`, { headers: authHeaders() }).then(r => r.ok && r.json()).then(setTenants)
}, [])
const handleCreateUser = async () => {
if (!userForm.username || !userForm.password) { showToast('请填写用户名和密码'); return }
if (userForm.password.length < 6) { showToast('密码至少6位'); return }
setUserSaving(true)
try {
const res = await fetch(`${API}/users`, {
method: 'POST', headers: authHeaders(), body: JSON.stringify(userForm),
})
if (res.ok) { showToast('创建成功'); setShowUserForm(false); setUserForm({ username: '', password: '', name: '', tenantId: '' }); loadUsers() }
else { const err = await res.json(); showToast(err.message || '创建失败') }
} catch { showToast('创建失败')
} finally { setUserSaving(false) }
}
const filtered = users.filter((u) =>
[u.username, u.email, u.name, u.tenant?.companyName].some((v) => v?.toLowerCase().includes(search.toLowerCase()))
)
const handleResetPwd = async () => {
if (!resetPwd || !newPwd || newPwd.length < 6) { showToast('密码至少6位'); return }
setSaving(true)
try {
const res = await fetch(`${API}/users/${resetPwd.id}/reset-password`, {
method: 'POST', headers: authHeaders(), body: JSON.stringify({ password: newPwd }),
})
if (res.ok) { showToast('密码重置成功'); setResetPwd(null); setNewPwd('') } else showToast('重置失败')
} catch { showToast('重置失败')
} finally { setSaving(false) }
}
const handleToggleActive = async (id: string) => {
const res = await fetch(`${API}/users/${id}/toggle-active`, { method: 'POST', headers: authHeaders() })
if (res.ok) loadUsers(); else showToast('操作失败')
}
return (
<div>
<div className="flex items-center justify-between mb-4">
<input type="text" placeholder="搜索邮箱、姓名、所属租户..." value={search} onChange={(e) => setSearch(e.target.value)}
className="w-full max-w-md px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
<button onClick={() => setShowUserForm(true)} className="ml-4 bg-secondary text-white px-5 py-2.5 rounded-lg text-sm font-semibold hover:opacity-90 transition-opacity whitespace-nowrap">
+ 新增用户
</button>
</div>
<div className="bg-white rounded-xl border border-border-subtle overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-surface-container-low text-left text-on-surface-variant">
<th className="px-4 py-3 font-semibold">姓名</th>
<th className="px-4 py-3 font-semibold">邮箱</th>
<th className="px-4 py-3 font-semibold">角色</th>
<th className="px-4 py-3 font-semibold">所属租户</th>
<th className="px-4 py-3 font-semibold">状态</th>
<th className="px-4 py-3 font-semibold">操作</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={6} className="px-4 py-12 text-center text-on-surface-variant">加载中...</td></tr>
) : filtered.length === 0 ? (
<tr><td colSpan={6} className="px-4 py-12 text-center text-on-surface-variant">暂无用户数据</td></tr>
) : filtered.map((u) => (
<tr key={u.id} className="border-t border-border-subtle hover:bg-surface-container-low/50 transition-colors">
<td className="px-4 py-3 font-semibold">{u.name || '-'}</td>
<td className="px-4 py-3 text-on-surface-variant">{u.username || u.email}</td>
<td className="px-4 py-3">
<span className={`inline-block px-2 py-0.5 rounded text-xs font-semibold ${u.role === 'admin' ? 'bg-purple-50 text-purple-700' : 'bg-gray-100 text-gray-700'}`}>
{u.role === 'admin' ? '管理员' : '普通用户'}
</span>
</td>
<td className="px-4 py-3">{u.tenant?.companyName || '-'}</td>
<td className="px-4 py-3">
<span className={`inline-block px-2 py-0.5 rounded text-xs font-semibold ${u.isActive ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'}`}>
{u.isActive ? '正常' : '已禁用'}
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<button onClick={() => setResetPwd({ id: u.id, name: u.name || u.username || u.email || '' })} className="text-xs text-secondary hover:underline">重置密码</button>
<button onClick={() => handleToggleActive(u.id)} className={`text-xs hover:underline ${u.isActive ? 'text-red-600' : 'text-green-600'}`}>
{u.isActive ? '禁用' : '启用'}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{showUserForm && (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50" onClick={() => setShowUserForm(false)}>
<div className="bg-white rounded-xl shadow-xl w-full max-w-md mx-4" onClick={(e) => e.stopPropagation()}>
<div className="p-6 border-b border-border-subtle">
<h2 className="text-lg font-bold">新增用户</h2>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-semibold mb-1">用户名 *</label>
<input type="text" value={userForm.username} onChange={(e) => setUserForm({ ...userForm, username: e.target.value })} placeholder="请输入用户名"
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
</div>
<div>
<label className="block text-sm font-semibold mb-1">密码 *</label>
<input type="password" value={userForm.password} onChange={(e) => setUserForm({ ...userForm, password: e.target.value })} placeholder="至少6位"
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
</div>
<div>
<label className="block text-sm font-semibold mb-1">姓名</label>
<input type="text" value={userForm.name} onChange={(e) => setUserForm({ ...userForm, name: e.target.value })} placeholder="可选"
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
</div>
<div>
<label className="block text-sm font-semibold mb-1">所属租户</label>
<select value={userForm.tenantId} onChange={(e) => setUserForm({ ...userForm, tenantId: e.target.value })}
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors bg-white">
<option value="">不归属租户</option>
{tenants.map((t) => (
<option key={t.id} value={t.id}>{t.companyName}</option>
))}
</select>
</div>
</div>
<div className="p-6 border-t border-border-subtle flex items-center justify-end gap-3">
<button onClick={() => setShowUserForm(false)} className="px-4 py-2 text-sm text-on-surface-variant hover:text-gray-900 transition-colors" disabled={userSaving}>取消</button>
<button onClick={handleCreateUser} disabled={userSaving}
className="bg-secondary text-white px-5 py-2 rounded-lg text-sm font-semibold hover:opacity-90 transition-opacity disabled:opacity-50">
{userSaving ? '创建中...' : '创建'}
</button>
</div>
</div>
</div>
)}
{resetPwd && (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50" onClick={() => setResetPwd(null)}>
<div className="bg-white rounded-xl shadow-xl w-full max-w-sm mx-4" onClick={(e) => e.stopPropagation()}>
<div className="p-6 border-b border-border-subtle">
<h2 className="text-lg font-bold">重置密码</h2>
<p className="text-sm text-on-surface-variant mt-1">用户:{resetPwd.name}</p>
</div>
<div className="p-6">
<label className="block text-sm font-semibold mb-1">新密码</label>
<input type="password" value={newPwd} onChange={(e) => setNewPwd(e.target.value)} placeholder="至少6位"
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
</div>
<div className="p-6 border-t border-border-subtle flex items-center justify-end gap-3">
<button onClick={() => setResetPwd(null)} className="px-4 py-2 text-sm text-on-surface-variant hover:text-gray-900 transition-colors">取消</button>
<button onClick={handleResetPwd} disabled={saving}
className="bg-secondary text-white px-5 py-2 rounded-lg text-sm font-semibold hover:opacity-90 transition-opacity disabled:opacity-50">
{saving ? '重置中...' : '确认重置'}
</button>
</div>
</div>
</div>
)}
</div>
)
}
// ====================== Stats Tab ======================
function StatsTab() {
const [stats, setStats] = useState<Stats | null>(null)
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState('all')
const [selectedTenant, setSelectedTenant] = useState<Stats['perTenant'][0] | null>(null)
useEffect(() => {
(async () => {
try {
const res = await fetch(`${API}/stats`, { headers: authHeaders() })
if (res.ok) setStats(await res.json())
} catch {} finally { setLoading(false) }
})()
}, [])
if (loading) return <div className="text-center py-12 text-on-surface-variant">加载中...</div>
if (!stats) return <div className="text-center py-12 text-on-surface-variant">暂无数据</div>
const cards = [
{ label: '租户总数', value: stats.overview.tenantCount, color: 'bg-blue-50 text-blue-700' },
{ label: '用户总数', value: stats.overview.userCount, color: 'bg-purple-50 text-purple-700' },
{ label: '商品数', value: stats.overview.productCount, color: 'bg-green-50 text-green-700' },
{ label: '线索数', value: stats.overview.leadCount, color: 'bg-orange-50 text-orange-700' },
{ label: '生成图片', value: stats.overview.imageCount, color: 'bg-pink-50 text-pink-700' },
{ label: '询价数', value: stats.overview.inquiryCount, color: 'bg-teal-50 text-teal-700' },
]
const METRICS: { key: keyof Stats['perTenant'][0]['usage']; label: string; quotaKey: keyof Stats['perTenant'][0]['quotas']; desc: string }[] = [
{ key: 'users', label: '用户数', quotaKey: 'maxUsers', desc: '已用 / 上限' },
{ key: 'products', label: '商品上架', quotaKey: 'monthlyProducts', desc: '已上架 / 月配额' },
{ key: 'leads', label: '月线索', quotaKey: 'monthlyLeads', desc: '本月 / 月配额' },
{ key: 'images', label: '月生图', quotaKey: 'monthlyImages', desc: '本月 / 月配额' },
{ key: 'comparisons', label: '月比价', quotaKey: 'monthlyComparisons', desc: '本月 / 月配额' },
]
function UsageBar({ usage, quota }: { usage: number; quota: number }) {
if (quota === 0) return <span className="text-xs text-gray-400">未开通</span>
const pct = Math.min(usage / quota, 1)
const color = pct >= 1 ? 'bg-red-500' : pct >= 0.8 ? 'bg-yellow-500' : 'bg-green-500'
return (
<div className="flex items-center gap-2">
<div className="flex-1 bg-gray-100 rounded-full h-2">
<div className={`${color} h-2 rounded-full transition-all`} style={{ width: `${Math.min(pct * 100, 100)}%` }} />
</div>
<span className="text-xs font-semibold whitespace-nowrap min-w-[56px] text-right">
{usage} / {quota}
</span>
</div>
)
}
const filtered = stats.perTenant.filter((t) => {
if (statusFilter !== 'all' && t.status !== statusFilter) return false
if (search && !t.companyName.toLowerCase().includes(search.toLowerCase())) return false
return true
})
// Detail view
if (selectedTenant) {
const t = selectedTenant
return (
<div>
<div className="flex items-center gap-3 mb-6">
<button onClick={() => setSelectedTenant(null)}
className="text-sm text-secondary hover:underline flex items-center gap-1">
← 返回列表
</button>
</div>
<div className="bg-white rounded-xl border border-border-subtle overflow-hidden">
<div className="px-5 py-4 border-b border-border-subtle flex items-center gap-3">
<span className="font-semibold text-lg">{t.companyName}</span>
<span className={`inline-block px-2 py-0.5 rounded text-xs font-semibold ${PLAN_COLORS[t.plan] || ''}`}>{PLAN_LABELS[t.plan] || t.plan}</span>
<span className={`inline-block px-2 py-0.5 rounded text-xs font-semibold ${STATUS_COLORS[t.status] || ''}`}>{STATUS_LABELS[t.status] || t.status}</span>
<span className="text-xs text-on-surface-variant ml-auto">
有效期至 {t.validUntil ? new Date(t.validUntil).toLocaleDateString('zh-CN') : '-'}
</span>
</div>
<div className="p-6 grid grid-cols-2 gap-6">
{METRICS.map((m) => (
<div key={m.key}>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-semibold">{m.label}</span>
<span className="text-xs text-on-surface-variant">{m.desc}</span>
</div>
<UsageBar usage={t.usage[m.key]} quota={t.quotas[m.quotaKey]} />
</div>
))}
</div>
</div>
</div>
)
}
// List view
return (
<div>
<div className="grid grid-cols-3 gap-4 mb-6">
{cards.map((c) => (
<div key={c.label} className="bg-white rounded-xl border border-border-subtle p-5">
<p className="text-sm text-on-surface-variant">{c.label}</p>
<p className={`text-3xl font-bold mt-1 ${c.color.split(' ')[1]}`}>{c.value}</p>
</div>
))}
</div>
<div className="bg-white rounded-xl border border-border-subtle overflow-hidden">
<div className="p-4 border-b border-border-subtle flex items-center gap-3">
<input type="text" placeholder="搜索企业名称..." value={search} onChange={(e) => setSearch(e.target.value)}
className="max-w-xs px-3 py-1.5 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-1.5 border border-border-subtle rounded-lg text-sm outline-none bg-white">
<option value="all">全部状态</option>
<option value="active">正常</option>
<option value="inactive">已停用</option>
<option value="expired">已过期</option>
</select>
<span className="text-xs text-on-surface-variant ml-auto">共 {filtered.length} 个租户</span>
</div>
<table className="w-full text-sm">
<thead>
<tr className="bg-surface-container-low text-left text-on-surface-variant">
<th className="px-4 py-3 font-semibold">企业名称</th>
<th className="px-4 py-3 font-semibold">版本</th>
<th className="px-4 py-3 font-semibold">状态</th>
<th className="px-4 py-3 font-semibold">有效期</th>
<th className="px-4 py-3 font-semibold">用户</th>
<th className="px-4 py-3 font-semibold">商品上架</th>
<th className="px-4 py-3 font-semibold">月线索</th>
<th className="px-4 py-3 font-semibold">月生图</th>
<th className="px-4 py-3 font-semibold">月比价</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 ? (
<tr><td colSpan={9} className="px-4 py-12 text-center text-on-surface-variant">无匹配租户</td></tr>
) : filtered.map((t) => (
<tr key={t.id} className="border-t border-border-subtle hover:bg-surface-container-low/50 transition-colors cursor-pointer"
onClick={() => setSelectedTenant(t)}>
<td className="px-4 py-3 font-semibold">{t.companyName}</td>
<td className="px-4 py-3">
<span className={`inline-block px-2 py-0.5 rounded text-xs font-semibold ${PLAN_COLORS[t.plan] || ''}`}>{PLAN_LABELS[t.plan] || t.plan}</span>
</td>
<td className="px-4 py-3">
<span className={`inline-block px-2 py-0.5 rounded text-xs font-semibold ${STATUS_COLORS[t.status] || ''}`}>{STATUS_LABELS[t.status] || t.status}</span>
</td>
<td className="px-4 py-3 text-xs">{t.validUntil ? new Date(t.validUntil).toLocaleDateString('zh-CN') : '-'}</td>
<td className="px-4 py-3"><UsageBar usage={t.usage.users} quota={t.quotas.maxUsers} /></td>
<td className="px-4 py-3"><UsageBar usage={t.usage.products} quota={t.quotas.monthlyProducts} /></td>
<td className="px-4 py-3"><UsageBar usage={t.usage.leads} quota={t.quotas.monthlyLeads} /></td>
<td className="px-4 py-3"><UsageBar usage={t.usage.images} quota={t.quotas.monthlyImages} /></td>
<td className="px-4 py-3"><UsageBar usage={t.usage.comparisons} quota={t.quotas.monthlyComparisons} /></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
// ====================== Config Tab ======================
interface Integration {
id: string; name: string; type: string; endpoint: string; appId: string | null; apiKey: string; customerCode: string
status: string; tools: string | null; lastConnected: string | null; error: string | null
createdAt: string; updatedAt: string
}
const INTEGRATION_API = `${API_BASE}/api/integrations`
function ConfigTab({ showToast }: { showToast: (m: string) => void }) {
const [configs, setConfigs] = useState<SystemConfig[]>([])
const [loading, setLoading] = useState(true)
const [editing, setEditing] = useState<SystemConfig | null>(null)
const [editValue, setEditValue] = useState('')
const [newConfig, setNewConfig] = useState({ key: '', value: '', group: 'system', description: '' })
const [showNew, setShowNew] = useState(false)
const [saving, setSaving] = useState(false)
const [integrations, setIntegrations] = useState<Integration[]>([])
const [integrationLoading, setIntegrationLoading] = useState(true)
const [showIntegrationForm, setShowIntegrationForm] = useState(false)
const [editingIntegration, setEditingIntegration] = useState<Integration | null>(null)
const [integrationForm, setIntegrationForm] = useState({
name: '', endpoint: 'http://100.111.84.16:3002/mcp', appId: '', apiKey: '', customerCode: ''
})
const [testingId, setTestingId] = useState<string | null>(null)
const [connectingId, setConnectingId] = useState<string | null>(null)
const DEVBOX_ADMIN_APPLY_URL = 'http://100.111.84.16:3000/openapi/app'
const loadConfigs = useCallback(async () => {
try {
const res = await fetch(`${API}/configs`, { headers: authHeaders() })
if (res.ok) setConfigs(await res.json())
} catch { showToast('加载失败')
} finally { setLoading(false) }
}, [showToast])
const loadIntegrations = useCallback(async () => {
try {
const res = await fetch(INTEGRATION_API, { headers: authHeaders() })
if (res.ok) setIntegrations(await res.json())
} catch { showToast('加载对接配置失败')
} finally { setIntegrationLoading(false) }
}, [showToast])
useEffect(() => { loadConfigs(); loadIntegrations() }, [loadConfigs, loadIntegrations])
const grouped = configs.reduce((acc, c) => {
const subGroup = c.group === 'platform' ? `platform-${c.key.startsWith('jubian') ? 'jubian' : 'custom'}` : c.group
if (!acc[subGroup]) acc[subGroup] = []
acc[subGroup].push(c)
return acc
}, {} as Record<string, SystemConfig[]>)
const GROUP_LABELS: Record<string, string> = {
ai: 'AI 模型', 'platform-jubian': '巨变平台配置', 'platform-custom': '自建平台配置', system: '系统参数',
}
const handleSaveEdit = async () => {
if (!editing) return
setSaving(true)
try {
const res = await fetch(`${API}/configs`, {
method: 'POST', headers: authHeaders(),
body: JSON.stringify({ key: editing.key, value: editValue, group: editing.group, description: editing.description }),
})
if (res.ok) { showToast('更新成功'); setEditing(null); loadConfigs() } else showToast('更新失败')
} catch { showToast('更新失败')
} finally { setSaving(false) }
}
const handleCreate = async () => {
if (!newConfig.key || !newConfig.value) { showToast('请填写键和值'); return }
setSaving(true)
try {
const res = await fetch(`${API}/configs`, {
method: 'POST', headers: authHeaders(),
body: JSON.stringify(newConfig),
})
if (res.ok) { showToast('创建成功'); setShowNew(false); setNewConfig({ key: '', value: '', group: 'system', description: '' }); loadConfigs() }
else showToast('创建失败')
} catch { showToast('创建失败')
} finally { setSaving(false) }
}
const handleDelete = async (id: string) => {
if (!confirm('确定删除此配置?')) return
const res = await fetch(`${API}/configs/${id}`, { method: 'DELETE', headers: authHeaders() })
if (res.ok) { showToast('删除成功'); loadConfigs() } else showToast('删除失败')
}
const handleSaveIntegration = async () => {
if (!integrationForm.name || !integrationForm.endpoint) { showToast('请填写名称和 MCP 地址'); return }
if (!integrationForm.appId || !integrationForm.apiKey) { showToast('请填写 appId 和 appSecret(先在 devbox 运营后台申请)'); return }
setSaving(true)
try {
const body = { ...integrationForm, type: 'mcp' }
const res = await fetch(editingIntegration ? `${INTEGRATION_API}/${editingIntegration.id}` : INTEGRATION_API, {
method: editingIntegration ? 'PUT' : 'POST', headers: authHeaders(), body: JSON.stringify(body),
})
if (res.ok) { showToast(editingIntegration ? '更新成功' : '创建成功'); setShowIntegrationForm(false); setEditingIntegration(null); loadIntegrations() }
else showToast('保存失败')
} catch { showToast('保存失败')
} finally { setSaving(false) }
}
const handleDeleteIntegration = async (id: string) => {
if (!confirm('确定删除此对接配置?')) return
const res = await fetch(`${INTEGRATION_API}/${id}`, { method: 'DELETE', headers: authHeaders() })
if (res.ok) { showToast('删除成功'); loadIntegrations() } else showToast('删除失败')
}
const handleTestIntegration = async (id: string) => {
setTestingId(id)
try {
const res = await fetch(`${INTEGRATION_API}/${id}/connect`, { method: 'POST', headers: authHeaders() })
const data = await res.json()
if (res.ok) { showToast(`测试成功!发现 ${data.tools?.length || 0} 个工具`); loadIntegrations() }
else showToast(data.error || data.message || '测试失败')
} catch { showToast('测试失败')
} finally { setTestingId(null) }
}
const handleConnectIntegration = async (id: string) => {
setConnectingId(id)
try {
const res = await fetch(`${INTEGRATION_API}/${id}/connect`, { method: 'POST', headers: authHeaders() })
if (res.ok) { showToast('连接成功'); loadIntegrations() } else showToast('连接失败')
} catch { showToast('连接失败')
} finally { setConnectingId(null) }
}
const handleDisconnectIntegration = async (id: string) => {
try {
const res = await fetch(`${INTEGRATION_API}/${id}/disconnect`, { method: 'POST', headers: authHeaders() })
if (res.ok) { showToast('已断开'); loadIntegrations() } else showToast('断开失败')
} catch { showToast('断开失败') }
}
if (loading) return <div className="text-center py-12 text-on-surface-variant">加载中...</div>
return (
<div>
<div className="flex items-center justify-between mb-4">
<p className="text-sm text-on-surface-variant">平台全局参数配置,修改后立即生效</p>
<button onClick={() => setShowNew(true)} className="bg-secondary text-white px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90 transition-opacity">+ 新增配置</button>
</div>
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold text-on-surface-variant">MCP 对接配置</h3>
<button onClick={() => { setEditingIntegration(null); setIntegrationForm({ name: '', endpoint: 'http://100.111.84.16:3002/mcp', appId: '', apiKey: '', customerCode: '' }); setShowIntegrationForm(true) }}
className="bg-secondary text-white px-3 py-1.5 rounded-lg text-xs font-semibold hover:opacity-90 transition-opacity">
+ 新增对接
</button>
</div>
<div className="bg-white rounded-xl border border-border-subtle overflow-hidden">
{integrationLoading ? (
<div className="px-4 py-8 text-center text-on-surface-variant text-sm">加载中...</div>
) : integrations.length === 0 ? (
<div className="px-4 py-8 text-center text-on-surface-variant text-sm">暂无对接配置,点击"新增对接"添加</div>
) : (
<table className="w-full text-sm">
<thead>
<tr className="bg-surface-container-low text-left text-on-surface-variant">
<th className="px-4 py-3 font-semibold">名称</th>
<th className="px-4 py-3 font-semibold">MCP 地址</th>
<th className="px-4 py-3 font-semibold">客户编码</th>
<th className="px-4 py-3 font-semibold">状态</th>
<th className="px-4 py-3 font-semibold">工具数</th>
<th className="px-4 py-3 font-semibold">最后连接</th>
<th className="px-4 py-3 font-semibold">操作</th>
</tr>
</thead>
<tbody>
{integrations.map((item) => {
let toolsList: any[] = []
try {
if (item.tools) toolsList = JSON.parse(item.tools)
} catch { /* ignore */ }
return (
<React.Fragment key={item.id}>
<tr className="border-t border-border-subtle hover:bg-surface-container-low/50 transition-colors">
<td className="px-4 py-3 font-semibold">{item.name}</td>
<td className="px-4 py-3 font-mono text-xs text-on-surface-variant truncate max-w-[200px]">{item.endpoint}</td>
<td className="px-4 py-3">{item.customerCode || '-'}</td>
<td className="px-4 py-3">
<span className={`inline-block px-2 py-0.5 rounded text-xs font-semibold ${
item.status === 'active' ? 'bg-green-50 text-green-700' :
item.status === 'error' ? 'bg-red-50 text-red-700' : 'bg-gray-100 text-gray-600'
}`}>
{item.status === 'active' ? '已连接' : item.status === 'error' ? '错误' : '未连接'}
</span>
</td>
<td className="px-4 py-3 text-xs">{toolsList.length}</td>
<td className="px-4 py-3 text-xs text-on-surface-variant">
{item.lastConnected ? new Date(item.lastConnected).toLocaleString('zh-CN') : '-'}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<button onClick={() => handleTestIntegration(item.id)} disabled={testingId === item.id}
className="text-xs text-secondary hover:underline disabled:opacity-50">
{testingId === item.id ? '测试中...' : '测试'}
</button>
{item.status === 'active' && (
<button onClick={() => handleDisconnectIntegration(item.id)} className="text-xs text-orange-600 hover:underline">断开</button>
)}
{item.status !== 'active' && (
<button onClick={() => handleConnectIntegration(item.id)} disabled={connectingId === item.id}
className="text-xs text-green-600 hover:underline disabled:opacity-50">
{connectingId === item.id ? '连接中...' : '连接'}
</button>
)}
<button onClick={() => {
setEditingIntegration(item)
setIntegrationForm({ name: item.name, endpoint: item.endpoint, appId: item.appId || '', apiKey: '', customerCode: item.customerCode || '' })
setShowIntegrationForm(true)
}} className="text-xs text-secondary hover:underline">编辑</button>
<button onClick={() => handleDeleteIntegration(item.id)} className="text-xs text-error-red hover:underline">删除</button>
</div>
</td>
</tr>
{toolsList.length > 0 && (
<tr className="bg-surface-container-low/30">
<td colSpan={7} className="px-4 py-2 text-xs">
<div className="flex flex-wrap gap-1.5">
<span className="text-on-surface-variant font-medium">已加载 Tools:</span>
{toolsList.map((t: any) => (
<span key={t.name} className="inline-block px-2 py-0.5 bg-blue-50 text-blue-700 rounded font-mono"
title={t.description || t.name}>
{t.name}
</span>
))}
</div>
{item.error && (
<div className="mt-1 text-error-red">最近错误: {item.error}</div>
)}
</td>
</tr>
)}
</React.Fragment>
)
})}
</tbody>
</table>
)}
</div>
</div>
{Object.entries(grouped).map(([group, items]) => (
<div key={group} className="mb-6">
<h3 className="text-sm font-semibold text-on-surface-variant mb-2">{GROUP_LABELS[group] || CONFIG_GROUPS[group] || group}</h3>
<div className="bg-white rounded-xl border border-border-subtle overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-surface-container-low text-left text-on-surface-variant">
<th className="px-4 py-3 font-semibold">配置键</th>
<th className="px-4 py-3 font-semibold">值</th>
<th className="px-4 py-3 font-semibold">说明</th>
<th className="px-4 py-3 font-semibold">操作</th>
</tr>
</thead>
<tbody>
{items.map((c) => (
<tr key={c.id} className="border-t border-border-subtle">
<td className="px-4 py-3 font-mono text-xs">{c.key}</td>
<td className="px-4 py-3">
{c.value ? (
<span className="font-mono text-xs bg-gray-50 px-2 py-1 rounded max-w-[200px] inline-block truncate">{c.value}</span>
) : <span className="text-xs text-on-surface-variant">未设置</span>}
</td>
<td className="px-4 py-3 text-xs text-on-surface-variant">{c.description || '-'}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<button onClick={() => { setEditing(c); setEditValue(c.value) }} className="text-xs text-secondary hover:underline">编辑</button>
<button onClick={() => handleDelete(c.id)} className="text-xs text-error-red hover:underline">删除</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
))}
{/* Edit modal */}
{editing && (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50" onClick={() => setEditing(null)}>
<div className="bg-white rounded-xl shadow-xl w-full max-w-md mx-4" onClick={(e) => e.stopPropagation()}>
<div className="p-6 border-b border-border-subtle">
<h2 className="text-lg font-bold">编辑配置</h2>
<p className="text-sm text-on-surface-variant mt-1 font-mono">{editing.key}</p>
</div>
<div className="p-6">
<label className="block text-sm font-semibold mb-1">值</label>
<input type="text" value={editValue} onChange={(e) => setEditValue(e.target.value)}
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
{editing.description && <p className="text-xs text-on-surface-variant mt-1">{editing.description}</p>}
</div>
<div className="p-6 border-t border-border-subtle flex items-center justify-end gap-3">
<button onClick={() => setEditing(null)} className="px-4 py-2 text-sm text-on-surface-variant hover:text-gray-900 transition-colors">取消</button>
<button onClick={handleSaveEdit} disabled={saving}
className="bg-secondary text-white px-5 py-2 rounded-lg text-sm font-semibold hover:opacity-90 transition-opacity disabled:opacity-50">
{saving ? '保存中...' : '保存'}
</button>
</div>
</div>
</div>
)}
{showNew && (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50" onClick={() => setShowNew(false)}>
<div className="bg-white rounded-xl shadow-xl w-full max-w-md mx-4" onClick={(e) => e.stopPropagation()}>
<div className="p-6 border-b border-border-subtle"><h2 className="text-lg font-bold">新增配置</h2></div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-semibold mb-1">配置键 *</label>
<input type="text" value={newConfig.key} onChange={(e) => setNewConfig({ ...newConfig, key: e.target.value })}
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" placeholder="例如: llm_model" />
</div>
<div>
<label className="block text-sm font-semibold mb-1">值 *</label>
<input type="text" value={newConfig.value} onChange={(e) => setNewConfig({ ...newConfig, value: e.target.value })}
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
</div>
<div>
<label className="block text-sm font-semibold mb-1">分组</label>
<select value={newConfig.group} onChange={(e) => setNewConfig({ ...newConfig, group: e.target.value })}
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors bg-white">
<option value="ai">AI 模型(大模型、生图等)</option>
<option value="platform">平台对接(API 地址、密钥等)</option>
<option value="system">系统参数(上传限制、超时等)</option>
</select>
</div>
<div>
<label className="block text-sm font-semibold mb-1">说明</label>
<input type="text" value={newConfig.description} onChange={(e) => setNewConfig({ ...newConfig, description: e.target.value })}
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
</div>
</div>
<div className="p-6 border-t border-border-subtle flex items-center justify-end gap-3">
<button onClick={() => setShowNew(false)} className="px-4 py-2 text-sm text-on-surface-variant hover:text-gray-900 transition-colors">取消</button>
<button onClick={handleCreate} disabled={saving}
className="bg-secondary text-white px-5 py-2 rounded-lg text-sm font-semibold hover:opacity-90 transition-opacity disabled:opacity-50">
{saving ? '创建中...' : '创建'}
</button>
</div>
</div>
</div>
)}
{showIntegrationForm && (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50" onClick={() => { setShowIntegrationForm(false); setEditingIntegration(null) }}>
<div className="bg-white rounded-xl shadow-xl w-full max-w-md mx-4" onClick={(e) => e.stopPropagation()}>
<div className="p-6 border-b border-border-subtle">
<h2 className="text-lg font-bold">{editingIntegration ? '编辑对接' : '新增对接'}</h2>
<p className="text-xs text-on-surface-variant mt-1">
APIKey 需先在
<a href={DEVBOX_ADMIN_APPLY_URL} target="_blank" rel="noreferrer" className="text-secondary hover:underline mx-1">devbox B2B 运营后台 → 集成管理 → 应用列表</a>
申请获取
</p>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-semibold mb-1">名称 *</label>
<input type="text" value={integrationForm.name} onChange={(e) => setIntegrationForm({ ...integrationForm, name: e.target.value })}
placeholder="例如: B2B 平台对接"
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
</div>
<div>
<label className="block text-sm font-semibold mb-1">MCP 地址 *</label>
<input type="text" value={integrationForm.endpoint} onChange={(e) => setIntegrationForm({ ...integrationForm, endpoint: e.target.value })}
placeholder="http://100.111.84.16:3002/mcp"
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
</div>
<div>
<label className="block text-sm font-semibold mb-1">appId *</label>
<input type="text" value={integrationForm.appId} onChange={(e) => setIntegrationForm({ ...integrationForm, appId: e.target.value })}
placeholder="例如: app_2a66d12104e64b02a195"
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm font-mono outline-none focus:border-secondary transition-colors" />
<p className="text-xs text-on-surface-variant mt-1">申请后从运营后台"应用列表"详情里复制</p>
</div>
<div>
<label className="block text-sm font-semibold mb-1">appSecret *</label>
<input type="password" value={integrationForm.apiKey} onChange={(e) => setIntegrationForm({ ...integrationForm, apiKey: e.target.value })}
placeholder="申请时一次性返回, 仅显示一次, 请妥善保存"
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
<p className="text-xs text-on-surface-variant mt-1">AES-256-GCM 加密存储于本地, 不会明文返回列表</p>
</div>
<div>
<label className="block text-sm font-semibold mb-1">客户编码 (可选)</label>
<input type="text" value={integrationForm.customerCode} onChange={(e) => setIntegrationForm({ ...integrationForm, customerCode: e.target.value })}
placeholder="例如: test-customer-001 (MCP 会从 app 自动识别, 这里仅做备注)"
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
</div>
</div>
<div className="p-6 border-t border-border-subtle flex items-center justify-end gap-3">
<button onClick={() => { setShowIntegrationForm(false); setEditingIntegration(null) }}
className="px-4 py-2 text-sm text-on-surface-variant hover:text-gray-900 transition-colors">取消</button>
<button onClick={handleSaveIntegration} disabled={saving}
className="bg-secondary text-white px-5 py-2 rounded-lg text-sm font-semibold hover:opacity-90 transition-opacity disabled:opacity-50">
{saving ? '保存中...' : '保存'}
</button>
</div>
</div>
</div>
)}
</div>
)
}
// ====================== Plan Config Tab ======================
interface PlanEntry {
label: string
price: number
description: string
features: Features
quotas: UsageQuotas
platformCount: number
supportLevel: string
}
const PLAN_KEYS = ['basic', 'pro', 'enterprise']
function PlanConfigTab({ showToast }: { showToast: (m: string) => void }) {
const [config, setConfig] = useState<Record<string, PlanEntry> | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [editPlan, setEditPlan] = useState<string | null>(null)
const [editData, setEditData] = useState<PlanEntry | null>(null)
const load = useCallback(async () => {
try {
const res = await fetch(`${API}/plan-config`, { headers: authHeaders() })
if (res.ok) setConfig(await res.json())
} catch { showToast('加载失败')
} finally { setLoading(false) }
}, [showToast])
useEffect(() => { load() }, [load])
const openEdit = (key: string) => {
if (!config) return
setEditPlan(key)
setEditData(JSON.parse(JSON.stringify(config[key])))
}
const handleSave = async () => {
if (!config || !editPlan || !editData) return
const updated = { ...config, [editPlan]: editData }
setSaving(true)
try {
const res = await fetch(`${API}/plan-config`, {
method: 'PUT', headers: authHeaders(),
body: JSON.stringify(updated),
})
if (res.ok) { showToast('保存成功'); setConfig(updated); setEditPlan(null) }
else showToast('保存失败')
} catch { showToast('保存失败')
} finally { setSaving(false) }
}
if (loading) return <div className="text-center py-12 text-on-surface-variant">加载中...</div>
if (!config) return <div className="text-center py-12 text-on-surface-variant">暂无数据</div>
return (
<div>
<div className="flex items-center justify-between mb-4">
<p className="text-sm text-on-surface-variant">配置订阅方案的定价、功能权限和用量配额。修改后创建租户时将自动应用新的默认值。</p>
</div>
<div className="grid grid-cols-3 gap-6">
{PLAN_KEYS.map((key) => {
const p = config[key]
if (!p) return null
return (
<div key={key} className={`bg-white rounded-xl border-2 overflow-hidden ${key === 'pro' ? 'border-secondary' : 'border-border-subtle'}`}>
{key === 'pro' && <div className="bg-secondary text-white text-center text-xs font-semibold py-1">推荐</div>}
<div className="p-5">
<div className="flex items-center justify-between mb-1">
<h3 className="text-lg font-bold">{p.label}</h3>
<span className={`inline-block px-2 py-0.5 rounded text-xs font-semibold ${PLAN_COLORS[key] || ''}`}>{PLAN_LABELS[key]}</span>
</div>
<p className="text-sm text-on-surface-variant mb-4">{p.description}</p>
<div className="mb-4">
<span className="text-3xl font-bold">¥{p.price.toLocaleString()}</span>
<span className="text-sm text-on-surface-variant ml-1">/年</span>
<div className="text-xs text-on-surface-variant mt-1">折合 ¥{Math.round(p.price / 12)}/月</div>
</div>
<div className="space-y-3 text-sm">
<div>
<div className="font-semibold mb-1">功能权限</div>
<div className="flex flex-wrap gap-1">
{(Object.entries(FEATURE_LABELS) as [keyof Features, string][]).map(([fk, fl]) => (
<span key={fk} className={`inline-block px-1.5 py-0.5 rounded text-xs ${p.features[fk] ? 'bg-green-50 text-green-700' : 'bg-gray-100 text-gray-400'}`}>{fl}</span>
))}
</div>
</div>
<div>
<div className="font-semibold mb-1">用量配额</div>
<div className="text-xs text-on-surface-variant space-y-0.5">
<div>月线索: {p.quotas.monthlyLeads >= 99999 ? '不限' : p.quotas.monthlyLeads} 条</div>
<div>月生图: {p.quotas.monthlyImages >= 99999 ? '不限' : p.quotas.monthlyImages} 张</div>
<div>月上架: {p.quotas.monthlyProducts >= 99999 ? '不限' : p.quotas.monthlyProducts} 条</div>
<div>月比价: {p.quotas.monthlyComparisons >= 99999 ? '不限' : p.quotas.monthlyComparisons} 次</div>
<div>用户数: {p.quotas.maxUsers >= 99999 ? '不限' : p.quotas.maxUsers} 人</div>
</div>
</div>
<div className="flex items-center gap-2">
<span className="font-semibold">平台对接:</span>
<span className="text-xs">{p.platformCount >= 999 ? '不限' : p.platformCount} 个平台</span>
</div>
<div className="flex items-center gap-2">
<span className="font-semibold">技术支持:</span>
<span className="text-xs">{p.supportLevel}</span>
</div>
</div>
<button onClick={() => openEdit(key)} className="mt-5 w-full bg-secondary text-white px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90 transition-opacity">
编辑方案
</button>
</div>
</div>
)
})}
</div>
{/* Edit modal */}
{editPlan && editData && (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50" onClick={() => setEditPlan(null)}>
<div className="bg-white rounded-xl shadow-xl w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
<div className="p-6 border-b border-border-subtle">
<h2 className="text-lg font-bold">编辑方案 — {editData.label}</h2>
</div>
<div className="p-6 space-y-5">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold mb-1">方案名称</label>
<input type="text" value={editData.label} onChange={(e) => setEditData({ ...editData, label: e.target.value })}
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
</div>
<div>
<label className="block text-sm font-semibold mb-1">年费 (元)</label>
<input type="number" min={0} value={editData.price} onChange={(e) => setEditData({ ...editData, price: +e.target.value })}
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
</div>
<div className="col-span-2">
<label className="block text-sm font-semibold mb-1">描述</label>
<input type="text" value={editData.description} onChange={(e) => setEditData({ ...editData, description: e.target.value })}
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
</div>
<div>
<label className="block text-sm font-semibold mb-1">对接平台数</label>
<input type="number" min={1} value={editData.platformCount} onChange={(e) => setEditData({ ...editData, platformCount: +e.target.value })}
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
</div>
<div>
<label className="block text-sm font-semibold mb-1">技术支持级别</label>
<input type="text" value={editData.supportLevel} onChange={(e) => setEditData({ ...editData, supportLevel: e.target.value })}
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
</div>
</div>
{/* Features */}
<div className="border-t border-border-subtle pt-4">
<h3 className="text-sm font-semibold mb-3">功能权限</h3>
<div className="grid grid-cols-2 gap-3">
{(Object.entries(FEATURE_LABELS) as [keyof Features, string][]).map(([k, label]) => (
<label key={k} className="flex items-center gap-3 p-3 border border-border-subtle rounded-lg cursor-pointer hover:bg-surface-container-low transition-colors">
<input type="checkbox" checked={editData.features[k]} onChange={() => setEditData({ ...editData, features: { ...editData.features, [k]: !editData.features[k] } })}
className="w-4 h-4 rounded border-gray-300 text-secondary focus:ring-secondary" />
<span className="text-sm">{label}</span>
</label>
))}
</div>
</div>
{/* Quotas */}
<div className="border-t border-border-subtle pt-4">
<h3 className="text-sm font-semibold mb-3">用量配额</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-semibold mb-1">月线索数上限(0=禁用,99999+=不限)</label>
<input type="number" min={0} value={editData.quotas.monthlyLeads} onChange={(e) => setEditData({ ...editData, quotas: { ...editData.quotas, monthlyLeads: +e.target.value } })}
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
</div>
<div>
<label className="block text-sm font-semibold mb-1">月生图数上限</label>
<input type="number" min={0} value={editData.quotas.monthlyImages} onChange={(e) => setEditData({ ...editData, quotas: { ...editData.quotas, monthlyImages: +e.target.value } })}
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
</div>
<div>
<label className="block text-sm font-semibold mb-1">月商品上架上限</label>
<input type="number" min={0} value={editData.quotas.monthlyProducts} onChange={(e) => setEditData({ ...editData, quotas: { ...editData.quotas, monthlyProducts: +e.target.value } })}
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
</div>
<div>
<label className="block text-sm font-semibold mb-1">月比价次数上限</label>
<input type="number" min={0} value={editData.quotas.monthlyComparisons} onChange={(e) => setEditData({ ...editData, quotas: { ...editData.quotas, monthlyComparisons: +e.target.value } })}
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
</div>
<div>
<label className="block text-sm font-semibold mb-1">最大用户数</label>
<input type="number" min={1} value={editData.quotas.maxUsers} onChange={(e) => setEditData({ ...editData, quotas: { ...editData.quotas, maxUsers: +e.target.value } })}
className="w-full px-4 py-2 border border-border-subtle rounded-lg text-sm outline-none focus:border-secondary transition-colors" />
</div>
</div>
</div>
</div>
<div className="p-6 border-t border-border-subtle flex items-center justify-end gap-3">
<button onClick={() => setEditPlan(null)} className="px-4 py-2 text-sm text-on-surface-variant hover:text-gray-900 transition-colors" disabled={saving}>取消</button>
<button onClick={handleSave} disabled={saving}
className="bg-secondary text-white px-5 py-2 rounded-lg text-sm font-semibold hover:opacity-90 transition-opacity disabled:opacity-50">
{saving ? '保存中...' : '保存方案'}
</button>
</div>
</div>
</div>
)}
</div>
)
}