feat: 多租户数据权限 + 数据库连接修正

- adminPool 连服务器本地 5432 sbrain_admin (SCRAM 认证)
- tenantPool/pool 连 FRP 隧道 15432 bill_query (trust 认证)
- query() 自动注入 store_code 数据权限 (store/regional 角色)
- 区域经理 region 字段存储逗号分隔的 store_code 列表
- /overview 和 /overview/daily 对 scoped 用户从 bill_fact 聚合
- tenant_users.region 扩展为 VARCHAR(500)
- deploy.sh .env 增加 ADMIN_DB_* 配置
- run.md 更新数据库连接架构说明
- 新增海淀区区域经理用户 (21 家海淀区门店)
This commit is contained in:
freedakgmail
2026-08-02 20:10:42 +08:00
parent c3fbe4ab81
commit cfa12c9b7f
24 changed files with 2003 additions and 173 deletions
+5 -1
View File
@@ -36,6 +36,8 @@ import { MenuEngineeringPage } from '@/pages/MenuEngineeringPage'
import { RegionComparisonPage } from '@/pages/RegionComparisonPage'
import { EmployeePerformancePage } from '@/pages/EmployeePerformancePage'
import { InventoryTurnoverPage } from '@/pages/InventoryTurnoverPage'
import { TenantManagementPage } from '@/pages/TenantManagementPage'
import { TenantUsersPage } from '@/pages/TenantUsersPage'
const queryClient = new QueryClient({
defaultOptions: {
@@ -78,7 +80,7 @@ export default function App() {
) : (
<Layout user={user} onLogout={handleLogout}>
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/" element={user?.role === 'platform_admin' ? <Navigate to="/tenant-management" /> : <DashboardPage />} />
<Route path="/boss" element={<BossPage />} />
<Route path="/revenue" element={<RevenuePage />} />
<Route path="/bank" element={<BankPage />} />
@@ -112,6 +114,8 @@ export default function App() {
<Route path="/region-comparison" element={<RegionComparisonPage />} />
<Route path="/employee-performance" element={<EmployeePerformancePage />} />
<Route path="/inventory-turnover" element={<InventoryTurnoverPage />} />
<Route path="/tenant-management" element={<TenantManagementPage />} />
<Route path="/tenant-users" element={<TenantUsersPage />} />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</Layout>
+13 -4
View File
@@ -1,6 +1,6 @@
import { ReactNode, useState } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { LayoutDashboard, Store, ClipboardList, TrendingUp, Settings, LogOut, Menu, X, Package, DollarSign, ShoppingBag, Users, AlertTriangle, Clock, Database, MapPin, PieChart, Wallet, CalendarClock, Activity, Crown, BarChart3, Receipt, Utensils, Landmark, ChefHat, Truck, Network, TrendingUp as TrendingUpIcon } from 'lucide-react'
import { LayoutDashboard, Store, ClipboardList, TrendingUp, Settings, LogOut, Menu, X, Package, DollarSign, ShoppingBag, Users, AlertTriangle, Clock, Database, MapPin, PieChart, Wallet, CalendarClock, Activity, Crown, BarChart3, Receipt, Utensils, Landmark, ChefHat, Truck, Network, TrendingUp as TrendingUpIcon, Building2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { StatusBanner } from '@/components/StatusBanner'
@@ -84,11 +84,18 @@ const menuGroups: MenuGroup[] = [
{
title: '系统管理',
items: [
{ path: '/tenant-users', label: '用户管理', icon: Users, roles: ['hq'] },
{ path: '/data-quality', label: '数据质量', icon: Database, roles: ['hq', 'dept'] },
{ path: '/indicators', label: '指标字典', icon: Settings, roles: ['hq', 'dept', 'regional', 'store'] },
{ path: '/ontology', label: '本体标准', icon: Receipt, roles: ['hq', 'dept'] },
],
},
{
title: '平台管理',
items: [
{ path: '/tenant-management', label: '租户管理', icon: Building2, roles: ['platform_admin'] },
],
},
]
export function Layout({ children, user, onLogout }: LayoutProps) {
@@ -166,9 +173,11 @@ export function Layout({ children, user, onLogout }: LayoutProps) {
{/* Main content */}
<div className="lg:pl-56">
<main className="min-h-screen p-4 pt-16 lg:pt-4">
<div className="mb-4">
<StatusBanner />
</div>
{user?.role !== 'platform_admin' && (
<div className="mb-4">
<StatusBanner />
</div>
)}
{children}
</main>
</div>
@@ -0,0 +1,60 @@
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { MetricCard } from '@/components/MetricCard'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { cn } from '@/lib/utils'
export function OverviewTab() {
const { data, isLoading } = useQuery({
queryKey: ['admin-tenants'],
queryFn: () => api.get('/admin/tenants'),
})
if (isLoading) return <LoadingSpinner text="加载平台数据..." />
const tenants = (data as any)?.data || []
const activeCount = tenants.filter((t: any) => t.status === 'active').length
const inactiveCount = tenants.filter((t: any) => t.status === 'inactive').length
return (
<div className="space-y-4">
<div className="grid grid-cols-4 gap-3">
<MetricCard title="租户总数" value={tenants.length} description="已注册的租户数量" />
<MetricCard title="运行中" value={activeCount} status="good" description="状态为 active 的租户" />
<MetricCard title="已停用" value={inactiveCount} status={inactiveCount > 0 ? 'warn' : undefined} description="状态为 inactive 的租户" />
<MetricCard title="FRP端口数" value={tenants.length} description="已分配的 FRP 隧道端口数量" />
</div>
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
<div className="space-y-2">
{tenants.map((t: any) => (
<div key={t.tenant_id} className="flex items-center justify-between rounded-md border px-3 py-2">
<div className="flex items-center gap-3">
<span className={cn(
'h-2 w-2 rounded-full',
t.status === 'active' ? 'bg-green-500' : 'bg-gray-400'
)} />
<span className="font-medium text-sm">{t.tenant_name}</span>
<span className="font-mono text-xs text-muted-foreground">{t.tenant_id}</span>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span>DB: <span className="font-mono">{t.db_host}:{t.db_port}</span></span>
<span>FRP: <span className="font-mono">{t.frp_port}</span></span>
<span className={cn(
'rounded px-2 py-0.5 font-medium',
t.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'
)}>
{t.status === 'active' ? '运行中' : '已停用'}
</span>
</div>
</div>
))}
{tenants.length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground"></p>
)}
</div>
</div>
</div>
)
}
+91
View File
@@ -0,0 +1,91 @@
import { useQuery } from '@tanstack/react-query'
import api from '@/lib/api'
import { MetricCard } from '@/components/MetricCard'
import { cn } from '@/lib/utils'
export function SystemTab() {
const { data: healthData } = useQuery({
queryKey: ['system-health'],
queryFn: () => api.get('/health'),
refetchInterval: 10000,
})
const { data: tenantsData } = useQuery({
queryKey: ['admin-tenants'],
queryFn: () => api.get('/admin/tenants'),
refetchInterval: 30000,
})
const tenants = (tenantsData as any)?.data || []
const health = (healthData as any)?.data || {}
return (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-3">
<MetricCard title="后端服务" value={health.status === 'ok' ? '正常' : '异常'} format="text" status={health.status === 'ok' ? 'good' : 'bad'} description="服务器后端健康状态" />
<MetricCard title="租户总数" value={tenants.length} description="已注册租户数" />
<MetricCard title="活跃租户" value={tenants.filter((t: any) => t.status === 'active').length} status="good" description="运行中的租户数" />
</div>
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
<div className="space-y-2">
{tenants.map((t: any) => (
<div key={t.tenant_id} className="flex items-center justify-between rounded-md border px-3 py-2">
<div className="flex items-center gap-3">
<span className={cn(
'h-2 w-2 rounded-full',
t.status === 'active' ? 'bg-green-500' : 'bg-gray-400'
)} />
<span className="text-sm font-medium">{t.tenant_name}</span>
<span className="font-mono text-xs text-muted-foreground">{t.tenant_id}</span>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="font-mono">{t.db_host}:{t.db_port}</span>
<span className={cn(
'rounded px-2 py-0.5 font-medium',
t.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'
)}>
{t.status === 'active' ? '运行中' : '已停用'}
</span>
</div>
</div>
))}
{tenants.length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground"></p>
)}
</div>
</div>
<div className="rounded-lg border bg-card p-4">
<h2 className="mb-3 text-sm font-bold"></h2>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="flex justify-between rounded-md border px-3 py-2">
<span className="text-muted-foreground"></span>
<span className="font-mono">9333</span>
</div>
<div className="flex justify-between rounded-md border px-3 py-2">
<span className="text-muted-foreground"></span>
<span className="font-mono">sbrain_admin (PostgreSQL 16)</span>
</div>
<div className="flex justify-between rounded-md border px-3 py-2">
<span className="text-muted-foreground">Nginx </span>
<span className="font-mono">/api/ 127.0.0.1:9333</span>
</div>
<div className="flex justify-between rounded-md border px-3 py-2">
<span className="text-muted-foreground">FRP </span>
<span className="font-mono">frps :7000</span>
</div>
<div className="flex justify-between rounded-md border px-3 py-2">
<span className="text-muted-foreground"></span>
<span className="font-mono">pm2</span>
</div>
<div className="flex justify-between rounded-md border px-3 py-2">
<span className="text-muted-foreground"></span>
<span className="font-mono">{health.time ? (health.time as string).substring(0, 19) : '-'}</span>
</div>
</div>
</div>
</div>
)
}
+501
View File
@@ -0,0 +1,501 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '@/lib/api'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { cn } from '@/lib/utils'
import { Building2, Plus, Trash2, Plug, UserPlus, X, Pencil } from 'lucide-react'
// 平台只为租户创建管理员(hq 角色)
export function TenantsTab() {
const qc = useQueryClient()
const [showCreate, setShowCreate] = useState(false)
const [expandedTenant, setExpandedTenant] = useState<string | null>(null)
const [showUserAdd, setShowUserAdd] = useState(false)
const [showEdit, setShowEdit] = useState(false)
const [editTenantId, setEditTenantId] = useState('')
const [editForm, setEditForm] = useState({
tenant_name: '',
db_host: '',
db_port: '',
db_name: '',
db_user: '',
db_password: '',
frp_port: '',
})
const [userForm, setUserForm] = useState({ username: '', password: '', name: '' })
const [form, setForm] = useState({
tenant_id: '',
tenant_name: '',
db_host: '127.0.0.1',
db_port: '',
db_name: 'bill_query',
db_user: 'freedak',
db_password: '',
frp_port: '',
})
const { data, isLoading } = useQuery({
queryKey: ['admin-tenants'],
queryFn: () => api.get('/admin/tenants'),
})
const tenants = (data as any)?.data || []
const { data: tenantDetail } = useQuery({
queryKey: ['admin-tenant-detail', expandedTenant],
queryFn: () => api.get(`/admin/tenants/${expandedTenant}`),
enabled: !!expandedTenant,
})
const createTenant = useMutation({
mutationFn: (data: any) => api.post('/admin/tenants', data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin-tenants'] })
setShowCreate(false)
setForm({ tenant_id: '', tenant_name: '', db_host: '127.0.0.1', db_port: '', db_name: 'bill_query', db_user: 'freedak', db_password: '', frp_port: '' })
},
})
const deleteTenant = useMutation({
mutationFn: (tenantId: string) => api.delete(`/admin/tenants/${tenantId}`),
onSuccess: (_data: any, tenantId: string) => {
qc.invalidateQueries({ queryKey: ['admin-tenants'] })
if (expandedTenant === tenantId) setExpandedTenant(null)
},
})
const updateTenantStatus = useMutation({
mutationFn: ({ tenantId, status }: { tenantId: string; status: string }) =>
api.put(`/admin/tenants/${tenantId}`, { status }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin-tenants'] }),
})
const updateTenant = useMutation({
mutationFn: ({ tenantId, data }: { tenantId: string; data: any }) =>
api.put(`/admin/tenants/${tenantId}`, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin-tenants'] })
setShowEdit(false)
},
})
const checkStatus = useMutation({
mutationFn: (tenantId: string) => api.get(`/admin/tenants/${tenantId}/status`),
onSuccess: (data: any) => {
const connected = data.data?.connected
alert(connected ? '数据库连接正常' : `连接失败: ${data.data?.error || '未知错误'}`)
},
})
const addUser = useMutation({
mutationFn: ({ tenantId, data }: { tenantId: string; data: any }) =>
api.post(`/admin/tenants/${tenantId}/users`, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin-tenant-detail', expandedTenant] })
setShowUserAdd(false)
setUserForm({ username: '', password: '', name: '' })
},
})
const deleteUser = useMutation({
mutationFn: ({ tenantId, userId }: { tenantId: string; userId: number }) =>
api.delete(`/admin/tenants/${tenantId}/users/${userId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin-tenant-detail', expandedTenant] }),
})
if (isLoading) return <LoadingSpinner text="加载租户列表..." />
const users = (tenantDetail as any)?.data?.users || []
return (
<div className="space-y-4">
<div className="flex justify-end">
<button
onClick={() => setShowCreate(true)}
className="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground"
>
<Plus size={16} />
</button>
</div>
{/* 租户列表 + 可展开用户管理 */}
<div className="space-y-2">
{tenants.map((t: any) => (
<div key={t.tenant_id} className="rounded-lg border bg-card">
{/* 租户行 */}
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-3">
<button
onClick={() => setExpandedTenant(expandedTenant === t.tenant_id ? null : t.tenant_id)}
className="flex items-center gap-2"
>
<span className={cn(
'h-2 w-2 rounded-full',
t.status === 'active' ? 'bg-green-500' : 'bg-gray-400'
)} />
<span className="font-medium text-sm">{t.tenant_name}</span>
<span className="font-mono text-xs text-muted-foreground">{t.tenant_id}</span>
<span className={cn(
'text-xs transition-transform',
expandedTenant === t.tenant_id ? 'rotate-90' : ''
)}></span>
</button>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span>DB: <span className="font-mono">{t.db_host}:{t.db_port}/{t.db_name}</span></span>
<span>FRP: <span className="font-mono">{t.frp_port}</span></span>
<span
className={cn(
'cursor-pointer rounded px-2 py-0.5 font-medium',
t.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'
)}
onClick={() =>
updateTenantStatus.mutate({
tenantId: t.tenant_id,
status: t.status === 'active' ? 'inactive' : 'active',
})
}
>
{t.status === 'active' ? '运行中' : '已停用'}
</span>
<button
onClick={() => checkStatus.mutate(t.tenant_id)}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-blue-600 hover:bg-blue-50"
title="检测数据库连接"
>
<Plug size={12} />
</button>
<button
onClick={() => {
setEditTenantId(t.tenant_id)
setEditForm({
tenant_name: t.tenant_name,
db_host: t.db_host,
db_port: String(t.db_port),
db_name: t.db_name,
db_user: t.db_user,
db_password: '',
frp_port: String(t.frp_port),
})
setShowEdit(true)
}}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-amber-600 hover:bg-amber-50"
title="编辑租户"
>
<Pencil size={12} />
</button>
<button
onClick={() => {
if (confirm(`确认删除租户 ${t.tenant_name}`)) deleteTenant.mutate(t.tenant_id)
}}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-red-600 hover:bg-red-50"
>
<Trash2 size={12} />
</button>
</div>
</div>
{/* 展开的用户管理区 */}
{expandedTenant === t.tenant_id && (
<div className="border-t bg-muted/30 px-4 py-3">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-xs font-bold text-muted-foreground"></h3>
<button
onClick={() => setShowUserAdd(true)}
className="flex items-center gap-1 rounded px-2 py-1 text-xs font-medium text-primary hover:bg-primary/10"
>
<UserPlus size={14} />
</button>
</div>
<div className="overflow-hidden rounded-md border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground"></th>
<th className="px-3 py-2 text-left text-xs font-medium text-muted-foreground"></th>
<th className="px-3 py-2 text-right text-xs font-medium text-muted-foreground"></th>
</tr>
</thead>
<tbody>
{users.map((u: any) => (
<tr key={u.id} className="border-t">
<td className="px-3 py-2 font-medium">{u.username}</td>
<td className="px-3 py-2">{u.name}</td>
<td className="px-3 py-2 text-right">
<button
onClick={() => {
if (confirm(`确认删除用户 ${u.username}`)) deleteUser.mutate({ tenantId: t.tenant_id, userId: u.id })
}}
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-red-600 hover:bg-red-50"
>
<Trash2 size={12} />
</button>
</td>
</tr>
))}
{users.length === 0 && (
<tr>
<td colSpan={3} className="px-3 py-4 text-center text-xs text-muted-foreground"></td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)}
</div>
))}
{tenants.length === 0 && (
<div className="rounded-lg border bg-card p-8 text-center">
<Building2 size={32} className="mx-auto mb-2 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground"></p>
</div>
)}
</div>
{/* 新建租户弹窗 */}
{showCreate && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div className="w-96 rounded-lg border bg-card p-5 shadow-lg">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-sm font-bold"></h2>
<button onClick={() => setShowCreate(false)}>
<X size={16} className="text-muted-foreground" />
</button>
</div>
<div className="space-y-3">
<div>
<label className="text-xs text-muted-foreground">ID</label>
<input
value={form.tenant_id}
onChange={(e) => setForm({ ...form, tenant_id: e.target.value })}
placeholder="如 tenant_b"
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={form.tenant_name}
onChange={(e) => setForm({ ...form, tenant_name: e.target.value })}
placeholder="如 XX餐饮集团"
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={form.db_host}
onChange={(e) => setForm({ ...form, db_host: e.target.value })}
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={form.db_port}
onChange={(e) => setForm({ ...form, db_port: e.target.value })}
placeholder="如 16002"
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={form.db_name}
onChange={(e) => setForm({ ...form, db_name: e.target.value })}
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">FRP端口</label>
<input
value={form.frp_port}
onChange={(e) => setForm({ ...form, frp_port: e.target.value })}
placeholder="如 16002"
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={form.db_user}
onChange={(e) => setForm({ ...form, db_user: e.target.value })}
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={form.db_password}
onChange={(e) => setForm({ ...form, db_password: e.target.value })}
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
</div>
{createTenant.isError && (
<p className="text-xs text-red-600">{(createTenant.error as any)?.data?.error || '创建失败'}</p>
)}
<button
onClick={() => createTenant.mutate(form)}
disabled={!form.tenant_id || !form.tenant_name || !form.db_port || !form.frp_port}
className="w-full rounded-md bg-primary py-2 text-sm font-medium text-primary-foreground disabled:opacity-50"
>
</button>
</div>
</div>
</div>
)}
{/* 添加用户弹窗 */}
{showUserAdd && expandedTenant && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div className="w-80 rounded-lg border bg-card p-5 shadow-lg">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-sm font-bold"></h2>
<button onClick={() => setShowUserAdd(false)}>
<X size={16} className="text-muted-foreground" />
</button>
</div>
<div className="space-y-3">
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={userForm.username}
onChange={(e) => setUserForm({ ...userForm, username: e.target.value })}
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={userForm.password}
onChange={(e) => setUserForm({ ...userForm, password: e.target.value })}
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={userForm.name}
onChange={(e) => setUserForm({ ...userForm, name: e.target.value })}
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
{addUser.isError && (
<p className="text-xs text-red-600">{(addUser.error as any)?.data?.error || '添加失败'}</p>
)}
<button
onClick={() => addUser.mutate({ tenantId: expandedTenant, data: { ...userForm, role: 'hq' } })}
disabled={!userForm.username || !userForm.password}
className="w-full rounded-md bg-primary py-2 text-sm font-medium text-primary-foreground disabled:opacity-50"
>
</button>
</div>
</div>
</div>
)}
{/* 编辑租户弹窗 */}
{showEdit && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div className="w-96 rounded-lg border bg-card p-5 shadow-lg">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-sm font-bold"></h2>
<button onClick={() => setShowEdit(false)}>
<X size={16} className="text-muted-foreground" />
</button>
</div>
<div className="space-y-3">
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={editForm.tenant_name}
onChange={(e) => setEditForm({ ...editForm, tenant_name: e.target.value })}
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={editForm.db_host}
onChange={(e) => setEditForm({ ...editForm, db_host: e.target.value })}
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={editForm.db_port}
onChange={(e) => setEditForm({ ...editForm, db_port: e.target.value })}
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={editForm.db_name}
onChange={(e) => setEditForm({ ...editForm, db_name: e.target.value })}
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground">FRP端口</label>
<input
value={editForm.frp_port}
onChange={(e) => setEditForm({ ...editForm, frp_port: e.target.value })}
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={editForm.db_user}
onChange={(e) => setEditForm({ ...editForm, db_user: e.target.value })}
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={editForm.db_password}
onChange={(e) => setEditForm({ ...editForm, db_password: e.target.value })}
placeholder="留空不修改"
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
</div>
{updateTenant.isError && (
<p className="text-xs text-red-600">{(updateTenant.error as any)?.data?.error || '更新失败'}</p>
)}
<button
onClick={() => {
const data: any = { ...editForm, db_port: Number(editForm.db_port), frp_port: Number(editForm.frp_port) }
if (!data.db_password) delete data.db_password
updateTenant.mutate({ tenantId: editTenantId, data })
}}
disabled={!editForm.tenant_name}
className="w-full rounded-md bg-primary py-2 text-sm font-medium text-primary-foreground disabled:opacity-50"
>
</button>
</div>
</div>
</div>
)}
</div>
)
}
+6 -4
View File
@@ -8,17 +8,19 @@ interface LoginPageProps {
export function LoginPage({ onLogin }: LoginPageProps) {
const navigate = useNavigate()
const [username, setUsername] = useState('总部管理员')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const handleLogin = async () => {
try {
const res: any = await api.post('/auth/login', { username, password })
const isPlatformAdmin = username === '平台管理员'
const endpoint = isPlatformAdmin ? '/admin/login' : '/auth/login'
const res: any = await api.post(endpoint, { username, password })
localStorage.setItem('token', res.data.token)
localStorage.setItem('user', JSON.stringify(res.data.user))
onLogin?.(res.data.user)
navigate('/')
navigate(isPlatformAdmin ? '/tenant-management' : '/')
} catch {
setError('登录失败,请重试')
}
@@ -56,7 +58,7 @@ export function LoginPage({ onLogin }: LoginPageProps) {
</button>
<p className="text-center text-xs text-muted-foreground">
/ / /
·
</p>
</div>
</div>
+58
View File
@@ -0,0 +1,58 @@
import { useState } from 'react'
import { cn } from '@/lib/utils'
import { Building2, LayoutDashboard, Server, Shield } from 'lucide-react'
import { OverviewTab } from '@/components/admin/OverviewTab'
import { TenantsTab } from '@/components/admin/TenantsTab'
import { SystemTab } from '@/components/admin/SystemTab'
const TABS = [
{ key: 'overview', label: '平台概览', icon: LayoutDashboard },
{ key: 'tenants', label: '租户管理', icon: Building2 },
{ key: 'system', label: '系统监控', icon: Server },
]
export function TenantManagementPage() {
const [tab, setTab] = useState('overview')
return (
<div className="space-y-4">
<div>
<h1 className="flex items-center gap-2 text-xl font-bold">
<Shield size={22} className="text-primary" />
</h1>
<p className="mt-0.5 text-xs text-muted-foreground">SaaS · · · </p>
</div>
{/* Tab 切换 */}
<div className="flex gap-1 border-b">
{TABS.map((t) => {
const Icon = t.icon
const active = tab === t.key
return (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={cn(
'flex items-center gap-1.5 border-b-2 px-4 py-2 text-sm font-medium transition-colors',
active ? 'border-primary text-primary' : 'border-transparent text-muted-foreground hover:text-foreground'
)}
>
<Icon size={16} />
{t.label}
</button>
)
})}
</div>
{tab === 'overview' && <OverviewTab />}
{tab === 'tenants' && <TenantsTab />}
{tab === 'system' && <SystemTab />}
</div>
)
}
// Tab 组件拆分至 @/components/admin/ 目录:
// - OverviewTab.tsx 平台概览
// - TenantsTab.tsx 租户管理(含用户管理)
// - SystemTab.tsx 系统监控
+275
View File
@@ -0,0 +1,275 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '@/lib/api'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { UserPlus, Trash2, X, KeyRound, Users } from 'lucide-react'
const ROLE_LABELS: Record<string, string> = {
hq: '总部管理员',
regional: '区域经理',
store: '店长',
dept: '商品部',
}
export function TenantUsersPage() {
const qc = useQueryClient()
const [showAdd, setShowAdd] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [editUserId, setEditUserId] = useState<number | null>(null)
const [addForm, setAddForm] = useState({ username: '', password: '', role: 'regional', name: '', store_code: '', region: '', dept: '' })
const [passwordForm, setPasswordForm] = useState('')
const { data, isLoading } = useQuery({
queryKey: ['tenant-users'],
queryFn: () => api.get('/admin/tenant/users'),
})
const addUser = useMutation({
mutationFn: (data: any) => api.post('/admin/tenant/users', data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['tenant-users'] })
setShowAdd(false)
setAddForm({ username: '', password: '', role: 'regional', name: '', store_code: '', region: '', dept: '' })
},
})
const deleteUser = useMutation({
mutationFn: (userId: number) => api.delete(`/admin/tenant/users/${userId}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant-users'] }),
})
const updatePassword = useMutation({
mutationFn: ({ userId, password }: { userId: number; password: string }) =>
api.put(`/admin/tenant/users/${userId}/password`, { password }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['tenant-users'] })
setShowPassword(false)
setPasswordForm('')
setEditUserId(null)
},
})
if (isLoading) return <LoadingSpinner text="加载用户列表..." />
const users = (data as any)?.data || []
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="flex items-center gap-2 text-xl font-bold">
<Users size={22} className="text-primary" />
</h1>
<p className="mt-0.5 text-xs text-muted-foreground"></p>
</div>
<button
onClick={() => setShowAdd(true)}
className="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground"
>
<UserPlus size={16} />
</button>
</div>
<div className="overflow-hidden rounded-lg border bg-card">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground"></th>
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground"></th>
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground"></th>
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground"></th>
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground"></th>
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground"></th>
<th className="px-4 py-2 text-right text-xs font-medium text-muted-foreground"></th>
</tr>
</thead>
<tbody>
{users.map((u: any) => (
<tr key={u.id} className="border-t">
<td className="px-4 py-2 font-medium">{u.username}</td>
<td className="px-4 py-2">{u.name}</td>
<td className="px-4 py-2">
<span className="rounded bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">
{ROLE_LABELS[u.role] || u.role}
</span>
</td>
<td className="px-4 py-2 text-xs text-muted-foreground">
{u.store_code && <span className="mr-1">:{u.store_code}</span>}
{u.region && <span className="mr-1">:{u.region}</span>}
{u.dept && <span>:{u.dept}</span>}
{!u.store_code && !u.region && !u.dept && '-'}
</td>
<td className="px-4 py-2">
<span className={u.is_active ? 'text-green-600' : 'text-gray-400'}>
{u.is_active ? '启用' : '禁用'}
</span>
</td>
<td className="px-4 py-2 text-xs text-muted-foreground">
{u.created_at ? String(u.created_at).substring(0, 10) : '-'}
</td>
<td className="px-4 py-2 text-right">
<div className="flex items-center justify-end gap-1">
<button
onClick={() => {
setEditUserId(u.id)
setShowPassword(true)
setPasswordForm('')
}}
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-amber-600 hover:bg-amber-50"
title="修改密码"
>
<KeyRound size={12} />
</button>
<button
onClick={() => {
if (confirm(`确认删除用户 ${u.username}`)) deleteUser.mutate(u.id)
}}
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-red-600 hover:bg-red-50"
>
<Trash2 size={12} />
</button>
</div>
</td>
</tr>
))}
{users.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-xs text-muted-foreground"></td>
</tr>
)}
</tbody>
</table>
</div>
{/* 添加用户弹窗 */}
{showAdd && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div className="w-80 rounded-lg border bg-card p-5 shadow-lg">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-sm font-bold"></h2>
<button onClick={() => setShowAdd(false)}>
<X size={16} className="text-muted-foreground" />
</button>
</div>
<div className="space-y-3">
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={addForm.username}
onChange={(e) => setAddForm({ ...addForm, username: e.target.value })}
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={addForm.password}
onChange={(e) => setAddForm({ ...addForm, password: e.target.value })}
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
<div>
<label className="text-xs text-muted-foreground"></label>
<select
value={addForm.role}
onChange={(e) => setAddForm({ ...addForm, role: e.target.value })}
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
>
<option value="regional"></option>
<option value="store"></option>
<option value="dept"></option>
</select>
</div>
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={addForm.name}
onChange={(e) => setAddForm({ ...addForm, name: e.target.value })}
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
{addForm.role === 'store' && (
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={addForm.store_code}
onChange={(e) => setAddForm({ ...addForm, store_code: e.target.value })}
placeholder="如:pjy"
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
)}
{addForm.role === 'regional' && (
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={addForm.region}
onChange={(e) => setAddForm({ ...addForm, region: e.target.value })}
placeholder="如:0054,0032,0001"
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
)}
{addForm.role === 'dept' && (
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={addForm.dept}
onChange={(e) => setAddForm({ ...addForm, dept: e.target.value })}
placeholder="如:商品部"
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
)}
{addUser.isError && (
<p className="text-xs text-red-600">{(addUser.error as any)?.data?.error || '添加失败'}</p>
)}
<button
onClick={() => addUser.mutate(addForm)}
disabled={!addForm.username || !addForm.password}
className="w-full rounded-md bg-primary py-2 text-sm font-medium text-primary-foreground disabled:opacity-50"
>
</button>
</div>
</div>
</div>
)}
{/* 修改密码弹窗 */}
{showPassword && editUserId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div className="w-72 rounded-lg border bg-card p-5 shadow-lg">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-sm font-bold"></h2>
<button onClick={() => { setShowPassword(false); setEditUserId(null) }}>
<X size={16} className="text-muted-foreground" />
</button>
</div>
<div className="space-y-3">
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={passwordForm}
onChange={(e) => setPasswordForm(e.target.value)}
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
{updatePassword.isError && (
<p className="text-xs text-red-600">{(updatePassword.error as any)?.data?.error || '修改失败'}</p>
)}
<button
onClick={() => updatePassword.mutate({ userId: editUserId, password: passwordForm })}
disabled={!passwordForm}
className="w-full rounded-md bg-primary py-2 text-sm font-medium text-primary-foreground disabled:opacity-50"
>
</button>
</div>
</div>
</div>
)}
</div>
)
}