From 1ae4f92804c630f8801b1a1746ce05a86fac6277 Mon Sep 17 00:00:00 2001 From: Freedom <459102951@qq.com> Date: Mon, 13 Apr 2026 11:14:04 +0800 Subject: [PATCH 1/3] chore: update dependencies and enhance application structure - Add @remixicon/react package for icon support - Introduce @trivago/prettier-plugin-sort-imports for improved import organization - Refactor app layout to include SettingsModalProvider and SettingsDialog for better settings management - Update TypeScript configuration to enable JSON module resolution - Enhance API structure by exporting additional modules for better organization - Implement internationalization support in various components for improved user experience --- package.json | 3 +- pnpm-lock.yaml | 12 + src/api/admin.ts | 12 + src/api/index.ts | 4 + src/api/permission.ts | 8 + src/api/request.ts | 139 +++-- src/api/role.ts | 29 + src/api/user.ts | 31 +- src/api/workspace.ts | 66 ++ src/app.tsx | 54 +- src/components/bulk-selection-bar.tsx | 44 +- src/components/chart-area-interactive.tsx | 43 +- src/components/command-menu.tsx | 88 +-- src/components/data-table/pagination.tsx | 23 +- src/components/field-layout.tsx | 95 +++ src/components/layout/app-layout.tsx | 20 +- src/components/layout/app-sidebar.tsx | 49 +- src/components/layout/breadcrumb.tsx | 66 +- src/components/layout/data/sidebar-data.ts | 68 ++- src/components/layout/header.tsx | 6 +- src/components/layout/nav-group.tsx | 166 +++-- src/components/layout/nav-user.tsx | 22 +- src/components/layout/types.ts | 25 +- src/components/layout/workspace-switcher.tsx | 178 ++++++ src/components/no-permission.tsx | 26 + src/components/require-permission.tsx | 35 ++ src/components/search.tsx | 9 +- src/components/ui/alert-dialog.tsx | 2 +- src/components/ui/avatar.tsx | 83 +-- src/components/ui/command.tsx | 2 +- src/components/ui/dialog.tsx | 214 ++++--- src/components/ui/drawer.tsx | 2 +- src/components/ui/input.tsx | 2 +- src/components/ui/sidebar.tsx | 2 +- src/components/ui/textarea.tsx | 2 +- src/contexts/auth-context.tsx | 250 +++++--- src/contexts/settings-modal-context.tsx | 62 ++ src/hooks/use-permission.ts | 30 + src/hooks/useSSEConnection.ts | 25 +- src/i18n/index.ts | 99 +++ src/locales/en/common.json | 80 +++ src/locales/en/files.json | 301 +++++++++ src/locales/en/home.json | 44 ++ src/locales/en/layout.json | 48 ++ src/locales/en/login.json | 69 +++ src/locales/en/settings.json | 276 +++++++++ src/locales/en/share.json | 60 ++ src/locales/en/storage.json | 68 +++ src/locales/en/transfer.json | 79 +++ src/locales/en/workspace.json | 22 + src/locales/zh/common.json | 80 +++ src/locales/zh/files.json | 301 +++++++++ src/locales/zh/home.json | 44 ++ src/locales/zh/layout.json | 48 ++ src/locales/zh/login.json | 69 +++ src/locales/zh/settings.json | 276 +++++++++ src/locales/zh/share.json | 60 ++ src/locales/zh/storage.json | 68 +++ src/locales/zh/transfer.json | 79 +++ src/locales/zh/workspace.json | 22 + src/main.tsx | 1 + .../files/components/CreateFolderModal.tsx | 12 +- .../files/components/DeleteConfirmDialog.tsx | 24 +- src/pages/files/components/FileBreadcrumb.tsx | 35 +- .../files/components/FileBulkSelectionBar.tsx | 221 +++---- .../files/components/FileDetailModal.tsx | 31 +- src/pages/files/components/FileGridView.tsx | 338 +++++----- src/pages/files/components/FileListView.tsx | 348 ++++++----- src/pages/files/components/MoveModal.tsx | 34 +- src/pages/files/components/MySharesView.tsx | 411 +++++++------ src/pages/files/components/RecycleBinView.tsx | 224 ++++--- src/pages/files/components/RenameModal.tsx | 12 +- src/pages/files/components/ShareModal.tsx | 198 +++--- src/pages/files/components/Toolbar.tsx | 17 +- src/pages/files/components/UploadModal.tsx | 34 +- src/pages/files/components/UploadPanel.tsx | 17 +- src/pages/files/hooks/useFileDragDrop.ts | 1 + src/pages/files/hooks/useFileList.ts | 11 +- src/pages/files/hooks/useFileOperations.ts | 42 +- src/pages/files/index.tsx | 164 +++-- .../home/components/open-source-card.tsx | 11 +- .../home/components/recent-files-table.tsx | 35 +- src/pages/home/components/section-cards.tsx | 36 +- .../home/components/storage-usage-card.tsx | 18 +- src/pages/home/index.tsx | 13 +- .../components/ForgotPasswordContent.tsx | 32 +- src/pages/login/components/LoginForm.tsx | 12 +- .../login/components/LoginFormContent.tsx | 270 ++++++-- .../components/LoginLanguageSwitcher.tsx | 36 ++ .../login/components/RegisterFormContent.tsx | 68 ++- src/pages/login/index.tsx | 57 +- src/pages/not-found/index.tsx | 6 +- src/pages/profile/index.tsx | 7 +- src/pages/settings/account/account-form.tsx | 302 --------- src/pages/settings/account/index.tsx | 21 - .../settings/appearance/appearance-form.tsx | 465 ++++++++------ src/pages/settings/appearance/index.tsx | 25 +- .../settings/components/content-section.tsx | 34 +- .../components/settings-page-header.tsx | 39 ++ .../settings/components/settings-row.tsx | 74 +++ src/pages/settings/components/sidebar-nav.tsx | 173 ++++-- src/pages/settings/index.tsx | 64 +- src/pages/settings/members/index.tsx | 502 +++++++++++++++ src/pages/settings/members/invite-dialog.tsx | 152 +++++ .../settings/members/role-option-label.tsx | 43 ++ src/pages/settings/profile/account-forms.tsx | 427 +++++++++++++ .../profile/account-security-section.tsx | 101 +++ src/pages/settings/profile/index.tsx | 98 ++- src/pages/settings/profile/profile-form.tsx | 188 ++++-- src/pages/settings/roles/index.tsx | 269 ++++++++ src/pages/settings/roles/role-dialog.tsx | 317 ++++++++++ src/pages/settings/settings-dialog.tsx | 192 ++++++ src/pages/settings/transfer/index.tsx | 25 +- src/pages/settings/transfer/transfer-form.tsx | 576 +++++++++++------- src/pages/settings/workspace/index.tsx | 259 ++++++++ .../share/components/ShareFileGridView.tsx | 6 +- .../share/components/ShareFileListView.tsx | 14 +- src/pages/share/index.tsx | 109 ++-- .../storage/components/AddStorageModal.tsx | 68 ++- .../storage/components/StorageSettingCard.tsx | 231 ++++--- src/pages/storage/index.tsx | 146 ++--- .../components/TransferSettingModal.tsx | 77 +-- .../transfer/components/TransferTable.tsx | 91 +-- src/pages/transfer/index.tsx | 48 +- src/pages/workspace/new.tsx | 186 ++++++ src/router/index.tsx | 281 +++++++-- src/store/transfer.ts | 12 +- src/store/user.ts | 29 +- src/store/workspace.ts | 55 ++ src/types/index.ts | 3 + src/types/permission.ts | 25 + src/types/role.ts | 35 ++ src/types/user.ts | 33 +- src/types/workspace.ts | 71 +++ src/utils/format.ts | 5 +- src/utils/md5.ts | 8 +- src/utils/merge-user-info.ts | 16 + src/utils/preview.ts | 5 +- tsconfig.app.json | 1 + 139 files changed, 9834 insertions(+), 3033 deletions(-) create mode 100644 src/api/admin.ts create mode 100644 src/api/permission.ts create mode 100644 src/api/role.ts create mode 100644 src/api/workspace.ts create mode 100644 src/components/field-layout.tsx create mode 100644 src/components/layout/workspace-switcher.tsx create mode 100644 src/components/no-permission.tsx create mode 100644 src/components/require-permission.tsx create mode 100644 src/contexts/settings-modal-context.tsx create mode 100644 src/hooks/use-permission.ts create mode 100644 src/i18n/index.ts create mode 100644 src/locales/en/common.json create mode 100644 src/locales/en/files.json create mode 100644 src/locales/en/home.json create mode 100644 src/locales/en/layout.json create mode 100644 src/locales/en/login.json create mode 100644 src/locales/en/settings.json create mode 100644 src/locales/en/share.json create mode 100644 src/locales/en/storage.json create mode 100644 src/locales/en/transfer.json create mode 100644 src/locales/en/workspace.json create mode 100644 src/locales/zh/common.json create mode 100644 src/locales/zh/files.json create mode 100644 src/locales/zh/home.json create mode 100644 src/locales/zh/layout.json create mode 100644 src/locales/zh/login.json create mode 100644 src/locales/zh/settings.json create mode 100644 src/locales/zh/share.json create mode 100644 src/locales/zh/storage.json create mode 100644 src/locales/zh/transfer.json create mode 100644 src/locales/zh/workspace.json create mode 100644 src/pages/login/components/LoginLanguageSwitcher.tsx delete mode 100644 src/pages/settings/account/account-form.tsx delete mode 100644 src/pages/settings/account/index.tsx create mode 100644 src/pages/settings/components/settings-page-header.tsx create mode 100644 src/pages/settings/components/settings-row.tsx create mode 100644 src/pages/settings/members/index.tsx create mode 100644 src/pages/settings/members/invite-dialog.tsx create mode 100644 src/pages/settings/members/role-option-label.tsx create mode 100644 src/pages/settings/profile/account-forms.tsx create mode 100644 src/pages/settings/profile/account-security-section.tsx create mode 100644 src/pages/settings/roles/index.tsx create mode 100644 src/pages/settings/roles/role-dialog.tsx create mode 100644 src/pages/settings/settings-dialog.tsx create mode 100644 src/pages/settings/workspace/index.tsx create mode 100644 src/pages/workspace/new.tsx create mode 100644 src/store/workspace.ts create mode 100644 src/types/permission.ts create mode 100644 src/types/role.ts create mode 100644 src/types/workspace.ts create mode 100644 src/utils/merge-user-info.ts diff --git a/package.json b/package.json index e8fa172..b1dbdaa 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.8", + "@remixicon/react": "^4.9.0", "@tailwindcss/vite": "^4.2.2", "@tanstack/react-query": "^5.20.0", "@tanstack/react-table": "^8.21.3", @@ -74,13 +75,13 @@ "zustand": "^5.0.10" }, "devDependencies": { + "@trivago/prettier-plugin-sort-imports": "^6.0.0", "@types/node": "^20.11.19", "@types/nprogress": "^0.2.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@types/react-router-dom": "^5.3.3", "@types/spark-md5": "^3.0.4", - "@trivago/prettier-plugin-sort-imports": "^6.0.0", "@vitejs/plugin-react": "^6.0.1", "autoprefixer": "^10.4.17", "eslint": "^9.39.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1b0bc3..748cfa7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,6 +86,9 @@ importers: '@radix-ui/react-tooltip': specifier: ^1.2.8 version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@remixicon/react': + specifier: ^4.9.0 + version: 4.9.0(react@19.2.4) '@tailwindcss/vite': specifier: ^4.2.2 version: 4.2.2(vite@8.0.2(@types/node@20.19.37)(jiti@2.6.1)) @@ -1020,6 +1023,11 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@remixicon/react@4.9.0': + resolution: {integrity: sha512-5/jLDD4DtKxH2B4QVXTobvV1C2uL8ab9D5yAYNtFt+w80O0Ys1xFOrspqROL3fjrZi+7ElFUWE37hBfaAl6U+Q==} + peerDependencies: + react: '>=18.2.0' + '@rolldown/binding-android-arm64@1.0.0-rc.11': resolution: {integrity: sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3379,6 +3387,10 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@remixicon/react@4.9.0(react@19.2.4)': + dependencies: + react: 19.2.4 + '@rolldown/binding-android-arm64@1.0.0-rc.11': optional: true diff --git a/src/api/admin.ts b/src/api/admin.ts new file mode 100644 index 0000000..970df6c --- /dev/null +++ b/src/api/admin.ts @@ -0,0 +1,12 @@ +import { request } from './request' + +/** + * 系统级管理 API(跨工作空间的全局操作) + * 工作空间级的成员管理和邀请已迁移到 workspaceApi + */ +export const adminApi = { + /** 禁用/启用用户(全局) */ + updateUserStatus: (userId: string, status: number) => { + return request.put(`/apis/admin/users/${userId}/status`, { status }) + }, +} diff --git a/src/api/index.ts b/src/api/index.ts index cff7fcb..b171c2f 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,6 +1,10 @@ +export * from './admin' +export * from './role' +export * from './permission' export * from './user' export * from './file' export * from './transfer' export * from './home' export * from './share' +export * from './workspace' export { request } from './request' diff --git a/src/api/permission.ts b/src/api/permission.ts new file mode 100644 index 0000000..65845c2 --- /dev/null +++ b/src/api/permission.ts @@ -0,0 +1,8 @@ +import type { PermissionDef } from '@/types/permission' +import { request } from './request' + +export const permissionApi = { + list: () => { + return request.get('/apis/permission/list') + }, +} diff --git a/src/api/request.ts b/src/api/request.ts index f23200d..848cb51 100644 --- a/src/api/request.ts +++ b/src/api/request.ts @@ -4,21 +4,56 @@ import axios, { InternalAxiosRequestConfig, } from 'axios' import { toast } from 'sonner' +import i18n, { getRequestLangHeader } from '@/i18n' import { getToken, clearToken } from '@/utils/auth' +import { getCurrentWorkspaceId } from '@/store/workspace' +/** 与后端统一包装 `{ code, msg, data }` 一致 */ export interface HttpResponse { - status: number - msg: string code: number + msg: string data: T } -let isShowingLogoutModal = false -let logoutDialogCallback: (() => void) | null = null +let isRedirectingToLogin = false + +/** 登录页上失败类 401,不应整页踢回(避免密码错误也触发跳转) */ +function shouldSkipUnauthorizedRedirect(url: string | undefined): boolean { + if (!url) return false + return ( + url.includes('/apis/auth/login') || + url.includes('/apis/auth/register') || + url.includes('/apis/user/register') + ) +} -// 设置登录过期回调 -export const setLogoutCallback = (callback: () => void) => { - logoutDialogCallback = callback +/** 401:清本地状态并整页跳转登录(带 redirect 便于登录后返回) */ +export function redirectToLoginDueToUnauthorized() { + if (isRedirectingToLogin) return + isRedirectingToLogin = true + + clearToken() + localStorage.removeItem('userInfo') + sessionStorage.removeItem('userInfo') + localStorage.removeItem('current-storage-platform') + localStorage.removeItem('user-storage') + localStorage.removeItem('workspace-storage') + + import('@/store/workspace').then(({ useWorkspaceStore }) => { + useWorkspaceStore.getState().clear() + }) + import('@/store/user').then(({ useUserStore }) => { + useUserStore.getState().clearUserInfo() + }) + + const path = + window.location.pathname + window.location.search + window.location.hash + const loginBase = '/login' + if (path === loginBase || path.startsWith(`${loginBase}?`)) { + window.location.href = loginBase + return + } + window.location.href = `${loginBase}?redirect=${encodeURIComponent(path)}` } const service = axios.create({ @@ -53,6 +88,15 @@ service.interceptors.request.use( config.headers['X-Storage-Platform-Config-Id'] = platformId } + const workspaceId = getCurrentWorkspaceId() + if (workspaceId) { + config.headers = config.headers || {} + config.headers['X-Workspace-Id'] = workspaceId + } + + config.headers = config.headers || {} + config.headers.lang = getRequestLangHeader() + return config }, (error) => { @@ -74,30 +118,26 @@ service.interceptors.response.use( const showError = (config as any).showErrorMessage !== false - if ([401, 403].includes(res.code)) { - if (response.config.url !== '/apis/user/info' && !isShowingLogoutModal) { - isShowingLogoutModal = true - if (logoutDialogCallback) { - logoutDialogCallback() - } else { - // 降级方案:如果没有设置回调,使用 toast - toast.error('登录已过期,请重新登录') - setTimeout(() => { - clearToken() - localStorage.removeItem('userInfo') - sessionStorage.removeItem('userInfo') - window.location.href = '/login' - isShowingLogoutModal = false - }, 1500) - } + if (res.code === 401) { + if (!shouldSkipUnauthorizedRedirect(response.config?.url)) { + redirectToLoginDueToUnauthorized() + } else if (showError) { + toast.error(res.msg || i18n.t('common:api.loginFailed')) + } + } else if (res.code === 403) { + if (showError) { + toast.error(res.msg || i18n.t('common:api.noPermission')) } } else if (showError) { - toast.error(res.msg || '操作失败') + toast.error(res.msg || i18n.t('common:api.operationFailed')) } const error: any = new Error(res.msg || 'Error') error.code = res.code error.response = response + /** 已在上方 toast 或 401 跳转时,避免业务层再弹一层 */ + error.handled = + res.code === 401 || res.code === 403 || showError return Promise.reject(error) }, (error) => { @@ -105,49 +145,52 @@ service.interceptors.response.use( const showError = (config as any).showErrorMessage !== false if (showError && !error.isErrorShown) { - let errorMessage = '网络请求失败' + let errorMessage = i18n.t('common:api.networkFailed') + let skipToast = false if (error.response) { const { status } = error.response switch (status) { case 400: - errorMessage = error.response.data?.msg || '请求参数错误' + errorMessage = + error.response.data?.msg || i18n.t('common:api.badRequest') break case 401: - case 403: - errorMessage = '登录已过期,请重新登录' - if (!isShowingLogoutModal) { - isShowingLogoutModal = true - if (logoutDialogCallback) { - logoutDialogCallback() - } else { - // 降级方案 - setTimeout(() => { - clearToken() - localStorage.removeItem('userInfo') - sessionStorage.removeItem('userInfo') - window.location.href = '/login' - isShowingLogoutModal = false - }, 1500) - } + if (!shouldSkipUnauthorizedRedirect(error.config?.url)) { + redirectToLoginDueToUnauthorized() + skipToast = true + } else { + errorMessage = + error.response.data?.msg || i18n.t('common:api.wrongCredentials') } break + case 403: + errorMessage = + error.response.data?.msg || i18n.t('common:api.noPermission') + break case 404: - errorMessage = '请求的资源不存在' + errorMessage = + error.response.data?.msg || i18n.t('common:api.notFound') break case 500: - errorMessage = error.response.data?.msg || '服务器内部错误' + errorMessage = + error.response.data?.msg || i18n.t('common:api.serverError') break default: - errorMessage = error.response.data?.msg || `请求失败(${status})` + errorMessage = + error.response.data?.msg || + i18n.t('common:api.requestFailed', { status }) } } else if (error.message.includes('timeout')) { - errorMessage = '请求超时,请检查网络连接' + errorMessage = i18n.t('common:api.timeout') } else if (error.message.includes('Network Error')) { - errorMessage = '网络连接失败,请检查网络' + errorMessage = i18n.t('common:api.networkError') } - toast.error(errorMessage) + if (!skipToast) { + toast.error(errorMessage) + } + error.handled = true } return Promise.reject(error) diff --git a/src/api/role.ts b/src/api/role.ts new file mode 100644 index 0000000..5e04e41 --- /dev/null +++ b/src/api/role.ts @@ -0,0 +1,29 @@ +import type { + Role, + RoleListItem, + CreateRoleParams, + UpdateRoleParams, +} from '@/types/role' +import { request } from './request' + +export const roleApi = { + list: () => { + return request.get('/apis/role/list') + }, + + get: (roleId: number) => { + return request.get(`/apis/role/${roleId}`) + }, + + create: (data: CreateRoleParams) => { + return request.post('/apis/role', data) + }, + + update: (roleId: number, data: UpdateRoleParams) => { + return request.put(`/apis/role/${roleId}`, data) + }, + + delete: (roleId: number) => { + return request.delete(`/apis/role/${roleId}`) + }, +} diff --git a/src/api/user.ts b/src/api/user.ts index c27bf46..3ee567d 100644 --- a/src/api/user.ts +++ b/src/api/user.ts @@ -8,7 +8,9 @@ import { UserInfo, UserRegisterParams, ChangePasswordParams, + SetPasswordParams, ForgotPasswordParams, + UpdateUserInfoParams, } from '@/types/user' import { request } from './request' @@ -18,6 +20,11 @@ export const userApi = { return request.post('/apis/auth/login', data) }, + /** 登录-邮箱验证码:向 account 对应邮箱发送验证码(与后端路径对齐) */ + sendLoginEmailCode: (account: string) => { + return request.post('/apis/auth/login/email-code', { account }) + }, + // 注册 register: (data: UserRegisterParams) => { return request.post('/apis/user/register', data) @@ -28,16 +35,32 @@ export const userApi = { return request.get('/apis/user/info') }, - // 更新用户信息 - updateUserInfo: (data: Partial) => { - return request.put('/apis/user/info', data) + // 更新用户信息(请求体仅传可改字段;成功后需再调 getUserInfo,因接口可能不返回 data) + updateUserInfo: async (data: UpdateUserInfoParams): Promise => { + await request.put('/apis/user/info', data) + }, + + /** 上传头像(multipart,字段名 file;成功后请 getUserInfo) */ + uploadAvatar: async (file: File): Promise => { + const formData = new FormData() + formData.append('file', file) + await request.put('/apis/user/avatar', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }) }, - // 修改密码 + // 修改密码(需原密码) changePassword: (data: ChangePasswordParams) => { return request.put('/apis/user/password', data) }, + /** 首次设置密码(无原密码,邮箱验证码注册用户) */ + setPassword: async (data: SetPasswordParams): Promise => { + await request.post('/apis/user/password', data) + }, + // 退出登录 logout: () => { return request.post('/apis/auth/logout') diff --git a/src/api/workspace.ts b/src/api/workspace.ts new file mode 100644 index 0000000..329f68b --- /dev/null +++ b/src/api/workspace.ts @@ -0,0 +1,66 @@ +import type { + Workspace, + WorkspaceDetail, + WorkspaceMember, + WorkspaceInvitation, + CreateWorkspaceParams, + UpdateWorkspaceParams, + CreateWorkspaceInvitationParams, + WorkspaceMemberQueryParams, +} from '@/types/workspace' +import type { PageResult } from '@/types/permission' +import { request } from './request' + +export const workspaceApi = { + /** 获取当前用户的工作空间列表(不需要 X-Workspace-Id) */ + list: () => request.get('/apis/workspace/list'), + + /** 创建工作空间 */ + create: (data: CreateWorkspaceParams) => + request.post('/apis/workspace', data), + + /** 获取当前工作空间详情 + 用户在其中的角色权限(需要 X-Workspace-Id) */ + getCurrent: () => + request.get('/apis/workspace/current'), + + /** 更新工作空间信息 */ + update: (data: UpdateWorkspaceParams) => + request.put('/apis/workspace', data), + + /** 删除工作空间(仅 owner) */ + delete: () => request.delete('/apis/workspace'), + + /** 检查 slug 是否可用 */ + checkSlug: (slug: string) => + request.get('/apis/workspace/check-slug', { params: { slug } }), + + // ---- 成员管理 ---- + + /** 获取工作空间成员列表(分页) */ + getMembers: (params: WorkspaceMemberQueryParams) => + request.get>('/apis/workspace/members', { + params, + }), + + /** 更新成员角色 */ + updateMemberRole: (userId: string, roleId: number) => + request.put(`/apis/workspace/members/${userId}/role`, { roleId }), + + /** 移除成员 */ + removeMember: (userId: string) => + request.delete(`/apis/workspace/members/${userId}`), + + // ---- 邀请管理 ---- + + /** 获取邀请列表 */ + getInvitations: () => + request.get('/apis/workspace/invitations'), + + /** 创建邀请 */ + createInvitation: (data: CreateWorkspaceInvitationParams) => + request.post('/apis/workspace/invitations', data), + + /** 取消邀请 */ + deleteInvitation: (id: string) => + request.delete(`/apis/workspace/invitations/${id}`), +} diff --git a/src/app.tsx b/src/app.tsx index 1f9a1f1..2a46094 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,69 +1,18 @@ -import { useState, useEffect } from 'react' import { AuthProvider } from '@/contexts/auth-context' import { router } from '@/router' import { RouterProvider } from 'react-router-dom' -import { setLogoutCallback } from '@/api/request' -import { clearToken } from '@/utils/auth' import { useSSEConnection } from '@/hooks/useSSEConnection' import { useUploadGuard } from '@/hooks/useUploadGuard' -import { - AlertDialog, - AlertDialogAction, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog' import { Toaster } from '@/components/ui/sonner' import { NavigationProgress } from '@/components/navigation-progress' // SSE 初始化组件(必须在 AuthProvider 内部) function SSEInitializer() { useSSEConnection() - useUploadGuard() // 添加上传保护 + useUploadGuard() return null } -// 登录过期对话框组件 -function LogoutDialog() { - const [showLogoutDialog, setShowLogoutDialog] = useState(false) - - useEffect(() => { - // 设置登录过期回调 - setLogoutCallback(() => { - setShowLogoutDialog(true) - }) - }, []) - - const handleLogoutConfirm = () => { - setShowLogoutDialog(false) - clearToken() - localStorage.removeItem('userInfo') - sessionStorage.removeItem('userInfo') - // 强制刷新页面并跳转到登录页 - window.location.href = '/login' - } - - return ( - - - - 登录已过期 - - 您的登录状态已过期,请重新登录以继续使用。 - - - - - 返回登录 - - - - - ) -} - export default function App() { return ( @@ -71,7 +20,6 @@ export default function App() { - ) } diff --git a/src/components/bulk-selection-bar.tsx b/src/components/bulk-selection-bar.tsx index 4703acf..91d8c67 100644 --- a/src/components/bulk-selection-bar.tsx +++ b/src/components/bulk-selection-bar.tsx @@ -1,5 +1,6 @@ import { useEffect, useId, useRef, useState } from 'react' import { createPortal } from 'react-dom' +import { useTranslation } from 'react-i18next' import { X } from 'lucide-react' import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' @@ -17,7 +18,7 @@ export type BulkSelectionBarProps = { children: React.ReactNode /** 工具栏无障碍名称 */ ariaLabel?: string - /** 数量右侧说明文案,默认「项已选择」 */ + /** 数量右侧说明文案,默认使用 common.bulkSelection.suffix */ selectionSuffix?: string /** 选中变化时读屏播报 */ getAnnouncement?: (count: number) => string @@ -27,10 +28,13 @@ export function BulkSelectionBar({ selectedCount, onClear, children, - ariaLabel = '批量操作', - selectionSuffix = '项已选择', - getAnnouncement = (count) => `已选择 ${count} 项,批量操作栏可用。`, + ariaLabel, + selectionSuffix, + getAnnouncement, }: BulkSelectionBarProps) { + const { t } = useTranslation('common') + const label = ariaLabel ?? t('bulkSelection.defaultToolbarName') + const suffix = selectionSuffix ?? t('bulkSelection.suffix') const [mounted, setMounted] = useState(false) const toolbarRef = useRef(null) const [announcement, setAnnouncement] = useState('') @@ -43,13 +47,16 @@ export function BulkSelectionBar({ useEffect(() => { if (selectedCount > 0) { queueMicrotask(() => { - setAnnouncement(getAnnouncement(selectedCount)) + setAnnouncement( + getAnnouncement + ? getAnnouncement(selectedCount) + : t('bulkSelection.announce', { count: selectedCount }) + ) }) - const t = setTimeout(() => setAnnouncement(''), 3000) - return () => clearTimeout(t) + const hideTimer = setTimeout(() => setAnnouncement(''), 3000) + return () => clearTimeout(hideTimer) } - // 仅随数量变化播报;getAnnouncement 由调用方保证稳定或接受首次传入 - }, [selectedCount]) + }, [selectedCount, getAnnouncement, t]) const handleKeyDown = (event: React.KeyboardEvent) => { const buttons = toolbarRef.current?.querySelectorAll('button') @@ -112,7 +119,10 @@ export function BulkSelectionBar({
- 取消选择 + {t('bulkSelection.clear')} -

取消选择 (Esc)

+

{t('bulkSelection.clearHint')}

@@ -152,12 +162,14 @@ export function BulkSelectionBar({ > {selectedCount} - {selectionSuffix} + {suffix}
diff --git a/src/components/chart-area-interactive.tsx b/src/components/chart-area-interactive.tsx index 7f4c963..f067611 100644 --- a/src/components/chart-area-interactive.tsx +++ b/src/components/chart-area-interactive.tsx @@ -1,4 +1,5 @@ import * as React from 'react' +import { useTranslation } from 'react-i18next' import { useQuery } from '@tanstack/react-query' import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts' import { @@ -67,8 +68,10 @@ export function ChartAreaInteractive({ unit, onUnitChange, }: ChartAreaInteractiveProps) { + const { t, i18n } = useTranslation('home') const isMobile = useIsMobile() const [timeRange, setTimeRange] = React.useState('90d') + const dateLocale = i18n.language?.startsWith('zh') ? 'zh-CN' : 'en-US' React.useEffect(() => { if (isMobile) { @@ -94,11 +97,11 @@ export function ChartAreaInteractive({ () => ({ used: { - label: `已用存储 (${unitLabel})`, + label: t('chart.seriesLabel', { unit: unitLabel }), color: 'var(--chart-1)', }, }) satisfies ChartConfig, - [unitLabel] + [unitLabel, t] ) const chartData = React.useMemo(() => { @@ -126,12 +129,14 @@ export function ChartAreaInteractive({ )} > - 存储增长 + {t('chart.title')} - 已用存储随时间变化({unitLabel}) + {t('chart.descDesktop', { unit: unitLabel })} + + + {t('chart.descMobile')} - 存储趋势 - 近 3 个月 - 近 30 天 - 近 7 天 + {t('chart.range90d')} + {t('chart.range30d')} + {t('chart.range7d')} @@ -197,11 +202,11 @@ export function ChartAreaInteractive({ ) : isError ? (
- 加载失败,请稍后重试 + {t('chart.loadFailed')}
) : chartData.length === 0 ? (
- 暂无数据 + {t('chart.noData')}
) : ( { const date = new Date(value) - return date.toLocaleDateString('zh-CN', { + return date.toLocaleDateString(dateLocale, { month: 'short', day: 'numeric', }) @@ -263,7 +268,7 @@ export function ChartAreaInteractive({ labelFormatter={(value) => { const d = new Date(value as string) if (Number.isNaN(d.getTime())) return String(value) - return d.toLocaleDateString('zh-CN', { + return d.toLocaleDateString(dateLocale, { month: 'short', day: 'numeric', }) @@ -284,7 +289,7 @@ export function ChartAreaInteractive({ } /> - label: string icon: ComponentType<{ className?: string }> }[] = [ - { id: 'image', label: '图片', icon: Image }, - { id: 'video', label: '视频', icon: Video }, - { id: 'folder', label: '文件夹', icon: Folder }, - { id: 'document', label: '文档', icon: FileText }, - { id: 'audio', label: '音频', icon: Music }, + { id: 'image', icon: Image }, + { id: 'video', icon: Video }, + { id: 'folder', icon: Folder }, + { id: 'document', icon: FileText }, + { id: 'audio', icon: Music }, ] -function scopeLabel(scope: SearchScopeId): string { - if (scope === 'all') return '' - return FILE_TYPE_OPTIONS.find((o) => o.id === scope)?.label ?? '' -} - /** 与 `useFileList` / 文件页 `searchParams` 约定一致:`keyword`、`type`、`isDir` */ function buildFilesSearchHref(keyword: string, scope: SearchScopeId): string { const k = keyword.trim() @@ -65,6 +61,7 @@ function buildFilesSearchHref(keyword: string, scope: SearchScopeId): string { } export function CommandMenu() { + const { t } = useTranslation('common') const { open, setOpen } = useSearch() const navigate = useNavigate() const inputRef = useRef(null) @@ -89,6 +86,22 @@ export function CommandMenu() { const showScopeBadge = scopeId !== 'all' const hasKeyword = keyword.trim().length > 0 + const scopeLabel = useMemo(() => { + if (scopeId === 'all') return '' + return t(`commandMenu.types.${scopeId}`) + }, [scopeId, t]) + + const footerHint = useMemo(() => { + if (showScopeBadge) { + return hasKeyword + ? t('commandMenu.footerScopedWithKeyword') + : t('commandMenu.footerScopedNoKeyword') + } + return hasKeyword + ? t('commandMenu.footerAllWithKeyword') + : t('commandMenu.footerAllDefault') + }, [showScopeBadge, hasKeyword, t]) + const submitToFilesPage = () => { const q = keyword.trim() if (!q) return @@ -107,7 +120,7 @@ export function CommandMenu() { @@ -118,14 +131,16 @@ export function CommandMenu() { {showScopeBadge ? ( - {scopeLabel(scopeId)}: + {scopeLabel}: ) : null} - 清空 + {t('commandMenu.clear')} @@ -100,7 +107,9 @@ export function DataTablePagination({ className='h-8 min-w-8 px-2' onClick={() => table.setPageIndex((pageNumber as number) - 1)} > - 第 {pageNumber} 页 + + {t('pagination.pageSr', { n: pageNumber })} + {pageNumber} )} @@ -113,7 +122,7 @@ export function DataTablePagination({ onClick={() => table.nextPage()} disabled={!table.getCanNextPage()} > - 下一页 + {t('pagination.next')} diff --git a/src/components/field-layout.tsx b/src/components/field-layout.tsx new file mode 100644 index 0000000..e2df5ed --- /dev/null +++ b/src/components/field-layout.tsx @@ -0,0 +1,95 @@ +import * as React from 'react' +import { cn } from '@/lib/utils' + +/* --- 只读详情:Label(上)+ Value(下),与设计稿一致 --- */ + +export function DescriptionFieldList({ + className, + ...props +}: React.ComponentProps<'div'>) { + return
+} + +export function DescriptionField({ + className, + ...props +}: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +export function DescriptionFieldLabel({ + className, + ...props +}: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +type DescriptionFieldValueProps = React.ComponentProps<'div'> & { + /** 长文件名、URL 等 */ + breakAll?: boolean +} + +export function DescriptionFieldValue({ + className, + breakAll, + ...props +}: DescriptionFieldValueProps) { + return ( +
+ ) +} + +/** 值与右侧操作(复制等)同一行;置于 DescriptionField 内、Label 下方 */ +export function DescriptionFieldValueRow({ + className, + ...props +}: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +/* --- 表单:区块标题 + 控件区(单选/多选横排等)--- */ + +/** 标题与控件间距 10px(设计 8–12px) */ +export function FormFieldStack({ + className, + ...props +}: React.ComponentProps<'div'>) { + return
+} + +/** 横向选项容器:选项水平间距 24px */ +export function FormInlineOptions({ + className, + ...props +}: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +/** 单个选项:控件与文案间距 8px */ +export function FormInlineOption({ + className, + ...props +}: React.ComponentProps<'div'>) { + return
+} diff --git a/src/components/layout/app-layout.tsx b/src/components/layout/app-layout.tsx index f58569a..0137942 100644 --- a/src/components/layout/app-layout.tsx +++ b/src/components/layout/app-layout.tsx @@ -1,5 +1,7 @@ import { useMemo } from 'react' import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar' +import { SettingsModalProvider } from '@/contexts/settings-modal-context' +import { SettingsDialog } from '@/pages/settings' import { AppSidebar } from './app-sidebar' import { Header } from './header' import { Main } from './main' @@ -18,12 +20,16 @@ interface AppLayoutProps { export function AppLayout({ children }: AppLayoutProps) { const defaultOpen = useMemo(getDefaultSidebarOpen, []) return ( - - - -
-
{children}
- - + + + + +
+
{children}
+ + {/* 与主侧栏同处 SidebarProvider,设置内外观表单的 useSidebar 才能取到主布局侧边栏 */} + + + ) } diff --git a/src/components/layout/app-sidebar.tsx b/src/components/layout/app-sidebar.tsx index e16e327..3a5ae4b 100644 --- a/src/components/layout/app-sidebar.tsx +++ b/src/components/layout/app-sidebar.tsx @@ -1,7 +1,9 @@ import { Fragment } from 'react' +import { useTranslation } from 'react-i18next' import { useAuth } from '@/contexts/auth-context' -import { Settings } from 'lucide-react' -import { Link } from 'react-router-dom' +import { usePermission } from '@/hooks/use-permission' +import { RiSettings3Line } from '@remixicon/react' +import { useSettingsModal } from '@/contexts/settings-modal-context' import { Sidebar, SidebarContent, @@ -14,31 +16,29 @@ import { SidebarSeparator, useSidebar, } from '@/components/ui/sidebar' -import { AppTitle } from './app-title' +import { WorkspaceSwitcher } from './workspace-switcher' import { sidebarData } from './data/sidebar-data' import { NavGroup } from './nav-group' import { NavUser } from './nav-user' function SettingsButton() { + const { t } = useTranslation('layout') const { state } = useSidebar() + const { openSettings } = useSettingsModal() return ( openSettings('profile')} > - - - - 设置 - - + + + {t('sidebar.settings')} + @@ -46,7 +46,9 @@ function SettingsButton() { } export function AppSidebar() { + const { t } = useTranslation('layout') const { user: authUser } = useAuth() + const { hasPermission } = usePermission() // 使用真实用户信息,如果未登录则使用占位符 const user = authUser @@ -55,20 +57,29 @@ export function AppSidebar() { email: authUser.email, avatar: authUser.avatar, } - : sidebarData.user + : { ...sidebarData.user, name: t('sidebar.userPlaceholder') } + + const navGroups = sidebarData.navGroups + .map((group) => ({ + ...group, + items: group.items.filter( + (item) => !item.permission || hasPermission(item.permission) + ), + })) + .filter((group) => group.items.length > 0) return ( - + - {sidebarData.navGroups.map((group, index) => ( - + {navGroups.map((group, index) => ( + {index > 0 && ( )} - + ))} diff --git a/src/components/layout/breadcrumb.tsx b/src/components/layout/breadcrumb.tsx index 3ece425..2a6f2e6 100644 --- a/src/components/layout/breadcrumb.tsx +++ b/src/components/layout/breadcrumb.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next' import { useLocation, Link } from 'react-router-dom' import { Breadcrumb, @@ -8,57 +9,78 @@ import { BreadcrumbSeparator, } from '@/components/ui/breadcrumb' -const routeNames: Record = { - '/': '首页', - '/files': '全部文件', - '/transfer': '传输', - '/storage': '云存储配置', - '/settings': '设置', - '/settings/appearance': '外观', - '/settings/transfer': '传输设置', +/** 逻辑路径(含 `/w/:slug` 前缀剥离后)→ `breadcrumb` 下的键名 */ +const BREADCRUMB_PATH_KEYS: Record = { + '/': 'home', + '/files': 'allFiles', + '/transfer': 'transfer', + '/storage': 'storage', + '/settings': 'settings', + '/settings/appearance': 'appearance', + '/settings/transfer': 'transferSettings', +} + +function parseSegments(pathname: string): { homeHref: string; segments: string[] } { + const ws = pathname.match(/^(\/w\/[^/]+)(?:\/(.*))?$/) + if (ws) { + const base = ws[1] + const tail = ws[2] + const segments = tail + ? tail.split('/').filter(Boolean) + : [] + return { homeHref: `${base}/`, segments } + } + return { + homeHref: '/', + segments: pathname.split('/').filter(Boolean), + } } export function AppBreadcrumb() { + const { t } = useTranslation('layout') const location = useLocation() - const pathSegments = location.pathname.split('/').filter(Boolean) + const { homeHref, segments } = parseSegments(location.pathname) - // 首页特殊处理 - if (pathSegments.length === 0) { + if (segments.length === 0) { return ( - 首页 + {t('breadcrumb.home')} ) } + const ws = location.pathname.match(/^(\/w\/[^/]+)/) + return ( - 首页 + {t('breadcrumb.home')} - {pathSegments.length > 0 && ( - - )} - {pathSegments.map((segment, index) => { - const path = `/${pathSegments.slice(0, index + 1).join('/')}` - const isLast = index === pathSegments.length - 1 - const name = routeNames[path] || segment + + {segments.map((segment, index) => { + const isLast = index === segments.length - 1 + const logicalPath = `/${segments.slice(0, index + 1).join('/')}` + const key = BREADCRUMB_PATH_KEYS[logicalPath] + const name = key ? t(`breadcrumb.${key}`) : segment + const pathHref = ws + ? `${ws[1]}/${segments.slice(0, index + 1).join('/')}` + : `/${segments.slice(0, index + 1).join('/')}` return ( -
+
{isLast ? ( {name} ) : ( - {name} + {name} )} diff --git a/src/components/layout/data/sidebar-data.ts b/src/components/layout/data/sidebar-data.ts index d82ddaf..76b3221 100644 --- a/src/components/layout/data/sidebar-data.ts +++ b/src/components/layout/data/sidebar-data.ts @@ -1,70 +1,82 @@ import { - Home, - FolderOpen, - ArrowLeftRight, - Server, - Star, - Clock, - Share2, - Trash2, -} from 'lucide-react' + RiArrowLeftRightFill, + RiArrowLeftRightLine, + RiDeleteBinFill, + RiDeleteBinLine, + RiFolderOpenFill, + RiFolderOpenLine, + RiHistoryFill, + RiHistoryLine, + RiHome9Fill, + RiHome9Line, + RiServerFill, + RiServerLine, + RiShareFill, + RiShareLine, + RiStarFill, + RiStarLine, +} from '@remixicon/react' import { type SidebarData } from '../types' export const sidebarData: SidebarData = { user: { - name: '用户', + name: '', email: 'user@example.com', avatar: '/avatars/default.jpg', }, teams: [], navGroups: [ { - title: '主导航', + titleKey: 'sidebar.groups.main', items: [ { - title: '首页', + titleKey: 'sidebar.nav.home', url: '/', - icon: Home, + icon: { line: RiHome9Line, fill: RiHome9Fill }, }, { - title: '全部文件', + titleKey: 'sidebar.nav.allFiles', url: '/files', - icon: FolderOpen, + icon: { line: RiFolderOpenLine, fill: RiFolderOpenFill }, + permission: 'file:read', }, { - title: '传输', + titleKey: 'sidebar.nav.transfer', url: '/transfer', - icon: ArrowLeftRight, + icon: { line: RiArrowLeftRightLine, fill: RiArrowLeftRightFill }, }, { - title: '云存储配置', + titleKey: 'sidebar.nav.storage', url: '/storage', - icon: Server, + icon: { line: RiServerLine, fill: RiServerFill }, + permission: 'storage:manage', }, ], }, { - title: '常用', + titleKey: 'sidebar.groups.common', items: [ { - title: '我的收藏', + titleKey: 'sidebar.nav.favorites', url: '/files?view=favorites', - icon: Star, + icon: { line: RiStarLine, fill: RiStarFill }, }, { - title: '最近使用', + titleKey: 'sidebar.nav.recents', url: '/files?view=recents', - icon: Clock, + icon: { line: RiHistoryLine, fill: RiHistoryFill }, }, { - title: '我的分享', + titleKey: 'sidebar.nav.shares', url: '/files?view=shares', - icon: Share2, + icon: { line: RiShareLine, fill: RiShareFill }, + permission: 'file:share', }, { - title: '回收站', + titleKey: 'sidebar.nav.recycle', url: '/files?view=recycle', - icon: Trash2, + icon: { line: RiDeleteBinLine, fill: RiDeleteBinFill }, + permission: 'file:write', }, ], }, diff --git a/src/components/layout/header.tsx b/src/components/layout/header.tsx index 8cd4eef..6c8b1af 100644 --- a/src/components/layout/header.tsx +++ b/src/components/layout/header.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' import { cn } from '@/lib/utils' import { AnimatedThemeToggler } from '@/components/ui/animated-theme-toggler' import { Button } from '@/components/ui/button' @@ -19,6 +20,7 @@ type HeaderProps = React.HTMLAttributes & { } export function Header({ className, fixed, children, ...props }: HeaderProps) { + const { t } = useTranslation('layout') const [offset, setOffset] = useState(0) const { theme } = useTheme() @@ -127,7 +129,9 @@ export function Header({ className, fixed, children, ...props }: HeaderProps) {
-

{isDark ? '明亮模式' : '暗黑模式'}

+

+ {isDark ? t('header.lightMode') : t('header.darkMode')} +

diff --git a/src/components/layout/nav-group.tsx b/src/components/layout/nav-group.tsx index 1bc7aa0..c371240 100644 --- a/src/components/layout/nav-group.tsx +++ b/src/components/layout/nav-group.tsx @@ -1,6 +1,8 @@ import { type ReactNode } from 'react' -import { ChevronRight } from 'lucide-react' -import { Link, useLocation } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import { RiArrowRightSLine } from '@remixicon/react' +import { Link, useLocation, useParams } from 'react-router-dom' +import { cn } from '@/lib/utils' import { Collapsible, CollapsibleContent, @@ -31,28 +33,80 @@ import { type NavItem, type NavLink, type NavGroup as NavGroupProps, + type SidebarNavIconPair, } from './types' -export function NavGroup({ title, items }: NavGroupProps) { +function NavItemIcon({ + icon, + active, + className, +}: { + icon: SidebarNavIconPair + active: boolean + className?: string +}) { + const Cmp = active ? icon.fill : icon.line + return +} + +/** 将 sidebar-data 中的相对路径拼接为 /w/{slug}{path} */ +function useSlugPrefix() { + const { slug } = useParams<{ slug: string }>() + return (path: string) => `/w/${slug}${path}` +} + +/** + * 从当前 URL 中剥离 /w/:slug 前缀, + * 使 checkIsActive 能和 sidebar-data 中的相对路径进行匹配 + */ +function stripSlugPrefix(fullPath: string): string { + const match = fullPath.match(/^\/w\/[^/]+(.*)$/) + return match ? match[1] || '/' : fullPath +} + +export function NavGroup({ titleKey, items }: NavGroupProps) { + const { t } = useTranslation('layout') const { state, isMobile } = useSidebar() const location = useLocation() - const href = location.pathname + location.search + const rawHref = location.pathname + location.search + const href = stripSlugPrefix(rawHref) + const prefix = useSlugPrefix() + return ( - {title} + {t(titleKey)} {items.map((item) => { - const key = `${item.title}-${item.url}` + const key = `${item.titleKey}-${item.url}` if (!item.items) - return + return ( + + ) if (state === 'collapsed' && !isMobile) return ( - + ) - return + return ( + + ) })} @@ -63,18 +117,29 @@ function NavBadge({ children }: { children: ReactNode }) { return {children} } -function SidebarMenuLink({ item, href }: { item: NavLink; href: string }) { +function SidebarMenuLink({ + item, + href, + prefix, +}: { + item: NavLink + href: string + prefix: (path: string) => string +}) { + const { t } = useTranslation('layout') const { setOpenMobile } = useSidebar() + const active = checkIsActive(href, item) + const label = t(item.titleKey) return ( - setOpenMobile(false)}> - {item.icon && } - {item.title} + setOpenMobile(false)}> + {item.icon && } + {label} {item.badge && {item.badge}} @@ -85,37 +150,52 @@ function SidebarMenuLink({ item, href }: { item: NavLink; href: string }) { function SidebarMenuCollapsible({ item, href, + prefix, }: { item: NavCollapsible href: string + prefix: (path: string) => string }) { + const { t } = useTranslation('layout') const { setOpenMobile } = useSidebar() + const parentActive = checkIsActive(href, item, true) + const label = t(item.titleKey) return ( - - {item.icon && } - {item.title} + + {item.icon && } + {label} {item.badge && {item.badge}} - + {item.items.map((subItem) => ( - + - setOpenMobile(false)}> - {subItem.icon && } - {subItem.title} + setOpenMobile(false)} + > + {subItem.icon && ( + + )} + + {t(subItem.titleKey)} + {subItem.badge && {subItem.badge}} @@ -131,37 +211,49 @@ function SidebarMenuCollapsible({ function SidebarMenuCollapsedDropdown({ item, href, + prefix, }: { item: NavCollapsible href: string + prefix: (path: string) => string }) { + const { t } = useTranslation('layout') + const parentActive = checkIsActive(href, item) + const label = t(item.titleKey) return ( - {item.icon && } - {item.title} + {item.icon && ( + + )} + {label} {item.badge && {item.badge}} - + - {item.title} {item.badge ? `(${item.badge})` : ''} + {label} {item.badge ? `(${item.badge})` : ''} {item.items.map((sub) => ( - + - {sub.icon && } - {sub.title} + {sub.icon && ( + + )} + {t(sub.titleKey)} {sub.badge && ( {sub.badge} )} @@ -176,9 +268,9 @@ function SidebarMenuCollapsedDropdown({ function checkIsActive(href: string, item: NavItem, mainNav = false) { return ( - href === item.url || // /endpint?search=param - href.split('?')[0] === item.url || // endpoint - !!item?.items?.filter((i) => i.url === href).length || // if child nav is active + href === item.url || + href.split('?')[0] === item.url || + !!item?.items?.filter((i) => i.url === href).length || (mainNav && href.split('/')[1] !== '' && href.split('/')[1] === item?.url?.split('/')[1]) diff --git a/src/components/layout/nav-user.tsx b/src/components/layout/nav-user.tsx index 14f377a..54c7167 100644 --- a/src/components/layout/nav-user.tsx +++ b/src/components/layout/nav-user.tsx @@ -1,6 +1,12 @@ +import { useTranslation } from 'react-i18next' import { useAuth } from '@/contexts/auth-context' -import { ChevronsUpDown, LogOut, Settings } from 'lucide-react' +import { + RiExpandUpDownLine, + RiLogoutBoxLine, + RiSettings3Line, +} from '@remixicon/react' import { useNavigate } from 'react-router-dom' +import { useSettingsModal } from '@/contexts/settings-modal-context' import { getAvatarFallback } from '@/utils/avatar' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { @@ -27,7 +33,9 @@ interface NavUserProps { } export function NavUser({ user }: NavUserProps) { + const { t } = useTranslation('layout') const navigate = useNavigate() + const { openSettings } = useSettingsModal() const { logout } = useAuth() const { isMobile, state } = useSidebar() @@ -37,7 +45,7 @@ export function NavUser({ user }: NavUserProps) { } const handleSettings = () => { - navigate('/settings') + openSettings('profile') } const avatarFallback = getAvatarFallback(user.name) @@ -63,7 +71,7 @@ export function NavUser({ user }: NavUserProps) { {user.name} {user.email}
- + )} @@ -90,16 +98,16 @@ export function NavUser({ user }: NavUserProps) { - - 账户设置 + + {t('navUser.accountSettings')} - - 退出登录 + + {t('navUser.logout')} diff --git a/src/components/layout/types.ts b/src/components/layout/types.ts index 5dac9a6..38b0e7b 100644 --- a/src/components/layout/types.ts +++ b/src/components/layout/types.ts @@ -1,3 +1,11 @@ +import type { PermissionCodeType } from '@/types/permission' + +/** 侧边栏导航:默认 Line,选中时 Fill(@remixicon/react) */ +type SidebarNavIconPair = { + line: React.ElementType<{ className?: string }> + fill: React.ElementType<{ className?: string }> +} + type User = { name: string email: string @@ -11,9 +19,11 @@ type Team = { } type BaseNavItem = { - title: string + /** i18n key under `layout` namespace, e.g. sidebar.nav.home */ + titleKey: string badge?: string - icon?: React.ElementType + icon?: SidebarNavIconPair + permission?: PermissionCodeType } type NavLink = BaseNavItem & { @@ -29,7 +39,7 @@ type NavCollapsible = BaseNavItem & { type NavItem = NavCollapsible | NavLink type NavGroup = { - title: string + titleKey: string items: NavItem[] } @@ -39,4 +49,11 @@ type SidebarData = { navGroups: NavGroup[] } -export type { SidebarData, NavGroup, NavItem, NavCollapsible, NavLink } +export type { + SidebarData, + NavGroup, + NavItem, + NavCollapsible, + NavLink, + SidebarNavIconPair, +} diff --git a/src/components/layout/workspace-switcher.tsx b/src/components/layout/workspace-switcher.tsx new file mode 100644 index 0000000..4545005 --- /dev/null +++ b/src/components/layout/workspace-switcher.tsx @@ -0,0 +1,178 @@ +import { useCallback, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useNavigate } from 'react-router-dom' +import { + RiAddLine, + RiBuildingLine, + RiExpandUpDownLine, + RiSettings3Line, + RiUserAddLine, +} from '@remixicon/react' +import { toast } from 'sonner' +import { useWorkspaceStore } from '@/store/workspace' +import { useAuth } from '@/contexts/auth-context' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { + SidebarMenu, + SidebarMenuItem, + SidebarMenuButton, + useSidebar, +} from '@/components/ui/sidebar' + +export function WorkspaceSwitcher() { + const { t } = useTranslation('layout') + const navigate = useNavigate() + const { isMobile, state } = useSidebar() + const { activateWorkspace } = useAuth() + const workspaces = useWorkspaceStore((s) => s.workspaces) + const currentWorkspaceId = useWorkspaceStore((s) => s.currentWorkspaceId) + const [switching, setSwitching] = useState(false) + + const currentWorkspace = workspaces.find((w) => w.id === currentWorkspaceId) + + const handleSwitch = useCallback( + async (workspaceId: string) => { + if (workspaceId === currentWorkspaceId || switching) return + const target = workspaces.find((w) => w.id === workspaceId) + if (!target) return + + setSwitching(true) + try { + await activateWorkspace(workspaceId) + navigate(`/w/${target.slug}/`) + toast.success(t('workspaceSwitcher.switchToast', { name: target.name })) + } catch (err: any) { + if (!err?.handled) toast.error(t('workspaceSwitcher.switchFailed')) + } finally { + setSwitching(false) + } + }, + [currentWorkspaceId, switching, workspaces, navigate, activateWorkspace, t] + ) + + const displayName = + currentWorkspace?.name ?? t('workspaceSwitcher.selectWorkspace') + + return ( + + + + + +
+ +
+ {state === 'expanded' && ( + <> +
+ + {displayName} + +
+ + + )} +
+
+ + {currentWorkspace && ( + <> +
+
+
+ +
+
+

+ {currentWorkspace.name} +

+

+ {t('workspaceSwitcher.memberCount', { + count: currentWorkspace.memberCount, + })} +

+
+
+
+ + +
+
+ + + )} + + {t('workspaceSwitcher.sectionTitle')} + + {workspaces.map((ws) => ( + handleSwitch(ws.id)} + className='cursor-pointer gap-2' + disabled={switching} + > +
+ +
+
+ {ws.name} + + {t('workspaceSwitcher.memberCount', { count: ws.memberCount })} + +
+ {ws.id === currentWorkspaceId && ( + + {t('workspaceSwitcher.current')} + + )} +
+ ))} + + navigate('/workspace/new')} + > +
+ +
+ {t('workspaceSwitcher.createWorkspace')} +
+
+
+
+
+ ) +} diff --git a/src/components/no-permission.tsx b/src/components/no-permission.tsx new file mode 100644 index 0000000..99a5e11 --- /dev/null +++ b/src/components/no-permission.tsx @@ -0,0 +1,26 @@ +import { ShieldX } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { useNavigate, useParams } from 'react-router-dom' +import { Button } from '@/components/ui/button' + +export function NoPermission() { + const { t } = useTranslation('settings') + const navigate = useNavigate() + const { slug } = useParams<{ slug: string }>() + + return ( +
+ +

{t('noAccess.title')}

+

+ {t('noAccess.description')} +

+ +
+ ) +} diff --git a/src/components/require-permission.tsx b/src/components/require-permission.tsx new file mode 100644 index 0000000..f5cef18 --- /dev/null +++ b/src/components/require-permission.tsx @@ -0,0 +1,35 @@ +import type { ReactNode } from 'react' +import { usePermission } from '@/hooks/use-permission' +import type { PermissionCodeType } from '@/types/permission' + +interface RequirePermissionProps { + code: PermissionCodeType + children: ReactNode + fallback?: ReactNode +} + +export function RequirePermission({ + code, + children, + fallback = null, +}: RequirePermissionProps) { + const { hasPermission } = usePermission() + + return hasPermission(code) ? <>{children} : <>{fallback} +} + +interface RequireAnyPermissionProps { + codes: PermissionCodeType[] + children: ReactNode + fallback?: ReactNode +} + +export function RequireAnyPermission({ + codes, + children, + fallback = null, +}: RequireAnyPermissionProps) { + const { hasAnyPermission } = usePermission() + + return hasAnyPermission(...codes) ? <>{children} : <>{fallback} +} diff --git a/src/components/search.tsx b/src/components/search.tsx index eeef323..8e44819 100644 --- a/src/components/search.tsx +++ b/src/components/search.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next' import { SearchIcon } from 'lucide-react' import { cn } from '@/lib/utils' import { useSearch } from '@/context/search-provider' @@ -11,8 +12,10 @@ type SearchProps = { export function Search({ className = '', - placeholder = '快捷搜索文件', + placeholder, }: SearchProps) { + const { t } = useTranslation('common') + const defaultPlaceholder = placeholder ?? t('searchShortcut') const { setOpen } = useSearch() return (