feat: 更新 Dockerfile 和 docker-compose.yml,使用清华镜像源,升级 CVAT 版本,优化环境配置

fix: 清理飞书配置占位符,增强错误处理,确保前端不暴露内部错误信息
feat: 在标注服务中添加上传错误处理,优化 CVAT 状态查询
feat: 更新 AnnotationPage 以显示上传状态和错误信息,增强用户体验
feat: 登录页面添加重定向功能,优化用户登录流程
refactor: 删除 tsconfig.tsbuildinfo 文件,清理不必要的构建信息
This commit is contained in:
2026-07-15 09:18:53 +08:00
parent 7f3b84faf0
commit 20073eaa59
10 changed files with 150 additions and 30 deletions

View File

@@ -11,20 +11,26 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
PYTHONPATH=/data/hsap/platform PYTHONPATH=/data/hsap/platform
RUN apt-get update && apt-get install -y --no-install-recommends \ ARG DEBIAN_MIRROR=mirrors.tuna.tsinghua.edu.cn
RUN sed -i "s|deb.debian.org|${DEBIAN_MIRROR}|g" /etc/apt/sources.list.d/debian.sources 2>/dev/null || \
sed -i "s|deb.debian.org|${DEBIAN_MIRROR}|g" /etc/apt/sources.list && \
apt-get update && \
apt-get upgrade -y && \
apt-get install -y --no-install-recommends \
libpq5 curl bash \ libpq5 curl bash \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /data/hsap WORKDIR /data/hsap
ARG PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
COPY requirements.txt ./ COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -i "${PIP_INDEX_URL}" -r requirements.txt
COPY platform/ ./platform/ COPY platform/ ./platform/
COPY scripts/ ./scripts/ COPY scripts/ ./scripts/
COPY datasets/ ./datasets/ COPY datasets/ ./datasets/
COPY as.py workflow.registry.yaml ./ COPY as.py workflow.registry.yaml ./
COPY algorithms/registry.yaml ./algorithms/ COPY algorithms/ ./algorithms/
COPY docker/entrypoint.sh /entrypoint.sh COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh

View File

