@@ -1060,3 +1060,15 @@
|
||||
- 角色管理页面(`/admin/roles`)当前仅提供角色基础信息与“可见菜单”配置,不再提供权限点(`permission_codes`)配置入口。
|
||||
- 菜单管理页面(`/admin/menus`)当前不再提供菜单权限码(`permission_code`)配置入口与列表展示。
|
||||
- 后端权限字段与接口兼容能力保留,作为历史数据与鉴权映射兜底;前端交互层默认不暴露该配置。
|
||||
|
||||
## 文件管理上传进度口径(2026-05-02)
|
||||
|
||||
- 文件管理页(`/admin/files`)上传链路已从 `fetch` 切换为 `XMLHttpRequest`,用于获取浏览器原生上传进度事件并展示百分比。
|
||||
- 上传进度展示基线:
|
||||
- 上传进行中显示文件名、百分比数值与进度条;
|
||||
- 上传成功后清空进度状态;
|
||||
- 上传失败时进度归零并沿用现有错误提示通道。
|
||||
- 鉴权口径:
|
||||
- XHR 上传沿用 `withCredentials + Authorization: Bearer <token>`;
|
||||
- 401 时触发一次 `refreshAccessToken` 并重试上传,保持与现有鉴权刷新机制一致。
|
||||
- AuthContext 对外新增 `getAccessToken()`,用于在异步重试场景读取最新 token,避免闭包持有旧值。
|
||||
|
||||
@@ -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 <status>`。
|
||||
- 401 场景下自动调用 `refreshAccessToken` 后重试一次上传,保持与现有鉴权刷新逻辑一致。
|
||||
- 文件:`web/src/components/auth-provider.tsx`
|
||||
- 新增 `getAccessToken()`,用于读取最新 token(避免异步刷新后闭包持有旧 token)。
|
||||
|
||||
- 验证:
|
||||
- 按任务要求未执行编译/安装/构建检查;
|
||||
- 通过 `git diff` 人工核对:
|
||||
- 改动范围仅限上述两个前端文件;
|
||||
- 文件管理页已接入上传百分比显示与进度条 UI。
|
||||
|
||||
- 风险与影响:
|
||||
- 影响范围仅文件管理上传链路与认证上下文类型定义,不涉及后端接口契约变更。
|
||||
- 上传改为 XHR 后仍保留 `withCredentials + Bearer token`,与现有认证模式兼容。
|
||||
|
||||
@@ -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<FileEntryItem | null>(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<FileOperationResponse>((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<EventTarget>) => {
|
||||
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() {
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{uploadMutation.isPending && (
|
||||
<div className="mt-3 rounded-md border border-[var(--gray-5)] bg-[var(--gray-a2)] px-3 py-2">
|
||||
<div className="mb-1 flex items-center justify-between gap-3">
|
||||
<Typography.Text ellipsis={{ tooltip: uploadFileName || undefined }} className="max-w-[420px]">
|
||||
正在上传:{uploadFileName || "未命名文件"}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">{uploadProgress}%</Typography.Text>
|
||||
</div>
|
||||
<Progress percent={uploadProgress} size="small" showInfo={false} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 rounded-lg border border-[var(--gray-5)] bg-[var(--gray-a2)] px-3 py-2 [&_.ant-breadcrumb-link]:!text-[var(--gray-12)] [&_.ant-breadcrumb-separator]:!text-[var(--gray-10)]">
|
||||
<Breadcrumb items={breadcrumbItems} />
|
||||
</div>
|
||||
|
||||
@@ -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<void>;
|
||||
register: (email: string, username: string, password: string) => Promise<void>;
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user