fix: 角色权限与数据范围修复

- 添加 RoleRoute 路由守卫组件,防止越权访问页面
- 菜单配置:区域经理加'区域对比',店长加'态势感知'
- database.ts: scope注入时检测主表别名避免store_code歧义
- analytics-enhanced.ts: KPI region模式手动注入scope过滤
- situational-awareness.ts: health-score手动注入scope,alerts加skipScope
- smart-scheduling.ts: position-salary-compare店长角色降低HAVING阈值
This commit is contained in:
freedakgmail
2026-08-02 21:28:25 +08:00
parent cfa12c9b7f
commit a011729fc9
13 changed files with 474 additions and 110 deletions
+38 -37
View File
@@ -36,8 +36,9 @@ 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'
import { TenantManagementPage } from './pages/TenantManagementPage'
import { TenantUsersPage } from './pages/TenantUsersPage'
import { RoleRoute } from './components/RoleRoute'
const queryClient = new QueryClient({
defaultOptions: {
@@ -80,42 +81,42 @@ export default function App() {
) : (
<Layout user={user} onLogout={handleLogout}>
<Routes>
<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 />} />
<Route path="/central-kitchen" element={<CentralKitchenPage />} />
<Route path="/distribution-reconciliation" element={<DistributionReconciliationPage />} />
<Route path="/bom-penetration" element={<BomPenetrationPage />} />
<Route path="/production-plan" element={<ProductionPlanPage />} />
<Route path="/regional" element={<RegionalPage />} />
<Route path="/store" element={<StorePage />} />
<Route path="/tasks" element={<TasksPage />} />
<Route path="/tasks/:id" element={<TaskDetailPage />} />
<Route path="/stores/:code" element={<StoreDetailPage />} />
<Route path="/monthly-review" element={<MonthlyReviewPage />} />
<Route path="/sku" element={<SKUPage />} />
<Route path="/cost" element={<CostPage />} />
<Route path="/cost-analysis" element={<CostAnalysisPage />} />
<Route path="/store-expense" element={<StoreExpensePage />} />
<Route path="/platform" element={<PlatformPage />} />
<Route path="/member" element={<MemberPage />} />
<Route path="/risk" element={<RiskPage />} />
<Route path="/time" element={<TimePage />} />
<Route path="/data-quality" element={<DataQualityPage />} />
<Route path="/indicators" element={<IndicatorsPage />} />
<Route path="/ontology" element={<OntologyPage />} />
<Route path="/site-selection" element={<SiteSelectionPage />} />
<Route path="/smart-scheduling" element={<SmartSchedulingPage />} />
<Route path="/situational-awareness" element={<SituationalAwarenessPage />} />
<Route path="/" element={user?.role === 'platform_admin' ? <Navigate to="/tenant-management" /> : user?.role === 'store' ? <Navigate to="/store" /> : user?.role === 'regional' ? <Navigate to="/regional" /> : <DashboardPage />} />
<Route path="/boss" element={<RoleRoute roles={['hq','dept']}><BossPage /></RoleRoute>} />
<Route path="/revenue" element={<RoleRoute roles={['hq','dept']}><RevenuePage /></RoleRoute>} />
<Route path="/bank" element={<RoleRoute roles={['hq']}><BankPage /></RoleRoute>} />
<Route path="/central-kitchen" element={<RoleRoute roles={['hq','dept']}><CentralKitchenPage /></RoleRoute>} />
<Route path="/distribution-reconciliation" element={<RoleRoute roles={['hq','dept']}><DistributionReconciliationPage /></RoleRoute>} />
<Route path="/bom-penetration" element={<RoleRoute roles={['hq','dept']}><BomPenetrationPage /></RoleRoute>} />
<Route path="/production-plan" element={<RoleRoute roles={['hq','dept']}><ProductionPlanPage /></RoleRoute>} />
<Route path="/regional" element={<RoleRoute roles={['hq','dept','regional']}><RegionalPage /></RoleRoute>} />
<Route path="/store" element={<RoleRoute roles={['hq','dept','store']}><StorePage /></RoleRoute>} />
<Route path="/tasks" element={<RoleRoute roles={['hq','regional','store','dept']}><TasksPage /></RoleRoute>} />
<Route path="/tasks/:id" element={<RoleRoute roles={['hq','regional','store','dept']}><TaskDetailPage /></RoleRoute>} />
<Route path="/stores/:code" element={<RoleRoute roles={['hq','regional','store','dept']}><StoreDetailPage /></RoleRoute>} />
<Route path="/monthly-review" element={<RoleRoute roles={['hq','dept','regional']}><MonthlyReviewPage /></RoleRoute>} />
<Route path="/sku" element={<RoleRoute roles={['hq','dept']}><SKUPage /></RoleRoute>} />
<Route path="/cost" element={<RoleRoute roles={['hq','dept']}><CostPage /></RoleRoute>} />
<Route path="/cost-analysis" element={<RoleRoute roles={['hq','dept']}><CostAnalysisPage /></RoleRoute>} />
<Route path="/store-expense" element={<RoleRoute roles={['hq','dept']}><StoreExpensePage /></RoleRoute>} />
<Route path="/platform" element={<RoleRoute roles={['hq','dept']}><PlatformPage /></RoleRoute>} />
<Route path="/member" element={<RoleRoute roles={['hq','dept']}><MemberPage /></RoleRoute>} />
<Route path="/risk" element={<RoleRoute roles={['hq','dept']}><RiskPage /></RoleRoute>} />
<Route path="/time" element={<RoleRoute roles={['hq','dept']}><TimePage /></RoleRoute>} />
<Route path="/data-quality" element={<RoleRoute roles={['hq','dept']}><DataQualityPage /></RoleRoute>} />
<Route path="/indicators" element={<RoleRoute roles={['hq','dept','regional','store']}><IndicatorsPage /></RoleRoute>} />
<Route path="/ontology" element={<RoleRoute roles={['hq','dept']}><OntologyPage /></RoleRoute>} />
<Route path="/site-selection" element={<RoleRoute roles={['hq','dept']}><SiteSelectionPage /></RoleRoute>} />
<Route path="/smart-scheduling" element={<RoleRoute roles={['hq','dept','regional','store']}><SmartSchedulingPage /></RoleRoute>} />
<Route path="/situational-awareness" element={<RoleRoute roles={['hq','dept','regional','store']}><SituationalAwarenessPage /></RoleRoute>} />
<Route path="/login" element={<Navigate to="/" />} />
<Route path="/member-ltv" element={<MemberLTVPage />} />
<Route path="/menu-engineering" element={<MenuEngineeringPage />} />
<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="/member-ltv" element={<RoleRoute roles={['hq','dept']}><MemberLTVPage /></RoleRoute>} />
<Route path="/menu-engineering" element={<RoleRoute roles={['hq','dept']}><MenuEngineeringPage /></RoleRoute>} />
<Route path="/region-comparison" element={<RoleRoute roles={['hq','dept','regional']}><RegionComparisonPage /></RoleRoute>} />
<Route path="/employee-performance" element={<RoleRoute roles={['hq','dept']}><EmployeePerformancePage /></RoleRoute>} />
<Route path="/inventory-turnover" element={<RoleRoute roles={['hq','dept']}><InventoryTurnoverPage /></RoleRoute>} />
<Route path="/tenant-management" element={<RoleRoute roles={['platform_admin']}><TenantManagementPage /></RoleRoute>} />
<Route path="/tenant-users" element={<RoleRoute roles={['hq']}><TenantUsersPage /></RoleRoute>} />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</Layout>
+7 -7
View File
@@ -27,19 +27,19 @@ const menuGroups: MenuGroup[] = [
title: '经营总览',
items: [
{ path: '/boss', label: '老板驾驶舱', icon: Crown, roles: ['hq', 'dept'] },
{ path: '/', label: '总部驾驶舱', icon: LayoutDashboard, roles: ['hq', 'dept', 'regional', 'store'] },
{ path: '/situational-awareness', label: '态势感知', icon: Activity, roles: ['hq', 'dept', 'regional'] },
{ path: '/', label: '总部驾驶舱', icon: LayoutDashboard, roles: ['hq', 'dept'] },
{ path: '/situational-awareness', label: '态势感知', icon: Activity, roles: ['hq', 'dept', 'regional', 'store'] },
{ path: '/bank', label: '银行授信', icon: Landmark, roles: ['hq'] },
],
},
{
title: '角色工作台',
items: [
{ path: '/regional', label: '区域经理', icon: Store, roles: ['hq', 'dept', 'regional', 'store'] },
{ path: '/region-comparison', label: '区域对比', icon: Store, roles: ['hq', 'dept'] },
{ path: '/store', label: '店长工作台', icon: ClipboardList, roles: ['hq', 'dept', 'regional', 'store'] },
{ path: '/regional', label: '区域经理', icon: Store, roles: ['hq', 'dept', 'regional'] },
{ path: '/region-comparison', label: '区域对比', icon: Store, roles: ['hq', 'dept', 'regional'] },
{ path: '/store', label: '店长工作台', icon: ClipboardList, roles: ['hq', 'dept', 'store'] },
{ path: '/tasks', label: '任务管理', icon: ClipboardList, roles: ['hq', 'regional', 'store', 'dept'] },
{ path: '/monthly-review', label: '月度验收', icon: TrendingUp, roles: ['hq', 'dept', 'regional', 'store'] },
{ path: '/monthly-review', label: '月度验收', icon: TrendingUp, roles: ['hq', 'dept', 'regional'] },
],
},
{
@@ -70,7 +70,7 @@ const menuGroups: MenuGroup[] = [
{ path: '/sku', label: '商品SKU', icon: Package, roles: ['hq', 'dept'] },
{ path: '/menu-engineering', label: '菜单工程', icon: Package, roles: ['hq', 'dept'] },
{ path: '/time', label: '时间分析', icon: Clock, roles: ['hq', 'dept'] },
{ path: '/smart-scheduling', label: '智能排班', icon: CalendarClock, roles: ['hq', 'dept', 'regional'] },
{ path: '/smart-scheduling', label: '智能排班', icon: CalendarClock, roles: ['hq', 'dept', 'regional', 'store'] },
{ path: '/employee-performance', label: '员工绩效', icon: CalendarClock, roles: ['hq', 'dept'] },
],
},
+21
View File
@@ -0,0 +1,21 @@
import { ReactNode } from 'react'
import { Navigate } from 'react-router-dom'
interface RoleRouteProps {
roles: string[]
children: ReactNode
fallback?: string
}
export function RoleRoute({ roles, children, fallback }: RoleRouteProps) {
const userStr = localStorage.getItem('user')
const user = userStr ? JSON.parse(userStr) : null
const role = user?.role
if (!role || !roles.includes(role)) {
const redirect = fallback || (role === 'store' ? '/store' : role === 'regional' ? '/regional' : role === 'platform_admin' ? '/tenant-management' : '/')
return <Navigate to={redirect} replace />
}
return <>{children}</>
}
+8 -1
View File
@@ -20,7 +20,14 @@ export function LoginPage({ onLogin }: LoginPageProps) {
localStorage.setItem('token', res.data.token)
localStorage.setItem('user', JSON.stringify(res.data.user))
onLogin?.(res.data.user)
navigate(isPlatformAdmin ? '/tenant-management' : '/')
if (isPlatformAdmin) {
navigate('/tenant-management')
} else {
const role = res.data.user?.role
if (role === 'store') navigate('/store')
else if (role === 'regional') navigate('/regional')
else navigate('/')
}
} catch {
setError('登录失败,请重试')
}
+7 -1
View File
@@ -37,7 +37,13 @@ export function StorePage() {
const queryClient = useQueryClient()
const [executeText, setExecuteText] = useState('')
const [activeTaskId, setActiveTaskId] = useState<number | null>(null)
const [storeCode, setStoreCode] = useState('1111')
const userStoreCode = (() => {
try {
const u = JSON.parse(localStorage.getItem('user') || '{}')
return u.storeCode || ''
} catch { return '' }
})()
const [storeCode, setStoreCode] = useState(userStoreCode || '1111')
const [month, setMonth] = useState('2026-04')
const [detailTab, setDetailTab] = useState<DetailTab>('overview')
+255 -25
View File
@@ -1,8 +1,8 @@
import { useState } from 'react'
import { useState, useMemo } 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'
import { UserPlus, Trash2, X, KeyRound, Users, Edit2 } from 'lucide-react'
const ROLE_LABELS: Record<string, string> = {
hq: '总部管理员',
@@ -11,19 +11,48 @@ const ROLE_LABELS: Record<string, string> = {
dept: '商品部',
}
interface StoreInfo {
store_code: string
store_name: string
region: string
business_type: string
business_area: string | null
}
export function TenantUsersPage() {
const qc = useQueryClient()
const [showAdd, setShowAdd] = useState(false)
const [showEdit, setShowEdit] = 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 [editForm, setEditForm] = useState({ store_code: '', region: '', dept: '' })
const [passwordForm, setPasswordForm] = useState('')
const [storeFilter, setStoreFilter] = useState('')
const { data, isLoading } = useQuery({
queryKey: ['tenant-users'],
queryFn: () => api.get('/admin/tenant/users'),
})
const { data: storesData } = useQuery({
queryKey: ['tenant-stores'],
queryFn: () => api.get('/admin/tenant/stores'),
})
const stores: StoreInfo[] = (storesData as any)?.data || []
const storeMap = useMemo(() => {
const m: Record<string, StoreInfo> = {}
for (const s of stores) m[s.store_code] = s
return m
}, [stores])
const storeNames = (codes: string) => {
if (!codes) return ''
return codes.split(',').map(c => c.trim()).filter(Boolean).map(c => storeMap[c]?.store_name || c).join(', ')
}
const addUser = useMutation({
mutationFn: (data: any) => api.post('/admin/tenant/users', data),
onSuccess: () => {
@@ -49,10 +78,24 @@ export function TenantUsersPage() {
},
})
const updateUser = useMutation({
mutationFn: ({ userId, data }: { userId: number; data: any }) =>
api.put(`/admin/tenant/users/${userId}`, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['tenant-users'] })
setShowEdit(false)
setEditUserId(null)
},
})
if (isLoading) return <LoadingSpinner text="加载用户列表..." />
const users = (data as any)?.data || []
const filteredStores = storeFilter
? stores.filter(s => s.store_name.includes(storeFilter) || s.region.includes(storeFilter) || s.store_code.includes(storeFilter))
: stores
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
@@ -75,30 +118,32 @@ export function TenantUsersPage() {
<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 whitespace-nowrap"></th>
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground whitespace-nowrap"></th>
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground whitespace-nowrap"></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>
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground whitespace-nowrap"></th>
<th className="px-4 py-2 text-left text-xs font-medium text-muted-foreground whitespace-nowrap"></th>
<th className="px-4 py-2 text-right text-xs font-medium text-muted-foreground whitespace-nowrap"></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">
<td className="px-4 py-2 font-medium whitespace-nowrap">{u.username}</td>
<td className="px-4 py-2 whitespace-nowrap">{u.name}</td>
<td className="px-4 py-2 whitespace-nowrap">
<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 className="px-4 py-2 text-xs text-muted-foreground max-w-xs">
<div className="space-y-0.5">
{u.store_code && <div>: {storeNames(u.store_code)}</div>}
{u.region && <div>: {storeNames(u.region)}</div>}
{u.dept && <div>: {u.dept}</div>}
{!u.store_code && !u.region && !u.dept && '-'}
</div>
</td>
<td className="px-4 py-2">
<span className={u.is_active ? 'text-green-600' : 'text-gray-400'}>
@@ -110,6 +155,18 @@ export function TenantUsersPage() {
</td>
<td className="px-4 py-2 text-right">
<div className="flex items-center justify-end gap-1">
<button
onClick={() => {
setEditUserId(u.id)
setEditForm({ store_code: u.store_code || '', region: u.region || '', dept: u.dept || '' })
setShowEdit(true)
setStoreFilter('')
}}
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-blue-600 hover:bg-blue-50"
title="编辑关联门店"
>
<Edit2 size={12} />
</button>
<button
onClick={() => {
setEditUserId(u.id)
@@ -145,7 +202,7 @@ export function TenantUsersPage() {
{/* 添加用户弹窗 */}
{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="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={() => setShowAdd(false)}>
@@ -191,24 +248,76 @@ export function TenantUsersPage() {
</div>
{addForm.role === 'store' && (
<div>
<label className="text-xs text-muted-foreground"></label>
<label className="text-xs text-muted-foreground"></label>
<input
value={addForm.store_code}
onChange={(e) => setAddForm({ ...addForm, store_code: e.target.value })}
placeholder="如:pjy"
value={storeFilter}
onChange={(e) => setStoreFilter(e.target.value)}
placeholder="搜索门店名称或区域..."
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
<div className="mt-1 max-h-40 overflow-y-auto rounded-md border p-2 space-y-1">
{filteredStores.map(s => {
const selected = addForm.store_code.split(',').map(c => c.trim()).includes(s.store_code)
return (
<label key={s.store_code} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
<input
type="checkbox"
checked={selected}
onChange={(e) => {
const codes = addForm.store_code.split(',').map(c => c.trim()).filter(Boolean)
if (e.target.checked) {
setAddForm({ ...addForm, store_code: [...codes, s.store_code].join(',') })
} else {
setAddForm({ ...addForm, store_code: codes.filter(c => c !== s.store_code).join(',') })
}
}}
/>
<span>{s.store_name}</span>
<span className="text-muted-foreground">{s.region}</span>
</label>
)
})}
</div>
{addForm.store_code && (
<p className="mt-1 text-xs text-muted-foreground"> {addForm.store_code.split(',').filter(Boolean).length} </p>
)}
</div>
)}
{addForm.role === 'regional' && (
<div>
<label className="text-xs text-muted-foreground"></label>
<label className="text-xs text-muted-foreground"></label>
<input
value={addForm.region}
onChange={(e) => setAddForm({ ...addForm, region: e.target.value })}
placeholder="如:0054,0032,0001"
value={storeFilter}
onChange={(e) => setStoreFilter(e.target.value)}
placeholder="搜索门店名称或区域..."
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
<div className="mt-1 max-h-40 overflow-y-auto rounded-md border p-2 space-y-1">
{filteredStores.map(s => {
const selected = addForm.region.split(',').map(c => c.trim()).includes(s.store_code)
return (
<label key={s.store_code} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
<input
type="checkbox"
checked={selected}
onChange={(e) => {
const codes = addForm.region.split(',').map(c => c.trim()).filter(Boolean)
if (e.target.checked) {
setAddForm({ ...addForm, region: [...codes, s.store_code].join(',') })
} else {
setAddForm({ ...addForm, region: codes.filter(c => c !== s.store_code).join(',') })
}
}}
/>
<span>{s.store_name}</span>
<span className="text-muted-foreground">{s.region}</span>
</label>
)
})}
</div>
{addForm.region && (
<p className="mt-1 text-xs text-muted-foreground"> {addForm.region.split(',').filter(Boolean).length} </p>
)}
</div>
)}
{addForm.role === 'dept' && (
@@ -237,6 +346,127 @@ export function TenantUsersPage() {
</div>
)}
{/* 编辑用户弹窗 */}
{showEdit && editUserId !== null && (
<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); setEditUserId(null) }}>
<X size={16} className="text-muted-foreground" />
</button>
</div>
<div className="space-y-3">
{(() => {
const u = users.find((x: any) => x.id === editUserId)
if (!u) return null
return (
<>
<div className="text-xs text-muted-foreground">
<b>{u.username}</b>{ROLE_LABELS[u.role] || u.role}
</div>
{u.role === 'store' && (
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={storeFilter}
onChange={(e) => setStoreFilter(e.target.value)}
placeholder="搜索门店名称或区域..."
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
<div className="mt-1 max-h-40 overflow-y-auto rounded-md border p-2 space-y-1">
{filteredStores.map(s => {
const selected = editForm.store_code.split(',').map(c => c.trim()).includes(s.store_code)
return (
<label key={s.store_code} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
<input
type="checkbox"
checked={selected}
onChange={(e) => {
const codes = editForm.store_code.split(',').map(c => c.trim()).filter(Boolean)
if (e.target.checked) {
setEditForm({ ...editForm, store_code: [...codes, s.store_code].join(',') })
} else {
setEditForm({ ...editForm, store_code: codes.filter(c => c !== s.store_code).join(',') })
}
}}
/>
<span>{s.store_name}</span>
<span className="text-muted-foreground">{s.region}</span>
</label>
)
})}
</div>
{editForm.store_code && (
<p className="mt-1 text-xs text-muted-foreground"> {editForm.store_code.split(',').filter(Boolean).length} </p>
)}
</div>
)}
{u.role === 'regional' && (
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={storeFilter}
onChange={(e) => setStoreFilter(e.target.value)}
placeholder="搜索门店名称或区域..."
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
<div className="mt-1 max-h-40 overflow-y-auto rounded-md border p-2 space-y-1">
{filteredStores.map(s => {
const selected = editForm.region.split(',').map(c => c.trim()).includes(s.store_code)
return (
<label key={s.store_code} className="flex items-center gap-2 text-xs cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
<input
type="checkbox"
checked={selected}
onChange={(e) => {
const codes = editForm.region.split(',').map(c => c.trim()).filter(Boolean)
if (e.target.checked) {
setEditForm({ ...editForm, region: [...codes, s.store_code].join(',') })
} else {
setEditForm({ ...editForm, region: codes.filter(c => c !== s.store_code).join(',') })
}
}}
/>
<span>{s.store_name}</span>
<span className="text-muted-foreground">{s.region}</span>
</label>
)
})}
</div>
{editForm.region && (
<p className="mt-1 text-xs text-muted-foreground"> {editForm.region.split(',').filter(Boolean).length} </p>
)}
</div>
)}
{u.role === 'dept' && (
<div>
<label className="text-xs text-muted-foreground"></label>
<input
value={editForm.dept}
onChange={(e) => setEditForm({ ...editForm, dept: e.target.value })}
placeholder="如:商品部"
className="mt-1 w-full rounded-md border px-3 py-1.5 text-sm"
/>
</div>
)}
</>
)
})()}
{updateUser.isError && (
<p className="text-xs text-red-600">{(updateUser.error as any)?.data?.error || '修改失败'}</p>
)}
<button
onClick={() => updateUser.mutate({ userId: editUserId, data: editForm })}
className="w-full rounded-md bg-primary py-2 text-sm font-medium text-primary-foreground"
>
</button>
</div>
</div>
</div>
)}
{/* 修改密码弹窗 */}
{showPassword && editUserId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">