diff --git a/MEMORY.md b/MEMORY.md index c1cd919..8da8aeb 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -1060,3 +1060,15 @@ - 角色管理页面(`/admin/roles`)当前仅提供角色基础信息与“可见菜单”配置,不再提供权限点(`permission_codes`)配置入口。 - 菜单管理页面(`/admin/menus`)当前不再提供菜单权限码(`permission_code`)配置入口与列表展示。 - 后端权限字段与接口兼容能力保留,作为历史数据与鉴权映射兜底;前端交互层默认不暴露该配置。 + +## 文件管理上传进度口径(2026-05-02) + +- 文件管理页(`/admin/files`)上传链路已从 `fetch` 切换为 `XMLHttpRequest`,用于获取浏览器原生上传进度事件并展示百分比。 +- 上传进度展示基线: + - 上传进行中显示文件名、百分比数值与进度条; + - 上传成功后清空进度状态; + - 上传失败时进度归零并沿用现有错误提示通道。 +- 鉴权口径: + - XHR 上传沿用 `withCredentials + Authorization: Bearer `; + - 401 时触发一次 `refreshAccessToken` 并重试上传,保持与现有鉴权刷新机制一致。 +- AuthContext 对外新增 `getAccessToken()`,用于在异步重试场景读取最新 token,避免闭包持有旧值。 diff --git a/memory/2026-05-02.md b/memory/2026-05-02.md index 5872549..857dc1d 100644 --- a/memory/2026-05-02.md +++ b/memory/2026-05-02.md @@ -308,3 +308,29 @@ - 风险与影响: - 影响面仅部署配置与环境模板,不涉及业务代码逻辑。 - 若生产实际使用了外部注入的 `FLOWER_BASIC_AUTH`,该值仍会覆盖默认值,不改变既有安全策略。 + +## Work Log - 文件管理上传进度可视化(2026-05-02) + +- 背景: + - Issue `FL-172` 要求“文件管理页面上传时可见上传进度”。 + - 现状为 `fetch` 上传文件,浏览器原生 `fetch` 无可靠上传进度回调,用户无法看到实时进度。 + +- 本次改动(最小闭环): + - 文件:`web/src/app/admin/files/page.tsx` + - 上传请求由 `fetchWithAuth` 改为 `XMLHttpRequest`(仅上传接口),接入 `xhr.upload.onprogress` 实时计算百分比。 + - 新增上传状态:`uploadProgress`、`uploadFileName`。 + - 上传按钮下方新增进度展示区:文件名 + 百分比 + `Progress` 进度条。 + - 新增 XHR 错误解析函数,优先读取后端 `detail` 字段,失败时回退 `HTTP `。 + - 401 场景下自动调用 `refreshAccessToken` 后重试一次上传,保持与现有鉴权刷新逻辑一致。 + - 文件:`web/src/components/auth-provider.tsx` + - 新增 `getAccessToken()`,用于读取最新 token(避免异步刷新后闭包持有旧 token)。 + +- 验证: + - 按任务要求未执行编译/安装/构建检查; + - 通过 `git diff` 人工核对: + - 改动范围仅限上述两个前端文件; + - 文件管理页已接入上传百分比显示与进度条 UI。 + +- 风险与影响: + - 影响范围仅文件管理上传链路与认证上下文类型定义,不涉及后端接口契约变更。 + - 上传改为 XHR 后仍保留 `withCredentials + Bearer token`,与现有认证模式兼容。 diff --git a/web/src/app/admin/files/page.tsx b/web/src/app/admin/files/page.tsx index a47c4ba..c8a257a 100644 --- a/web/src/app/admin/files/page.tsx +++ b/web/src/app/admin/files/page.tsx @@ -13,6 +13,7 @@ import { Typography, Upload, Alert, + Progress, Dropdown, message as antdMessage, type MenuProps, @@ -72,10 +73,24 @@ function buildFilesApiPath(path: string): string { return `/api/v1/admin/files?${params.toString()}`; } +function readXhrError(xhr: XMLHttpRequest): string { + const fallback = `HTTP ${xhr.status}`; + const raw = xhr.responseText?.trim(); + if (!raw) { + return fallback; + } + try { + const parsed = JSON.parse(raw) as { detail?: string }; + return parsed.detail?.trim() ? parsed.detail : fallback; + } catch { + return raw.length > 200 ? fallback : raw; + } +} + export default function AdminFilesPage() { const queryClient = useQueryClient(); const [messageApi, messageContextHolder] = antdMessage.useMessage(); - const { user, initializing, fetchWithAuth, hasPermission } = useAuth(); + const { user, initializing, fetchWithAuth, hasPermission, getAccessToken, refreshAccessToken } = useAuth(); const [currentPath, setCurrentPath] = useState("/"); const [createDirectoryModalOpen, setCreateDirectoryModalOpen] = useState(false); @@ -90,6 +105,8 @@ export default function AdminFilesPage() { const [moveTarget, setMoveTarget] = useState(null); const [moveTargetParentPath, setMoveTargetParentPath] = useState("/"); const [moveNewName, setMoveNewName] = useState(""); + const [uploadProgress, setUploadProgress] = useState(0); + const [uploadFileName, setUploadFileName] = useState(""); const canRead = hasPermission("file.read") || hasPermission("file.manage"); const canManage = hasPermission("file.manage"); @@ -291,27 +308,76 @@ export default function AdminFilesPage() { if (!activeMountCode) { throw new Error("当前无可用存储挂载"); } + setUploadProgress(0); + setUploadFileName(file.name || "未命名文件"); + const params = new URLSearchParams({ mount_code: activeMountCode, parent_path: filesQuery.data?.current_path ?? currentPath, }); - const formData = new FormData(); - formData.append("file", file); + const uploadWithXhr = (token: string | null) => + new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("POST", `${window.location.origin}/api/v1/admin/files/upload?${params.toString()}`); + xhr.withCredentials = true; + if (token) { + xhr.setRequestHeader("Authorization", `Bearer ${token}`); + } - const response = await fetchWithAuth(`/api/v1/admin/files/upload?${params.toString()}`, { - method: "POST", - body: formData, - }); - if (!response.ok) { - throw new Error(await readApiError(response)); + xhr.upload.onprogress = (event: ProgressEvent) => { + if (!event.lengthComputable || event.total <= 0) { + return; + } + const percent = Math.min(99, Math.max(0, Math.round((event.loaded / event.total) * 100))); + setUploadProgress(percent); + }; + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + setUploadProgress(100); + try { + resolve(JSON.parse(xhr.responseText) as FileOperationResponse); + } catch { + reject(new Error("上传成功但响应解析失败")); + } + return; + } + reject(new Error(readXhrError(xhr))); + }; + + xhr.onerror = () => reject(new Error("网络异常,上传失败")); + xhr.onabort = () => reject(new Error("上传已取消")); + + const formData = new FormData(); + formData.append("file", file); + xhr.send(formData); + }); + + let payload: FileOperationResponse; + try { + payload = await uploadWithXhr(getAccessToken()); + } catch (error) { + const message = error instanceof Error ? error.message : "上传失败"; + const isUnauthorized = message.includes("401") || message.includes("未授权"); + if (!isUnauthorized) { + throw error; + } + const refreshed = await refreshAccessToken(); + if (!refreshed) { + throw error; + } + payload = await uploadWithXhr(getAccessToken()); } - return (await response.json()) as FileOperationResponse; + return payload; }, onSuccess: async (payload) => { await applyMutationSuccess(payload, "上传成功"); + setUploadFileName(""); + setUploadProgress(0); }, onError: (error) => { + setUploadProgress(0); const message = error instanceof Error ? error.message : "上传失败"; setErrorMessage(message); messageApi.error(message); @@ -755,6 +821,18 @@ export default function AdminFilesPage() { + {uploadMutation.isPending && ( +
+
+ + 正在上传:{uploadFileName || "未命名文件"} + + {uploadProgress}% +
+ +
+ )} +
diff --git a/web/src/components/auth-provider.tsx b/web/src/components/auth-provider.tsx index 7fc1daa..b56dcff 100644 --- a/web/src/components/auth-provider.tsx +++ b/web/src/components/auth-provider.tsx @@ -16,6 +16,7 @@ import type { AuthTokenResponse, UserPublic } from "@/types/auth"; type AuthContextValue = { user: UserPublic | null; accessToken: string | null; + getAccessToken: () => string | null; initializing: boolean; login: (userId: string, password: string) => Promise; register: (email: string, username: string, password: string) => Promise; @@ -50,6 +51,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { accessTokenRef.current = token; setAccessToken(token); }, []); + const getAccessToken = useCallback(() => accessTokenRef.current, []); const clearAuth = useCallback(() => { setUser(null); @@ -214,6 +216,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { () => ({ user, accessToken, + getAccessToken, initializing, login, register, @@ -226,6 +229,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { [ user, accessToken, + getAccessToken, initializing, login, register,