Просмотр исходного кода

feat(web): 前端基础设施与路由(WorkspaceContext、API 客户端、鉴权上下文)

bob 1 месяц назад
Родитель
Сommit
b5fa8c3103
7 измененных файлов с 377 добавлено и 23 удалено
  1. 37 2
      src/App.tsx
  2. 3 3
      src/AuthContext.tsx
  3. 120 0
      src/WorkspaceContext.tsx
  4. 111 14
      src/api.ts
  5. 75 3
      src/components/Layout.tsx
  6. 2 1
      src/main.tsx
  7. 29 0
      src/utils/date.ts

+ 37 - 2
src/App.tsx

@@ -10,9 +10,15 @@ import { MarketingPage } from './pages/MarketingPage'
 import { AdminLayout } from './pages/admin/AdminLayout'
 import { AdminDashboard } from './pages/admin/AdminDashboard'
 import { AdminCourses } from './pages/admin/AdminCourses'
-import { AdminCourseDetail } from './pages/admin/AdminCourseDetail'
+import { AdminProductionQueue } from './pages/admin/AdminProductionQueue'
 import { AdminUsers } from './pages/admin/AdminUsers'
 import { AdminLoginPage } from './pages/admin/AdminLoginPage'
+import { AdminOrganizations, AdminOrganizationDetail } from './pages/admin/AdminOrganizations'
+import { OrganizationPage, InviteJoinPage } from './pages/OrganizationPage'
+import { OrganizationManage } from './pages/OrganizationManage'
+import { DigitalInstructorsPage } from './pages/DigitalInstructorsPage'
+import { AdminDigitalInstructors } from './pages/admin/AdminDigitalInstructors'
+import { DigitalInstructorEditorPage } from './pages/DigitalInstructorEditorPage'
 import { useAuth } from './AuthContext'
 
 export default function App() {
@@ -20,15 +26,41 @@ export default function App() {
 
   if (loading) return <div className="center-loading">正在加载星痕工坊…</div>
 
+  if (user?.mustChangePassword) return <Routes>
+    <Route element={<Layout />}><Route path="profile" element={<Profile />} /></Route>
+    <Route path="*" element={<Navigate to="/profile?tab=security" replace />} />
+  </Routes>
+
   return (
     <Routes>
       {/* ================= 管理员专属体系 (/admin/*) ================= */}
       <Route path="admin/login" element={<AdminLoginPage />} />
+      <Route path="join/:token" element={<InviteJoinPage />} />
+      {/* ================= 组织页面体系 ================= */}
+      {user ? (
+        <>
+          <Route element={<Layout />}>
+            <Route path="organizations/:slug" element={<OrganizationPage />} />
+          </Route>
+          <Route path="organizations/:slug/manage" element={<OrganizationManage />} />
+          <Route path="organizations/:slug/manage/courses/:id" element={<OrganizationManage />} />
+        </>
+      ) : (
+        <>
+          <Route path="organizations/:slug" element={<Navigate to="/" replace />} />
+          <Route path="organizations/:slug/manage" element={<Navigate to="/" replace />} />
+          <Route path="organizations/:slug/manage/courses/:id" element={<Navigate to="/" replace />} />
+        </>
+      )}
       <Route path="admin" element={<AdminLayout />}>
         <Route index element={<AdminDashboard />} />
         <Route path="courses" element={<AdminCourses />} />
-        <Route path="courses/:id" element={<AdminCourseDetail />} />
+        <Route path="production-queue" element={<AdminProductionQueue />} />
         <Route path="users" element={<AdminUsers />} />
+        <Route path="organizations" element={<AdminOrganizations />} />
+        <Route path="digital-instructors" element={<AdminDigitalInstructors />} />
+        <Route path="digital-instructors/:id/edit" element={<DigitalInstructorEditorPage admin />} />
+        <Route path="organizations/:id" element={<AdminOrganizationDetail />} />
         <Route path="*" element={<Navigate to="/admin" replace />} />
       </Route>
 
@@ -40,6 +72,9 @@ export default function App() {
         <Route element={<Layout />}>
           <Route index element={<Dashboard />} />
           <Route path="courses/new" element={<NewCourse />} />
+          <Route path="digital-instructors" element={<DigitalInstructorsPage />} />
+          <Route path="digital-instructors/new" element={<DigitalInstructorEditorPage />} />
+          <Route path="digital-instructors/:id/edit" element={<DigitalInstructorEditorPage />} />
           <Route path="courses/:id" element={<CourseWorkspace />} />
           <Route path="profile" element={<Profile />} />
           <Route path="help" element={<HelpPage />} />

+ 3 - 3
src/AuthContext.tsx

@@ -5,7 +5,7 @@ import type { User } from './types'
 type AuthContextType = {
   user: User | null
   loading: boolean
-  authenticate: (mode: 'login' | 'register', phone: string, password: string) => Promise<void>
+  authenticate: (mode: 'login' | 'register', phone: string, password: string, contactName?: string, inviteToken?: string) => Promise<void>
   adminLogin: (phone: string, password: string) => Promise<User>
   logout: () => Promise<void>
   setUser: (u: User) => void
@@ -32,8 +32,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
     return () => removeEventListener('auth-expired', load)
   }, [])
 
-  async function authenticate(mode: 'login' | 'register', phone: string, password: string) {
-    const r = await api[mode](phone, password)
+  async function authenticate(mode: 'login' | 'register', phone: string, password: string, contactName?: string, inviteToken?: string) {
+    const r = mode === 'register' ? await api.register(phone, password, contactName || '', inviteToken) : await api.login(phone, password)
     localStorage.setItem('xinghen_token', r.token)
     setUser(r.user)
   }

+ 120 - 0
src/WorkspaceContext.tsx

@@ -0,0 +1,120 @@
+import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
+import { api } from './api'
+import { useAuth } from './AuthContext'
+import type { Organization } from './types'
+
+type WorkspaceContextValue = {
+  organizations: Organization[]
+  workspaceId: string
+  currentOrganization: Organization | null
+  selectWorkspace: (id: string) => void
+  refreshOrganizations: () => Promise<void>
+}
+
+const WorkspaceContext = createContext<WorkspaceContextValue>(null!)
+
+const USER_WORKSPACE_KEY = (userId: string) => `xinghen_workspace_user_${userId}`
+
+export function WorkspaceProvider({ children }: { children: ReactNode }) {
+  const { user } = useAuth()
+  const [organizations, setOrganizations] = useState<Organization[]>([])
+  const [workspaceId, setWorkspaceId] = useState<string>(() => {
+    return localStorage.getItem('xinghen_workspace') || 'personal'
+  })
+
+  // 当 user 变动时(登录/登出/切换用户):
+  // 1. 若登出或管理员,重置状态
+  // 2. 若普通用户登录,优先尝试同步预设其上次选择的工作区,避免闪烁
+  useEffect(() => {
+    if (!user || user.role === 'ADMIN') {
+      setOrganizations([])
+      if (workspaceId !== 'personal') {
+        localStorage.setItem('xinghen_workspace', 'personal')
+        setWorkspaceId('personal')
+      }
+      return
+    }
+
+    const saved = localStorage.getItem(USER_WORKSPACE_KEY(user.id))
+    if (saved && saved !== workspaceId) {
+      localStorage.setItem('xinghen_workspace', saved)
+      setWorkspaceId(saved)
+    }
+
+    refreshOrganizations().catch(() => setOrganizations([]))
+  }, [user?.id])
+
+  async function refreshOrganizations() {
+    if (!user || user.role === 'ADMIN') {
+      setOrganizations([])
+      if (workspaceId !== 'personal') {
+        localStorage.setItem('xinghen_workspace', 'personal')
+        setWorkspaceId('personal')
+      }
+      return
+    }
+
+    const result = await api.organizations.mine()
+    const orgs = result.organizations || []
+    setOrganizations(orgs)
+
+    const saved = localStorage.getItem(USER_WORKSPACE_KEY(user.id))
+
+    let targetWorkspace = 'personal'
+    if (saved) {
+      // 用户之前已做过选择
+      if (saved === 'personal') {
+        targetWorkspace = 'personal'
+      } else if (orgs.some((org) => org.id === saved)) {
+        targetWorkspace = saved
+      } else {
+        // 用户上次选择的组织已不存在(可能被移除或解散),安全回退到首个可用组织或个人工作区
+        const firstActiveOrg = orgs.find((org) => org.isActive) || orgs[0]
+        targetWorkspace = firstActiveOrg ? firstActiveOrg.id : 'personal'
+        localStorage.setItem(USER_WORKSPACE_KEY(user.id), targetWorkspace)
+      }
+    } else {
+      // 用户首次登录或未曾显式选择:如果属于组织,默认进入该人的第一个组织;否则进入个人工作区
+      const firstActiveOrg = orgs.find((org) => org.isActive) || orgs[0]
+      targetWorkspace = firstActiveOrg ? firstActiveOrg.id : 'personal'
+      localStorage.setItem(USER_WORKSPACE_KEY(user.id), targetWorkspace)
+    }
+
+    localStorage.setItem('xinghen_workspace', targetWorkspace)
+    setWorkspaceId(targetWorkspace)
+  }
+
+  useEffect(() => {
+    const refresh = () => { refreshOrganizations().catch(() => {}) }
+    window.addEventListener('workspace-updated', refresh)
+    window.addEventListener('focus', refresh)
+    return () => {
+      window.removeEventListener('workspace-updated', refresh)
+      window.removeEventListener('focus', refresh)
+    }
+  }, [user?.id, workspaceId])
+
+  function selectWorkspace(id: string) {
+    if (user?.id) {
+      localStorage.setItem(USER_WORKSPACE_KEY(user.id), id)
+    }
+    localStorage.setItem('xinghen_workspace', id)
+    setWorkspaceId(id)
+  }
+
+  return (
+    <WorkspaceContext.Provider
+      value={{
+        organizations,
+        workspaceId,
+        currentOrganization: organizations.find((org) => org.id === workspaceId) || null,
+        selectWorkspace,
+        refreshOrganizations
+      }}
+    >
+      {children}
+    </WorkspaceContext.Provider>
+  )
+}
+
+export const useWorkspace = () => useContext(WorkspaceContext)

+ 111 - 14
src/api.ts

@@ -1,10 +1,11 @@
-import type { AdminStats, Course, CourseStatus, CourseTemplate, PointsTransaction, User } from './types'
+import type { AdminStats, Course, CourseStatus, CourseTemplate, DigitalInstructor, Organization, PointsTransaction, User } from './types'
 
 const token = () => localStorage.getItem('xinghen_token')
 
 async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
   const headers = new Headers(init.headers)
   if (token()) headers.set('Authorization', `Bearer ${token()}`)
+  if (!headers.has('X-Workspace-Id')) headers.set('X-Workspace-Id', localStorage.getItem('xinghen_workspace') || 'personal')
   const response = await fetch(`/api${path}`, { ...init, headers })
   if (!response.ok) {
     const body = await response.json().catch(() => ({ message: '请求失败,请稍后重试' }))
@@ -14,16 +15,94 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
     }
     throw new Error(body.message || '请求失败,请稍后重试')
   }
-  return response.json() as Promise<T>
+  const result = await response.json() as T
+  if (init.method && init.method !== 'GET') window.dispatchEvent(new Event('workspace-updated'))
+  return result
+}
+
+export async function downloadPrivateFile(url: string, filename: string) {
+  const response = await fetch(url.replace('/uploads/', '/api/files/'), {
+    headers: { Authorization: `Bearer ${token()}` }
+  })
+  if (!response.ok) throw new Error('文件下载失败')
+  const objectUrl = URL.createObjectURL(await response.blob())
+  const link = document.createElement('a')
+  link.href = objectUrl
+  link.download = filename
+  link.click()
+  setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000)
+}
+
+export async function privateImageUrl(url: string) {
+  const response = await fetch(url.replace('/uploads/', '/api/files/'), {
+    headers: { Authorization: `Bearer ${token()}` }
+  })
+  if (!response.ok) throw new Error('图片不可访问')
+  return URL.createObjectURL(await response.blob())
 }
 
 export const api = {
+  digitalInstructors: {
+    list: (workspaceId?: string) => request<{ instructors: DigitalInstructor[] }>('/digital-instructors', workspaceId ? { headers: { 'X-Workspace-Id': workspaceId } } : {}),
+    get: (id: string, workspaceId?: string) => request<{ instructor: DigitalInstructor }>(`/digital-instructors/${id}`, workspaceId ? { headers: { 'X-Workspace-Id': workspaceId } } : {}),
+    usage: (id: string, workspaceId?: string) => request<{ courses: { id: string; name: string; status: string }[] }>(`/digital-instructors/${id}/usage`, workspaceId ? { headers: { 'X-Workspace-Id': workspaceId } } : {}),
+    create: (data: { name: string; note: string; images: File[]; audio?: File | null; coverNewIndex?: number }) => {
+      const form = new FormData()
+      form.append('name', data.name)
+      form.append('note', data.note)
+      if (data.coverNewIndex !== undefined) form.append('coverNewIndex', String(data.coverNewIndex))
+      data.images.forEach(file => form.append('images', file))
+      if (data.audio) form.append('audio', data.audio)
+      return request<{ instructor: DigitalInstructor }>('/digital-instructors', { method: 'POST', body: form })
+    },
+    update: (id: string, data: Pick<DigitalInstructor, 'name' | 'note'>, workspaceId?: string) => request<{ instructor: DigitalInstructor }>(`/digital-instructors/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', ...(workspaceId ? { 'X-Workspace-Id': workspaceId } : {}) }, body: JSON.stringify(data) }),
+    manage: (id: string, data: { name: string; note: string; ownerId?: string; images: File[]; audio: File | null; removeImageIds: string[]; coverImageId?: string; coverNewIndex?: number }, workspaceId?: string) => {
+      const form = new FormData()
+      form.append('name', data.name); form.append('note', data.note)
+      if (data.ownerId) form.append('ownerId', data.ownerId)
+      form.append('removeImageIds', JSON.stringify(data.removeImageIds))
+      if (data.coverImageId) form.append('coverImageId', data.coverImageId)
+      if (data.coverNewIndex !== undefined) form.append('coverNewIndex', String(data.coverNewIndex))
+      data.images.forEach(file => form.append('images', file))
+      if (data.audio) form.append('audio', data.audio)
+      return request<{ instructor: DigitalInstructor }>(`/digital-instructors/${id}/manage`, { method: 'POST', body: form, ...(workspaceId ? { headers: { 'X-Workspace-Id': workspaceId } } : {}) })
+    },
+    owner: (id: string, ownerId: string, workspaceId?: string) => request<{ instructor: DigitalInstructor }>(`/digital-instructors/${id}/owner`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', ...(workspaceId ? { 'X-Workspace-Id': workspaceId } : {}) }, body: JSON.stringify({ ownerId }) }),
+    copy: (id: string, organizationId: string) => request<{ instructor: DigitalInstructor }>(`/digital-instructors/${id}/copy-to-organization`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ organizationId }) }),
+    duplicate: (id: string, workspaceId?: string) => request<{ instructor: DigitalInstructor }>(`/digital-instructors/${id}/duplicate`, { method: 'POST', ...(workspaceId ? { headers: { 'X-Workspace-Id': workspaceId } } : {}) }),
+    delete: (id: string, workspaceId?: string) => request<{ ok: true }>(`/digital-instructors/${id}`, { method: 'DELETE', ...(workspaceId ? { headers: { 'X-Workspace-Id': workspaceId } } : {}) })
+  },
+  organizations: {
+    mine: () => request<{ organizations: Organization[] }>('/me/organizations'),
+    publicInfo: (slug: string) => request<{ organization: { id: string; name: string; isActive: boolean } | null }>(`/organizations/${slug}`),
+    content: (id: string) => request<any>(`/organizations/${id}/content`),
+    apply: (slug: string) => request<{ ok: boolean }>(`/organizations/${slug}/apply`, { method: 'POST' }),
+    join: (inviteToken: string) => request<{ organizationId: string }>(`/organizations/join/${inviteToken}`, { method: 'POST' }),
+    manage: (id: string) => request<any>(`/organizations/${id}/manage`),
+    review: (id: string, applicationId: string, approve: boolean) => request<{ ok: boolean }>(`/organizations/${id}/applications/${applicationId}/review`, {
+      method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ approve })
+    }),
+    createUser: (id: string, phone: string, contactName: string) => request<{ user: User; initialPassword: string }>(`/organizations/${id}/users`, {
+      method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phone, contactName })
+    }),
+    updateMemberName: (id: string, userId: string, contactName: string) => request<{ user: User }>(`/organizations/${id}/members/${userId}`, {
+      method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contactName })
+    }),
+    rotateInvite: (id: string) => request<{ token: string }>(`/organizations/${id}/invite`, { method: 'POST' }),
+    disableInvite: (id: string) => request<{ ok: boolean }>(`/organizations/${id}/invite`, { method: 'DELETE' }),
+    points: async (id: string) => {
+      const result = await request<{ balance: number; transactions: any[] }>(`/organizations/${id}/points`)
+      return { balance: result.balance, transactions: result.transactions.map((row) => ({
+        id: row.id, courseId: row.course_id, courseName: row.course_name || row.reason, points: row.amount, createdAt: row.created_at
+      })) as PointsTransaction[] }
+    }
+  },
   // 用户鉴权与信息
-  register: (phone: string, password: string) =>
+  register: (phone: string, password: string, contactName: string, inviteToken?: string) =>
     request<{ token: string; user: User }>('/auth/register', {
       method: 'POST',
       headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ phone, password })
+      body: JSON.stringify({ phone, password, contactName, inviteToken })
     }),
   login: (phone: string, password: string) =>
     request<{ token: string; user: User }>('/auth/login', {
@@ -35,7 +114,7 @@ export const api = {
   me: () => request<{ user: User }>('/me'),
   getPointsTransactions: () =>
     request<{ balance: number; transactions: PointsTransaction[] }>('/me/points-transactions'),
-  updateMe: (body: Pick<User, 'organization' | 'wechat' | 'contactName' | 'bio'>) =>
+  updateMe: (body: Pick<User, 'wechat' | 'contactName' | 'bio'>) =>
     request<{ user: User }>('/me', {
       method: 'PATCH',
       headers: { 'Content-Type': 'application/json' },
@@ -59,14 +138,14 @@ export const api = {
 
   // 普通用户课程操作
   listCourses: () => request<{ courses: Course[] }>('/courses'),
-  createCourse: (body: Pick<Course, 'name' | 'category' | 'description'>) =>
+  createCourse: (body: Pick<Course, 'name' | 'category' | 'description'> & { digitalInstructorId?: string | null }) =>
     request<{ course: Course }>('/courses', {
       method: 'POST',
       headers: { 'Content-Type': 'application/json' },
       body: JSON.stringify(body)
     }),
   getCourse: (id: string) => request<{ course: Course }>(`/courses/${id}`),
-  updateCourse: (id: string, body: Pick<Course, 'name' | 'category' | 'description'>) =>
+  updateCourse: (id: string, body: Pick<Course, 'name' | 'category' | 'description'> & { digitalInstructorId?: string | null }) =>
     request<{ course: Course }>(`/courses/${id}`, {
       method: 'PATCH',
       headers: { 'Content-Type': 'application/json' },
@@ -133,6 +212,30 @@ export const api = {
 
   // 管理员后台专区 API
   admin: {
+    organizations: () => request<{ organizations: any[] }>('/admin/organizations'),
+    createOrganization: (name: string, slug: string) => request<any>('/admin/organizations', {
+      method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, slug })
+    }),
+    organization: (id: string) => request<any>(`/admin/organizations/${id}`),
+    renameOrganization: (id: string, name: string) => request<any>(`/admin/organizations/${id}`, {
+      method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name })
+    }),
+    updateOrganizationSlug: (id: string, slug: string) => request<any>(`/admin/organizations/${id}`, {
+      method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ slug })
+    }),
+    setOrganizationActive: (id: string, isActive: boolean) => request<any>(`/admin/organizations/${id}`, {
+      method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ isActive })
+    }),
+    setOrganizationMember: (id: string, userId: string, role: 'MEMBER' | 'MANAGER') => request<any>(`/admin/organizations/${id}/members/${userId}`, {
+      method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role })
+    }),
+    removeOrganizationMember: (id: string, userId: string) => request<any>(`/admin/organizations/${id}/members/${userId}`, { method: 'DELETE' }),
+    reviewOrganizationApplication: (id: string, applicationId: string, approve: boolean) => request<any>(`/admin/organizations/${id}/applications/${applicationId}/review`, {
+      method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ approve })
+    }),
+    adjustOrganizationPoints: (id: string, amount: number, reason: string) => request<any>(`/admin/organizations/${id}/points`, {
+      method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ amount, reason })
+    }),
     login: (phone: string, password: string) =>
       request<{ token: string; user: User }>('/admin/auth/login', {
         method: 'POST',
@@ -178,7 +281,7 @@ export const api = {
       const qs = keyword ? `?keyword=${encodeURIComponent(keyword)}` : ''
       return request<{ users: User[] }>(`/admin/users${qs}`)
     },
-    createUser: (body: { phone: string; password: string; contactName?: string; organization?: string }) =>
+    createUser: (body: { phone: string; password: string; contactName?: string }) =>
       request<{ user: User }>('/admin/users', {
         method: 'POST',
         headers: { 'Content-Type': 'application/json' },
@@ -189,12 +292,6 @@ export const api = {
         method: 'PATCH',
         headers: { 'Content-Type': 'application/json' },
         body: JSON.stringify({ password })
-      }),
-    updateUserRole: (id: string, role: 'USER' | 'ADMIN') =>
-      request<{ user: User }>(`/admin/users/${id}/role`, {
-        method: 'PATCH',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ role })
       })
   }
 }

+ 75 - 3
src/components/Layout.tsx

@@ -1,11 +1,45 @@
-import { CircleHelp, LogOut, UserRound } from 'lucide-react'
+import { ChevronDown, CircleHelp, LogOut, ShieldCheck, UserRound } from 'lucide-react'
+import { useEffect, useRef, useState } from 'react'
 import { NavLink, Outlet } from 'react-router-dom'
 import { Brand } from './Brand'
 import { Footer } from './Footer'
 import { useAuth } from '../AuthContext'
+import { useWorkspace } from '../WorkspaceContext'
+import { useNavigate } from 'react-router-dom'
 
 export function Layout() {
   const { user, logout } = useAuth()
+  const { organizations, workspaceId, selectWorkspace, currentOrganization } = useWorkspace()
+  const navigate = useNavigate()
+  const [isWorkspaceMenuOpen, setIsWorkspaceMenuOpen] = useState(false)
+  const workspaceMenuRef = useRef<HTMLDivElement>(null)
+  const personalWorkspaceName = user?.contactName || '个人工作区'
+  const currentWorkspaceName = currentOrganization?.name || personalWorkspaceName
+  const hasMultipleWorkspaces = organizations.length > 0
+
+  useEffect(() => {
+    if (!isWorkspaceMenuOpen) return
+
+    const closeWorkspaceMenu = (event: PointerEvent) => {
+      if (!workspaceMenuRef.current?.contains(event.target as Node)) setIsWorkspaceMenuOpen(false)
+    }
+
+    window.addEventListener('pointerdown', closeWorkspaceMenu)
+    return () => window.removeEventListener('pointerdown', closeWorkspaceMenu)
+  }, [isWorkspaceMenuOpen])
+
+  const switchWorkspace = (id: string) => {
+    selectWorkspace(id)
+    setIsWorkspaceMenuOpen(false)
+    navigate('/')
+  }
+
+  const isOrgManager = Boolean(
+    currentOrganization?.role === 'MANAGER' || organizations.some((org) => org.role === 'MANAGER')
+  )
+  const managerBadgeTitle = currentOrganization?.role === 'MANAGER'
+    ? `组织管理员 · ${currentOrganization.name}`
+    : '组织管理员'
 
   return (
     <div className="app-shell">
@@ -14,19 +48,57 @@ export function Layout() {
           <Brand />
         </NavLink>
         <nav>
+          <div className="workspace-switcher" ref={workspaceMenuRef}>
+            {hasMultipleWorkspaces ? (
+              <>
+                <button
+                  type="button"
+                  className="workspace-switcher-trigger"
+                  aria-haspopup="menu"
+                  aria-expanded={isWorkspaceMenuOpen}
+                  onClick={() => setIsWorkspaceMenuOpen((open) => !open)}
+                >
+                  <span>{currentWorkspaceName}</span>
+                  <ChevronDown size={16} aria-hidden="true" />
+                </button>
+                {isWorkspaceMenuOpen && (
+                  <div className="workspace-switcher-menu" role="menu" aria-label="切换工作区">
+                    <button type="button" role="menuitem" className={workspaceId === 'personal' ? 'active' : ''} onClick={() => switchWorkspace('personal')}>
+                      {personalWorkspaceName}
+                    </button>
+                    {organizations.map((organization) => (
+                      <button type="button" role="menuitem" className={workspaceId === organization.id ? 'active' : ''} onClick={() => switchWorkspace(organization.id)} key={organization.id}>
+                        {organization.name}{organization.isActive ? '' : '(已停用)'}
+                      </button>
+                    ))}
+                  </div>
+                )}
+              </>
+            ) : (
+              <span className="workspace-switcher-current">{currentWorkspaceName}</span>
+            )}
+          </div>
           <NavLink to="/" end>
-            我的课程
+            课程
           </NavLink>
+          <NavLink to="/digital-instructors">数字讲师</NavLink>
+          {currentOrganization && <NavLink to={`/organizations/${currentOrganization.slug}`}>机构主页</NavLink>}
         </nav>
         <div className="top-actions">
           {user?.role !== 'ADMIN' && (
             <NavLink to="/profile?tab=points" className="points-balance-badge" title="查看积分消费记录">
-              积分 {user?.pointsBalance ?? 0}
+              积分 {currentOrganization ? currentOrganization.pointsBalance : user?.pointsBalance ?? 0}
             </NavLink>
           )}
           <NavLink to="/profile" className="user-link">
             <UserRound size={16} />
             <span>{user?.contactName || user?.phone}</span>
+            {isOrgManager && (
+              <span className="topbar-role-badge" title={managerBadgeTitle}>
+                <ShieldCheck size={12} />
+                <span>管理员</span>
+              </span>
+            )}
           </NavLink>
           <NavLink to="/help" className="icon-button" title="使用帮助">
             <CircleHelp size={19} />

+ 2 - 1
src/main.tsx

@@ -3,8 +3,9 @@ import ReactDOM from 'react-dom/client'
 import { BrowserRouter } from 'react-router-dom'
 import App from './App'
 import { AuthProvider } from './AuthContext'
+import { WorkspaceProvider } from './WorkspaceContext'
 import './styles.css'
 
 ReactDOM.createRoot(document.getElementById('root')!).render(
-  <React.StrictMode><BrowserRouter><AuthProvider><App /></AuthProvider></BrowserRouter></React.StrictMode>,
+  <React.StrictMode><BrowserRouter><AuthProvider><WorkspaceProvider><App /></WorkspaceProvider></AuthProvider></BrowserRouter></React.StrictMode>,
 )

+ 29 - 0
src/utils/date.ts

@@ -0,0 +1,29 @@
+export function formatDateTime(dateStr?: string | null): string {
+  if (!dateStr) return '-'
+  try {
+    const d = new Date(dateStr)
+    if (isNaN(d.getTime())) return '-'
+    const y = d.getFullYear()
+    const m = String(d.getMonth() + 1).padStart(2, '0')
+    const day = String(d.getDate()).padStart(2, '0')
+    const h = String(d.getHours()).padStart(2, '0')
+    const min = String(d.getMinutes()).padStart(2, '0')
+    return `${y}-${m}-${day} ${h}:${min}`
+  } catch {
+    return '-'
+  }
+}
+
+export function formatDate(dateStr?: string | null): string {
+  if (!dateStr) return '-'
+  try {
+    const d = new Date(dateStr)
+    if (isNaN(d.getTime())) return '-'
+    const y = d.getFullYear()
+    const m = String(d.getMonth() + 1).padStart(2, '0')
+    const day = String(d.getDate()).padStart(2, '0')
+    return `${y}-${m}-${day}`
+  } catch {
+    return '-'
+  }
+}