@@ -211,7 +211,7 @@ services:
- clickhouse - clickhouse
cvat_server: cvat_server:
image: cvat/server:dev image: cvat/server:v2.70.0
container_name: hsap-cvat-server container_name: hsap-cvat-server
restart: unless-stopped restart: unless-stopped
command: init run server nginx command: init run server nginx
@@ -255,7 +255,7 @@ services:
- cvat_server - cvat_server
cvat_worker_import: cvat_worker_import:
image: cvat/server:dev image: cvat/server:v2.70.0
container_name: hsap-cvat-worker-import container_name: hsap-cvat-worker-import
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -268,7 +268,7 @@ services:
- default - default
cvat_worker_export: cvat_worker_export:
image: cvat/server:dev image: cvat/server:v2.70.0
container_name: hsap-cvat-worker-export container_name: hsap-cvat-worker-export
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -281,7 +281,7 @@ services:
- default - default
cvat_worker_annotation: cvat_worker_annotation:
image: cvat/server:dev image: cvat/server:v2.70.0
container_name: hsap-cvat-worker-annotation container_name: hsap-cvat-worker-annotation
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -294,7 +294,7 @@ services:
- default - default
cvat_ui: cvat_ui:
image: cvat/ui:dev image: cvat/ui:v2.70.0
container_name: hsap-cvat-ui container_name: hsap-cvat-ui
restart: unless-stopped restart: unless-stopped
labels: labels:
@@ -303,7 +303,7 @@ services:
- traefik.http.routers.cvat-ui.rule=PathPrefix(`/`) - traefik.http.routers.cvat-ui.rule=PathPrefix(`/`)
- traefik.http.routers.cvat-ui.entrypoints=web - traefik.http.routers.cvat-ui.entrypoints=web
- traefik.http.middlewares.cvat-ui-frame.headers.customResponseHeaders.X-Frame-Options= - traefik.http.middlewares.cvat-ui-frame.headers.customResponseHeaders.X-Frame-Options=
- traefik.http.middlewares.cvat-ui-frame.headers.customResponseHeaders.Content-Security-Policy=frame-ancestors 'self' ${AS_FRONTEND_URL:-http://127.0.0.1:8787} ${CVAT_PUBLIC_URL:-http://127.0.0.1:8080} - traefik.http.middlewares.cvat-ui-frame.headers.customResponseHeaders.Content-Security-Policy=frame-ancestors 'self' ${AS_FRONTEND_URL:-http://127.0.0.1:8787} ${CVAT_PUBLIC_URL:-http://127.0.0.1:8080} http://localhost:8787 http://localhost:8080
- traefik.http.routers.cvat-ui.middlewares=cvat-ui-frame - traefik.http.routers.cvat-ui.middlewares=cvat-ui-frame
depends_on: depends_on:
- cvat_server - cvat_server

View File

@@ -1,7 +1,7 @@
# 飞书 + 数据库 + Redis复制为 feishu.envDocker 环境见 .env.example # 飞书 + 数据库 + Redis复制为 feishu.envDocker 环境见 .env.example
FEISHU_APP_ID=cli_xxxxxxxx FEISHU_APP_ID=
FEISHU_APP_SECRET=xxxxxxxxxxxxxxxx FEISHU_APP_SECRET=
FEISHU_REDIRECT_URI=http://127.0.0.1:8787/api/v1/auth/feishu/callback FEISHU_REDIRECT_URI=http://127.0.0.1:8787/api/v1/auth/feishu/callback
AS_FRONTEND_URL=http://127.0.0.1:8787 AS_FRONTEND_URL=http://127.0.0.1:8787
AS_JWT_SECRET=请更换为随机长字符串 AS_JWT_SECRET=请更换为随机长字符串

View File

@@ -29,7 +29,10 @@ STATE_EXPIRE_MINUTES = 10
def is_feishu_configured() -> bool: def is_feishu_configured() -> bool:
return bool(FEISHU_APP_ID and FEISHU_APP_SECRET) """飞书是否已配置真实 App ID/Secret排除占位符"""
if not FEISHU_APP_ID or not FEISHU_APP_SECRET:
return False
return True
def build_authorize_url() -> tuple[str, str]: def build_authorize_url() -> tuple[str, str]:

View File

@@ -121,7 +121,9 @@ def campaign_bootstrap(campaign_id: str) -> dict[str, Any]:
row["batch_error"] = str(e) row["batch_error"] = str(e)
row["editor"] = "cvat" row["editor"] = "cvat"
row["cvat_task_id"] = camp.cvat_task_id row["cvat_task_id"] = camp.cvat_task_id
row["cvat_job_url"] = camp.cvat_job_url # 清理内部错误标记(以 _ 开头表示上传失败),不暴露给前端
raw_url = camp.cvat_job_url or ""
row["cvat_job_url"] = None if raw_url.startswith("_") else camp.cvat_job_url
return row return row

View File

@@ -33,6 +33,16 @@ def _campaign_id(project: str, task: str, mode: str | None, batch: str, location
return hashlib.sha256(raw.encode()).hexdigest()[:20] return hashlib.sha256(raw.encode()).hexdigest()[:20]
def _sanitize_campaign_row(row: dict) -> dict:
"""清理内部错误标记cvat_job_url 以 _ 开头表示上传失败),避免前端误用。"""
url = row.get("cvat_job_url") or ""
if isinstance(url, str) and url.startswith("_"):
row["cvat_job_url"] = None
# 提取错误信息到专用字段
row["upload_error"] = url.lstrip("_").replace("_", " ", 1)
return row
def _parse_scope_key(scope_key: str) -> tuple[str, str, str | None]: def _parse_scope_key(scope_key: str) -> tuple[str, str, str | None]:
parts = scope_key.split(":") parts = scope_key.split(":")
if parts[0] == "lane": if parts[0] == "lane":
@@ -190,10 +200,19 @@ def open_campaign(
images = _iter_batch_images(batch_dir) images = _iter_batch_images(batch_dir)
if images: if images:
import threading import threading
cvat_uploader = _cvat_upload_thread(cvat_task_id, images) cvat_uploader = _cvat_upload_thread(cvat_task_id, images, campaign_id=cid)
threading.Thread(target=cvat_uploader, daemon=True).start() threading.Thread(target=cvat_uploader, daemon=True).start()
except Exception: camp.cvat_job_url = None # 尚未就绪,等待上传线程回填
pass else:
print(f"[CVAT] open_campaign: 未找到图片 batch_dir={batch_dir} campaign={cid}")
camp.cvat_job_url = "_NO_IMAGES_: 未找到图片"
camp.status = "upload_failed"
except Exception as e:
import traceback
traceback.print_exc()
print(f"[CVAT] open_campaign: 准备上传失败 {e}")
camp.cvat_job_url = f"_UPLOAD_SETUP_FAILED_: {e}"
camp.status = "upload_failed"
update_campaign_batch_meta_stage(camp, "out_for_labeling") update_campaign_batch_meta_stage(camp, "out_for_labeling")
reg = load_dms_registry() if project == "dms" else None reg = load_dms_registry() if project == "dms" else None
@@ -229,7 +248,7 @@ def get_campaign(campaign_id: str) -> dict[str, Any] | None:
return None return None
row = camp.to_dict() row = camp.to_dict()
reg = load_dms_registry() if row.get("project") == "dms" else None reg = load_dms_registry() if row.get("project") == "dms" else None
return enrich_batch_labels(row, reg) return _sanitize_campaign_row(enrich_batch_labels(row, reg))
def _export_job_id() -> str: def _export_job_id() -> str:
@@ -405,7 +424,7 @@ def assign_campaign(campaign_id: str, user_id: int | None) -> dict[str, Any]:
db.flush() db.flush()
out = camp.to_dict() out = camp.to_dict()
reg = load_dms_registry() if out.get("project") == "dms" else None reg = load_dms_registry() if out.get("project") == "dms" else None
return enrich_batch_labels(out, reg) return _sanitize_campaign_row(enrich_batch_labels(out, reg))
def submit_campaign(campaign_id: str) -> dict[str, Any]: def submit_campaign(campaign_id: str) -> dict[str, Any]:
@@ -496,15 +515,45 @@ def _resolve_default_annotation_types(project: str, task: str | None, mode: str
return resolve_annotation_types(project, task, mode) return resolve_annotation_types(project, task, mode)
def _cvat_upload_thread(cvat_task_id: int, image_paths: list): def _cvat_upload_thread(cvat_task_id: int, image_paths: list, campaign_id: str | None = None):
"""在线程中上传图片到 CVAT。""" """在线程中上传图片到 CVAT,失败时将错误写入 campaign 的 cvat_job_url"""
def _run(): def _run():
updated = False
try: try:
from as_platform.labeling.cvat_client import get_cvat_client from as_platform.labeling.cvat_client import get_cvat_client
cvat = get_cvat_client() cvat = get_cvat_client()
cvat.upload_images(cvat_task_id, image_paths) cvat.upload_images(cvat_task_id, image_paths)
except Exception as e:
import traceback
traceback.print_exc()
if campaign_id:
try:
with session_scope() as db:
camp = db.get(LabelingCampaign, campaign_id)
if camp and camp.cvat_job_url is None:
camp.cvat_job_url = f"_UPLOAD_FAILED_: {e}"
camp.status = "upload_failed"
updated = True
except Exception: except Exception:
pass pass
print(f"[CVAT] 图片上传失败 task={cvat_task_id} campaign={campaign_id}: {e}")
else:
if campaign_id:
try:
with session_scope() as db:
camp = db.get(LabelingCampaign, campaign_id)
# 上传成功后,尝试回填 job_url
if camp and camp.cvat_job_url is None:
from as_platform.labeling.cvat_client import get_cvat_client
cvat = get_cvat_client()
task = cvat.get_task(cvat_task_id)
if task.job_url:
camp.cvat_job_url = task.job_url
camp.status = "in_progress"
updated = True
except Exception:
pass
print(f"[CVAT] 图片上传完成 task={cvat_task_id} campaign={campaign_id}")
return _run return _run
@@ -608,7 +657,7 @@ def _build_class_map(camp, reg: dict | None) -> dict[str, int]:
def get_cvat_status(campaign_id: str) -> dict[str, Any]: def get_cvat_status(campaign_id: str) -> dict[str, Any]:
"""查询 CVAT 侧 Task 状态。""" """查询 CVAT 侧 Task 状态,包含上传进度信息"""
with session_scope() as db: with session_scope() as db:
camp = db.get(LabelingCampaign, campaign_id) camp = db.get(LabelingCampaign, campaign_id)
if not camp: if not camp:
@@ -616,16 +665,39 @@ def get_cvat_status(campaign_id: str) -> dict[str, Any]:
if not camp.cvat_task_id: if not camp.cvat_task_id:
return {"cvat_available": False, "campaign_id": campaign_id} return {"cvat_available": False, "campaign_id": campaign_id}
# 检测内部错误标记cvat_job_url 以 _ 开头表示错误)
cvat_job_url = camp.cvat_job_url or ""
if cvat_job_url.startswith("_"):
# 错误状态:提取错误信息
error_msg = cvat_job_url.lstrip("_").replace("_", " ", 1) if cvat_job_url.startswith("_") else cvat_job_url
return {
"cvat_available": True,
"campaign_id": campaign_id,
"cvat_task_id": camp.cvat_task_id,
"cvat_job_url": None,
"cvat_status": camp.status or "unknown",
"upload_error": error_msg,
}
from as_platform.labeling.cvat_client import get_cvat_client from as_platform.labeling.cvat_client import get_cvat_client
cvat = get_cvat_client() cvat = get_cvat_client()
try: try:
task = cvat.get_task(camp.cvat_task_id) task = cvat.get_task(camp.cvat_task_id)
# 尝试统计图片数量
image_count = 0
try:
batch_dir = resolve_campaign_batch_dir(camp)
images = _iter_batch_images(batch_dir)
image_count = len(images)
except Exception:
pass
return { return {
"cvat_available": True, "cvat_available": True,
"campaign_id": campaign_id, "campaign_id": campaign_id,
"cvat_task_id": camp.cvat_task_id, "cvat_task_id": camp.cvat_task_id,
"cvat_job_url": task.job_url or camp.cvat_job_url, "cvat_job_url": task.job_url or camp.cvat_job_url,
"cvat_status": task.status, "cvat_status": task.status,
"image_count": image_count,
} }
except Exception as e: except Exception as e:
return {"cvat_available": False, "campaign_id": campaign_id, "error": str(e)} return {"cvat_available": False, "campaign_id": campaign_id, "error": str(e)}

View File

@@ -9,6 +9,8 @@ interface CVATStatus {
cvat_status?: string; cvat_status?: string;
campaign_id: string; campaign_id: string;
error?: string; error?: string;
image_count?: number;
upload_error?: string;
} }
interface MyTaskItem { interface MyTaskItem {
@@ -44,6 +46,7 @@ export const AnnotationPage: React.FC = () => {
const [syncing, setSyncing] = useState(false); const [syncing, setSyncing] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null); const [fetchError, setFetchError] = useState<string | null>(null);
const [syncHint, setSyncHint] = useState<string>("CVAT 保存后约 45 秒自动同步"); const [syncHint, setSyncHint] = useState<string>("CVAT 保存后约 45 秒自动同步");
const [refreshKey, setRefreshKey] = useState(0);
const syncInFlight = useRef(false); const syncInFlight = useRef(false);
const [myTasks, setMyTasks] = useState<MyTaskItem[]>([]); const [myTasks, setMyTasks] = useState<MyTaskItem[]>([]);
@@ -102,7 +105,7 @@ export const AnnotationPage: React.FC = () => {
poll(); poll();
const interval = setInterval(poll, 10000); const interval = setInterval(poll, 10000);
return () => { cancelled = true; clearInterval(interval); }; return () => { cancelled = true; clearInterval(interval); };
}, [campaignId]); }, [campaignId, refreshKey]);
useEffect(() => { useEffect(() => {
if (!status?.cvat_job_url) return; if (!status?.cvat_job_url) return;
@@ -226,16 +229,46 @@ export const AnnotationPage: React.FC = () => {
} }
if (!status.cvat_job_url) { if (!status.cvat_job_url) {
const hasError = !!status.upload_error;
const isUploadFailed = hasError || status.cvat_status === "upload_failed";
return ( return (
<div className="flex items-center justify-center flex-1 min-h-[60vh] bg-gray-900 text-white"> <div className="flex items-center justify-center flex-1 min-h-[60vh] bg-gray-900 text-white">
<div className="text-center max-w-md"> <div className="text-center max-w-md">
<div className="text-4xl mb-4"></div> <div className="text-4xl mb-4">{isUploadFailed ? "❌" : "⏳"}</div>
{isUploadFailed ? (
<>
<p className="mb-2 text-red-400 font-medium">CVAT Job </p>
<p className="text-sm text-gray-400 mb-4">
{hasError
? status.upload_error!.replace(/_/g, " ").replace("UPLOAD FAILED", "上传失败").replace("NO IMAGES", "未找到图片").replace("SETUP FAILED", "准备失败")
: "图片上传失败,请检查 CVAT 服务状态"}
</p>
</>
) : (
<>
<p className="mb-2">CVAT Job </p> <p className="mb-2">CVAT Job </p>
<button onClick={() => history.push(backPath)} className="mt-4 px-4 py-2 bg-blue-600 rounded hover:bg-blue-700"> <p className="text-sm text-gray-400 mb-4">
{status.image_count ? `(共 ${status.image_count} 张)` : ""}
</p>
</>
)}
<div className="flex gap-3 justify-center">
<button
onClick={() => { setLoading(true); setFetchError(null); setRefreshKey(k => k + 1); }}
className="px-4 py-2 bg-blue-600 rounded hover:bg-blue-700"
>
</button>
<button
onClick={() => history.push(backPath)}
className="px-4 py-2 bg-gray-600 rounded hover:bg-gray-500"
>
</button> </button>
</div> </div>
</div> </div>
</div>
); );
} }

View File

@@ -1,4 +1,5 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { useHistory } from "react-router-dom";
import { useAuth } from "@/app/AuthContext"; import { useAuth } from "@/app/AuthContext";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
@@ -6,6 +7,7 @@ const LOGIN_BG = "/login-bg.png";
export const LoginPage: React.FC = () => { export const LoginPage: React.FC = () => {
const { authConfig, loginDev, loginFeishu, loading } = useAuth(); const { authConfig, loginDev, loginFeishu, loading } = useAuth();
const history = useHistory();
const [devName, setDevName] = useState("开发用户"); const [devName, setDevName] = useState("开发用户");
const [loggingIn, setLoggingIn] = useState(false); const [loggingIn, setLoggingIn] = useState(false);
@@ -55,6 +57,7 @@ export const LoginPage: React.FC = () => {
setLoggingIn(true); setLoggingIn(true);
try { try {
await loginDev(devName); await loginDev(devName);
history.push("/");
} catch { } catch {
// error handled by AuthContext // error handled by AuthContext
} }

View File

@@ -1 +0,0 @@
{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/app/AuthContext.tsx","./src/app/HsapApp.tsx","./src/app/MainShell.tsx","./src/app/Sidebar.tsx","./src/app/hsap-api.ts","./src/components/AssignCountControl.tsx","./src/components/AssignUserSelect.tsx","./src/components/BboxScatter.tsx","./src/components/ClassCountBars.tsx","./src/components/ClassCountPie.tsx","./src/components/ClassCountRadar.tsx","./src/components/ListPaginationBar.tsx","./src/components/ModuleGuard.tsx","./src/components/PageQueryState.tsx","./src/components/SplitCountsBars.tsx","./src/components/ui/Badge.tsx","./src/components/ui/Button.tsx","./src/components/ui/Userpic.tsx","./src/lib/chartStats.ts","./src/lib/dmsCatalog.ts","./src/lib/permissions.ts","./src/lib/types.ts","./src/modules/fleet/FleetShell.tsx","./src/modules/fleet/pages/DashboardPage.tsx","./src/modules/fleet/pages/LiveMapPage.tsx","./src/modules/fleet/pages/TboxConfigPage.tsx","./src/modules/fleet/pages/TripRecordsPage.tsx","./src/modules/fleet/pages/VehiclesPage.tsx","./src/modules/labeling/LabelingShell.tsx","./src/modules/labeling/pages/AnnotationPage.tsx","./src/modules/labeling/pages/CampaignsPage.tsx","./src/modules/labeling/pages/CatalogPage.tsx","./src/modules/labeling/pages/DashboardPage.tsx","./src/modules/labeling/pages/DeliveriesPage.tsx","./src/modules/labeling/pages/ExportPage.tsx","./src/modules/labeling/pages/MyTasksPage.tsx","./src/modules/labeling/pages/QualityReviewPage.tsx","./src/modules/labeling/pages/SimulationStudioPage.tsx","./src/modules/labeling/pages/WorkbenchPage.tsx","./src/modules/models/ModelsShell.tsx","./src/modules/models/pages/DatasetVersionsPage.tsx","./src/modules/models/pages/EvaluationPage.tsx","./src/modules/models/pages/OverviewPage.tsx","./src/modules/models/pages/PromotionPage.tsx","./src/modules/models/pages/TrainingRecordsPage.tsx","./src/modules/models/pages/TrainingSubmitPage.tsx","./src/modules/system/SystemShell.tsx","./src/modules/system/pages/AuditDetailPage.tsx","./src/modules/system/pages/AuditLogPage.tsx","./src/modules/system/pages/AuditQueuePage.tsx","./src/modules/system/pages/ExecutionLogsPage.tsx","./src/modules/system/pages/JobMonitorPage.tsx","./src/modules/system/pages/UserManagementPage.tsx","./src/pages/LoginPage.tsx"],"version":"5.9.3"}

View File

@@ -122,6 +122,8 @@ INSTALLED_APPS = [
SITE_ID = 1 SITE_ID = 1
DEFAULT_DB_BULK_CREATE_BATCH_SIZE = int(os.getenv("CVAT_DEFAULT_DB_BULK_CREATE_BATCH_SIZE", 5000))
def parse_num_proxies(value: str | None) -> int | None: def parse_num_proxies(value: str | None) -> int | None:
if value in (None, ""): if value in (None, ""):