Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion deployment/docker/datamate/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
DB_PASSWORD=your-secure-password-here

# JWT Authentication
JWT_SECRET=your-secure-jwt-secret-here
# Generate a random secret: openssl rand -hex 64
# Use the same value for gateway and backend; at least 64 UTF-8 bytes for HS256/HS512.
JWT_SECRET=
DATAMATE_JWT_ENABLE=false

# MinIO (for Milvus storage)
Expand Down
3 changes: 2 additions & 1 deletion deployment/docker/datamate/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ services:
ports:
- '8080:8080'
environment:
- JWT_SECRET=${JWT_SECRET:-}
- DB_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required. Set in .env file}
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required. Set in .env file}
- datamate.jwt.enable=${DATAMATE_JWT_ENABLE:-false}
- LOG_ROTATION_MAX_SIZE=${LOG_ROTATION_MAX_SIZE:-100MB}
- LOG_ROTATION_BACKUP_COUNT=${LOG_ROTATION_BACKUP_COUNT:-30}
Expand Down
1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"dev": "vite",
"mock": "cd src/mock && nodemon --config nodemon.json --inspect=0.0.0.0:9229 mock.cjs --env=development --port=8002",
"build": "vite build",
"test": "node --test tests/basic-functionality.test.cjs tests/bootstrap-auth.test.cjs",
"lint": "eslint .",
"preview": "vite preview"
},
Expand Down
108 changes: 49 additions & 59 deletions frontend/src/hooks/useFetchData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,21 @@
// stopPolling(); // 停止轮询
// 手动调用 fetchData() 时,如果正在轮询,会重新开始轮询计时
// 轮询时会同时执行主要的 fetchFunction 和所有额外的轮询函数
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { useDebouncedEffect } from "./useDebouncedEffect";
import { useState, useRef, useEffect, useCallback } from "react";
import Loading from "@/utils/loading";
import { App } from "antd";
import { useTranslation } from "react-i18next";

interface SearchFilters {
type?: string[];
status?: string[];
tags?: string[];
categories?: string[][];
selectedStar?: boolean;
builtIn?: string[];
[key: string]: string[] | string[][] | boolean | undefined;
}

