Browse Source

feat: frontend types, API client, AuthContext, and dynamic sidebar

master
dark 1 month ago
parent
commit
b411ac169a
  1. 89
      frontend/react-shadcn/pc/src/components/layout/Sidebar.tsx
  2. 101
      frontend/react-shadcn/pc/src/contexts/AuthContext.tsx
  3. 204
      frontend/react-shadcn/pc/src/services/api.ts
  4. 164
      frontend/react-shadcn/pc/src/types/index.ts

89
frontend/react-shadcn/pc/src/components/layout/Sidebar.tsx

@ -1,15 +1,43 @@
import { NavLink } from 'react-router-dom'
import { LayoutDashboard, Users, LogOut, Settings } from 'lucide-react'
import {
LayoutDashboard, Users, LogOut, Settings, FolderOpen,
Shield, Menu as MenuIcon, Building2, User, ChevronDown,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { useAuth } from '@/contexts/AuthContext'
import { useState, useRef, useEffect } from 'react'
import type { MenuItem } from '@/types'
const navItems = [
{ path: '/dashboard', icon: LayoutDashboard, label: '首页' },
{ path: '/users', icon: Users, label: '用户管理' },
{ path: '/settings', icon: Settings, label: '设置' },
]
const iconMap: Record<string, LucideIcon> = {
User, LayoutDashboard, Users, FolderOpen, Shield,
Menu: MenuIcon, Building2, Settings,
}
function getIcon(iconName: string): LucideIcon {
return iconMap[iconName] || LayoutDashboard
}
export function Sidebar() {
const { user, logout } = useAuth()
const { user, logout, userMenus, currentOrg, userOrgs, switchOrg } = useAuth()
const [orgDropdownOpen, setOrgDropdownOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setOrgDropdownOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const handleSwitchOrg = async (orgId: number) => {
await switchOrg(orgId)
setOrgDropdownOpen(false)
}
const menuItems: MenuItem[] = userMenus.length > 0 ? userMenus : []
return (
<aside className="w-64 h-screen bg-gray-900/80 backdrop-blur-xl border-r border-gray-800 flex flex-col fixed left-0 top-0 z-40">
@ -26,9 +54,43 @@ export function Sidebar() {
</div>
</div>
{/* Org Switcher */}
{userOrgs.length > 0 && (
<div className="px-4 pt-4" ref={dropdownRef}>
<button
onClick={() => setOrgDropdownOpen(!orgDropdownOpen)}
className="w-full flex items-center justify-between px-3 py-2 rounded-lg bg-gray-800/50 border border-gray-700 text-sm text-gray-300 hover:bg-gray-800 transition-colors"
>
<span className="truncate">{currentOrg?.name || '选择机构'}</span>
<ChevronDown className={cn('h-4 w-4 transition-transform', orgDropdownOpen && 'rotate-180')} />
</button>
{orgDropdownOpen && (
<div className="mt-1 py-1 rounded-lg bg-gray-800 border border-gray-700 shadow-xl">
{userOrgs.map((org) => (
<button
key={org.orgId}
onClick={() => handleSwitchOrg(org.orgId)}
className={cn(
'w-full text-left px-3 py-2 text-sm transition-colors',
currentOrg?.id === org.orgId
? 'text-sky-400 bg-sky-500/10'
: 'text-gray-400 hover:text-gray-200 hover:bg-gray-700/50'
)}
>
<div className="truncate">{org.orgName}</div>
<div className="text-xs text-gray-500">{org.roleName}</div>
</button>
))}
</div>
)}
</div>
)}
{/* Navigation */}
<nav className="flex-1 p-4 space-y-1">
{navItems.map((item) => (
<nav className="flex-1 p-4 space-y-1 overflow-y-auto">
{menuItems.map((item) => {
const Icon = getIcon(item.icon)
return (
<NavLink
key={item.path}
to={item.path}
@ -42,10 +104,11 @@ export function Sidebar() {
)
}
>
<item.icon className="h-5 w-5" />
{item.label}
<Icon className="h-5 w-5" />
{item.name}
</NavLink>
))}
)
})}
</nav>
{/* User Info */}
@ -61,7 +124,7 @@ export function Sidebar() {
{user?.username || 'User'}
</p>
<p className="text-xs text-gray-500 font-body truncate">
{user?.email || ''}
{user?.role || ''}
</p>
</div>
<button

101
frontend/react-shadcn/pc/src/contexts/AuthContext.tsx

@ -1,5 +1,5 @@
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'
import type { User } from '@/types'
import type { User, MenuItem, UserOrgInfo } from '@/types'
import { apiClient } from '@/services/api'
interface AuthContextType {
@ -10,6 +10,11 @@ interface AuthContextType {
login: (account: string, password: string) => Promise<void>
loginWithToken: (token: string) => void
logout: () => void
currentOrg: { id: number; name: string } | null
userOrgs: UserOrgInfo[]
userMenus: MenuItem[]
switchOrg: (orgId: number) => Promise<void>
refreshMenus: () => Promise<void>
}
const AuthContext = createContext<AuthContextType | undefined>(undefined)
@ -18,6 +23,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [token, setToken] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [currentOrg, setCurrentOrg] = useState<{ id: number; name: string } | null>(null)
const [userOrgs, setUserOrgs] = useState<UserOrgInfo[]>([])
const [userMenus, setUserMenus] = useState<MenuItem[]>([])
useEffect(() => {
const storedToken = localStorage.getItem('token')
@ -35,9 +43,91 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
}
const savedOrg = localStorage.getItem('currentOrg')
if (savedOrg) {
try { setCurrentOrg(JSON.parse(savedOrg)) } catch {}
}
setIsLoading(false)
}, [])
const refreshMenus = async () => {
try {
const data = await apiClient.getCurrentMenus()
setUserMenus(data.list || [])
} catch (e) {
console.error('Failed to fetch menus:', e)
}
}
const loadUserContext = async () => {
try {
const orgsData = await apiClient.getUserOrgs()
const orgs = orgsData.list || []
setUserOrgs(orgs)
if (orgs.length > 0) {
const storedOrg = localStorage.getItem('currentOrg')
let selectedOrg = orgs[0]
if (storedOrg) {
try {
const parsed = JSON.parse(storedOrg)
const found = orgs.find((o: UserOrgInfo) => o.orgId === parsed.id)
if (found) selectedOrg = found
} catch {}
}
setCurrentOrg({ id: selectedOrg.orgId, name: selectedOrg.orgName })
localStorage.setItem('currentOrg', JSON.stringify({ id: selectedOrg.orgId, name: selectedOrg.orgName }))
}
await refreshMenus()
} catch (e) {
console.error('Failed to load user context:', e)
await refreshMenus()
}
}
const switchOrgFn = async (orgId: number) => {
try {
const data = await apiClient.switchOrg(orgId)
if (data.token) {
localStorage.setItem('token', data.token)
setToken(data.token)
try {
const payload = JSON.parse(atob(data.token.split('.')[1]))
const userData: User = {
id: payload.userId || 0,
username: payload.username || '',
email: '',
role: payload.role || 'user',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
setUser(userData)
localStorage.setItem('user', JSON.stringify(userData))
} catch {}
const org = userOrgs.find((o: UserOrgInfo) => o.orgId === orgId)
if (org) {
setCurrentOrg({ id: org.orgId, name: org.orgName })
localStorage.setItem('currentOrg', JSON.stringify({ id: org.orgId, name: org.orgName }))
}
await refreshMenus()
}
} catch (e) {
console.error('Failed to switch org:', e)
throw e
}
}
useEffect(() => {
if (token && !isLoading) {
loadUserContext()
}
}, [token, isLoading])
const login = async (account: string, password: string) => {
try {
const response = await apiClient.login({ account, password })
@ -110,7 +200,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
apiClient.logout()
setToken(null)
setUser(null)
setCurrentOrg(null)
setUserOrgs([])
setUserMenus([])
localStorage.removeItem('user')
localStorage.removeItem('currentOrg')
}
const value: AuthContextType = {
@ -121,6 +215,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
login,
loginWithToken,
logout,
currentOrg,
userOrgs,
userMenus,
switchOrg: switchOrgFn,
refreshMenus,
}
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>

204
frontend/react-shadcn/pc/src/services/api.ts

@ -12,6 +12,21 @@ import type {
Activity,
UpdateProfileRequest,
ChangePasswordRequest,
FileInfo,
FileListRequest,
FileListResponse,
UpdateFileRequest,
MenuItem,
CreateMenuRequest,
UpdateMenuRequest,
RoleInfo,
CreateRoleRequest,
UpdateRoleRequest,
OrgInfo,
CreateOrgRequest,
UpdateOrgRequest,
OrgMember,
UserOrgInfo,
} from '@/types'
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8888/api/v1'
@ -84,6 +99,11 @@ class ApiClient {
this.token = null
}
// SSO
async getSSOLoginUrl(): Promise<{ login_url: string }> {
return this.request<{ login_url: string }>('/auth/sso/login-url')
}
async refreshToken(refreshToken: string): Promise<LoginResponse> {
return this.request<LoginResponse>('/refresh', {
method: 'POST',
@ -103,7 +123,7 @@ class ApiClient {
// 如果后端已经返回标准格式,直接返回
if ('success' in rawData) {
return rawData as UserListResponse
return rawData as unknown as UserListResponse
}
// 包装成标准格式
@ -169,7 +189,7 @@ class ApiClient {
// 如果后端已经返回标准格式,直接返回
if ('success' in rawData) {
return rawData as ApiResponse<DashboardStats>
return rawData as unknown as ApiResponse<DashboardStats>
}
// 包装成标准格式
@ -186,7 +206,7 @@ class ApiClient {
// 如果后端已经返回标准格式,直接返回
if ('success' in rawData) {
return rawData as ApiResponse<Activity[]>
return rawData as unknown as ApiResponse<Activity[]>
}
// 包装成标准格式
@ -198,6 +218,184 @@ class ApiClient {
}
}
// File Management
async uploadFile(file: File, category?: string, isPublic?: boolean): Promise<ApiResponse<FileInfo>> {
const url = `${API_BASE_URL}/file/upload`
const formData = new FormData()
formData.append('file', file)
if (category) formData.append('category', category)
if (isPublic !== undefined) formData.append('isPublic', isPublic ? 'true' : 'false')
const headers: Record<string, string> = {}
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`
}
const response = await fetch(url, { method: 'POST', headers, body: formData })
const data = await response.json()
if (!response.ok) throw new Error(data.message || 'Upload failed')
if ('id' in data) return { code: 200, message: 'success', success: true, data }
return data
}
async getFiles(params: FileListRequest = {}): Promise<FileListResponse> {
const queryParams = new URLSearchParams()
if (params.page) queryParams.append('page', params.page.toString())
if (params.pageSize) queryParams.append('pageSize', params.pageSize.toString())
if (params.keyword) queryParams.append('keyword', params.keyword)
if (params.category) queryParams.append('category', params.category)
if (params.mimeType) queryParams.append('mimeType', params.mimeType)
const rawData = await this.request<{ total: number; list: FileInfo[] }>(`/files?${queryParams}`)
if ('success' in rawData) return rawData as unknown as FileListResponse
return {
code: 200, message: 'success', success: true,
data: { list: rawData.list || [], total: rawData.total || 0 },
}
}
async getFile(id: number): Promise<ApiResponse<FileInfo>> {
const rawData = await this.request<FileInfo>(`/file/${id}`)
if ('success' in rawData) return rawData as unknown as ApiResponse<FileInfo>
return { code: 200, message: 'success', success: true, data: rawData }
}
async getFileUrl(id: number): Promise<string> {
const url = `${API_BASE_URL}/file/${id}/url`
const headers: Record<string, string> = {}
if (this.token) headers['Authorization'] = `Bearer ${this.token}`
// This endpoint either serves the file directly (local) or returns JSON with URL
// For simplicity, return the endpoint URL — browser can use it directly
return url
}
async updateFile(id: number, data: UpdateFileRequest): Promise<ApiResponse<FileInfo>> {
const rawData = await this.request<FileInfo>(`/file/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
if ('success' in rawData) return rawData as unknown as ApiResponse<FileInfo>
return { code: 200, message: 'success', success: true, data: rawData }
}
async deleteFile(id: number): Promise<ApiResponse<void>> {
return this.request<ApiResponse<void>>(`/file/${id}`, { method: 'DELETE' })
}
// Menu Management
async getCurrentMenus(): Promise<{ list: MenuItem[] }> {
const rawData = await this.request<{ list: MenuItem[] }>('/menus/current')
if ('success' in rawData) return (rawData as any).data || rawData
return rawData
}
async getMenuList(): Promise<{ list: MenuItem[] }> {
const rawData = await this.request<{ list: MenuItem[] }>('/menus')
if ('success' in rawData) return (rawData as any).data || rawData
return rawData
}
async createMenu(data: CreateMenuRequest): Promise<MenuItem> {
return this.request<MenuItem>('/menu', { method: 'POST', body: JSON.stringify(data) })
}
async updateMenu(id: number, data: UpdateMenuRequest): Promise<MenuItem> {
return this.request<MenuItem>(`/menu/${id}`, { method: 'PUT', body: JSON.stringify(data) })
}
async deleteMenu(id: number): Promise<ApiResponse<void>> {
return this.request<ApiResponse<void>>(`/menu/${id}`, { method: 'DELETE' })
}
// Role Management
async getRoles(): Promise<{ list: RoleInfo[] }> {
const rawData = await this.request<{ list: RoleInfo[] }>('/roles')
if ('success' in rawData) return (rawData as any).data || rawData
return rawData
}
async createRole(data: CreateRoleRequest): Promise<RoleInfo> {
return this.request<RoleInfo>('/role', { method: 'POST', body: JSON.stringify(data) })
}
async updateRole(id: number, data: UpdateRoleRequest): Promise<RoleInfo> {
return this.request<RoleInfo>(`/role/${id}`, { method: 'PUT', body: JSON.stringify(data) })
}
async deleteRole(id: number): Promise<ApiResponse<void>> {
return this.request<ApiResponse<void>>(`/role/${id}`, { method: 'DELETE' })
}
async getRoleMenus(roleId: number): Promise<{ menuIds: number[] }> {
const rawData = await this.request<{ menuIds: number[] }>(`/role/${roleId}/menus`)
if ('success' in rawData) return (rawData as any).data || rawData
return rawData
}
async setRoleMenus(roleId: number, menuIds: number[]): Promise<ApiResponse<void>> {
return this.request<ApiResponse<void>>(`/role/${roleId}/menus`, {
method: 'PUT', body: JSON.stringify({ menuIds }),
})
}
// Organization Management
async getOrganizations(): Promise<{ list: OrgInfo[] }> {
const rawData = await this.request<{ list: OrgInfo[] }>('/organizations')
if ('success' in rawData) return (rawData as any).data || rawData
return rawData
}
async createOrganization(data: CreateOrgRequest): Promise<OrgInfo> {
return this.request<OrgInfo>('/organization', { method: 'POST', body: JSON.stringify(data) })
}
async updateOrganization(id: number, data: UpdateOrgRequest): Promise<OrgInfo> {
return this.request<OrgInfo>(`/organization/${id}`, { method: 'PUT', body: JSON.stringify(data) })
}
async deleteOrganization(id: number): Promise<ApiResponse<void>> {
return this.request<ApiResponse<void>>(`/organization/${id}`, { method: 'DELETE' })
}
async getOrgMembers(orgId: number): Promise<{ list: OrgMember[] }> {
const rawData = await this.request<{ list: OrgMember[] }>(`/organization/${orgId}/members`)
if ('success' in rawData) return (rawData as any).data || rawData
return rawData
}
async addOrgMember(orgId: number, userId: number, roleId: number): Promise<ApiResponse<void>> {
return this.request<ApiResponse<void>>(`/organization/${orgId}/member`, {
method: 'POST', body: JSON.stringify({ userId, roleId }),
})
}
async updateOrgMember(orgId: number, userId: number, roleId: number): Promise<ApiResponse<void>> {
return this.request<ApiResponse<void>>(`/organization/${orgId}/member/${userId}`, {
method: 'PUT', body: JSON.stringify({ roleId }),
})
}
async removeOrgMember(orgId: number, userId: number): Promise<ApiResponse<void>> {
return this.request<ApiResponse<void>>(`/organization/${orgId}/member/${userId}`, {
method: 'DELETE',
})
}
// User Org Context
async getUserOrgs(): Promise<{ list: UserOrgInfo[] }> {
const rawData = await this.request<{ list: UserOrgInfo[] }>('/profile/orgs')
if ('success' in rawData) return (rawData as any).data || rawData
return rawData
}
async switchOrg(orgId: number): Promise<{ token: string }> {
const rawData = await this.request<{ token: string }>('/profile/current-org', {
method: 'PUT', body: JSON.stringify({ orgId }),
})
if ('success' in rawData) return (rawData as any).data || rawData
return rawData
}
// Health check
async healthCheck(): Promise<{ status: string }> {
try {

164
frontend/react-shadcn/pc/src/types/index.ts

@ -119,3 +119,167 @@ export interface Activity {
time: string
status: string
}
// File Types
export interface FileInfo {
id: number
name: string
key: string
size: number
mimeType: string
category: string
isPublic: boolean
userId: number
storageType: string
url: string
createdAt: string
updatedAt: string
}
export interface FileListRequest {
page?: number
pageSize?: number
keyword?: string
category?: string
mimeType?: string
}
export interface FileListResponse {
code: number
message: string
success: boolean
data: {
list: FileInfo[]
total: number
}
}
export interface UpdateFileRequest {
name?: string
category?: string
isPublic?: boolean
}
// Menu Types
export interface MenuItem {
id: number
parentId: number
name: string
path: string
icon: string
component: string
type: 'default' | 'config'
sortOrder: number
visible: boolean
status: number
children: MenuItem[]
createdAt: string
updatedAt: string
}
export interface CreateMenuRequest {
parentId?: number
name: string
path?: string
icon?: string
component?: string
type?: string
sortOrder?: number
visible?: boolean
}
export interface UpdateMenuRequest {
parentId?: number
name?: string
path?: string
icon?: string
component?: string
type?: string
sortOrder?: number
visible?: boolean
status?: number
}
// Role Types
export interface RoleInfo {
id: number
name: string
code: string
description: string
isSystem: boolean
sortOrder: number
status: number
createdAt: string
updatedAt: string
}
export interface CreateRoleRequest {
name: string
code: string
description?: string
sortOrder?: number
}
export interface UpdateRoleRequest {
name?: string
description?: string
sortOrder?: number
status?: number
}
// Organization Types
export interface OrgInfo {
id: number
parentId: number
name: string
code: string
leader: string
phone: string
email: string
sortOrder: number
status: number
memberCount: number
children: OrgInfo[]
createdAt: string
updatedAt: string
}
export interface CreateOrgRequest {
parentId?: number
name: string
code: string
leader?: string
phone?: string
email?: string
sortOrder?: number
}
export interface UpdateOrgRequest {
parentId?: number
name?: string
code?: string
leader?: string
phone?: string
email?: string
sortOrder?: number
status?: number
}
export interface OrgMember {
userId: number
username: string
email: string
phone: string
roleId: number
roleName: string
roleCode: string
createdAt: string
}
export interface UserOrgInfo {
orgId: number
orgName: string
roleId: number
roleName: string
roleCode: string
}

Loading…
Cancel
Save