- 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
1599 lines
92 KiB
TypeScript
1599 lines
92 KiB
TypeScript
'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">×</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>
|
||
)
|
||
}
|