export default function useFetchData<T>(
fetchFunc: (params?: any) => Promise<any>,
mapDataFunc: (data: Partial<T>) => T = (data) => data as T,
Expand Down Expand Up @@ -48,7 +57,7 @@ export default function useFetchData<T>(
tags: [] as string[],
categories: [] as string[][],
selectedStar: false,
},
} as SearchFilters,
current: 1,
pageSize: 12,
});
Expand All @@ -60,9 +69,6 @@ export default function useFetchData<T>(
// 组件挂载状态 ref
const isMountedRef = useRef(true);

// 跟踪上一次的 searchParams,用于避免重复请求
const prevSearchParamsRef = useRef<string>("");

// Pagination configuration
const [pagination, setPagination] = useState({
total: 0,
Expand Down Expand Up @@ -94,7 +100,7 @@ export default function useFetchData<T>(
});
};

function getFirstOfArray(arr: string[]) {
function getFirstOfArray(arr?: string[]) {
if (!arr || arr.length === 0 || !Array.isArray(arr)) return undefined;
if (arr[0] === "all") return undefined;
return arr[0];
Expand Down Expand Up @@ -123,19 +129,26 @@ export default function useFetchData<T>(
}

try {
const apiParams = {
const builtIn = getFirstOfArray(filter.builtIn);
const apiParams: Record<string, unknown> = {
categories: filter.categories,
...extraParams,
keyword,
isStar: filter.selectedStar ? true : undefined,
type: getFirstOfArray(filter?.type) || undefined,
status: getFirstOfArray(filter?.status) || undefined,
built_in: filter?.builtIn !== undefined ? (getFirstOfArray(filter?.builtIn) === "true") : undefined,
built_in: builtIn === undefined ? undefined : builtIn === "true",
tags: filter?.tags?.length ? filter.tags.join(",") : undefined,
page: current - pageOffset,
size: pageSize,
};

Object.entries(filter).forEach(([key, value]) => {
if (!["type", "status", "tags", "categories", "selectedStar", "builtIn"].includes(key)) {
apiParams[key] = Array.isArray(value) ? getFirstOfArray(value as string[]) : value;
}
});

Object.keys(searchParamsRef.current).forEach(key => {
if (!['keyword', 'filter', 'current', 'pageSize'].includes(key) && apiParams[key as keyof typeof apiParams] === undefined) {
(apiParams as any)[key] = (searchParamsRef.current as any)[key];
Expand All @@ -160,37 +173,36 @@ export default function useFetchData<T>(
}));
let result = [];
if (mapDataFunc) {
result = data?.content.map(mapDataFunc) ?? [];
result = data?.content?.map(mapDataFunc) ?? [];
}
setTableData(result);

if (wasPolling) {
const poll = () => {
pollingTimerRef.current = setTimeout(() => {
fetchData({}, true).then(() => {
if (pollingTimerRef.current && isMountedRef.current) {
poll();
}
});
}, pollingInterval);
};
poll();
}
} catch (error) {
if (!isMountedRef.current) {
return;
}
if (error.status === 401) {
message.warn(t('hooks.fetchData.loginRequired'));

if (error && typeof error === "object" && "status" in error && error.status === 401) {
message.warning(t('hooks.fetchData.loginRequired'));
} else {
message.error(t('hooks.fetchData.fetchFailed'));
}
} finally {
if (isMountedRef.current) {
if (isMountedRef.current && !skipPollingRestart) {
Loading.hide();
setLoading(false);
}
if (wasPolling && isPollingRef.current && isMountedRef.current) {
const poll = () => {
pollingTimerRef.current = setTimeout(() => {
fetchDataRef.current({}, true).then(() => {
if (isPollingRef.current && isMountedRef.current) {
poll();
}
});
}, pollingInterval);
};
poll();
}
}
},
[
Expand All @@ -213,8 +225,8 @@ export default function useFetchData<T>(

const poll = () => {
pollingTimerRef.current = setTimeout(() => {
fetchData({}, true).then(() => {
if (pollingTimerRef.current && isMountedRef.current) {
fetchDataRef.current({}, true).then(() => {
if (isPollingRef.current && isMountedRef.current) {
poll();
}
});
Expand All @@ -231,45 +243,23 @@ export default function useFetchData<T>(
isPollingRef.current = false;
}, [clearPollingTimer]);

// 搜索参数变化时,自动刷新数据
// 使用 useEffect + 深比较,而不是将对象作为依赖项
// 使用最新回调,避免内联映射函数变化取消尚未执行的搜索请求。
const fetchDataRef = useRef(fetchData);
fetchDataRef.current = fetchData;
const searchParamsKey = JSON.stringify(searchParams);

useEffect(() => {
if (!isMountedRef.current) return;

// 序列化当前参数
const currentParamsString = JSON.stringify({
keyword: searchParams.keyword,
filterType: searchParams.filter.type,
filterStatus: searchParams.filter.status,
filterTags: searchParams.filter.tags,
filterCategories: searchParams.filter.categories,
selectedStar: searchParams.filter.selectedStar,
current: searchParams.current,
pageSize: searchParams.pageSize,
});

// 检查参数是否真的变化了
if (currentParamsString === prevSearchParamsRef.current) {
return;
}
prevSearchParamsRef.current = currentParamsString;

// 防抖处理
const timer = setTimeout(() => {
if (isMountedRef.current) {
fetchData();
fetchDataRef.current();
}
}, searchParams?.keyword ? 500 : 0);
}, searchParamsRef.current.keyword ? 500 : 0);

return () => {
clearTimeout(timer);
};
}, [searchParams, fetchData]);

// 组件挂载时重置 prevSearchParamsRef,解决 StrictMode 双重挂载问题
useEffect(() => {
prevSearchParamsRef.current = "";
}, []);
}, [searchParamsKey]);

// 组件卸载时清理轮询和状态
useEffect(() => {
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/hooks/useSliceUpload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ export function useFileSliceUpload(
let newTask = { ...task };
// 使用 datasetId 调用 API,key 用于唯一标识任务
await uploadChunk(datasetId || key, formData, {
signal: task.controller.signal,
onUploadProgress: (e) => {
const loadedSize = loaded + e.loaded;
const curPercent = Number((loadedSize / totalSize) * 100).toFixed(2);
Expand Down Expand Up @@ -185,12 +186,13 @@ export function useFileSliceUpload(
const totalSize = files.reduce((acc, file) => acc + file.size, 0);
await uploadFile({ task, files, totalSize });
} catch (err) {
if (task.controller.signal.aborted) return;
console.error(err);
message.error(t('hooks.sliceUpload.uploadFailed'));
removeTask({
...task,
isCancel: true,
...taskListRef.current.find((item) => item.key === task.key),
isCancel: true,
});
}
};
Expand Down
33 changes: 20 additions & 13 deletions frontend/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,13 @@ function getAuthToken(): string | null {
* 在任何渲染之前检查系统参数 sys.home.page.url,若已配置则立即跳转,确保无闪烁。
* 使用原始 fetch 但携带 JWT token,避免已登录用户仍收到 401。
*/
async function checkHomePageRedirect(requireLogin: boolean): Promise<{ redirected: boolean; authNeeded: boolean }> {
async function checkHomePageRedirect(requireLogin: boolean, authenticated: boolean): Promise<{ redirected: boolean; authNeeded: boolean }> {
if (window.location.pathname !== '/') {
return { redirected: false, authNeeded: false };
}

// 如果需要登录,检查是否有缓存的登录页URL
if (requireLogin) {
// 尚未登录且要求登录时,检查是否有缓存的登录页URL
if (requireLogin && !authenticated) {
const cachedUrl = getCachedHomePageUrl();
if (cachedUrl) {
window.location.replace(cachedUrl);
Expand All @@ -81,7 +81,7 @@ async function checkHomePageRedirect(requireLogin: boolean): Promise<{ redirecte
return { redirected: false, authNeeded: true };
}

// 不需要登录时,检查自定义首页
// 已认证或允许匿名访问时,检查自定义首页
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
const token = getAuthToken();
if (token) {
Expand Down Expand Up @@ -118,46 +118,53 @@ async function checkHomePageRedirect(requireLogin: boolean): Promise<{ redirecte
}

/**
* 检查是否需要登录(从后端获取配置)
* 检查登录策略和当前认证状态(从后端获取)
* 这个检查应该在应用启动时总是执行,无论当前路径是什么
*/
async function checkRequireLoginMode(): Promise<boolean> {
async function checkLoginState(): Promise<{ requireLogin: boolean; authenticated: boolean }> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
const token = getAuthToken();
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}

try {
const userResponse = await fetch('/api/user/me', {
method: 'GET',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
headers,
});

if (userResponse.ok) {
const userResult = await userResponse.json();
const requireLogin = userResult?.data?.requireLogin ?? false;
// 设置全局标记,供 request.ts 使用
const authenticated = userResult?.data?.authenticated === true;
// 登录策略与认证状态分别保存,已登录时仍需处理后续令牌过期。
setRequireLoginMode(requireLogin);
return requireLogin;
return { requireLogin, authenticated };
} else if (userResponse.status === 401) {
// /api/user/me 本身返回 401,说明需要登录
setRequireLoginMode(true);
return true;
return { requireLogin: true, authenticated: false };
}
} catch (e) {
console.error('[bootstrap] Failed to check login requirement:', e);
}

// 默认不需要登录
setRequireLoginMode(false);
return false;
return { requireLogin: false, authenticated: false };
}

async function bootstrap() {
const container = document.getElementById("root");
if (!container) return;

// 首先检查是否需要登录(无论当前路径是什么)
const requireLogin = await checkRequireLoginMode();
const { requireLogin, authenticated } = await checkLoginState();

// 然后检查自定义首页重定向(只在根路径时)
const { redirected, authNeeded } = await checkHomePageRedirect(requireLogin);
const { redirected, authNeeded } = await checkHomePageRedirect(requireLogin, authenticated);

if (redirected) {
return;
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/pages/DataAnnotation/annotation.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@ export function getTagConfigUsingGet() {

// 标注模板管理
export function queryAnnotationTemplatesUsingGet(params?: any) {
return get("/api/annotation/template", params);
const { built_in, ...rest } = params || {};
return get("/api/annotation/template", {
...rest,
builtIn: built_in ?? rest.builtIn,
});
}

export function createAnnotationTemplateUsingPost(data: any) {
Expand Down
Loading
Loading