feat: 合并 Docker Compose、标注表格优化与部署文档
将 platform + CVAT 合并为单文件 docker-compose.yml,完善 .env 与 init/dev_up 脚本; 新增 docs/DEPLOY.md 与更新 README 以支持新机器部署;含数据湖示例、车队地图、 紧凑表格 UI、ADAS det_7cls 路径与批次台账等近期改动。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -45,6 +45,45 @@ class DeliveryPatchBody(BaseModel):
|
||||
owner_name: str | None = None
|
||||
|
||||
|
||||
@router.get("/scan")
|
||||
def api_scan_deliveries(
|
||||
_user: Annotated[User, Depends(require_any_permission("read:deliveries", "read:pending", "*"))],
|
||||
projects: str | None = Query(None, description="逗号分隔: dms,adas,lane"),
|
||||
) -> dict[str, Any]:
|
||||
from as_platform.deliveries.scan import scan_delivery_sources
|
||||
|
||||
projs = [p.strip() for p in projects.split(",") if p.strip()] if projects else None
|
||||
return scan_delivery_sources(projects=projs)
|
||||
|
||||
|
||||
class ScanRegisterBody(BaseModel):
|
||||
items: list[dict[str, Any]] = Field(default_factory=list)
|
||||
sync_workbench: bool = True
|
||||
|
||||
|
||||
@router.post("/scan/register")
|
||||
def api_register_scanned_deliveries(
|
||||
body: ScanRegisterBody,
|
||||
user: Annotated[User, Depends(require_any_permission("write:delivery_submit", "*"))],
|
||||
) -> dict[str, Any]:
|
||||
from as_platform.deliveries.scan import register_scanned_to_ledger
|
||||
|
||||
return register_scanned_to_ledger(body.items, user, sync_workbench=body.sync_workbench)
|
||||
|
||||
|
||||
@router.post("/{delivery_id}/sync-workbench")
|
||||
def api_sync_delivery_workbench(
|
||||
delivery_id: str,
|
||||
_user: Annotated[User, Depends(require_any_permission("write:delivery_submit", "*"))],
|
||||
) -> dict[str, Any]:
|
||||
from as_platform.deliveries.scan import bridge_delivery_to_workbench
|
||||
|
||||
try:
|
||||
return bridge_delivery_to_workbench(delivery_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
|
||||
@router.get("")
|
||||
def api_list_deliveries(
|
||||
_user: Annotated[User, Depends(require_any_permission("read:deliveries", "read:pending", "*"))],
|
||||
|
||||
@@ -128,10 +128,40 @@ def api_assign_campaign(
|
||||
def api_labeling_batches(
|
||||
_user: Annotated[User, Depends(require_permission("read:pending"))],
|
||||
stage: str | None = Query(None),
|
||||
stages: str | None = Query(None, description="逗号分隔多阶段,一次扫描返回"),
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
refresh: bool = Query(False, description="true 时先重建索引再返回"),
|
||||
q: str | None = Query(None, description="搜索批次名/任务/项目"),
|
||||
) -> dict[str, Any]:
|
||||
return list_labeling_batches(stage=stage, offset=offset, limit=limit)
|
||||
stage_list = [s.strip() for s in stages.split(",")] if stages else None
|
||||
return list_labeling_batches(
|
||||
stage=stage, stages=stage_list, offset=offset, limit=limit, refresh=refresh, q=q,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/v1/labeling/batches/rebuild-index")
|
||||
def api_rebuild_batch_index(
|
||||
_user: Annotated[User, Depends(require_permission("write:labeling_assign"))],
|
||||
) -> dict[str, Any]:
|
||||
from as_platform.labeling.batch_index import rebuild_batch_index
|
||||
|
||||
return rebuild_batch_index()
|
||||
|
||||
|
||||
@router.post("/api/v1/labeling/batches/{campaign_id}/archive")
|
||||
def api_archive_batch(
|
||||
campaign_id: str,
|
||||
_user: Annotated[User, Depends(require_permission("write:labeling_assign"))],
|
||||
) -> dict[str, Any]:
|
||||
from as_platform.labeling.batch_index import archive_batch
|
||||
|
||||
try:
|
||||
return archive_batch(campaign_id)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(404, "batch not found") from None
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
|
||||
@router.post("/api/v1/labeling/campaigns/open")
|
||||
@@ -488,23 +518,7 @@ def api_review_image(
|
||||
from as_platform.audit.review import get_review_image
|
||||
import tempfile
|
||||
try:
|
||||
# Get class names from registry
|
||||
import yaml
|
||||
from as_platform.data.core import load_wf, proj_root
|
||||
wf = load_wf()
|
||||
root = proj_root(wf, "dms")
|
||||
reg = yaml.safe_load((root / wf["projects"]["dms"]["registry"]).read_text())
|
||||
# Build class_names dict from campaign scope
|
||||
class_names: dict[int, str] = {}
|
||||
# Try to get from the specific task
|
||||
tasks = reg.get("tasks", {})
|
||||
for task_cfg in tasks.values():
|
||||
names = task_cfg.get("names")
|
||||
if isinstance(names, list):
|
||||
for i, n in enumerate(names):
|
||||
class_names[i] = n
|
||||
|
||||
data = get_review_image(campaign_id, path, class_names)
|
||||
data = get_review_image(campaign_id, path)
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
|
||||
tmp.write(data)
|
||||
tmp.close()
|
||||
@@ -528,6 +542,16 @@ def api_review_submit(
|
||||
def api_review_progress(
|
||||
campaign_id: str,
|
||||
_user: Annotated[User, Depends(require_permission("read:pending"))],
|
||||
) -> dict[str, int]:
|
||||
) -> dict[str, Any]:
|
||||
from as_platform.audit.review import review_progress
|
||||
return review_progress(campaign_id)
|
||||
|
||||
|
||||
@router.get("/api/v1/labeling/review-progress")
|
||||
def api_review_progress_batch(
|
||||
_user: Annotated[User, Depends(require_permission("read:pending"))],
|
||||
campaign_ids: str = Query(..., description="逗号分隔 campaign id,最多 50 个"),
|
||||
) -> dict[str, Any]:
|
||||
from as_platform.audit.review import review_progress_batch
|
||||
ids = [x.strip() for x in campaign_ids.split(",") if x.strip()]
|
||||
return review_progress_batch(ids)
|
||||
|
||||
@@ -693,7 +693,8 @@ def api_scan_inbox(
|
||||
project: str = Query("dms"),
|
||||
) -> dict[str, Any]:
|
||||
"""扫描 inbox 目录,返回未登记的新批次。"""
|
||||
from as_platform.data.core import get_pending_report, load_wf, proj_root
|
||||
from as_platform.data.core import load_wf, proj_root
|
||||
from as_platform.labeling.batch_index import index_is_empty, rebuild_batch_index
|
||||
|
||||
wf = load_wf()
|
||||
root = proj_root(wf, project)
|
||||
@@ -701,8 +702,20 @@ def api_scan_inbox(
|
||||
if not inbox.is_dir():
|
||||
return {"project": project, "items": [], "inbox_path": str(inbox)}
|
||||
|
||||
report = get_pending_report()
|
||||
registered = {b.get("batch", "") for b in report.get("batches", [])}
|
||||
if index_is_empty():
|
||||
rebuild_batch_index(wf)
|
||||
|
||||
from as_platform.db.engine import session_scope
|
||||
from as_platform.db.models import BatchIndex
|
||||
|
||||
with session_scope() as db:
|
||||
registered = {
|
||||
(r.task or "", r.batch)
|
||||
for r in db.query(BatchIndex).filter(
|
||||
BatchIndex.project == project,
|
||||
BatchIndex.archived.is_(False),
|
||||
).all()
|
||||
}
|
||||
|
||||
items: list[dict[str, Any]] = []
|
||||
for task_dir in sorted(inbox.iterdir()):
|
||||
@@ -713,7 +726,7 @@ def api_scan_inbox(
|
||||
continue
|
||||
batch_name = batch_dir.name
|
||||
task_name = task_dir.name
|
||||
if batch_name in registered:
|
||||
if (task_name, batch_name) in registered:
|
||||
continue # 已登记
|
||||
|
||||
# Count images (含 images/ 子目录)
|
||||
|
||||
@@ -58,11 +58,107 @@ def _parse_labels(label_path: Path) -> list[dict[str, Any]]:
|
||||
results = []
|
||||
for line in label_path.read_text().strip().splitlines():
|
||||
ann = _parse_yolo_line(line)
|
||||
if ann:
|
||||
if ann and ann["bbox"][2] > 0 and ann["bbox"][3] > 0:
|
||||
results.append(ann)
|
||||
return results
|
||||
|
||||
|
||||
def _class_names_for_campaign(camp) -> dict[int, str]:
|
||||
"""campaign task → class_id → name。"""
|
||||
import yaml
|
||||
from as_platform.data.core import load_wf, proj_root
|
||||
|
||||
if not camp or camp.project != "dms":
|
||||
return {}
|
||||
wf = load_wf()
|
||||
root = proj_root(wf, "dms")
|
||||
reg = yaml.safe_load((root / wf["projects"]["dms"]["registry"]).read_text(encoding="utf-8")) or {}
|
||||
tcfg = (reg.get("tasks") or {}).get(camp.task) or {}
|
||||
if camp.mode and tcfg.get("type") == "multi":
|
||||
mcfg = (tcfg.get("modes") or {}).get(camp.mode) or {}
|
||||
names = mcfg.get("names")
|
||||
else:
|
||||
names = tcfg.get("names")
|
||||
if isinstance(names, list):
|
||||
return {i: str(n) for i, n in enumerate(names)}
|
||||
if isinstance(names, dict):
|
||||
return {int(k): str(v) for k, v in names.items()}
|
||||
return {}
|
||||
|
||||
|
||||
def _name_to_class_id(name: str, class_names: dict[int, str]) -> int:
|
||||
rev = {v.lower(): k for k, v in class_names.items()}
|
||||
return rev.get(name.lower(), 0)
|
||||
|
||||
|
||||
def _resolve_yolo_label_path(batch_dir: Path, img_path: Path) -> Path | None:
|
||||
stem = img_path.stem
|
||||
for rel in (
|
||||
f"labels/{stem}.txt",
|
||||
f"labels/train/{stem}.txt",
|
||||
f"labels/val/{stem}.txt",
|
||||
f"labels/yolo/{stem}.txt",
|
||||
):
|
||||
p = batch_dir / rel
|
||||
if p.is_file():
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def _parse_ls_annotations(path: Path, class_names: dict[int, str]) -> list[dict[str, Any]]:
|
||||
import json
|
||||
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return []
|
||||
out: list[dict[str, Any]] = []
|
||||
for item in data.get("result") or []:
|
||||
if item.get("type") not in ("rectanglelabels", "rectangle"):
|
||||
continue
|
||||
val = item.get("value") or {}
|
||||
w_pct = float(val.get("width") or 0)
|
||||
h_pct = float(val.get("height") or 0)
|
||||
if w_pct <= 0 or h_pct <= 0:
|
||||
continue
|
||||
x_pct = float(val.get("x") or 0)
|
||||
y_pct = float(val.get("y") or 0)
|
||||
labels = val.get("rectanglelabels") or val.get("labels") or []
|
||||
label = labels[0] if labels else "unknown"
|
||||
cid = _name_to_class_id(str(label), class_names)
|
||||
cx = (x_pct + w_pct / 2) / 100.0
|
||||
cy = (y_pct + h_pct / 2) / 100.0
|
||||
out.append({"class_id": cid, "bbox": (cx, cy, w_pct / 100.0, h_pct / 100.0)})
|
||||
return out
|
||||
|
||||
|
||||
def _load_image_annotations(
|
||||
batch_dir: Path,
|
||||
img_path: Path,
|
||||
class_names: dict[int, str],
|
||||
) -> list[dict[str, Any]]:
|
||||
yolo = _resolve_yolo_label_path(batch_dir, img_path)
|
||||
if yolo:
|
||||
anns = _parse_labels(yolo)
|
||||
if anns:
|
||||
return anns
|
||||
from as_platform.labeling.annotate import _task_id_for_image
|
||||
|
||||
ann_json = batch_dir / "labels" / "ls_annotations" / f"{_task_id_for_image(img_path, batch_dir)}.json"
|
||||
if ann_json.is_file():
|
||||
return _parse_ls_annotations(ann_json, class_names)
|
||||
return []
|
||||
|
||||
|
||||
def _image_has_labels(batch_dir: Path, img_path: Path, class_names: dict[int, str]) -> bool:
|
||||
return bool(_load_image_annotations(batch_dir, img_path, class_names))
|
||||
|
||||
|
||||
def _list_review_images(batch_dir: Path) -> list[Path]:
|
||||
from as_platform.labeling.annotate import _iter_batch_images
|
||||
|
||||
return list(_iter_batch_images(batch_dir))
|
||||
|
||||
# ── Optimized overlay render ──
|
||||
|
||||
PALETTE = [(220, 20, 60), (30, 144, 255), (50, 205, 50), (255, 165, 0), (186, 85, 211), (0, 206, 209)]
|
||||
@@ -70,7 +166,7 @@ PALETTE = [(220, 20, 60), (30, 144, 255), (50, 205, 50), (255, 165, 0), (186, 85
|
||||
|
||||
def render_review_overlay(
|
||||
image_path: Path,
|
||||
label_path: Path | None,
|
||||
batch_dir: Path,
|
||||
class_names: dict[int, str],
|
||||
*,
|
||||
max_size: int = 800,
|
||||
@@ -88,7 +184,7 @@ def render_review_overlay(
|
||||
font = _get_font(max(12, min(16, w // 50)))
|
||||
line_w = max(1, w // 400)
|
||||
|
||||
anns = _parse_labels(label_path) if label_path else []
|
||||
anns = _load_image_annotations(batch_dir, image_path, class_names)
|
||||
for ann in anns:
|
||||
cid = ann["class_id"]
|
||||
color = PALETTE[cid % len(PALETTE)]
|
||||
@@ -140,17 +236,14 @@ def get_review_queue(campaign_id: str, offset: int = 0, limit: int = 20) -> dict
|
||||
if not camp:
|
||||
return {"items": [], "total": 0, "hint": "Campaign 不存在"}
|
||||
batch_dir = resolve_campaign_batch_dir(camp)
|
||||
class_names = _class_names_for_campaign(camp)
|
||||
if not batch_dir or not batch_dir.is_dir():
|
||||
return {"items": [], "total": 0, "hint": "批次目录不存在"}
|
||||
|
||||
img_dir = batch_dir / "images"
|
||||
if not img_dir.is_dir():
|
||||
all_images = _list_review_images(batch_dir)
|
||||
if not all_images:
|
||||
return {"items": [], "total": 0, "hint": "无 images 目录"}
|
||||
|
||||
all_images: list[Path] = []
|
||||
for ext in IMAGE_EXTS:
|
||||
all_images.extend(sorted(img_dir.rglob(f"*{ext}")))
|
||||
|
||||
# Get existing reviews
|
||||
with session_scope() as db:
|
||||
reviewed = {
|
||||
@@ -160,26 +253,26 @@ def get_review_queue(campaign_id: str, offset: int = 0, limit: int = 20) -> dict
|
||||
|
||||
total = len(all_images)
|
||||
page = all_images[offset:offset + limit]
|
||||
score_counts = {"good": 0, "fine": 0, "bad": 0, "pending": 0}
|
||||
items = []
|
||||
for img in page:
|
||||
rel = str(img.relative_to(batch_dir))
|
||||
score = reviewed.get(rel, "pending")
|
||||
score_counts[score] += 1
|
||||
label_path = batch_dir / "labels" / (img.stem + ".txt")
|
||||
items.append({
|
||||
"id": rel, "image_path": rel,
|
||||
"fileName": img.name,
|
||||
"score": score,
|
||||
"has_label": label_path.is_file(),
|
||||
"has_label": _image_has_labels(batch_dir, img, class_names),
|
||||
})
|
||||
|
||||
# Fill remaining counts
|
||||
for img in all_images:
|
||||
rel = str(img.relative_to(batch_dir))
|
||||
s = reviewed.get(rel, "pending")
|
||||
if s not in score_counts:
|
||||
score_counts[s] = 0
|
||||
with session_scope() as db:
|
||||
db_counts = _review_db_counts(db, campaign_id)
|
||||
reviewed_n = sum(db_counts.values())
|
||||
score_counts = {
|
||||
"good": db_counts.get("good", 0),
|
||||
"fine": db_counts.get("fine", 0),
|
||||
"bad": db_counts.get("bad", 0),
|
||||
"pending": max(0, total - reviewed_n),
|
||||
}
|
||||
|
||||
return {
|
||||
"items": items, "total": total,
|
||||
@@ -188,7 +281,7 @@ def get_review_queue(campaign_id: str, offset: int = 0, limit: int = 20) -> dict
|
||||
}
|
||||
|
||||
|
||||
def get_review_image(campaign_id: str, image_rel_path: str, class_names: dict[int, str]) -> bytes:
|
||||
def get_review_image(campaign_id: str, image_rel_path: str) -> bytes:
|
||||
from as_platform.labeling.annotate import resolve_campaign_batch_dir
|
||||
from as_platform.db.engine import session_scope
|
||||
from as_platform.db.models import LabelingCampaign
|
||||
@@ -197,11 +290,13 @@ def get_review_image(campaign_id: str, image_rel_path: str, class_names: dict[in
|
||||
if not camp:
|
||||
raise FileNotFoundError("Campaign 不存在")
|
||||
batch_dir = resolve_campaign_batch_dir(camp)
|
||||
class_names = _class_names_for_campaign(camp)
|
||||
if not batch_dir:
|
||||
raise FileNotFoundError("批次不存在")
|
||||
img_path = batch_dir / image_rel_path
|
||||
lbl_path = batch_dir / "labels" / (img_path.stem + ".txt")
|
||||
return render_review_overlay(img_path, lbl_path if lbl_path.is_file() else None, class_names)
|
||||
if not img_path.is_file():
|
||||
raise FileNotFoundError(f"图片不存在: {image_rel_path}")
|
||||
return render_review_overlay(img_path, batch_dir, class_names)
|
||||
|
||||
|
||||
def submit_review_scores(
|
||||
@@ -251,22 +346,76 @@ def submit_review_scores(
|
||||
|
||||
reviewed = sum(counts.values())
|
||||
if reviewed >= total_images and total_images > 0:
|
||||
pass_rate = counts.get("good", 0) / max(total_images, 1)
|
||||
new_stage = "review_approved" if pass_rate >= 0.8 else "review_rejected"
|
||||
_update_campaign_stage(db, campaign_id, new_stage)
|
||||
new_stage = _effective_stage_from_review(
|
||||
counts.get("good", 0), counts.get("fine", 0), counts.get("bad", 0), total_images,
|
||||
)
|
||||
if new_stage and new_stage != "in_review":
|
||||
raw = "review_approved" if new_stage == "labeling_submitted" else new_stage
|
||||
_update_campaign_stage(db, campaign_id, raw)
|
||||
|
||||
return {"ok": True, "updated": updated, "auto_advanced": reviewed >= total_images if total_images > 0 else False}
|
||||
auto_advanced = reviewed >= total_images if total_images > 0 else False
|
||||
acceptable = counts.get("good", 0) + counts.get("fine", 0) if total_images > 0 else 0
|
||||
final_stage = None
|
||||
if auto_advanced and total_images > 0:
|
||||
eff = _effective_stage_from_review(
|
||||
counts.get("good", 0), counts.get("fine", 0), counts.get("bad", 0), total_images,
|
||||
)
|
||||
final_stage = "review_approved" if eff == "labeling_submitted" else eff
|
||||
return {
|
||||
"ok": True,
|
||||
"updated": updated,
|
||||
"auto_advanced": auto_advanced,
|
||||
"stage": final_stage,
|
||||
}
|
||||
|
||||
|
||||
def _review_db_counts(db, campaign_id: str) -> dict[str, int]:
|
||||
from sqlalchemy import func
|
||||
from collections import Counter
|
||||
rows = db.query(LabelingReview.score, func.count()).filter(
|
||||
LabelingReview.campaign_id == campaign_id
|
||||
).group_by(LabelingReview.score).all()
|
||||
return {score: cnt for score, cnt in rows}
|
||||
|
||||
|
||||
PASS_RATE_THRESHOLD = 0.8
|
||||
|
||||
|
||||
def _effective_stage_from_review(good: int, fine: int, bad: int, total: int) -> str | None:
|
||||
"""Return campaign status after QA is complete; None if images remain unreviewed."""
|
||||
if total <= 0:
|
||||
return None
|
||||
reviewed = good + fine + bad
|
||||
if reviewed < total:
|
||||
return "in_review"
|
||||
acceptable = good + fine
|
||||
approved = acceptable / total >= PASS_RATE_THRESHOLD
|
||||
return "labeling_submitted" if approved else "review_rejected"
|
||||
|
||||
|
||||
def reconcile_review_stage(campaign_id: str) -> str | None:
|
||||
"""Align stored campaign stage with current review scores (fixes stale rejections)."""
|
||||
summary = _review_summary(campaign_id)
|
||||
if not summary.get("complete"):
|
||||
return summary.get("stage")
|
||||
expected = _effective_stage_from_review(
|
||||
summary["good"], summary["fine"], summary["bad"], summary["total"],
|
||||
)
|
||||
if not expected:
|
||||
return summary.get("stage")
|
||||
with session_scope() as db:
|
||||
from as_platform.db.models import LabelingCampaign
|
||||
camp = db.get(LabelingCampaign, campaign_id)
|
||||
if not camp:
|
||||
return None
|
||||
if camp.status == expected:
|
||||
return expected
|
||||
camp.status = expected
|
||||
from as_platform.labeling.batch_stage import update_campaign_batch_meta_stage
|
||||
update_campaign_batch_meta_stage(camp, expected)
|
||||
db.commit()
|
||||
return expected
|
||||
|
||||
|
||||
def _update_campaign_stage(db, campaign_id: str, new_stage: str) -> None:
|
||||
from as_platform.db.models import LabelingCampaign
|
||||
from as_platform.labeling.batch_stage import update_campaign_batch_meta_stage
|
||||
@@ -278,10 +427,63 @@ def _update_campaign_stage(db, campaign_id: str, new_stage: str) -> None:
|
||||
update_campaign_batch_meta_stage(camp, effective)
|
||||
|
||||
|
||||
def review_progress(campaign_id: str) -> dict[str, int]:
|
||||
def _review_summary(campaign_id: str) -> dict[str, Any]:
|
||||
from as_platform.labeling.annotate import resolve_campaign_batch_dir
|
||||
from as_platform.db.models import LabelingCampaign
|
||||
|
||||
with session_scope() as db:
|
||||
rows = db.query(LabelingReview).filter(LabelingReview.campaign_id == campaign_id).all()
|
||||
counts = {"good": 0, "fine": 0, "bad": 0, "pending": 0}
|
||||
for r in rows:
|
||||
counts[r.score] = counts.get(r.score, 0) + 1
|
||||
return counts
|
||||
camp = db.get(LabelingCampaign, campaign_id)
|
||||
if not camp:
|
||||
return {"good": 0, "fine": 0, "bad": 0, "pending": 0, "total": 0, "reviewed": 0, "pass_rate": 0, "complete": False, "stage": ""}
|
||||
batch_dir = resolve_campaign_batch_dir(camp)
|
||||
stage = camp.status or ""
|
||||
if not batch_dir or not batch_dir.is_dir():
|
||||
counts = _review_db_counts(db, campaign_id)
|
||||
reviewed = sum(counts.values())
|
||||
return {
|
||||
**{k: counts.get(k, 0) for k in ("good", "fine", "bad")},
|
||||
"pending": 0,
|
||||
"total": reviewed,
|
||||
"reviewed": reviewed,
|
||||
"pass_rate": round((counts.get("good", 0) + counts.get("fine", 0)) / max(reviewed, 1) * 100),
|
||||
"complete": reviewed > 0,
|
||||
"stage": stage,
|
||||
}
|
||||
|
||||
all_images = _list_review_images(batch_dir)
|
||||
db_counts = _review_db_counts(db, campaign_id)
|
||||
|
||||
total = len(all_images)
|
||||
good = db_counts.get("good", 0)
|
||||
fine = db_counts.get("fine", 0)
|
||||
bad = db_counts.get("bad", 0)
|
||||
reviewed = good + fine + bad
|
||||
acceptable = good + fine
|
||||
return {
|
||||
"good": good,
|
||||
"fine": fine,
|
||||
"bad": bad,
|
||||
"pending": max(0, total - reviewed),
|
||||
"total": total,
|
||||
"reviewed": reviewed,
|
||||
"pass_rate": round(acceptable / max(total, 1) * 100),
|
||||
"complete": reviewed >= total and total > 0,
|
||||
"stage": stage,
|
||||
}
|
||||
|
||||
|
||||
def review_progress(campaign_id: str) -> dict[str, Any]:
|
||||
result = _review_summary(campaign_id)
|
||||
if result.get("complete"):
|
||||
reconciled = reconcile_review_stage(campaign_id)
|
||||
if reconciled:
|
||||
result["stage"] = reconciled
|
||||
return result
|
||||
|
||||
|
||||
def review_progress_batch(campaign_ids: list[str]) -> dict[str, Any]:
|
||||
ids = [c.strip() for c in campaign_ids if c and c.strip()][:50]
|
||||
items: dict[str, Any] = {}
|
||||
for cid in ids:
|
||||
items[cid] = review_progress(cid)
|
||||
return {"items": items}
|
||||
|
||||
@@ -1098,15 +1098,22 @@ def register_batch(
|
||||
|
||||
meta_path = write_meta(batch_dir, data)
|
||||
invalidate_catalog_cache()
|
||||
batch_row = enrich_batch(
|
||||
batch_dir,
|
||||
project=project,
|
||||
task=task,
|
||||
pack=pack,
|
||||
batch=batch,
|
||||
location=location,
|
||||
)
|
||||
try:
|
||||
from as_platform.labeling.batch_index import upsert_batch_dict
|
||||
|
||||
upsert_batch_dict(batch_row)
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"ok": True,
|
||||
"meta_path": str(meta_path),
|
||||
"batch": enrich_batch(
|
||||
batch_dir,
|
||||
project=project,
|
||||
task=task,
|
||||
pack=pack,
|
||||
batch=batch,
|
||||
location=location,
|
||||
),
|
||||
"batch": batch_row,
|
||||
}
|
||||
|
||||
@@ -425,6 +425,11 @@ def promote_candidate_to_inbox(
|
||||
raise ValueError(f"任务 {task} 为 multi,须指定 mode(如 batch_0516)")
|
||||
dest = _resolve_dms_inbox_dest(root, reg, task, eff_mode, batch_name)
|
||||
reg_batch = eff_mode or batch_name
|
||||
elif project == "adas":
|
||||
if not task:
|
||||
raise ValueError("ADAS 晋级需要 task(det_7cls 或 cuboid_7cls)")
|
||||
dest = root / "inbox" / task / batch_name
|
||||
reg_batch = batch_name
|
||||
else:
|
||||
dest = root / "inbox" / batch_name
|
||||
reg_batch = batch_name
|
||||
@@ -439,7 +444,7 @@ def promote_candidate_to_inbox(
|
||||
row = enrich_batch(
|
||||
dest,
|
||||
project=project,
|
||||
task=task if project == "dms" else None,
|
||||
task=task,
|
||||
pack=None,
|
||||
batch=reg_batch,
|
||||
location="inbox",
|
||||
@@ -482,6 +487,12 @@ def promote_candidate_to_inbox(
|
||||
db.flush()
|
||||
|
||||
invalidate_catalog_cache()
|
||||
try:
|
||||
from as_platform.labeling.batch_index import upsert_batch_dict
|
||||
|
||||
upsert_batch_dict({**meta, "path": str(dest), "location": "inbox"})
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"ok": True,
|
||||
"candidate_id": candidate_id,
|
||||
|
||||
@@ -6,20 +6,35 @@ from pathlib import Path
|
||||
|
||||
from as_platform.data.promote.base import PackPromoteAdapter, PromoteContext, PromoteResult
|
||||
from as_platform.data.promote.manifest import refresh_dms_yaml
|
||||
from as_platform.data.promote.validate.dms_yolo import validate_dms_task
|
||||
from as_platform.data.promote.validate.dms_yolo import validate_dms_inbox_batch
|
||||
|
||||
_DMS_SCRIPTS = Path(__file__).resolve().parents[4] / "datasets" / "dms" / "scripts"
|
||||
if str(_DMS_SCRIPTS) not in sys.path:
|
||||
sys.path.insert(0, str(_DMS_SCRIPTS))
|
||||
|
||||
|
||||
def _resolve_promote_pack_dir(project_root: Path, pack: str) -> Path:
|
||||
"""解析 pack 目录;损坏的 workspace 软链则回退为 HSAP 内真实目录。"""
|
||||
candidate = project_root / "packs" / pack
|
||||
if candidate.is_symlink():
|
||||
try:
|
||||
resolved = candidate.resolve()
|
||||
if resolved.is_dir():
|
||||
return resolved
|
||||
except OSError:
|
||||
pass
|
||||
candidate.unlink()
|
||||
candidate.mkdir(parents=True, exist_ok=True)
|
||||
return candidate
|
||||
|
||||
|
||||
class DmsYoloPromoteAdapter(PackPromoteAdapter):
|
||||
project = "dms"
|
||||
|
||||
def validate(self, ctx: PromoteContext) -> list[str]:
|
||||
if ctx.skip_validate:
|
||||
return []
|
||||
return validate_dms_task(ctx.task)
|
||||
return validate_dms_inbox_batch(ctx.batch_dir)
|
||||
|
||||
def promote(self, ctx: PromoteContext) -> PromoteResult:
|
||||
from ingest_incremental import promote_inbox_batch
|
||||
@@ -34,8 +49,7 @@ class DmsYoloPromoteAdapter(PackPromoteAdapter):
|
||||
warnings=[f"batch_dir missing: {ctx.batch_dir}"],
|
||||
)
|
||||
|
||||
pack_dir = ctx.project_root / "packs" / ctx.pack
|
||||
pack_dir.mkdir(parents=True, exist_ok=True)
|
||||
pack_dir = _resolve_promote_pack_dir(ctx.project_root, ctx.pack)
|
||||
|
||||
detail = promote_inbox_batch(
|
||||
root=ctx.project_root,
|
||||
@@ -46,7 +60,7 @@ class DmsYoloPromoteAdapter(PackPromoteAdapter):
|
||||
dry_run=ctx.dry_run,
|
||||
refresh=ctx.refresh and not ctx.dry_run,
|
||||
)
|
||||
if ctx.refresh and not ctx.dry_run and not ctx.skip_validate:
|
||||
if ctx.refresh and not ctx.dry_run:
|
||||
refresh_dms_yaml(task=ctx.task)
|
||||
|
||||
added = int(detail.get("added") or 0)
|
||||
|
||||
@@ -8,6 +8,17 @@ from pathlib import Path
|
||||
from as_platform.config import WORKSPACE
|
||||
|
||||
|
||||
def validate_dms_inbox_batch(batch_dir: Path) -> list[str]:
|
||||
"""Promote 前校验单个 inbox 批次(不要求 pack 目录已存在)。"""
|
||||
from as_platform.labeling.batch_stage import batch_has_yolo_labels
|
||||
|
||||
if not batch_dir.is_dir():
|
||||
return [f"batch_dir missing: {batch_dir}"]
|
||||
if not batch_has_yolo_labels(batch_dir):
|
||||
return [f"no YOLO labels under {batch_dir} (先执行 labeling_export)"]
|
||||
return []
|
||||
|
||||
|
||||
def validate_dms_task(task: str | None) -> list[str]:
|
||||
cmd = [sys.executable, str(WORKSPACE / "scripts" / "validate_dms_tasks.py")]
|
||||
if task:
|
||||
|
||||
@@ -28,7 +28,7 @@ ROLE_DEFS: dict[str, tuple[str, list[str]]] = {
|
||||
]),
|
||||
"engineer": ("算法工程师", [
|
||||
"read:catalog", "read:pending", "read:jobs", "read:audit", "read:fleet", "write:fleet",
|
||||
"write:approval_submit", "write:delivery_submit", "read:deliveries",
|
||||
"write:approval_submit", "write:approval_review", "write:delivery_submit", "read:deliveries",
|
||||
"write:labeling_vendor", "write:labeling_assign",
|
||||
]),
|
||||
"labeler": ("标注协调", [
|
||||
@@ -79,6 +79,7 @@ def init_database() -> None:
|
||||
_ensure_feishu_bitable_columns(db)
|
||||
_ensure_approval_columns(db)
|
||||
_ensure_operation_log_columns(db)
|
||||
_ensure_batch_index_columns(db)
|
||||
_seed_roles_permissions(db)
|
||||
_seed_fleet_demo(db)
|
||||
_import_jsonl_if_empty(db)
|
||||
@@ -168,18 +169,22 @@ def _import_jsonl_if_empty(db: Session) -> None:
|
||||
|
||||
|
||||
def assign_default_role(db: Session, user: User) -> None:
|
||||
"""新用户默认角色;支持 open_id / 部门白名单自动 admin。"""
|
||||
"""新用户默认角色;白名单用户每次登录也会补齐 admin。"""
|
||||
admin_role = db.query(Role).filter_by(code="admin").first()
|
||||
user_dept_ids = set(user.feishu_department_ids())
|
||||
if user.feishu_open_id and user.feishu_open_id in FEISHU_ADMIN_OPEN_IDS:
|
||||
role_code = "admin"
|
||||
elif user_dept_ids and user_dept_ids.intersection(FEISHU_ADMIN_DEPARTMENT_IDS):
|
||||
role_code = "admin"
|
||||
elif not user.roles:
|
||||
role_code = "engineer"
|
||||
else:
|
||||
if admin_role and admin_role not in user.roles:
|
||||
user.roles.append(admin_role)
|
||||
return
|
||||
if user_dept_ids and user_dept_ids.intersection(FEISHU_ADMIN_DEPARTMENT_IDS):
|
||||
if admin_role and admin_role not in user.roles:
|
||||
user.roles.append(admin_role)
|
||||
return
|
||||
if user.roles:
|
||||
return
|
||||
role_code = "engineer"
|
||||
role = db.query(Role).filter_by(code=role_code).first()
|
||||
if role and role not in user.roles:
|
||||
if role:
|
||||
user.roles.append(role)
|
||||
|
||||
|
||||
@@ -325,6 +330,10 @@ def _ensure_approval_columns(db: Session) -> None:
|
||||
_ensure_table_columns(db, "approvals", {"rejection_category": "VARCHAR(32) DEFAULT ''"})
|
||||
|
||||
|
||||
def _ensure_batch_index_columns(db: Session) -> None:
|
||||
_ensure_table_columns(db, "batch_index", {"archived": "BOOLEAN DEFAULT FALSE"})
|
||||
|
||||
|
||||
def _ensure_operation_log_columns(db: Session) -> None:
|
||||
"""确保 operation_logs 表存在并包含所有列。"""
|
||||
inspector_args = {}
|
||||
|
||||
@@ -319,6 +319,67 @@ class BatchDelivery(Base):
|
||||
}
|
||||
|
||||
|
||||
class BatchIndex(Base):
|
||||
"""批次列表索引:由扫盘/登记/阶段变更写入,列表 API 只读此表。"""
|
||||
|
||||
__tablename__ = "batch_index"
|
||||
|
||||
campaign_id = Column(String(64), primary_key=True)
|
||||
project = Column(String(32), nullable=False, index=True)
|
||||
task = Column(String(64), nullable=True, index=True)
|
||||
mode = Column(String(64), nullable=True)
|
||||
batch = Column(String(128), nullable=False, index=True)
|
||||
pack = Column(String(64), nullable=True)
|
||||
location = Column(String(32), nullable=False, default="inbox")
|
||||
stage = Column(String(32), nullable=False, index=True)
|
||||
batch_path = Column(String(1024), nullable=True)
|
||||
scope_key = Column(String(128), nullable=True)
|
||||
engineer = Column(String(128), nullable=True)
|
||||
format = Column(String(32), nullable=True)
|
||||
image_count = Column(Integer, nullable=False, default=0)
|
||||
label_count = Column(Integer, nullable=False, default=0)
|
||||
has_meta = Column(Boolean, nullable=False, default=False)
|
||||
registry_only = Column(Boolean, nullable=False, default=False)
|
||||
next_cli = Column(String(512), nullable=True)
|
||||
domain = Column(String(32), nullable=True)
|
||||
domain_label = Column(String(64), nullable=True)
|
||||
task_label = Column(String(128), nullable=True)
|
||||
mode_label = Column(String(128), nullable=True)
|
||||
labeling_profile = Column(String(128), nullable=True)
|
||||
export_default = Column(String(128), nullable=True)
|
||||
ml_adapter = Column(String(128), nullable=True)
|
||||
indexed_at = Column(DateTime(timezone=True), nullable=False)
|
||||
archived = Column(Boolean, nullable=False, default=False, index=True)
|
||||
|
||||
def to_list_row(self) -> dict:
|
||||
return {
|
||||
"project": self.project,
|
||||
"task": self.task,
|
||||
"mode": self.mode,
|
||||
"batch": self.batch,
|
||||
"pack": self.pack,
|
||||
"stage": self.stage,
|
||||
"location": self.location,
|
||||
"path": self.batch_path,
|
||||
"engineer": self.engineer,
|
||||
"format": self.format,
|
||||
"counts": {"images": self.image_count, "labels": self.label_count},
|
||||
"has_meta": self.has_meta,
|
||||
"next_cli": self.next_cli,
|
||||
"scope_key": self.scope_key,
|
||||
"domain": self.domain,
|
||||
"domain_label": self.domain_label,
|
||||
"task_label": self.task_label,
|
||||
"mode_label": self.mode_label,
|
||||
"labeling_profile": self.labeling_profile,
|
||||
"export_default": self.export_default,
|
||||
"ml_adapter": self.ml_adapter,
|
||||
"campaign_id": self.campaign_id,
|
||||
"registry_only": self.registry_only,
|
||||
"indexed_at": self.indexed_at.isoformat() if self.indexed_at else None,
|
||||
}
|
||||
|
||||
|
||||
class FeishuBitableLink(Base):
|
||||
"""HSAP 批次与飞书多维表格行的对应关系。"""
|
||||
|
||||
|
||||
230
platform/as_platform/deliveries/scan.py
Normal file
230
platform/as_platform/deliveries/scan.py
Normal file
@@ -0,0 +1,230 @@
|
||||
"""扫描 inbox / 数据湖目录,与批次台账对齐。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from as_platform.data.core import load_wf, proj_root, register_batch
|
||||
from as_platform.db.engine import session_scope
|
||||
from as_platform.db.models import BatchDelivery, BatchIndex, User
|
||||
from as_platform.deliveries.service import _new_delivery_id, _normalize_task
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _dir_mtime_iso(path: Path) -> str | None:
|
||||
try:
|
||||
ts = path.stat().st_mtime
|
||||
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d")
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _scan_project_inbox(project: str, wf: dict | None = None) -> list[dict[str, Any]]:
|
||||
from as_platform.data.batch import count_images, count_label_files, dms_has_labels
|
||||
|
||||
wf = wf or load_wf()
|
||||
root = proj_root(wf, project)
|
||||
inbox = root / "inbox"
|
||||
if not inbox.is_dir():
|
||||
return []
|
||||
|
||||
with session_scope() as db:
|
||||
deliveries = {
|
||||
(r.project, r.task or "", r.mode or "", r.batch_name): r
|
||||
for r in db.query(BatchDelivery).filter(BatchDelivery.project == project).all()
|
||||
}
|
||||
indexed = {
|
||||
(r.task or "", r.batch)
|
||||
for r in db.query(BatchIndex).filter(
|
||||
BatchIndex.project == project,
|
||||
BatchIndex.archived.is_(False),
|
||||
).all()
|
||||
}
|
||||
|
||||
items: list[dict[str, Any]] = []
|
||||
for task_dir in sorted(inbox.iterdir()):
|
||||
if not task_dir.is_dir():
|
||||
continue
|
||||
for batch_dir in sorted(task_dir.iterdir()):
|
||||
if not batch_dir.is_dir():
|
||||
continue
|
||||
task_name = task_dir.name
|
||||
batch_name = batch_dir.name
|
||||
img_count = count_images(batch_dir)
|
||||
if not img_count and (batch_dir / "images").is_dir():
|
||||
img_count = count_images(batch_dir / "images")
|
||||
lbl_count = count_label_files(batch_dir / "labels") if (batch_dir / "labels").is_dir() else 0
|
||||
has_labels = lbl_count > 0 or dms_has_labels(batch_dir)
|
||||
stage_hint = "returned" if has_labels and lbl_count > 0 else "raw_pool"
|
||||
|
||||
key = (project, task_name, "", batch_name)
|
||||
delivery = deliveries.get(key)
|
||||
in_index = (task_name, batch_name) in indexed
|
||||
|
||||
items.append({
|
||||
"project": project,
|
||||
"task": task_name,
|
||||
"mode": None,
|
||||
"batch": batch_name,
|
||||
"batch_name": batch_name,
|
||||
"path": str(batch_dir),
|
||||
"data_path": str(batch_dir),
|
||||
"images": img_count,
|
||||
"labels": lbl_count,
|
||||
"has_labels": has_labels,
|
||||
"stage_hint": stage_hint,
|
||||
"source_type": "inbox_scan",
|
||||
"delivery_id": delivery.id if delivery else None,
|
||||
"delivery_status": delivery.status if delivery else None,
|
||||
"in_ledger": delivery is not None,
|
||||
"in_workbench": in_index,
|
||||
"collection_start": delivery.collection_start if delivery else _dir_mtime_iso(batch_dir),
|
||||
"collection_end": delivery.collection_end if delivery else None,
|
||||
"created_at": delivery.created_at.isoformat() if delivery and delivery.created_at else None,
|
||||
"needs_ledger": delivery is None,
|
||||
"needs_workbench": not in_index,
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
def scan_delivery_sources(*, projects: list[str] | None = None) -> dict[str, Any]:
|
||||
"""扫描 inbox,返回与台账、工作台对齐状态。"""
|
||||
projs = projects or ["dms", "adas", "lane"]
|
||||
wf = load_wf()
|
||||
items: list[dict[str, Any]] = []
|
||||
for p in projs:
|
||||
items.extend(_scan_project_inbox(p, wf))
|
||||
needs_ledger = sum(1 for i in items if i.get("needs_ledger"))
|
||||
needs_workbench = sum(1 for i in items if i.get("needs_workbench"))
|
||||
return {
|
||||
"items": items,
|
||||
"count": len(items),
|
||||
"needs_ledger": needs_ledger,
|
||||
"needs_workbench": needs_workbench,
|
||||
"scanned_at": _utcnow().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def register_scanned_to_ledger(
|
||||
items: list[dict[str, Any]],
|
||||
user: User,
|
||||
*,
|
||||
sync_workbench: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""将扫描结果登记到台账;已在 inbox 的批次直接标为 in_lake 并同步工作台。"""
|
||||
created = 0
|
||||
updated = 0
|
||||
synced = 0
|
||||
out_items: list[dict[str, Any]] = []
|
||||
|
||||
for raw in items:
|
||||
project = (raw.get("project") or "dms").strip()
|
||||
task = _normalize_task(project, raw.get("task"))
|
||||
mode = (raw.get("mode") or "").strip() or None
|
||||
batch_name = (raw.get("batch_name") or raw.get("batch") or "").strip()
|
||||
data_path = (raw.get("data_path") or raw.get("path") or "").strip()
|
||||
if not batch_name or not data_path:
|
||||
continue
|
||||
if not Path(data_path).is_dir():
|
||||
continue
|
||||
|
||||
stage_hint = raw.get("stage_hint") or "raw_pool"
|
||||
collection_start = (raw.get("collection_start") or "").strip() or _dir_mtime_iso(Path(data_path))
|
||||
collection_end = (raw.get("collection_end") or "").strip() or None
|
||||
estimated = raw.get("images")
|
||||
if estimated is None:
|
||||
estimated = raw.get("estimated_count")
|
||||
|
||||
with session_scope() as db:
|
||||
rec = (
|
||||
db.query(BatchDelivery)
|
||||
.filter_by(project=project, task=task, mode=mode, batch_name=batch_name)
|
||||
.first()
|
||||
)
|
||||
if not rec:
|
||||
rec = BatchDelivery(
|
||||
id=_new_delivery_id(),
|
||||
project=project,
|
||||
task=task,
|
||||
mode=mode,
|
||||
batch_name=batch_name,
|
||||
source_type=(raw.get("source_type") or "inbox_scan"),
|
||||
collection_start=collection_start,
|
||||
collection_end=collection_end,
|
||||
data_path=data_path,
|
||||
estimated_count=int(estimated) if estimated not in (None, "") else None,
|
||||
status="in_lake",
|
||||
inbox_path=data_path,
|
||||
owner_user_id=user.id,
|
||||
owner_name=user.name,
|
||||
submitted_by_user_id=user.id,
|
||||
submitted_by_name=user.name,
|
||||
)
|
||||
db.add(rec)
|
||||
created += 1
|
||||
else:
|
||||
if rec.status in ("draft", "rejected", "ingest_failed"):
|
||||
rec.status = "in_lake"
|
||||
if not rec.inbox_path:
|
||||
rec.inbox_path = data_path
|
||||
if not rec.data_path:
|
||||
rec.data_path = data_path
|
||||
if collection_start and not rec.collection_start:
|
||||
rec.collection_start = collection_start
|
||||
if estimated not in (None, "") and not rec.estimated_count:
|
||||
rec.estimated_count = int(estimated)
|
||||
if not rec.source_type:
|
||||
rec.source_type = "inbox_scan"
|
||||
rec.updated_at = _utcnow()
|
||||
updated += 1
|
||||
db.flush()
|
||||
out_items.append(rec.to_dict())
|
||||
|
||||
if sync_workbench and stage_hint in ("raw_pool", "returned"):
|
||||
try:
|
||||
register_batch(
|
||||
None,
|
||||
project,
|
||||
task,
|
||||
batch_name,
|
||||
stage=stage_hint,
|
||||
location="inbox",
|
||||
)
|
||||
synced += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"created": created,
|
||||
"updated": updated,
|
||||
"synced_workbench": synced,
|
||||
"items": out_items,
|
||||
}
|
||||
|
||||
|
||||
def bridge_delivery_to_workbench(delivery_id: str) -> dict[str, Any]:
|
||||
"""台账 in_lake 后同步到送标工作台索引。"""
|
||||
with session_scope() as db:
|
||||
rec = db.get(BatchDelivery, delivery_id)
|
||||
if not rec:
|
||||
raise ValueError("送标申请不存在")
|
||||
if rec.status != "in_lake":
|
||||
raise ValueError(f"当前状态不可同步工作台: {rec.status}")
|
||||
project = rec.project
|
||||
task = rec.task
|
||||
batch_name = rec.batch_name
|
||||
inbox_path = rec.inbox_path or rec.data_path
|
||||
|
||||
stage = "raw_pool"
|
||||
if inbox_path:
|
||||
labels_dir = Path(inbox_path) / "labels"
|
||||
if labels_dir.is_dir() and any(labels_dir.iterdir()):
|
||||
stage = "returned"
|
||||
|
||||
result = register_batch(None, project, task, batch_name, stage=stage, location="inbox")
|
||||
return {"ok": True, "delivery_id": delivery_id, "batch": result.get("batch")}
|
||||
@@ -24,26 +24,43 @@ def _new_delivery_id() -> str:
|
||||
|
||||
|
||||
def _normalize_task(project: str, task: str | None) -> str | None:
|
||||
if project == "dms":
|
||||
return (task or "").strip() or None
|
||||
return None
|
||||
t = (task or "").strip() or None
|
||||
if project == "adas":
|
||||
return t or "cuboid_7cls"
|
||||
if project == "lane":
|
||||
return t
|
||||
return t
|
||||
|
||||
|
||||
def _enrich_delivery_dict(db, rec: BatchDelivery) -> dict[str, Any]:
|
||||
def _enrich_delivery_dict(db, rec: BatchDelivery, *, ap_map: dict | None = None, job_map: dict | None = None) -> dict[str, Any]:
|
||||
d = rec.to_dict()
|
||||
d["submitted_by"] = rec.submitted_by_name
|
||||
if rec.approval_id:
|
||||
ap = db.get(Approval, rec.approval_id)
|
||||
ap = (ap_map or {}).get(rec.approval_id) if ap_map is not None else db.get(Approval, rec.approval_id)
|
||||
if ap:
|
||||
d["approval_status"] = ap.status
|
||||
d["job_id"] = ap.job_id
|
||||
if ap.job_id:
|
||||
job = db.get(Job, ap.job_id)
|
||||
job = (job_map or {}).get(ap.job_id) if job_map is not None else db.get(Job, ap.job_id)
|
||||
if job:
|
||||
d["job_status"] = job.status
|
||||
return d
|
||||
|
||||
|
||||
def _bulk_approval_maps(db, rows: list[BatchDelivery]) -> tuple[dict[str, Approval], dict[str, Job]]:
|
||||
approval_ids = [r.approval_id for r in rows if r.approval_id]
|
||||
ap_map: dict[str, Approval] = {}
|
||||
job_map: dict[str, Job] = {}
|
||||
if approval_ids:
|
||||
aps = db.query(Approval).filter(Approval.id.in_(approval_ids)).all()
|
||||
ap_map = {a.id: a for a in aps}
|
||||
job_ids = [a.job_id for a in aps if a.job_id]
|
||||
if job_ids:
|
||||
jobs = db.query(Job).filter(Job.id.in_(job_ids)).all()
|
||||
job_map = {j.id: j for j in jobs}
|
||||
return ap_map, job_map
|
||||
|
||||
|
||||
def list_deliveries(
|
||||
*,
|
||||
status: str | None = None,
|
||||
@@ -65,8 +82,9 @@ def list_deliveries(
|
||||
q = q.filter(BatchDelivery.status.in_(("draft", "rejected", "ingest_failed")))
|
||||
total = q.count()
|
||||
rows = q.offset(max(0, offset)).limit(max(1, limit)).all()
|
||||
ap_map, job_map = _bulk_approval_maps(db, rows)
|
||||
return {
|
||||
"items": [_enrich_delivery_dict(db, r) for r in rows],
|
||||
"items": [_enrich_delivery_dict(db, r, ap_map=ap_map, job_map=job_map) for r in rows],
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
|
||||
@@ -377,9 +377,14 @@ def get_live_fleet(db: Session) -> dict[str, Any]:
|
||||
items = []
|
||||
for v in vehicles:
|
||||
active = get_active_run(db, v.id)
|
||||
row = v.to_dict()
|
||||
row["vehicle_id"] = row["id"]
|
||||
row["lat"] = row.get("last_lat")
|
||||
row["lng"] = row.get("last_lng")
|
||||
row["speed_kmh"] = row.get("last_speed_kmh")
|
||||
items.append(
|
||||
{
|
||||
**v.to_dict(),
|
||||
**row,
|
||||
"active_run_id": active.id if active else None,
|
||||
"active_mileage_km": active.mileage_km if active else None,
|
||||
"active_run_no": active.run_no if active else None,
|
||||
|
||||
@@ -35,6 +35,15 @@ def validate_delivery_fields(
|
||||
return f"数据路径不存在: {path}"
|
||||
if project == "dms" and p.name in ("train", "val", "test") and p.parent.name == "images":
|
||||
return f"请填批次根目录(例如 {p.parent.parent}),不要填到 images/train"
|
||||
if project == "adas" and task == "det_7cls":
|
||||
if "/inbox/det_7cls/" not in str(p).replace("\\", "/"):
|
||||
return "ADAS 2D 须落在 adas/inbox/det_7cls/{批次}(project=adas, task=det_7cls)"
|
||||
if project == "adas" and task in (None, "", "cuboid_7cls"):
|
||||
if "/inbox/cuboid_7cls/" not in str(p).replace("\\", "/"):
|
||||
return "ADAS 3D 须落在 adas/inbox/cuboid_7cls/{批次}(project=adas, task=cuboid_7cls)"
|
||||
if project == "dms" and task == "adas":
|
||||
if "/inbox/adas/" not in str(p).replace("\\", "/"):
|
||||
return "旧版 ADAS 2D 路径 dms/inbox/adas/{批次};新数据请用 adas/inbox/det_7cls/"
|
||||
return None
|
||||
|
||||
|
||||
@@ -59,7 +68,7 @@ def ingest_from_directory(
|
||||
raise ValueError(err)
|
||||
|
||||
src = Path(data_path.strip())
|
||||
task_eff = task if project == "dms" else None
|
||||
task_eff = task if project in ("dms", "adas") else None
|
||||
cand = create_directory_candidate(
|
||||
project=project,
|
||||
task=task_eff,
|
||||
@@ -117,4 +126,11 @@ def run_delivery_ingest(delivery_id: str) -> dict[str, Any]:
|
||||
rec.error_message = None
|
||||
db.flush()
|
||||
|
||||
try:
|
||||
from as_platform.deliveries.scan import bridge_delivery_to_workbench
|
||||
|
||||
bridge_delivery_to_workbench(delivery_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
294
platform/as_platform/labeling/batch_index.py
Normal file
294
platform/as_platform/labeling/batch_index.py
Normal file
@@ -0,0 +1,294 @@
|
||||
"""批次索引:扫盘结果落库,列表页只查 DB(<200ms)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from as_platform.data.core import get_pending_report, load_wf
|
||||
from as_platform.db.engine import session_scope
|
||||
from as_platform.db.models import BatchIndex, LabelingCampaign
|
||||
from as_platform.labeling.scope import enrich_batch_labels, load_dms_registry
|
||||
from as_platform.labeling.stage import STAGE_ALIASES, effective_stage
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def campaign_id_for_row(project: str, task: str, mode: str | None, batch: str, location: str) -> str:
|
||||
from as_platform.labeling.service import _campaign_id
|
||||
|
||||
return _campaign_id(project, task, mode, batch, location)
|
||||
|
||||
|
||||
def _batch_dict_to_fields(b: dict[str, Any], reg: dict) -> dict[str, Any] | None:
|
||||
if b.get("registry_only") and not b.get("batch"):
|
||||
return None
|
||||
row = enrich_batch_labels(b, reg)
|
||||
if row.get("registry_only"):
|
||||
pass
|
||||
raw_stage = row.get("stage")
|
||||
eff = effective_stage(raw_stage) or raw_stage or "raw_pool"
|
||||
allowed = (
|
||||
"raw_pool", "out_for_labeling", "returned", "labeling_submitted",
|
||||
"in_review", "review_approved", "review_rejected", "ingested",
|
||||
)
|
||||
if eff not in allowed and raw_stage not in allowed:
|
||||
return None
|
||||
project = row.get("project") or "dms"
|
||||
task = row.get("task") or ""
|
||||
mode = row.get("mode")
|
||||
batch = row.get("batch") or ""
|
||||
location = row.get("location") or "inbox"
|
||||
counts = row.get("counts") or {}
|
||||
cid = campaign_id_for_row(project, task, mode, batch, location)
|
||||
return {
|
||||
"campaign_id": cid,
|
||||
"project": project,
|
||||
"task": task or None,
|
||||
"mode": mode,
|
||||
"batch": batch,
|
||||
"pack": row.get("pack"),
|
||||
"location": location,
|
||||
"stage": eff,
|
||||
"batch_path": row.get("path"),
|
||||
"scope_key": row.get("scope_key"),
|
||||
"engineer": row.get("engineer"),
|
||||
"format": row.get("format"),
|
||||
"image_count": int(counts.get("images") or 0),
|
||||
"label_count": int(counts.get("labels") or 0),
|
||||
"has_meta": bool(row.get("has_meta")),
|
||||
"registry_only": bool(row.get("registry_only")),
|
||||
"next_cli": row.get("next_cli"),
|
||||
"domain": row.get("domain"),
|
||||
"domain_label": row.get("domain_label"),
|
||||
"task_label": row.get("task_label"),
|
||||
"mode_label": row.get("mode_label"),
|
||||
"labeling_profile": row.get("labeling_profile"),
|
||||
"export_default": row.get("export_default"),
|
||||
"ml_adapter": row.get("ml_adapter"),
|
||||
"indexed_at": _utcnow(),
|
||||
}
|
||||
|
||||
|
||||
def upsert_batch_dict(b: dict[str, Any], *, reg: dict | None = None) -> str | None:
|
||||
"""登记/单批更新时写入索引。"""
|
||||
reg = reg or load_dms_registry()
|
||||
fields = _batch_dict_to_fields(b, reg)
|
||||
if not fields:
|
||||
return None
|
||||
cid = fields["campaign_id"]
|
||||
fields["archived"] = False
|
||||
with session_scope() as db:
|
||||
rec = db.get(BatchIndex, cid)
|
||||
if rec:
|
||||
for k, v in fields.items():
|
||||
setattr(rec, k, v)
|
||||
else:
|
||||
db.add(BatchIndex(**fields))
|
||||
db.commit()
|
||||
return cid
|
||||
|
||||
|
||||
def archive_batch(campaign_id: str) -> dict[str, Any]:
|
||||
"""软删除:仅从工作台隐藏,不删磁盘数据。"""
|
||||
with session_scope() as db:
|
||||
rec = db.get(BatchIndex, campaign_id)
|
||||
if not rec:
|
||||
raise FileNotFoundError(campaign_id)
|
||||
if rec.archived:
|
||||
return {"ok": True, "campaign_id": campaign_id, "already_archived": True}
|
||||
if rec.stage != "raw_pool":
|
||||
raise ValueError("仅「待送标」批次可移除")
|
||||
camp = db.get(LabelingCampaign, campaign_id)
|
||||
if camp and camp.status not in ("not_opened", ""):
|
||||
raise ValueError("已开标的批次不可移除,请先在标注进度中处理")
|
||||
rec.archived = True
|
||||
rec.indexed_at = _utcnow()
|
||||
db.commit()
|
||||
return {"ok": True, "campaign_id": campaign_id}
|
||||
|
||||
|
||||
def sync_index_stage(campaign_id: str, stage: str) -> None:
|
||||
eff = effective_stage(stage) or stage
|
||||
with session_scope() as db:
|
||||
rec = db.get(BatchIndex, campaign_id)
|
||||
if rec:
|
||||
rec.stage = eff
|
||||
rec.indexed_at = _utcnow()
|
||||
db.commit()
|
||||
|
||||
|
||||
def index_count() -> int:
|
||||
with session_scope() as db:
|
||||
return db.query(BatchIndex).count()
|
||||
|
||||
|
||||
def index_is_empty() -> bool:
|
||||
return index_count() == 0
|
||||
|
||||
|
||||
def get_batch_by_campaign_id(campaign_id: str) -> dict[str, Any] | None:
|
||||
with session_scope() as db:
|
||||
rec = db.get(BatchIndex, campaign_id)
|
||||
return rec.to_list_row() if rec else None
|
||||
|
||||
|
||||
def rebuild_batch_index(wf: dict | None = None) -> dict[str, Any]:
|
||||
"""全量扫盘并重建索引(刷新/首次加载/登记后批量同步)。"""
|
||||
from as_platform.labeling.service import _registry_fallback_batches
|
||||
|
||||
t0 = time.perf_counter()
|
||||
wf = wf or load_wf()
|
||||
reg = load_dms_registry()
|
||||
report = get_pending_report(wf)
|
||||
candidates: list[dict[str, Any]] = list(report.get("batches") or [])
|
||||
candidates.extend(_registry_fallback_batches(wf, reg))
|
||||
|
||||
seen: set[str] = set()
|
||||
upserted = 0
|
||||
with session_scope() as db:
|
||||
for b in candidates:
|
||||
fields = _batch_dict_to_fields(b, reg)
|
||||
if not fields:
|
||||
continue
|
||||
cid = fields["campaign_id"]
|
||||
seen.add(cid)
|
||||
rec = db.get(BatchIndex, cid)
|
||||
if rec and rec.archived:
|
||||
continue
|
||||
if rec:
|
||||
for k, v in fields.items():
|
||||
setattr(rec, k, v)
|
||||
else:
|
||||
db.add(BatchIndex(**fields))
|
||||
upserted += 1
|
||||
|
||||
if seen:
|
||||
db.query(BatchIndex).filter(
|
||||
BatchIndex.campaign_id.notin_(seen),
|
||||
BatchIndex.archived.is_(False),
|
||||
).delete(synchronize_session=False)
|
||||
db.commit()
|
||||
|
||||
elapsed_ms = round((time.perf_counter() - t0) * 1000)
|
||||
return {
|
||||
"ok": True,
|
||||
"count": upserted,
|
||||
"elapsed_ms": elapsed_ms,
|
||||
"updated_at": report.get("updated_at"),
|
||||
}
|
||||
|
||||
|
||||
def _expand_stage_filters_for_sql(stage_filters: list[str]) -> list[str]:
|
||||
"""将筛选阶段展开为索引表中的实际 stage 值(含 review_approved 等别名)。"""
|
||||
expanded: set[str] = set()
|
||||
for sf in stage_filters:
|
||||
expanded.add(sf)
|
||||
for alias, canonical in STAGE_ALIASES.items():
|
||||
if canonical == sf:
|
||||
expanded.add(alias)
|
||||
return list(expanded)
|
||||
|
||||
|
||||
def _index_query(db, *, stage_filters: list[str], q: str | None):
|
||||
from sqlalchemy import func, or_
|
||||
|
||||
from as_platform.config import IS_POSTGRES
|
||||
|
||||
query = db.query(BatchIndex).filter(
|
||||
BatchIndex.registry_only.is_(False),
|
||||
BatchIndex.archived.is_(False),
|
||||
)
|
||||
if stage_filters:
|
||||
query = query.filter(BatchIndex.stage.in_(_expand_stage_filters_for_sql(stage_filters)))
|
||||
text = (q or "").strip()
|
||||
if text:
|
||||
pattern = f"%{text}%"
|
||||
if IS_POSTGRES:
|
||||
query = query.filter(
|
||||
or_(
|
||||
BatchIndex.batch.ilike(pattern),
|
||||
BatchIndex.task.ilike(pattern),
|
||||
BatchIndex.project.ilike(pattern),
|
||||
)
|
||||
)
|
||||
else:
|
||||
lp = pattern.lower()
|
||||
query = query.filter(
|
||||
or_(
|
||||
func.lower(BatchIndex.batch).like(lp),
|
||||
func.lower(func.coalesce(BatchIndex.task, "")).like(lp),
|
||||
func.lower(BatchIndex.project).like(lp),
|
||||
)
|
||||
)
|
||||
return query
|
||||
|
||||
|
||||
def list_batches_from_index(
|
||||
*,
|
||||
stage: str | None = None,
|
||||
stages: list[str] | None = None,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
q: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
stage_filters = [s.strip() for s in (stages or []) if s and s.strip()]
|
||||
if stage and stage not in stage_filters:
|
||||
stage_filters.append(stage)
|
||||
|
||||
with session_scope() as db:
|
||||
base = _index_query(db, stage_filters=stage_filters, q=q)
|
||||
total = base.count()
|
||||
recs = (
|
||||
base.order_by(BatchIndex.indexed_at.desc())
|
||||
.offset(max(0, offset))
|
||||
.limit(max(1, limit))
|
||||
.all()
|
||||
)
|
||||
page = [rec.to_list_row() for rec in recs]
|
||||
latest_indexed = max((rec.indexed_at for rec in recs), default=None)
|
||||
|
||||
cids = [r["campaign_id"] for r in page if r.get("campaign_id")]
|
||||
|
||||
camp_map: dict[str, dict[str, Any]] = {}
|
||||
if cids:
|
||||
with session_scope() as db:
|
||||
camps = db.query(LabelingCampaign).filter(LabelingCampaign.id.in_(cids)).all()
|
||||
for c in camps:
|
||||
camp_map[c.id] = {
|
||||
"status": c.status,
|
||||
"assigned_to_user_id": c.assigned_to_user_id,
|
||||
"assigned_to_name": c.assigned_to_name,
|
||||
}
|
||||
|
||||
progress_stages = frozenset({"out_for_labeling", "in_progress"})
|
||||
out: list[dict[str, Any]] = []
|
||||
for row in page:
|
||||
cid = row.get("campaign_id")
|
||||
camp = camp_map.get(cid) if cid else None
|
||||
status = camp["status"] if camp else "not_opened"
|
||||
if camp:
|
||||
row["assigned_to_user_id"] = camp["assigned_to_user_id"]
|
||||
row["assigned_to_name"] = camp["assigned_to_name"]
|
||||
row["campaign_status"] = status
|
||||
eff_stage = row.get("stage") or ""
|
||||
if camp and (status in progress_stages or eff_stage in progress_stages):
|
||||
try:
|
||||
from as_platform.labeling.progress import campaign_progress_summary
|
||||
|
||||
row.update(campaign_progress_summary(cid))
|
||||
except Exception:
|
||||
row.update({"total_tasks": 0, "completed_tasks": 0, "assigned_tasks": 0})
|
||||
out.append(row)
|
||||
|
||||
updated_at = latest_indexed.isoformat() if latest_indexed else None
|
||||
return {
|
||||
"items": out,
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"updated_at": updated_at,
|
||||
"source": "index",
|
||||
}
|
||||
@@ -70,6 +70,12 @@ def update_campaign_batch_meta_stage(camp: LabelingCampaign, stage: str) -> bool
|
||||
if camp.mode:
|
||||
meta.setdefault("mode", camp.mode)
|
||||
write_meta(batch_dir, meta)
|
||||
try:
|
||||
from as_platform.labeling.batch_index import sync_index_stage
|
||||
|
||||
sync_index_stage(camp.id, stage)
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -107,7 +107,10 @@ def _labels_from_registry_profile(project: str, task: str, mode: str | None) ->
|
||||
prof = (load_labeling_registry().get("profiles") or {}).get(pk) or {}
|
||||
cvat_names = prof.get("cvat_labels")
|
||||
if cvat_names:
|
||||
return [{"name": str(n), "type": "cuboid"} for n in cvat_names]
|
||||
label_type = prof.get("cvat_label_type") or (
|
||||
"cuboid" if project == "adas" and task == "cuboid_7cls" else "rectangle"
|
||||
)
|
||||
return [{"name": str(n), "type": label_type} for n in cvat_names]
|
||||
return None
|
||||
|
||||
|
||||
@@ -126,6 +129,10 @@ def build_cvat_labels(
|
||||
if project == "adas":
|
||||
if task == "cuboid_7cls":
|
||||
return ADAS_CUBOID_7CLS_LABELS
|
||||
if task == "det_7cls":
|
||||
return [_rect_label(n) for n in [
|
||||
"pedestrian", "car", "truck", "bus", "motorcycle", "tricycle", "traffic cone",
|
||||
]]
|
||||
return ADAS_CUBOID_7CLS_LABELS
|
||||
|
||||
if project == "lane":
|
||||
@@ -145,4 +152,8 @@ def resolve_annotation_types(project: str, task: str | None = None, mode: str |
|
||||
}
|
||||
if project == "adas" and task == "cuboid_7cls":
|
||||
return ["cuboid"]
|
||||
if project == "adas" and task == "det_7cls":
|
||||
return ["bbox"]
|
||||
if project == "dms" and task == "adas":
|
||||
return ["bbox"]
|
||||
return mapping.get(project, ["bbox"])
|
||||
|
||||
@@ -108,66 +108,23 @@ def _registry_fallback_batches(wf: dict, reg: dict) -> list[dict[str, Any]]:
|
||||
def list_labeling_batches(
|
||||
*,
|
||||
stage: str | None = None,
|
||||
stages: list[str] | None = None,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
refresh: bool = False,
|
||||
q: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
wf = load_wf()
|
||||
report = get_pending_report(wf)
|
||||
reg = load_dms_registry()
|
||||
items: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
allowed_stages = ("raw_pool", "out_for_labeling", "returned", "labeling_submitted", "in_review", "review_approved", "review_rejected")
|
||||
from as_platform.labeling.batch_index import (
|
||||
index_is_empty,
|
||||
list_batches_from_index,
|
||||
rebuild_batch_index,
|
||||
)
|
||||
|
||||
def _append(b: dict[str, Any]) -> None:
|
||||
if b.get("registry_only"):
|
||||
return
|
||||
raw_stage = b.get("stage")
|
||||
eff = effective_stage(raw_stage)
|
||||
if stage and not matches_stage_filter(raw_stage, stage):
|
||||
return
|
||||
if eff not in allowed_stages and raw_stage not in allowed_stages:
|
||||
return
|
||||
row = enrich_batch_labels(b, reg)
|
||||
row["stage"] = eff or raw_stage
|
||||
cid = _campaign_id(
|
||||
row["project"], row.get("task") or "", row.get("mode"), row["batch"], row.get("location") or "inbox"
|
||||
)
|
||||
key = f"{cid}"
|
||||
if key in seen:
|
||||
return
|
||||
seen.add(key)
|
||||
with session_scope() as db:
|
||||
camp = db.get(LabelingCampaign, cid)
|
||||
status = camp.status if camp else "not_opened"
|
||||
if camp:
|
||||
row["assigned_to_user_id"] = camp.assigned_to_user_id
|
||||
row["assigned_to_name"] = camp.assigned_to_name
|
||||
row["campaign_id"] = cid
|
||||
row["campaign_status"] = status
|
||||
if camp and status in ("in_progress", "labeling_submitted"):
|
||||
try:
|
||||
from as_platform.labeling.progress import campaign_progress_summary
|
||||
|
||||
row.update(campaign_progress_summary(cid))
|
||||
except Exception:
|
||||
row.update({"total_tasks": 0, "completed_tasks": 0, "assigned_tasks": 0})
|
||||
items.append(row)
|
||||
|
||||
for b in report.get("batches", []):
|
||||
_append(b)
|
||||
|
||||
for b in _registry_fallback_batches(wf, reg):
|
||||
_append(b)
|
||||
|
||||
total = len(items)
|
||||
page = items[max(0, offset) : max(0, offset) + max(1, limit)]
|
||||
return {
|
||||
"items": page,
|
||||
"total": total,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"updated_at": report.get("updated_at"),
|
||||
}
|
||||
if refresh or index_is_empty():
|
||||
rebuild_batch_index()
|
||||
return list_batches_from_index(
|
||||
stage=stage, stages=stages, offset=offset, limit=limit, q=q,
|
||||
)
|
||||
|
||||
|
||||
def open_campaign(
|
||||
@@ -189,13 +146,14 @@ def open_campaign(
|
||||
|
||||
cvat = get_cvat_client()
|
||||
if not cvat.ping():
|
||||
raise ValueError("CVAT 标注引擎不可用,请执行: docker compose -f docker-compose.yml -f docker-compose.cvat.yml up -d")
|
||||
raise ValueError("CVAT 标注引擎不可用,请执行: docker compose up -d")
|
||||
|
||||
cvat_labels = build_cvat_labels(project, task, mode, ann_types)
|
||||
cvat_task = cvat.create_task(name=cid, labels=cvat_labels)
|
||||
cvat_task_id = cvat_task.id
|
||||
cvat_job_url = cvat_task.job_url
|
||||
|
||||
batch_dir = None
|
||||
with session_scope() as db:
|
||||
camp = db.get(LabelingCampaign, cid)
|
||||
if not camp:
|
||||
@@ -241,6 +199,26 @@ def open_campaign(
|
||||
reg = load_dms_registry() if project == "dms" else None
|
||||
row = enrich_batch_labels(out, reg)
|
||||
row["stage"] = "out_for_labeling"
|
||||
try:
|
||||
from as_platform.labeling.batch_index import upsert_batch_dict
|
||||
|
||||
if batch_dir and batch_dir.is_dir():
|
||||
from as_platform.data.batch import enrich_batch
|
||||
|
||||
upsert_batch_dict(
|
||||
enrich_batch(
|
||||
batch_dir,
|
||||
project=project,
|
||||
task=task,
|
||||
pack=pack,
|
||||
batch=batch,
|
||||
location=location,
|
||||
),
|
||||
)
|
||||
else:
|
||||
upsert_batch_dict(row)
|
||||
except Exception:
|
||||
pass
|
||||
return row
|
||||
|
||||
|
||||
@@ -376,23 +354,15 @@ def list_labeling_assignees() -> dict[str, Any]:
|
||||
|
||||
|
||||
def _find_batch_for_campaign_id(campaign_id: str) -> dict[str, Any] | None:
|
||||
"""由确定性 campaign_id 反查 pending / registry 批次行。"""
|
||||
wf = load_wf()
|
||||
reg = load_dms_registry()
|
||||
candidates: list[dict[str, Any]] = []
|
||||
report = get_pending_report(wf)
|
||||
candidates.extend(report.get("batches") or [])
|
||||
candidates.extend(_registry_fallback_batches(wf, reg))
|
||||
for b in candidates:
|
||||
cid = _campaign_id(
|
||||
b.get("project") or "dms",
|
||||
b.get("task") or "",
|
||||
b.get("mode"),
|
||||
b.get("batch") or "",
|
||||
b.get("location") or "inbox",
|
||||
)
|
||||
if cid == campaign_id:
|
||||
return b
|
||||
"""由 campaign_id 反查批次行(优先读索引)。"""
|
||||
from as_platform.labeling.batch_index import get_batch_by_campaign_id, index_is_empty, rebuild_batch_index
|
||||
|
||||
row = get_batch_by_campaign_id(campaign_id)
|
||||
if row:
|
||||
return row
|
||||
if index_is_empty():
|
||||
rebuild_batch_index()
|
||||
return get_batch_by_campaign_id(campaign_id)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
280
platform/as_platform/tests/run_dms_e2e_pipeline.py
Executable file
280
platform/as_platform/tests/run_dms_e2e_pipeline.py
Executable file
@@ -0,0 +1,280 @@
|
||||
#!/usr/bin/env python3
|
||||
"""DMS 2 图 E2E:标完后自动 提交→质检→导出→build 入库。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
PLATFORM = ROOT / "platform"
|
||||
if str(PLATFORM) not in sys.path:
|
||||
sys.path.insert(0, str(PLATFORM))
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
|
||||
def campaign_id(project: str, task: str, mode: str | None, batch: str, location: str = "inbox") -> str:
|
||||
from as_platform.labeling.scope import format_scope_key
|
||||
|
||||
sk = format_scope_key(project, task, mode)
|
||||
raw = f"{sk}:{batch}:{location}"
|
||||
return hashlib.sha256(raw.encode()).hexdigest()[:20]
|
||||
|
||||
|
||||
class ApiClient:
|
||||
def __init__(self, base: str, token: str) -> None:
|
||||
self.base = base.rstrip("/")
|
||||
self.token = token
|
||||
|
||||
def _request(self, method: str, path: str, body: dict | None = None) -> Any:
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(
|
||||
f"{self.base}{path}",
|
||||
data=data,
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method=method,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
raw = resp.read().decode()
|
||||
return json.loads(raw) if raw else {}
|
||||
except urllib.error.HTTPError as e:
|
||||
detail = e.read().decode()
|
||||
raise RuntimeError(f"{method} {path} -> {e.code}: {detail}") from e
|
||||
|
||||
def get(self, path: str) -> Any:
|
||||
return self._request("GET", path)
|
||||
|
||||
def post(self, path: str, body: dict | None = None) -> Any:
|
||||
return self._request("POST", path, body)
|
||||
|
||||
|
||||
def login(base: str, name: str = "e2e-runner") -> ApiClient:
|
||||
req = urllib.request.Request(
|
||||
f"{base.rstrip('/')}/api/v1/auth/dev/login",
|
||||
data=json.dumps({"name": name}).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
data = json.loads(resp.read().decode())
|
||||
return ApiClient(base, data["access_token"])
|
||||
|
||||
|
||||
def wait_job(api: ApiClient, job_id: str, timeout: int = 180) -> dict[str, Any]:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
job = api.get(f"/api/v1/jobs/{job_id}")
|
||||
st = job.get("status")
|
||||
if st in ("succeeded", "failed"):
|
||||
return job
|
||||
time.sleep(1)
|
||||
raise TimeoutError(f"job {job_id} not finished in {timeout}s")
|
||||
|
||||
|
||||
def labeled_count(batch_dir: Path) -> int:
|
||||
ann_dir = batch_dir / "labels" / "ls_annotations"
|
||||
if not ann_dir.is_dir():
|
||||
return 0
|
||||
from as_platform.labeling.progress import _annotation_has_result
|
||||
|
||||
n = 0
|
||||
for p in ann_dir.glob("*.json"):
|
||||
if _annotation_has_result(p):
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def batch_stage(batch_dir: Path) -> str:
|
||||
meta = batch_dir / "batch.meta.yaml"
|
||||
if not meta.is_file():
|
||||
return "raw_pool"
|
||||
import yaml
|
||||
|
||||
data = yaml.safe_load(meta.read_text(encoding="utf-8")) or {}
|
||||
return str(data.get("stage") or "raw_pool")
|
||||
|
||||
|
||||
def cmd_info(args: argparse.Namespace) -> None:
|
||||
cid = campaign_id(args.project, args.task, None, args.batch)
|
||||
print(f"campaign_id={cid}")
|
||||
print(f"annotate_url=/labeling/annotate/{cid}")
|
||||
print(f"batch_path=datasets/dms/inbox/{args.task}/{args.batch}")
|
||||
wf_root = ROOT / "datasets" / "dms"
|
||||
batch_dir = wf_root / "inbox" / args.task / args.batch
|
||||
if batch_dir.is_dir():
|
||||
print(f"stage={batch_stage(batch_dir)} labeled={labeled_count(batch_dir)}")
|
||||
try:
|
||||
api = login(args.api)
|
||||
row = next(
|
||||
(i for i in api.get("/api/v1/labeling/batches?limit=100").get("items", [])
|
||||
if i.get("batch") == args.batch and i.get("task") == args.task),
|
||||
None,
|
||||
)
|
||||
if row:
|
||||
print(f"platform_stage={row.get('stage')} status={row.get('campaign_status')}")
|
||||
print(f"progress={row.get('completed_tasks', '?')}/{row.get('total_tasks', '?')}")
|
||||
except Exception as e:
|
||||
print(f"api_skip={e}")
|
||||
|
||||
|
||||
def cmd_setup(args: argparse.Namespace) -> None:
|
||||
api = login(args.api)
|
||||
body = {
|
||||
"project": args.project,
|
||||
"task": args.task,
|
||||
"batch": args.batch,
|
||||
"location": "inbox",
|
||||
}
|
||||
row = api.post("/api/v1/labeling/campaigns/open", body)
|
||||
print(json.dumps({"campaign_id": row.get("id"), "stage": row.get("stage"), "cvat_task_id": row.get("cvat_task_id")}, ensure_ascii=False))
|
||||
|
||||
|
||||
def wait_labels(batch_dir: Path, min_images: int, wait_sec: int) -> None:
|
||||
if labeled_count(batch_dir) >= min_images:
|
||||
return
|
||||
if wait_sec <= 0:
|
||||
raise RuntimeError(
|
||||
f"仅标注 {labeled_count(batch_dir)}/{min_images} 张,请先在平台画框保存,再执行 run 或 run-wait"
|
||||
)
|
||||
print(f"等待标注 {min_images} 张 (最多 {wait_sec}s)...")
|
||||
deadline = time.time() + wait_sec
|
||||
while time.time() < deadline:
|
||||
n = labeled_count(batch_dir)
|
||||
if n >= min_images:
|
||||
print(f"labeled={n}")
|
||||
return
|
||||
time.sleep(3)
|
||||
raise TimeoutError(f"超时:仅 {labeled_count(batch_dir)}/{min_images} 张有标注")
|
||||
|
||||
|
||||
def cmd_run(args: argparse.Namespace) -> None:
|
||||
cid = campaign_id(args.project, args.task, None, args.batch)
|
||||
batch_dir = ROOT / "datasets" / "dms" / "inbox" / args.task / args.batch
|
||||
if not batch_dir.is_dir():
|
||||
raise FileNotFoundError(batch_dir)
|
||||
|
||||
wait_labels(batch_dir, args.min_images, args.wait_label_sec)
|
||||
api = login(args.api)
|
||||
|
||||
print("==> 1. 提交质检")
|
||||
api.post(f"/api/v1/labeling/campaigns/{cid}/submit")
|
||||
row = next(
|
||||
i for i in api.get("/api/v1/labeling/batches?limit=100").get("items", [])
|
||||
if i.get("campaign_id") == cid
|
||||
)
|
||||
assert row.get("stage") == "in_review", row
|
||||
|
||||
print("==> 2. 质检通过 (全部 good)")
|
||||
queue = api.get(f"/api/v1/labeling/campaigns/{cid}/review-queue?limit=50")
|
||||
items = queue.get("items") or []
|
||||
scores = [{"image_path": it["image_path"], "score": "good"} for it in items]
|
||||
res = api.post(
|
||||
f"/api/v1/labeling/campaigns/{cid}/review-submit",
|
||||
{"scores": scores},
|
||||
)
|
||||
print("review", res)
|
||||
row = next(
|
||||
i for i in api.get("/api/v1/labeling/batches?limit=100").get("items", [])
|
||||
if i.get("campaign_id") == cid
|
||||
)
|
||||
assert row.get("stage") == "labeling_submitted", row
|
||||
|
||||
print("==> 3. 执行导出")
|
||||
exp = api.post(f"/api/v1/labeling/campaigns/{cid}/export")
|
||||
job_id = (exp.get("job") or {}).get("id")
|
||||
assert job_id, exp
|
||||
job = wait_job(api, job_id)
|
||||
if job.get("status") != "succeeded":
|
||||
raise RuntimeError(f"export failed: {job}")
|
||||
print("export_job", job.get("result"))
|
||||
|
||||
row = next(
|
||||
i for i in api.get("/api/v1/labeling/batches?limit=100").get("items", [])
|
||||
if i.get("campaign_id") == cid
|
||||
)
|
||||
assert row.get("stage") == "returned", row
|
||||
yolo = list((batch_dir / "labels").rglob("*.txt"))
|
||||
assert yolo, "export 后应有 YOLO txt"
|
||||
|
||||
print("==> 4. 提交 build 审核")
|
||||
appr = api.post(
|
||||
"/api/v1/system/audit/submit-build-batch",
|
||||
{
|
||||
"project": args.project,
|
||||
"task": args.task,
|
||||
"batch": args.batch,
|
||||
"pack": args.pack,
|
||||
"location": "inbox",
|
||||
"note": f"E2E smoke {args.batch}",
|
||||
},
|
||||
)
|
||||
approval_id = appr.get("id")
|
||||
assert approval_id, appr
|
||||
print("approval_id", approval_id)
|
||||
|
||||
print("==> 5. 批准 build")
|
||||
done = api.post(f"/api/v1/system/audit/{approval_id}/approve", {"comment": "e2e auto approve"})
|
||||
build_job_id = done.get("job_id")
|
||||
if build_job_id:
|
||||
bjob = wait_job(api, build_job_id, timeout=300)
|
||||
if bjob.get("status") != "succeeded":
|
||||
raise RuntimeError(f"build failed: {bjob}")
|
||||
print("build_job", bjob.get("result"))
|
||||
|
||||
row = next(
|
||||
i for i in api.get("/api/v1/labeling/batches?limit=100").get("items", [])
|
||||
if i.get("campaign_id") == cid
|
||||
)
|
||||
assert row.get("stage") == "ingested", row
|
||||
assert batch_stage(batch_dir) == "ingested", batch_stage(batch_dir)
|
||||
|
||||
dest = ROOT / "datasets" / "dms" / "packs" / args.pack / args.task / "sources" / args.batch
|
||||
assert dest.is_dir(), f"missing pack source: {dest}"
|
||||
dest_labels = list(dest.rglob("labels/**/*.txt")) + list(dest.rglob("labels/*.txt"))
|
||||
assert dest_labels, f"pack 内应有 labels: {dest}"
|
||||
|
||||
print("DMS_E2E_PIPELINE_OK")
|
||||
print(json.dumps({
|
||||
"campaign_id": cid,
|
||||
"batch": args.batch,
|
||||
"pack": args.pack,
|
||||
"dest": str(dest),
|
||||
"yolo_in_inbox": len(yolo),
|
||||
"stage": row.get("stage"),
|
||||
}, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("command", choices=("setup", "run", "info"))
|
||||
ap.add_argument("--api", default="http://127.0.0.1:8787")
|
||||
ap.add_argument("--project", default="dms")
|
||||
ap.add_argument("--task", default="addw")
|
||||
ap.add_argument("--batch", default="e2e_2img_20260616")
|
||||
ap.add_argument("--pack", default="dms_v1")
|
||||
ap.add_argument("--min-images", type=int, default=2)
|
||||
ap.add_argument("--wait-label-sec", type=int, default=0)
|
||||
ap.add_argument("--skip-files", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.command == "info":
|
||||
cmd_info(args)
|
||||
elif args.command == "setup":
|
||||
cmd_setup(args)
|
||||
elif args.command == "run":
|
||||
cmd_run(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
54
platform/as_platform/tests/test_batch_index.py
Normal file
54
platform/as_platform/tests/test_batch_index.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""批次索引:列表走 DB,重建走扫盘。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from as_platform.labeling.batch_index import (
|
||||
index_is_empty,
|
||||
list_batches_from_index,
|
||||
rebuild_batch_index,
|
||||
)
|
||||
from as_platform.labeling.service import list_labeling_batches
|
||||
|
||||
|
||||
def test_rebuild_and_list_from_index():
|
||||
r = rebuild_batch_index()
|
||||
assert r["ok"] is True
|
||||
assert r["count"] >= 0
|
||||
|
||||
t0 = time.perf_counter()
|
||||
out = list_batches_from_index(limit=100)
|
||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||
assert out["source"] == "index"
|
||||
assert "items" in out
|
||||
assert elapsed_ms < 500, f"index list too slow: {elapsed_ms:.0f}ms"
|
||||
|
||||
|
||||
def test_list_labeling_batches_uses_index():
|
||||
if index_is_empty():
|
||||
rebuild_batch_index()
|
||||
t0 = time.perf_counter()
|
||||
out = list_labeling_batches(limit=50)
|
||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||
assert "items" in out
|
||||
assert elapsed_ms < 800, f"list_labeling_batches too slow: {elapsed_ms:.0f}ms"
|
||||
|
||||
|
||||
def test_archive_batch_hides_from_list():
|
||||
from as_platform.db.engine import session_scope
|
||||
from as_platform.db.models import BatchIndex
|
||||
from as_platform.labeling.batch_index import archive_batch, list_batches_from_index
|
||||
|
||||
with session_scope() as db:
|
||||
rec = (
|
||||
db.query(BatchIndex)
|
||||
.filter(BatchIndex.archived.is_(False), BatchIndex.stage == "raw_pool")
|
||||
.first()
|
||||
)
|
||||
if not rec:
|
||||
return
|
||||
cid = rec.campaign_id
|
||||
|
||||
archive_batch(cid)
|
||||
out = list_batches_from_index(stage="raw_pool", limit=500)
|
||||
assert all(r.get("campaign_id") != cid for r in out["items"])
|
||||
51
platform/web/package-lock.json
generated
51
platform/web/package-lock.json
generated
@@ -8,11 +8,14 @@
|
||||
"name": "hsap-platform-web",
|
||||
"version": "1.1.0",
|
||||
"dependencies": {
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-router-dom": "^5.3.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
@@ -807,6 +810,17 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@react-leaflet/core": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz",
|
||||
"integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -1216,6 +1230,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/history": {
|
||||
"version": "4.7.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz",
|
||||
@@ -1223,6 +1244,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/leaflet": {
|
||||
"version": "1.9.21",
|
||||
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
|
||||
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
@@ -1898,6 +1929,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
@@ -2323,6 +2360,20 @@
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
|
||||
"integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==",
|
||||
"license": "Hippocratic-2.1",
|
||||
"dependencies": {
|
||||
"@react-leaflet/core": "^2.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"leaflet": "^1.9.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
|
||||
@@ -9,11 +9,14 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"leaflet": "^1.9.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-router-dom": "^5.3.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
|
||||
BIN
platform/web/public/login-bg.png
Normal file
BIN
platform/web/public/login-bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
@@ -30,7 +30,7 @@ const MODULES: ModuleDef[] = [
|
||||
{ to: "/labeling/my-tasks", label: "我的标注", perm: "read:pending" },
|
||||
{ to: "/labeling/workbench", label: "送标工作台", perm: "read:pending" },
|
||||
{ to: "/labeling/campaigns", label: "标注进度", perm: "read:pending" },
|
||||
{ to: "/labeling/review", label: "标注质检", perm: "read:pending" },
|
||||
{ to: "/labeling/review", label: "标注质检", perm: "write:approval_review" },
|
||||
{ to: "/labeling/export", label: "导出与入库", perm: "read:pending" },
|
||||
{ to: "/labeling/deliveries", label: "批次台账", perm: "read:deliveries" },
|
||||
{ to: "/labeling/catalog", label: "数据目录", perm: "read:catalog" },
|
||||
@@ -148,7 +148,8 @@ export const Sidebar: React.FC = () => {
|
||||
{/* Brand */}
|
||||
<div className="sidebar-brand">
|
||||
<NavLink to="/" className="no-underline text-inherit">
|
||||
HSAP <span className="text-gray-400 font-normal text-sm">数据闭环平台</span>
|
||||
HSAP
|
||||
<span className="text-gray-400 font-normal text-[11px]">数据闭环平台</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -76,15 +76,35 @@ export const hsapApi = {
|
||||
health: () => fetchJson<{ status: string; database: string }>(`${API_BASE}/api/v1/health`),
|
||||
|
||||
// ── Labeling ──
|
||||
labelingBatches: (opts?: { stage?: string; offset?: number; limit?: number }) => {
|
||||
labelingBatches: (opts?: {
|
||||
stage?: string;
|
||||
stages?: string[];
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
refresh?: boolean;
|
||||
q?: string;
|
||||
}) => {
|
||||
const p = new URLSearchParams();
|
||||
if (opts?.stage) p.set("stage", opts.stage);
|
||||
if (opts?.stages?.length) p.set("stages", opts.stages.join(","));
|
||||
else if (opts?.stage) p.set("stage", opts.stage);
|
||||
if (opts?.offset != null) p.set("offset", String(opts.offset));
|
||||
if (opts?.limit != null) p.set("limit", String(opts.limit));
|
||||
const q = p.toString();
|
||||
return fetchJson<PagedResult<LabelingBatchRow>>(`${API_BASE}/api/v1/labeling/batches${q ? `?${q}` : ""}`);
|
||||
if (opts?.refresh) p.set("refresh", "true");
|
||||
if (opts?.q) p.set("q", opts.q);
|
||||
const qs = p.toString();
|
||||
return fetchJson<PagedResult<LabelingBatchRow> & { source?: string; updated_at?: string }>(
|
||||
`${API_BASE}/api/v1/labeling/batches${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
},
|
||||
|
||||
rebuildBatchIndex: () =>
|
||||
postJson<{ ok: boolean; count: number; elapsed_ms: number }>(`${API_BASE}/api/v1/labeling/batches/rebuild-index`),
|
||||
|
||||
archiveLabelingBatch: (campaignId: string) =>
|
||||
postJson<{ ok: boolean; campaign_id: string }>(
|
||||
`${API_BASE}/api/v1/labeling/batches/${encodeURIComponent(campaignId)}/archive`,
|
||||
),
|
||||
|
||||
openLabelingCampaign: (body: { project: string; task: string; batch: string; mode?: string | null; pack?: string | null; location?: string }) =>
|
||||
postJson(`${API_BASE}/api/v1/labeling/campaigns/open`, body),
|
||||
|
||||
@@ -165,6 +185,46 @@ export const hsapApi = {
|
||||
labelingExportStats: (campaignId: string) =>
|
||||
fetchJson<Record<string, unknown>>(`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/export-stats`),
|
||||
|
||||
fetchReviewImageBlob: async (campaignId: string, imagePath: string) => {
|
||||
const q = new URLSearchParams({ path: imagePath });
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/review-image?${q}`,
|
||||
{ headers: authHeaders(), cache: "no-store" },
|
||||
);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return URL.createObjectURL(await res.blob());
|
||||
},
|
||||
|
||||
reviewProgress: (campaignId: string) =>
|
||||
fetchJson<{
|
||||
good: number; fine: number; bad: number; pending: number;
|
||||
total: number; reviewed: number; pass_rate: number; complete: boolean; stage?: string;
|
||||
}>(`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/review-progress`),
|
||||
|
||||
reviewProgressBatch: (campaignIds: string[]) =>
|
||||
fetchJson<{ items: Record<string, {
|
||||
good: number; fine: number; bad: number; pending: number;
|
||||
total: number; reviewed: number; pass_rate: number; complete: boolean; stage?: string;
|
||||
}> }>(`${API_BASE}/api/v1/labeling/review-progress?campaign_ids=${encodeURIComponent(campaignIds.join(","))}`),
|
||||
|
||||
reviewQueue: (campaignId: string, opts?: { offset?: number; limit?: number }) => {
|
||||
const p = new URLSearchParams();
|
||||
if (opts?.offset != null) p.set("offset", String(opts.offset));
|
||||
if (opts?.limit != null) p.set("limit", String(opts.limit));
|
||||
const q = p.toString();
|
||||
return fetchJson<{
|
||||
items: { id: string; image_path: string; fileName: string; score: string; has_label: boolean }[];
|
||||
total: number;
|
||||
scores: { good: number; fine: number; bad: number; pending: number };
|
||||
}>(`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/review-queue${q ? `?${q}` : ""}`);
|
||||
},
|
||||
|
||||
reviewSubmit: (campaignId: string, scores: { image_path: string; score: string }[]) =>
|
||||
postJson<{ ok: boolean; auto_advanced?: boolean; stage?: string }>(
|
||||
`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/review-submit`,
|
||||
{ scores },
|
||||
),
|
||||
|
||||
cuboidFit: (campaignId: string) =>
|
||||
postJson<Record<string, unknown>>(`${API_BASE}/api/v1/labeling/campaigns/${campaignId}/cuboid-fit`),
|
||||
|
||||
@@ -285,6 +345,25 @@ export const hsapApi = {
|
||||
registerBatch: (body: Record<string, unknown>) => postJson(`${API_BASE}/api/v1/register-batch`, body),
|
||||
|
||||
// ── Deliveries ──
|
||||
scanDeliveries: (projects?: string[]) => {
|
||||
const p = new URLSearchParams();
|
||||
if (projects?.length) p.set("projects", projects.join(","));
|
||||
const qs = p.toString();
|
||||
return fetchJson<{
|
||||
items: Record<string, unknown>[];
|
||||
count: number;
|
||||
needs_ledger: number;
|
||||
needs_workbench: number;
|
||||
scanned_at?: string;
|
||||
}>(`${API_BASE}/api/v1/deliveries/scan${qs ? `?${qs}` : ""}`);
|
||||
},
|
||||
|
||||
registerScannedDeliveries: (items: Record<string, unknown>[], syncWorkbench = true) =>
|
||||
postJson<{ ok: boolean; created: number; updated: number; synced_workbench: number }>(
|
||||
`${API_BASE}/api/v1/deliveries/scan/register`,
|
||||
{ items, sync_workbench: syncWorkbench },
|
||||
),
|
||||
|
||||
listDeliveries: (opts?: { status?: string; mine?: boolean; offset?: number; limit?: number }) => {
|
||||
const p = new URLSearchParams();
|
||||
if (opts?.status) p.set("status", opts.status);
|
||||
@@ -465,7 +544,7 @@ export const hsapApi = {
|
||||
},
|
||||
|
||||
setUserRoles: (userId: number, roles: string[]) =>
|
||||
putJson(`${API_BASE}/api/v1/auth/users/${userId}/roles`, { roles }),
|
||||
putJson(`${API_BASE}/api/v1/auth/users/${userId}/roles`, { role_codes: roles }),
|
||||
|
||||
syncFeishuUsers: () =>
|
||||
postJson<{ ok: boolean; created: number; updated: number; total: number }>(`${API_BASE}/api/v1/system/feishu/sync-users`),
|
||||
|
||||
25
platform/web/src/components/CompactTableShell.tsx
Normal file
25
platform/web/src/components/CompactTableShell.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from "react";
|
||||
|
||||
type ColWidth = string | number;
|
||||
|
||||
type CompactTableShellProps = {
|
||||
children: React.ReactNode;
|
||||
colWidths?: ColWidth[];
|
||||
};
|
||||
|
||||
export const CompactTableShell: React.FC<CompactTableShellProps> = ({ children, colWidths }) => (
|
||||
<div className="rounded-xl border border-gray-200 bg-white overflow-hidden">
|
||||
<div className="overflow-hidden">
|
||||
<table className="table-auto table-fixed w-full min-w-0 [&_th]:py-1.5 [&_th]:px-2 [&_th]:text-center [&_td]:py-1.5 [&_td]:px-2 [&_td]:text-center">
|
||||
{colWidths && colWidths.length > 0 && (
|
||||
<colgroup>
|
||||
{colWidths.map((w, i) => (
|
||||
<col key={i} style={typeof w === "number" ? { width: w } : { width: w }} />
|
||||
))}
|
||||
</colgroup>
|
||||
)}
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
23
platform/web/src/components/TruncatedText.tsx
Normal file
23
platform/web/src/components/TruncatedText.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
|
||||
interface TruncatedTextProps {
|
||||
text: string;
|
||||
className?: string;
|
||||
maxWidthClass?: string;
|
||||
}
|
||||
|
||||
export const TruncatedText: React.FC<TruncatedTextProps> = ({
|
||||
text,
|
||||
className = "",
|
||||
maxWidthClass = "max-w-[12rem]",
|
||||
}) => {
|
||||
if (!text) return <span className={`${className} text-center`}>—</span>;
|
||||
return (
|
||||
<span
|
||||
className={`block truncate cursor-default text-center mx-auto ${maxWidthClass} ${className}`}
|
||||
title={text}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -63,7 +63,11 @@ export const StatusBadge: React.FC<{ status: string }> = ({ status }) => {
|
||||
failed: { variant: "danger", label: "失败" },
|
||||
draft: { variant: "default", label: "草稿" },
|
||||
submitted: { variant: "info", label: "已提交" },
|
||||
pending_review: { variant: "warning", label: "待审核" },
|
||||
ingesting: { variant: "info", label: "入湖中" },
|
||||
in_lake: { variant: "success", label: "已入湖" },
|
||||
ingested: { variant: "success", label: "已入湖" },
|
||||
ingest_failed: { variant: "danger", label: "入湖失败" },
|
||||
cancelled: { variant: "default", label: "已取消" },
|
||||
};
|
||||
const m = map[status] || { variant: "default" as const, label: status };
|
||||
|
||||
131
platform/web/src/lib/labelingDisplay.ts
Normal file
131
platform/web/src/lib/labelingDisplay.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { LabelingBatchRow } from "./types";
|
||||
|
||||
/** ADAS 3D MOON:project=adas, task=cuboid_7cls */
|
||||
export function isAdas3dScope(b: LabelingBatchRow): boolean {
|
||||
const project = b.project || "dms";
|
||||
const task = b.task || "";
|
||||
return project === "adas" && task === "cuboid_7cls";
|
||||
}
|
||||
|
||||
/** ADAS 2D 七类:project=adas, task=det_7cls(兼容旧 dms+adas) */
|
||||
export function isAdas2dScope(b: LabelingBatchRow): boolean {
|
||||
const project = b.project || "dms";
|
||||
const task = b.task || "";
|
||||
if (project === "adas" && task === "det_7cls") return true;
|
||||
return project === "dms" && task === "adas";
|
||||
}
|
||||
|
||||
/** 前向 ADAS 相关(2D 七类、3D、交通标志 forward) */
|
||||
export function isAdasScope(b: LabelingBatchRow): boolean {
|
||||
if (isAdas3dScope(b) || isAdas2dScope(b)) return true;
|
||||
const project = b.project || "dms";
|
||||
if (project === "lane") return false;
|
||||
const task = b.task || "";
|
||||
return b.domain === "forward" || task === "forward";
|
||||
}
|
||||
|
||||
/** 列表「项目」列:DMS / ADAS / 车道线 */
|
||||
export function displayProject(b: LabelingBatchRow): string {
|
||||
const project = b.project || "dms";
|
||||
if (project === "lane") return "车道线";
|
||||
if (isAdasScope(b)) return "ADAS";
|
||||
return "DMS";
|
||||
}
|
||||
|
||||
export function displayProjectFields(row: {
|
||||
project?: string;
|
||||
task?: string;
|
||||
domain?: string;
|
||||
}): string {
|
||||
return displayProject(row as LabelingBatchRow);
|
||||
}
|
||||
|
||||
export function displayTaskFields(row: {
|
||||
project?: string;
|
||||
task?: string;
|
||||
mode?: string;
|
||||
domain?: string;
|
||||
task_label?: string;
|
||||
mode_label?: string;
|
||||
}): string {
|
||||
return displayTask(row as LabelingBatchRow);
|
||||
}
|
||||
|
||||
/** 列表「任务」列 */
|
||||
export function displayTask(b: LabelingBatchRow): string {
|
||||
const project = b.project || "dms";
|
||||
if (project === "lane") return "车道线";
|
||||
|
||||
if (isAdas3dScope(b)) return "3D 七类";
|
||||
if (isAdas2dScope(b)) return "2D 七类";
|
||||
|
||||
if (isAdasScope(b)) {
|
||||
if (b.task === "forward") {
|
||||
return b.mode_label || (b.mode === "classify" ? "细分类" : "粗检测");
|
||||
}
|
||||
return "2D";
|
||||
}
|
||||
|
||||
const task = b.task || "";
|
||||
const cabin: Record<string, string> = {
|
||||
addw: "ADDW",
|
||||
addw_face: "ADDW 人脸",
|
||||
ddaw: "DDAW",
|
||||
dam: "DAM",
|
||||
};
|
||||
if (cabin[task]) {
|
||||
if (task === "dam" && b.mode_label) return `DAM · ${b.mode_label}`;
|
||||
return cabin[task];
|
||||
}
|
||||
return b.task_label || task || "—";
|
||||
}
|
||||
|
||||
/** 台账表单:业务线 → API project/task */
|
||||
export type DeliveryLineKind =
|
||||
| "dms"
|
||||
| "adas_2d"
|
||||
| "adas_3d"
|
||||
| "forward"
|
||||
| "lane";
|
||||
|
||||
export function deliveryLineToApi(line: DeliveryLineKind): { project: string; task: string; mode: string } {
|
||||
switch (line) {
|
||||
case "adas_2d":
|
||||
return { project: "adas", task: "det_7cls", mode: "" };
|
||||
case "adas_3d":
|
||||
return { project: "adas", task: "cuboid_7cls", mode: "" };
|
||||
case "forward":
|
||||
return { project: "dms", task: "forward", mode: "" };
|
||||
case "lane":
|
||||
return { project: "lane", task: "lane_v1", mode: "" };
|
||||
default:
|
||||
return { project: "dms", task: "", mode: "" };
|
||||
}
|
||||
}
|
||||
|
||||
export function defaultInboxPath(line: DeliveryLineKind, batch: string, task?: string, mode?: string): string {
|
||||
const b = batch.trim();
|
||||
if (line === "adas_2d") return `/data/hsap/datasets/adas/inbox/det_7cls/${b}`;
|
||||
if (line === "adas_3d") return `/data/hsap/datasets/adas/inbox/cuboid_7cls/${b}`;
|
||||
if (line === "lane") return `/data/hsap/datasets/lane/inbox/${b}`;
|
||||
if (line === "forward" && mode) return `/data/hsap/datasets/dms/inbox/forward/${mode}/${b}`;
|
||||
if (task) return `/data/hsap/datasets/dms/inbox/${task}/${b}`;
|
||||
return `/data/hsap/datasets/dms/inbox/{task}/${b}`;
|
||||
}
|
||||
|
||||
/** @deprecated 用 deliveryLineToApi */
|
||||
export function inferDeliveryProject(task: string, explicitProject?: string): string {
|
||||
const t = (task || "").trim().toLowerCase();
|
||||
if (explicitProject === "lane" || t === "lane" || t === "lane_v1") return "lane";
|
||||
if (explicitProject === "adas" || t === "cuboid_7cls" || t.includes("cuboid") || t === "moon3d") return "adas";
|
||||
return "dms";
|
||||
}
|
||||
|
||||
/** 提交 build 时默认训练包 */
|
||||
export function defaultBuildPack(b: LabelingBatchRow): string {
|
||||
if (b.pack) return b.pack;
|
||||
if (isAdas3dScope(b)) return "adas_moon3d_v1";
|
||||
if (isAdas2dScope(b)) return "adas_v1";
|
||||
if (b.project === "lane") return "lane_v1";
|
||||
return "dms_v2";
|
||||
}
|
||||
@@ -31,6 +31,7 @@ export type BatchRecord = {
|
||||
|
||||
export type LabelingBatchRow = BatchRecord & {
|
||||
scope_key?: string;
|
||||
domain?: string;
|
||||
domain_label?: string;
|
||||
task_label?: string;
|
||||
mode_label?: string;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { TileLayer, useMap } from "react-leaflet";
|
||||
|
||||
export type TileDef = {
|
||||
url: string;
|
||||
subdomains: string[];
|
||||
attribution: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
const OSM_FALLBACK: TileDef = {
|
||||
id: "osm",
|
||||
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
subdomains: ["a", "b", "c"],
|
||||
attribution: "© OpenStreetMap",
|
||||
};
|
||||
|
||||
type Props = {
|
||||
primary: TileDef;
|
||||
onActiveChange?: (tile: TileDef) => void;
|
||||
};
|
||||
|
||||
function TileErrorWatcher({ primary, onFallback }: { primary: TileDef; onFallback: () => void }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
let errors = 0;
|
||||
const onError = () => {
|
||||
errors += 1;
|
||||
if (errors >= 3 && primary.id !== "osm") onFallback();
|
||||
};
|
||||
map.on("tileerror", onError);
|
||||
return () => {
|
||||
map.off("tileerror", onError);
|
||||
};
|
||||
}, [map, primary.id, onFallback]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function FallbackTileLayer({ primary, onActiveChange }: Props) {
|
||||
const [active, setActive] = useState<TileDef>(primary);
|
||||
|
||||
useEffect(() => {
|
||||
setActive(primary);
|
||||
}, [primary.id, primary.url]);
|
||||
|
||||
useEffect(() => {
|
||||
onActiveChange?.(active);
|
||||
}, [active, onActiveChange]);
|
||||
|
||||
const fallback = () => {
|
||||
if (active.id !== "osm") setActive(OSM_FALLBACK);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TileLayer
|
||||
key={active.id}
|
||||
attribution={active.attribution}
|
||||
url={active.url}
|
||||
subdomains={active.subdomains}
|
||||
/>
|
||||
{active.id === primary.id && primary.id !== "osm" && (
|
||||
<TileErrorWatcher primary={primary} onFallback={fallback} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
139
platform/web/src/modules/fleet/components/FleetMapPanel.tsx
Normal file
139
platform/web/src/modules/fleet/components/FleetMapPanel.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { CircleMarker, MapContainer, Polyline, Popup, useMap } from "react-leaflet";
|
||||
import L from "leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import { FallbackTileLayer, type TileDef } from "./FallbackTileLayer";
|
||||
import "./fleet-map.css";
|
||||
|
||||
export type LiveVehicle = {
|
||||
vehicle_id: number;
|
||||
plate_no?: string;
|
||||
lat?: number | null;
|
||||
lng?: number | null;
|
||||
last_lat?: number | null;
|
||||
last_lng?: number | null;
|
||||
status?: string;
|
||||
online?: boolean;
|
||||
};
|
||||
|
||||
export type MapConfig = {
|
||||
provider?: string;
|
||||
tileProvider?: string;
|
||||
amapKey?: string;
|
||||
};
|
||||
|
||||
function vehicleLat(v: LiveVehicle): number | null {
|
||||
const lat = v.lat ?? v.last_lat;
|
||||
return lat != null ? Number(lat) : null;
|
||||
}
|
||||
|
||||
function vehicleLng(v: LiveVehicle): number | null {
|
||||
const lng = v.lng ?? v.last_lng;
|
||||
return lng != null ? Number(lng) : null;
|
||||
}
|
||||
|
||||
function resolveTiles(cfg: MapConfig): TileDef {
|
||||
const p = (cfg.tileProvider || cfg.provider || "gaode").toLowerCase();
|
||||
if (p === "gaode") {
|
||||
const base =
|
||||
"https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=8&x={x}&y={y}&z={z}";
|
||||
const url = cfg.amapKey ? `${base}&key=${cfg.amapKey}` : base;
|
||||
return { id: "gaode", url, subdomains: ["1", "2", "3", "4"], attribution: "© 高德" };
|
||||
}
|
||||
return {
|
||||
id: "osm",
|
||||
url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
subdomains: ["a", "b", "c"],
|
||||
attribution: "© OpenStreetMap",
|
||||
};
|
||||
}
|
||||
|
||||
function MapResizeFix() {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
const fix = () => map.invalidateSize();
|
||||
fix();
|
||||
const t1 = window.setTimeout(fix, 100);
|
||||
const t2 = window.setTimeout(fix, 400);
|
||||
const ro = new ResizeObserver(fix);
|
||||
const el = map.getContainer().parentElement;
|
||||
if (el) ro.observe(el);
|
||||
return () => {
|
||||
window.clearTimeout(t1);
|
||||
window.clearTimeout(t2);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [map]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function FitBounds({ points }: { points: [number, number][] }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (points.length < 1) return;
|
||||
if (points.length === 1) {
|
||||
map.setView(points[0], 14);
|
||||
return;
|
||||
}
|
||||
map.fitBounds(L.latLngBounds(points), { padding: [40, 40], maxZoom: 15 });
|
||||
}, [map, points]);
|
||||
return null;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
mapConfig: MapConfig | null;
|
||||
vehicles: LiveVehicle[];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function FleetMapPanel({ mapConfig, vehicles, className }: Props) {
|
||||
const tiles = useMemo(() => resolveTiles(mapConfig || {}), [mapConfig]);
|
||||
|
||||
const points = useMemo(() => {
|
||||
const pts: [number, number][] = [];
|
||||
for (const v of vehicles) {
|
||||
const lat = vehicleLat(v);
|
||||
const lng = vehicleLng(v);
|
||||
if (lat != null && lng != null) pts.push([lat, lng]);
|
||||
}
|
||||
return pts;
|
||||
}, [vehicles]);
|
||||
|
||||
const center: [number, number] = points[0] || [28.2, 112.98];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fleet-map-shell rounded-lg border border-gray-200 overflow-hidden min-h-[420px] ${className || ""}`}
|
||||
style={{ height: 420 }}
|
||||
>
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={12}
|
||||
className="h-full w-full"
|
||||
style={{ height: "100%", minHeight: 420, width: "100%" }}
|
||||
scrollWheelZoom
|
||||
>
|
||||
<FallbackTileLayer primary={tiles} />
|
||||
<MapResizeFix />
|
||||
<FitBounds points={points} />
|
||||
{vehicles.map((v) => {
|
||||
const lat = vehicleLat(v);
|
||||
const lng = vehicleLng(v);
|
||||
if (lat == null || lng == null) return null;
|
||||
return (
|
||||
<CircleMarker
|
||||
key={v.vehicle_id}
|
||||
center={[lat, lng]}
|
||||
radius={8}
|
||||
pathOptions={{ color: "#2563eb", fillColor: "#3b82f6", fillOpacity: 0.9 }}
|
||||
>
|
||||
<Popup>
|
||||
{v.plate_no || v.vehicle_id} · {v.online ? "在线" : v.status || "—"}
|
||||
</Popup>
|
||||
</CircleMarker>
|
||||
);
|
||||
})}
|
||||
</MapContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
platform/web/src/modules/fleet/components/fleet-map.css
Normal file
14
platform/web/src/modules/fleet/components/fleet-map.css
Normal file
@@ -0,0 +1,14 @@
|
||||
.fleet-map-shell {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fleet-map-shell .leaflet-container {
|
||||
height: 100% !important;
|
||||
width: 100% !important;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.fleet-map-shell .leaflet-pane,
|
||||
.fleet-map-shell .leaflet-tile-pane {
|
||||
z-index: 1;
|
||||
}
|
||||
@@ -1,49 +1,72 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { FleetMapPanel, type LiveVehicle } from "../components/FleetMapPanel";
|
||||
|
||||
type LiveResponse = {
|
||||
vehicles?: LiveVehicle[];
|
||||
stats?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export const LiveMapPage: React.FC = () => {
|
||||
const [live, setLive] = useState<Record<string, unknown> | null>(null);
|
||||
const [live, setLive] = useState<LiveResponse | null>(null);
|
||||
const [mapConfig, setMapConfig] = useState<Record<string, unknown> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
const load = useCallback(async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const [l, m] = await Promise.all([hsapApi.fleetLive(), hsapApi.fleetMapConfig()]);
|
||||
setLive(l);
|
||||
setLive(l as LiveResponse);
|
||||
setMapConfig(m);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
useEffect(() => {
|
||||
load();
|
||||
const pollSec = Number(mapConfig?.pollIntervalSec ?? 5);
|
||||
const ms = Math.max(3, pollSec) * 1000;
|
||||
const timer = window.setInterval(load, ms);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [load, mapConfig?.pollIntervalSec]);
|
||||
|
||||
const vehicles = (live?.vehicles || []) as Record<string, unknown>[];
|
||||
const vehicles = useMemo(
|
||||
() =>
|
||||
((live?.vehicles || []) as Record<string, unknown>[]).map((v) => ({
|
||||
vehicle_id: Number(v.vehicle_id ?? v.id ?? 0),
|
||||
plate_no: String(v.plate_no ?? ""),
|
||||
lat: (v.lat ?? v.last_lat) as number | null | undefined,
|
||||
lng: (v.lng ?? v.last_lng) as number | null | undefined,
|
||||
last_lat: v.last_lat as number | null | undefined,
|
||||
last_lng: v.last_lng as number | null | undefined,
|
||||
speed_kmh: (v.speed_kmh ?? v.last_speed_kmh) as number | null | undefined,
|
||||
online: Boolean(v.online),
|
||||
status: String(v.status ?? ""),
|
||||
})),
|
||||
[live?.vehicles],
|
||||
);
|
||||
|
||||
const tileLabel = String(mapConfig?.tileProvider ?? mapConfig?.provider ?? "gaode");
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1>实时地图</h1>
|
||||
<p>车辆 GPS 实时位置追踪</p>
|
||||
<p>车辆 GPS 实时位置追踪 · 底图 {tileLabel}</p>
|
||||
</div>
|
||||
|
||||
<PageQueryState loading={loading} error={error}>
|
||||
{/* Map placeholder — Leaflet/AMap will be integrated */}
|
||||
<div className="card mb-4" style={{ height: "400px", background: "#e5e7eb" }}>
|
||||
<div className="flex items-center justify-center h-full text-gray-400">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-2">🗺️</div>
|
||||
<p>地图组件 (Leaflet/高德)</p>
|
||||
<p className="text-xs mt-1">底图: {mapConfig?.tile_provider as string || "gaode"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FleetMapPanel
|
||||
mapConfig={mapConfig as { provider?: string; tileProvider?: string; amapKey?: string } | null}
|
||||
vehicles={vehicles}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">在线车辆 ({vehicles.length})</div>
|
||||
@@ -60,20 +83,30 @@ export const LiveMapPage: React.FC = () => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{vehicles.map((v, i) => (
|
||||
<tr key={i}>
|
||||
<td className="font-medium">{v.plate_no as string || "—"}</td>
|
||||
<td className="font-mono text-xs">
|
||||
{String(v.lat ?? "—")}, {String(v.lng ?? "—")}
|
||||
</td>
|
||||
<td>{v.speed_kmh != null ? `${v.speed_kmh} km/h` : "—"}</td>
|
||||
<td><Badge variant="success">在线</Badge></td>
|
||||
</tr>
|
||||
))}
|
||||
{vehicles.map((v) => {
|
||||
const lat = v.lat ?? v.last_lat;
|
||||
const lng = v.lng ?? v.last_lng;
|
||||
return (
|
||||
<tr key={v.vehicle_id}>
|
||||
<td className="font-medium">{v.plate_no || "—"}</td>
|
||||
<td className="font-mono text-xs">
|
||||
{lat != null && lng != null ? `${lat.toFixed(5)}, ${lng.toFixed(5)}` : "—, —"}
|
||||
</td>
|
||||
<td>{v.speed_kmh != null ? `${v.speed_kmh} km/h` : "—"}</td>
|
||||
<td>
|
||||
<Badge variant={v.online ? "success" : "default"}>
|
||||
{v.online ? "在线" : "离线"}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<button onClick={load} className="mt-3 text-sm text-blue-600 hover:underline">刷新</button>
|
||||
<button type="button" onClick={load} className="mt-3 text-sm text-blue-600 hover:underline">
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</PageQueryState>
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,8 @@ export const LabelingShell: React.FC = () => {
|
||||
const { hasPermission } = useAuth();
|
||||
const isCoordinator = hasPermission("write:labeling_assign");
|
||||
const isAnnotate = /\/labeling\/annotate\//.test(location.pathname);
|
||||
const isReviewDetail = /\/labeling\/review\/[^/]+/.test(location.pathname);
|
||||
const isImmersive = isAnnotate || isReviewDetail;
|
||||
|
||||
const visibleTabs = TABS.filter((tab) => {
|
||||
if (tab.coordinatorOnly && !isCoordinator) return false;
|
||||
@@ -40,8 +42,8 @@ export const LabelingShell: React.FC = () => {
|
||||
|
||||
return (
|
||||
<ModuleGuard requiredPerms={["read:pending", "read:deliveries", "read:catalog", "write:delivery_submit"]}>
|
||||
<div className={isAnnotate ? "h-[calc(100vh-0px)] flex flex-col min-h-0" : undefined}>
|
||||
{!isAnnotate && (
|
||||
<div className={isImmersive ? "labeling-immersive h-full min-h-0 flex flex-col overflow-hidden" : undefined}>
|
||||
{!isImmersive && (
|
||||
<nav className="module-tabs">
|
||||
{visibleTabs.map((tab) => (
|
||||
<NavLink
|
||||
@@ -55,7 +57,8 @@ export const LabelingShell: React.FC = () => {
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
<Switch>
|
||||
<div className={isImmersive ? "flex-1 min-h-0 flex flex-col overflow-hidden" : undefined}>
|
||||
<Switch>
|
||||
<Route exact path={`${path}`} render={() => <Redirect to={defaultPath} />} />
|
||||
<Route path={`${path}/annotate/:campaignId`} component={AnnotationPage} />
|
||||
<Route path={`${path}/my-tasks`} component={MyTasksPage} />
|
||||
@@ -67,7 +70,8 @@ export const LabelingShell: React.FC = () => {
|
||||
<Route path={`${path}/deliveries`} component={DeliveriesPage} />
|
||||
<Route path={`${path}/catalog`} component={CatalogPage} />
|
||||
<Route path={`${path}/simulate`} component={SimulationStudioPage} />
|
||||
</Switch>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
</ModuleGuard>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import React from "react";
|
||||
import { StageBadge } from "@/components/ui/Badge";
|
||||
import { CompactTableShell } from "@/components/CompactTableShell";
|
||||
import { TruncatedText } from "@/components/TruncatedText";
|
||||
import { TableIconAction } from "./TableIconAction";
|
||||
import { displayProject, displayTask } from "@/lib/labelingDisplay";
|
||||
import type { LabelingBatchRow } from "@/lib/types";
|
||||
|
||||
type CampaignBatchTableProps = {
|
||||
batches: LabelingBatchRow[];
|
||||
expandCampaign: string | null;
|
||||
onToggleExpand: (campaignId: string) => void;
|
||||
onExport: (campaignId: string) => void;
|
||||
onSubmit: (campaignId: string) => void;
|
||||
renderExpand?: (b: LabelingBatchRow) => React.ReactNode;
|
||||
};
|
||||
|
||||
const COLS = ["31%", "7%", "6%", "8%", "9%", "10%", "12.5rem"];
|
||||
|
||||
export const CampaignBatchTable: React.FC<CampaignBatchTableProps> = ({
|
||||
batches,
|
||||
expandCampaign,
|
||||
onToggleExpand,
|
||||
onExport,
|
||||
onSubmit,
|
||||
renderExpand,
|
||||
}) => (
|
||||
<CompactTableShell colWidths={COLS}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="py-2">批次</th>
|
||||
<th className="py-2">任务</th>
|
||||
<th className="py-2">项目</th>
|
||||
<th className="py-2">状态</th>
|
||||
<th className="py-2">进度</th>
|
||||
<th className="py-2">Campaign</th>
|
||||
<th className="py-2 w-[12.5rem]">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{batches.map((b) => {
|
||||
const cid = b.campaign_id || "";
|
||||
const isExpanded = expandCampaign === cid;
|
||||
const canExport = ["labeling_submitted", "returned"].includes(b.stage || "");
|
||||
const pct =
|
||||
b.total_tasks && b.total_tasks > 0
|
||||
? Math.round(((b.completed_tasks || 0) / b.total_tasks) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<React.Fragment key={cid || b.batch}>
|
||||
<tr className={`align-middle ${isExpanded ? "bg-blue-50/40" : ""}`}>
|
||||
<td className="py-2 max-w-0 overflow-hidden">
|
||||
<TruncatedText text={b.batch || "—"} className="font-medium text-gray-900" maxWidthClass="max-w-full" />
|
||||
</td>
|
||||
<td className="py-2 max-w-0 overflow-hidden">
|
||||
<TruncatedText text={displayTask(b)} maxWidthClass="max-w-full" />
|
||||
</td>
|
||||
<td className="py-2 whitespace-nowrap text-sm text-gray-700">{displayProject(b)}</td>
|
||||
<td className="py-2 whitespace-nowrap">
|
||||
<StageBadge stage={b.stage} />
|
||||
</td>
|
||||
<td className="py-2 whitespace-nowrap">
|
||||
{b.total_tasks != null && b.total_tasks > 0 ? (
|
||||
<div className="flex items-center justify-center gap-1 min-w-0">
|
||||
<div className="w-10 h-1.5 bg-gray-100 rounded-full overflow-hidden shrink-0">
|
||||
<div
|
||||
className={`h-full rounded-full ${pct >= 100 ? "bg-green-500" : "bg-blue-500"}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[11px] text-gray-500 tabular-nums">
|
||||
{b.completed_tasks}/{b.total_tasks}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 max-w-0 overflow-hidden">
|
||||
<TruncatedText text={cid || "—"} className="font-mono text-xs text-gray-500" maxWidthClass="max-w-full" />
|
||||
</td>
|
||||
<td className="py-2 px-0.5 align-middle w-[12.5rem]">
|
||||
{cid && (
|
||||
<div className="inline-flex flex-nowrap items-center justify-center gap-0.5">
|
||||
<TableIconAction
|
||||
dense
|
||||
to={`/labeling/annotate/${encodeURIComponent(cid)}`}
|
||||
tone="blue"
|
||||
>
|
||||
✏️ 标注
|
||||
</TableIconAction>
|
||||
<TableIconAction
|
||||
dense
|
||||
tone="gray"
|
||||
disabled={!canExport}
|
||||
title={canExport ? undefined : "质检通过后可导出"}
|
||||
onClick={() => onExport(cid)}
|
||||
>
|
||||
📤 导出
|
||||
</TableIconAction>
|
||||
<TableIconAction dense tone="green" onClick={() => onSubmit(cid)}>
|
||||
✅ 质检
|
||||
</TableIconAction>
|
||||
<TableIconAction dense tone="purple" onClick={() => onToggleExpand(cid)}>
|
||||
👥 {isExpanded ? "收起" : "分配"}
|
||||
</TableIconAction>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{isExpanded && renderExpand && (
|
||||
<tr>
|
||||
<td colSpan={7} className="p-0 border-b border-gray-100 bg-gray-50/50">
|
||||
{renderExpand(b)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</CompactTableShell>
|
||||
);
|
||||
@@ -0,0 +1,271 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import {
|
||||
defaultInboxPath,
|
||||
deliveryLineToApi,
|
||||
type DeliveryLineKind,
|
||||
} from "@/lib/labelingDisplay";
|
||||
|
||||
export type DeliveryFormValues = {
|
||||
line: DeliveryLineKind;
|
||||
project: string;
|
||||
task: string;
|
||||
mode: string;
|
||||
batch_name: string;
|
||||
data_path: string;
|
||||
source_type: string;
|
||||
collection_start: string;
|
||||
collection_end: string;
|
||||
estimated_count: string;
|
||||
vehicle_scene: string;
|
||||
remark: string;
|
||||
};
|
||||
|
||||
const EMPTY: DeliveryFormValues = {
|
||||
line: "dms",
|
||||
project: "dms",
|
||||
task: "",
|
||||
mode: "",
|
||||
batch_name: "",
|
||||
data_path: "",
|
||||
source_type: "platform_delivery",
|
||||
collection_start: "",
|
||||
collection_end: "",
|
||||
estimated_count: "",
|
||||
vehicle_scene: "",
|
||||
remark: "",
|
||||
};
|
||||
|
||||
const LINES: { id: DeliveryLineKind; label: string; desc: string }[] = [
|
||||
{ id: "dms", label: "DMS 舱内", desc: "addw / ddaw / dam / addw_face" },
|
||||
{ id: "adas_2d", label: "ADAS 2D 七类", desc: "project=adas · det_7cls · adas/inbox/det_7cls/" },
|
||||
{ id: "adas_3d", label: "ADAS 3D MOON", desc: "project=adas · cuboid_7cls · adas/inbox/" },
|
||||
{ id: "forward", label: "前向交通标志", desc: "project=dms · task=forward · 须填子模式" },
|
||||
{ id: "lane", label: "车道线", desc: "project=lane · lane/inbox/{批次}" },
|
||||
];
|
||||
|
||||
type DeliveryCreateModalProps = {
|
||||
open: boolean;
|
||||
saving: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (values: DeliveryFormValues) => void;
|
||||
};
|
||||
|
||||
const fieldCls =
|
||||
"w-full rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-200";
|
||||
const labelCls = "block text-xs font-medium text-gray-600 mb-1";
|
||||
|
||||
export const DeliveryCreateModal: React.FC<DeliveryCreateModalProps> = ({
|
||||
open,
|
||||
saving,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const [form, setForm] = useState<DeliveryFormValues>(EMPTY);
|
||||
const [intake, setIntake] = useState<"nas" | "inbox">("nas");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const api = deliveryLineToApi(form.line);
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
project: api.project,
|
||||
task: f.line === "dms" || f.line === "forward" ? f.task : api.task,
|
||||
mode: f.line === "forward" ? f.mode : api.mode,
|
||||
}));
|
||||
}, [form.line, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || intake !== "inbox" || !form.batch_name.trim()) return;
|
||||
if (form.data_path.trim()) return;
|
||||
const p = defaultInboxPath(
|
||||
form.line,
|
||||
form.batch_name.trim(),
|
||||
form.task || undefined,
|
||||
form.mode || undefined,
|
||||
);
|
||||
setForm((f) => ({ ...f, data_path: p }));
|
||||
}, [form.batch_name, form.line, form.mode, form.task, intake, open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const set = (k: keyof DeliveryFormValues, v: string) => setForm((f) => ({ ...f, [k]: v }));
|
||||
|
||||
const onLineChange = (line: DeliveryLineKind) => {
|
||||
const api = deliveryLineToApi(line);
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
line,
|
||||
project: api.project,
|
||||
task: line === "dms" ? "" : api.task,
|
||||
mode: line === "forward" ? f.mode : api.mode,
|
||||
data_path: "",
|
||||
}));
|
||||
};
|
||||
|
||||
const pathHint =
|
||||
intake === "nas"
|
||||
? "/data/nas/采集批次/..."
|
||||
: defaultInboxPath(form.line, "{batch}", form.task || undefined, form.mode || undefined);
|
||||
|
||||
const taskLocked = form.line === "adas_2d" || form.line === "adas_3d" || form.line === "lane";
|
||||
const modeRequired = form.line === "forward" || form.line === "dms";
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const api = deliveryLineToApi(form.line);
|
||||
onSubmit({
|
||||
...form,
|
||||
project: api.project,
|
||||
task: form.line === "dms" ? form.task.trim() : api.task,
|
||||
mode: form.line === "forward" || (form.line === "dms" && form.task === "dam") ? form.mode.trim() : api.mode,
|
||||
source_type: intake === "nas" ? "platform_delivery" : "inbox_scan",
|
||||
});
|
||||
};
|
||||
|
||||
const resetClose = () => {
|
||||
setForm(EMPTY);
|
||||
setIntake("nas");
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={resetClose}>
|
||||
<div
|
||||
className="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="px-6 py-4 border-b border-gray-100">
|
||||
<h3 className="text-lg font-semibold text-gray-900">新建送标申请</h3>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
ADAS 2D 与 3D 同在 <code className="text-xs">adas/inbox/</code>:2D 在{" "}
|
||||
<code className="text-xs">det_7cls</code>,3D 在{" "}
|
||||
<code className="text-xs">adas/inbox/cuboid_7cls</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="px-6 py-4 space-y-4">
|
||||
<div>
|
||||
<label className={labelCls}>业务线 *</label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
{LINES.map((l) => (
|
||||
<button
|
||||
key={l.id}
|
||||
type="button"
|
||||
onClick={() => onLineChange(l.id)}
|
||||
className={`rounded-lg border p-2.5 text-left text-xs transition-colors ${
|
||||
form.line === l.id ? "border-blue-400 bg-blue-50" : "border-gray-200 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium text-sm">{l.label}</div>
|
||||
<div className="text-gray-500 mt-0.5 leading-snug">{l.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{([
|
||||
{ id: "nas" as const, label: "NAS / 外挂盘", desc: "审批后拷贝入湖" },
|
||||
{ id: "inbox" as const, label: "已在 inbox", desc: "建议用「扫描数据湖」" },
|
||||
]).map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setIntake(t.id)}
|
||||
className={`flex-1 rounded-lg border p-3 text-left transition-colors ${
|
||||
intake === t.id ? "border-blue-400 bg-blue-50" : "border-gray-200 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-medium">{t.label}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{t.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className={labelCls}>API 项目</label>
|
||||
<input className={`${fieldCls} bg-gray-50`} readOnly value={form.project} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>任务 *</label>
|
||||
<input
|
||||
className={taskLocked ? `${fieldCls} bg-gray-50` : fieldCls}
|
||||
readOnly={taskLocked}
|
||||
placeholder={form.line === "dms" ? "addw / dam …" : ""}
|
||||
value={form.task}
|
||||
onChange={(e) => set("task", e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>子模式{form.line === "forward" ? " *" : ""}</label>
|
||||
<input
|
||||
className={fieldCls}
|
||||
placeholder={form.line === "forward" ? "detect / classify" : "dam: batch_0516"}
|
||||
value={form.mode}
|
||||
onChange={(e) => set("mode", e.target.value)}
|
||||
required={form.line === "forward"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>批次名称 *</label>
|
||||
<input
|
||||
className={fieldCls}
|
||||
placeholder="20260601_pilot(勿与任务名相同)"
|
||||
value={form.batch_name}
|
||||
onChange={(e) => set("batch_name", e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>数据路径 *</label>
|
||||
<input
|
||||
className={`${fieldCls} font-mono text-xs`}
|
||||
placeholder={pathHint}
|
||||
value={form.data_path}
|
||||
onChange={(e) => set("data_path", e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelCls}>采集开始</label>
|
||||
<input type="date" className={fieldCls} value={form.collection_start} onChange={(e) => set("collection_start", e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>采集结束</label>
|
||||
<input type="date" className={fieldCls} value={form.collection_end} onChange={(e) => set("collection_end", e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelCls}>预估张数</label>
|
||||
<input type="number" min={0} className={fieldCls} value={form.estimated_count} onChange={(e) => set("estimated_count", e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelCls}>车辆 / 场景</label>
|
||||
<input className={fieldCls} value={form.vehicle_scene} onChange={(e) => set("vehicle_scene", e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>备注</label>
|
||||
<textarea className={`${fieldCls} resize-none`} rows={2} value={form.remark} onChange={(e) => set("remark", e.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end pt-2 border-t border-gray-100">
|
||||
<Button type="button" variant="default" size="small" onClick={resetClose}>取消</Button>
|
||||
<Button type="submit" variant="primary" size="small" loading={saving}>保存草稿</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
|
||||
type DeliveryIntakePanelProps = {
|
||||
total: number;
|
||||
inLake: number;
|
||||
pending: number;
|
||||
scanPending?: number;
|
||||
scanning: boolean;
|
||||
canCreate: boolean;
|
||||
onScan: () => void;
|
||||
onCreate: () => void;
|
||||
};
|
||||
|
||||
export const DeliveryIntakePanel: React.FC<DeliveryIntakePanelProps> = ({
|
||||
total,
|
||||
inLake,
|
||||
pending,
|
||||
scanPending,
|
||||
scanning,
|
||||
canCreate,
|
||||
onScan,
|
||||
onCreate,
|
||||
}) => (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-3 mb-4">
|
||||
<div className="card p-4 border-blue-100 bg-blue-50/40">
|
||||
<div className="text-xs text-blue-600 font-medium mb-1">台账总批次</div>
|
||||
<div className="text-2xl font-semibold text-blue-900 tabular-nums">{total}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">已入湖 {inLake} · 待处理 {pending}</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onScan}
|
||||
className="card p-4 text-left border-emerald-100 bg-emerald-50/40 hover:border-emerald-300 transition-colors"
|
||||
>
|
||||
<div className="text-xs text-emerald-700 font-medium mb-1">① 扫描数据湖</div>
|
||||
<div className="text-sm font-semibold text-emerald-900">inbox 周期落盘</div>
|
||||
<div className="text-xs text-gray-500 mt-2">
|
||||
{scanPending != null ? `待登记 ${scanPending} 批` : "发现 inbox 新批次"}
|
||||
</div>
|
||||
<Button size="small" variant="primary" className="mt-3" loading={scanning} onClick={(e) => { e.stopPropagation(); onScan(); }}>
|
||||
立即扫描
|
||||
</Button>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canCreate}
|
||||
onClick={onCreate}
|
||||
className="card p-4 text-left border-amber-100 bg-amber-50/40 hover:border-amber-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<div className="text-xs text-amber-700 font-medium mb-1">② NAS 外挂送标</div>
|
||||
<div className="text-sm font-semibold text-amber-900">挂载盘 → 审批入湖</div>
|
||||
<div className="text-xs text-gray-500 mt-2">填写路径与采集周期</div>
|
||||
{canCreate && (
|
||||
<Button size="small" variant="default" className="mt-3" onClick={(e) => { e.stopPropagation(); onCreate(); }}>
|
||||
新建申请
|
||||
</Button>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="card p-4 border-purple-100 bg-purple-50/40">
|
||||
<div className="text-xs text-purple-700 font-medium mb-1">③ 送标工作台</div>
|
||||
<div className="text-sm font-semibold text-purple-900">入湖后开标</div>
|
||||
<div className="text-xs text-gray-500 mt-2">台账登记 → 工作台待送标</div>
|
||||
<a href="/labeling/workbench" className="inline-block mt-3">
|
||||
<Button size="small" variant="default">去开标 →</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
114
platform/web/src/modules/labeling/components/DeliveryTable.tsx
Normal file
114
platform/web/src/modules/labeling/components/DeliveryTable.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { StatusBadge } from "@/components/ui/Badge";
|
||||
import { CompactTableShell } from "@/components/CompactTableShell";
|
||||
import { TruncatedText } from "@/components/TruncatedText";
|
||||
import { displayProjectFields, displayTaskFields } from "@/lib/labelingDisplay";
|
||||
import type { BatchDelivery } from "@/lib/types";
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
inbox_scan: "inbox扫描",
|
||||
platform_delivery: "NAS送标",
|
||||
upload: "页面上传",
|
||||
feishu_bitable: "飞书",
|
||||
};
|
||||
|
||||
function formatSource(st?: string | null, d?: BatchDelivery): string {
|
||||
if (!st) return "—";
|
||||
if (d?.project === "adas" && d?.task === "det_7cls") return "ADAS 2D";
|
||||
if (d?.project === "dms" && d?.task === "adas") return "ADAS 2D";
|
||||
if (d?.project === "adas" && d?.task === "cuboid_7cls") return "ADAS 3D";
|
||||
return SOURCE_LABELS[st] || st;
|
||||
}
|
||||
|
||||
function formatDate(iso?: string | null): string {
|
||||
if (!iso) return "—";
|
||||
return iso.slice(0, 10);
|
||||
}
|
||||
|
||||
type DeliveryTableProps = {
|
||||
deliveries: BatchDelivery[];
|
||||
canSubmit: boolean;
|
||||
onSubmit: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
};
|
||||
|
||||
const COLS = ["22%", "8%", "8%", "8%", "9%", "10%", "9%", "9%", "7.5rem"];
|
||||
|
||||
export const DeliveryTable: React.FC<DeliveryTableProps> = ({
|
||||
deliveries,
|
||||
canSubmit,
|
||||
onSubmit,
|
||||
onDelete,
|
||||
}) => (
|
||||
<CompactTableShell colWidths={COLS}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="py-2">批次</th>
|
||||
<th className="py-2">来源</th>
|
||||
<th className="py-2">项目</th>
|
||||
<th className="py-2">任务</th>
|
||||
<th className="py-2">状态</th>
|
||||
<th className="py-2">采集周期</th>
|
||||
<th className="py-2">数量</th>
|
||||
<th className="py-2">登记时间</th>
|
||||
<th className="py-2">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{deliveries.map((d) => {
|
||||
const period = d.collection_start
|
||||
? `${formatDate(d.collection_start)}${d.collection_end ? ` ~ ${formatDate(d.collection_end)}` : ""}`
|
||||
: "—";
|
||||
return (
|
||||
<tr key={d.id} className="align-middle">
|
||||
<td className="py-2 max-w-0">
|
||||
<TruncatedText text={d.batch_name || "—"} className="font-medium text-gray-900" maxWidthClass="max-w-full" />
|
||||
</td>
|
||||
<td className="py-2 whitespace-nowrap text-xs text-gray-600">{formatSource(d.source_type, d)}</td>
|
||||
<td className="py-2 whitespace-nowrap text-sm text-gray-700">
|
||||
{displayProjectFields({ project: d.project, task: d.task || undefined })}
|
||||
</td>
|
||||
<td className="py-2 max-w-0">
|
||||
<TruncatedText
|
||||
text={displayTaskFields({ project: d.project, task: d.task || undefined })}
|
||||
maxWidthClass="max-w-full"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 whitespace-nowrap">
|
||||
<StatusBadge status={d.status} />
|
||||
</td>
|
||||
<td className="py-2 whitespace-nowrap text-xs text-gray-500">{period}</td>
|
||||
<td className="py-2 text-sm text-gray-600 tabular-nums">{d.estimated_count ?? "—"}</td>
|
||||
<td className="py-2 whitespace-nowrap text-xs text-gray-500">{formatDate(d.created_at)}</td>
|
||||
<td className="py-2 px-2 whitespace-nowrap w-[7.5rem]">
|
||||
<div className="inline-flex items-center justify-center gap-0.5 flex-wrap">
|
||||
{d.status === "draft" && canSubmit && (
|
||||
<>
|
||||
<Button size="small" variant="primary" className="!px-2" onClick={() => onSubmit(d.id)}>
|
||||
提交
|
||||
</Button>
|
||||
<Button size="small" variant="danger" className="!px-2" onClick={() => onDelete(d.id)}>
|
||||
删除
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{d.status === "in_lake" && (
|
||||
<Link to="/labeling/workbench">
|
||||
<Button size="small" variant="success" className="!px-2">开标</Button>
|
||||
</Link>
|
||||
)}
|
||||
{d.approval_id && (
|
||||
<Link to={`/system/audit/${d.approval_id}`}>
|
||||
<Button size="small" variant="default" className="!px-2">审核</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</CompactTableShell>
|
||||
);
|
||||
@@ -0,0 +1,108 @@
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { CompactTableShell } from "@/components/CompactTableShell";
|
||||
import { TruncatedText } from "@/components/TruncatedText";
|
||||
import { displayProject, displayTask } from "@/lib/labelingDisplay";
|
||||
import type { LabelingBatchRow } from "@/lib/types";
|
||||
|
||||
type ExportBatchTableProps = {
|
||||
batches: LabelingBatchRow[];
|
||||
importingId: string | null;
|
||||
buildingId: string | null;
|
||||
onExport: (campaignId: string) => void;
|
||||
onImportVendor: (campaignId: string) => void;
|
||||
onSubmitBuild: (batch: LabelingBatchRow) => void;
|
||||
};
|
||||
|
||||
const COLS = ["34%", "14%", "8%", "10%", "auto", "7.5rem"];
|
||||
|
||||
export const ExportBatchTable: React.FC<ExportBatchTableProps> = ({
|
||||
batches,
|
||||
importingId,
|
||||
buildingId,
|
||||
onExport,
|
||||
onImportVendor,
|
||||
onSubmitBuild,
|
||||
}) => (
|
||||
<CompactTableShell colWidths={COLS}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="py-2">批次</th>
|
||||
<th className="py-2">任务</th>
|
||||
<th className="py-2">项目</th>
|
||||
<th className="py-2">状态</th>
|
||||
<th className="py-2">Campaign</th>
|
||||
<th className="py-2 w-[7.5rem]">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{batches.map((b) => {
|
||||
const cid = b.campaign_id || "";
|
||||
const isExport = b.stage === "labeling_submitted";
|
||||
return (
|
||||
<tr key={cid || b.batch} className="align-middle">
|
||||
<td className="py-2 max-w-0">
|
||||
<TruncatedText text={b.batch || "—"} className="font-medium text-gray-900" maxWidthClass="max-w-full" />
|
||||
</td>
|
||||
<td className="py-2 max-w-0">
|
||||
<TruncatedText text={displayTask(b)} maxWidthClass="max-w-full" />
|
||||
</td>
|
||||
<td className="py-2 whitespace-nowrap">
|
||||
<span className="text-sm text-gray-700">{displayProject(b)}</span>
|
||||
</td>
|
||||
<td className="py-2 whitespace-nowrap">
|
||||
<Badge variant="warning" size="small">
|
||||
{isExport ? "待导出" : "待入库"}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-2 max-w-0">
|
||||
<TruncatedText
|
||||
text={cid || "—"}
|
||||
className="font-mono text-xs text-gray-500"
|
||||
maxWidthClass="max-w-full"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-2 whitespace-nowrap w-[7.5rem]">
|
||||
{cid && isExport && (
|
||||
<div className="inline-flex items-center justify-center gap-0.5">
|
||||
<Button size="small" variant="primary" className="!px-2" onClick={() => onExport(cid)}>
|
||||
导出
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="default"
|
||||
className="!px-2"
|
||||
loading={importingId === cid}
|
||||
onClick={() => onImportVendor(cid)}
|
||||
>
|
||||
导入
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{b.stage === "returned" && (
|
||||
<div className="inline-flex items-center justify-center gap-0.5">
|
||||
<Button
|
||||
size="small"
|
||||
variant="primary"
|
||||
className="!px-2"
|
||||
loading={buildingId === (cid || b.batch)}
|
||||
onClick={() => onSubmitBuild(b)}
|
||||
>
|
||||
入库
|
||||
</Button>
|
||||
<Link to="/system/audit">
|
||||
<Button size="small" variant="default" className="!px-2">
|
||||
审核
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</CompactTableShell>
|
||||
);
|
||||
@@ -0,0 +1,170 @@
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
|
||||
type ExportPipelineFlowProps = {
|
||||
pendingExport?: number;
|
||||
pendingBuild?: number;
|
||||
};
|
||||
|
||||
const Arrow: React.FC<{ className?: string }> = ({ className = "" }) => (
|
||||
<div className={`flex items-center justify-center shrink-0 text-gray-300 ${className}`} aria-hidden>
|
||||
<svg className="w-3.5 h-3.5 hidden lg:block" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 7l5 5m0 0l-5 5m5-5H6" />
|
||||
</svg>
|
||||
<svg className="w-3.5 h-3.5 lg:hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
|
||||
type StepProps = {
|
||||
step: number;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
role: string;
|
||||
badge?: React.ReactNode;
|
||||
icon: React.ReactNode;
|
||||
highlight?: "current" | "done" | "upstream";
|
||||
link?: string;
|
||||
footnote?: string;
|
||||
};
|
||||
|
||||
const StepCard: React.FC<StepProps> = ({ step, title, subtitle, role, badge, icon, highlight = "upstream", link, footnote }) => {
|
||||
const shell =
|
||||
highlight === "current"
|
||||
? "border-amber-200 bg-gradient-to-br from-amber-50/90 to-orange-50/50 shadow-sm ring-1 ring-amber-100"
|
||||
: highlight === "done"
|
||||
? "border-emerald-200 bg-gradient-to-br from-emerald-50/80 to-teal-50/40 ring-1 ring-emerald-100"
|
||||
: "border-gray-200 bg-gray-50/60";
|
||||
|
||||
const content = (
|
||||
<div className={`relative flex flex-col rounded-xl border p-3 min-h-[6.75rem] flex-1 min-w-0 transition-shadow hover:shadow-md ${shell}`}>
|
||||
<div className="flex items-start justify-between gap-2 mb-1.5">
|
||||
<span
|
||||
className={`inline-flex h-5 w-5 items-center justify-center rounded-md text-[10px] font-bold ${
|
||||
highlight === "current"
|
||||
? "bg-amber-500 text-white"
|
||||
: highlight === "done"
|
||||
? "bg-emerald-500 text-white"
|
||||
: "bg-gray-200 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{step}
|
||||
</span>
|
||||
<span className="text-xs leading-none opacity-75 select-none">{icon}</span>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 m-0">{title}</h3>
|
||||
<p className="text-[11px] text-gray-500 mt-1 leading-relaxed flex-1">{subtitle}</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5 mt-2 pt-2 border-t border-black/5">
|
||||
<span className="text-[10px] uppercase tracking-wide text-gray-400 font-medium">{role}</span>
|
||||
{badge}
|
||||
</div>
|
||||
{footnote && <p className="text-[10px] text-gray-400 mt-1.5 m-0">{footnote}</p>}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (link) {
|
||||
return (
|
||||
<Link to={link} className="flex-1 min-w-[9rem] max-w-[14rem] no-underline text-inherit group">
|
||||
<div className="group-hover:scale-[1.02] transition-transform">{content}</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return <div className="flex-1 min-w-[9rem] max-w-[14rem]">{content}</div>;
|
||||
};
|
||||
|
||||
export const ExportPipelineFlow: React.FC<ExportPipelineFlowProps> = ({
|
||||
pendingExport = 0,
|
||||
pendingBuild = 0,
|
||||
}) => {
|
||||
return (
|
||||
<div className="mb-4 rounded-2xl border border-gray-200 bg-white overflow-hidden shadow-sm">
|
||||
<div className="px-4 py-3 border-b border-gray-100 bg-gradient-to-r from-slate-50 to-white flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-gray-800 m-0">送标 → 入库 全流程</h2>
|
||||
<p className="text-xs text-gray-500 m-0 mt-0.5">本页负责第 3、4 步;前两步在其他模块完成</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{pendingExport > 0 && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100 text-amber-800 px-2.5 py-1 font-medium">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse" />
|
||||
{pendingExport} 待导出
|
||||
</span>
|
||||
)}
|
||||
{pendingBuild > 0 && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-orange-100 text-orange-800 px-2.5 py-1 font-medium">
|
||||
{pendingBuild} 待 build
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 lg:p-5">
|
||||
<div className="flex flex-col lg:flex-row lg:items-stretch gap-1 lg:gap-0">
|
||||
<StepCard
|
||||
step={1}
|
||||
title="提交质检"
|
||||
subtitle="标注员在标注进度页完成标注后提交"
|
||||
role="标注员"
|
||||
icon="📝"
|
||||
highlight="upstream"
|
||||
link="/labeling/campaigns"
|
||||
badge={<Badge variant="default" size="small">标注进度</Badge>}
|
||||
/>
|
||||
<Arrow className="py-1 lg:py-0 lg:px-1" />
|
||||
<StepCard
|
||||
step={2}
|
||||
title="质检通过"
|
||||
subtitle="协调员审核合格/可用,退回则返工"
|
||||
role="协调员"
|
||||
icon="✓"
|
||||
highlight="upstream"
|
||||
link="/labeling/review"
|
||||
badge={<Badge variant="warning" size="small">待导出</Badge>}
|
||||
/>
|
||||
<Arrow className="py-1 lg:py-0 lg:px-1" />
|
||||
<StepCard
|
||||
step={3}
|
||||
title="执行导出"
|
||||
subtitle="CVAT → 训练格式(DMS·YOLO / ADAS·quaternion_json)"
|
||||
role="本页操作"
|
||||
icon="📤"
|
||||
highlight="current"
|
||||
badge={<Badge variant="warning" size="small">待 build</Badge>}
|
||||
footnote={pendingExport > 0 ? `下方 ${pendingExport} 个批次可导出` : undefined}
|
||||
/>
|
||||
<Arrow className="py-1 lg:py-0 lg:px-1" />
|
||||
<StepCard
|
||||
step={4}
|
||||
title="提交 build"
|
||||
subtitle="审核队列批准后 merge 进训练包"
|
||||
role="审核员"
|
||||
icon="🏗"
|
||||
highlight="done"
|
||||
badge={<Badge variant="success" size="small">已入库</Badge>}
|
||||
footnote="待 build ≠ 已入库"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-2 rounded-xl bg-slate-50 border border-slate-100 px-3 py-2.5 text-[11px] text-gray-500">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-gray-300" />
|
||||
上游步骤(可点击跳转)
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-amber-400" />
|
||||
本页:格式转换 / 供应商回标
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500" />
|
||||
终点:ingested 不再显示于本列表
|
||||
</span>
|
||||
<Link to="/system/audit" className="ml-auto text-blue-600 hover:underline font-medium">
|
||||
审核队列 →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from "react";
|
||||
|
||||
type FilterChip = { label: string; value: string };
|
||||
|
||||
type LabelingListToolbarProps = {
|
||||
search: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
filters?: FilterChip[];
|
||||
filterValue?: string;
|
||||
onFilterChange?: (value: string) => void;
|
||||
total: number;
|
||||
extra?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const LabelingListToolbar: React.FC<LabelingListToolbarProps> = ({
|
||||
search,
|
||||
onSearchChange,
|
||||
placeholder = "搜索批次/任务/项目...",
|
||||
filters,
|
||||
filterValue = "",
|
||||
onFilterChange,
|
||||
total,
|
||||
extra,
|
||||
}) => (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-3 mb-4">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex-1 min-w-[200px] relative">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
className="w-full pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none"
|
||||
placeholder={placeholder}
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{filters && onFilterChange && (
|
||||
<div className="flex gap-1.5">
|
||||
{filters.map((f) => (
|
||||
<button
|
||||
key={f.value}
|
||||
type="button"
|
||||
onClick={() => onFilterChange(f.value)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||
filterValue === f.value ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{extra}
|
||||
<span className="text-xs text-gray-500 font-medium bg-gray-50 px-2.5 py-1 rounded-full">{total} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,89 @@
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { CompactTableShell } from "@/components/CompactTableShell";
|
||||
import { TruncatedText } from "@/components/TruncatedText";
|
||||
import { displayProjectFields, displayTaskFields } from "@/lib/labelingDisplay";
|
||||
|
||||
export type MyAssignmentRow = {
|
||||
campaign_id: string;
|
||||
batch: string;
|
||||
task: string;
|
||||
project?: string;
|
||||
status?: string;
|
||||
assigned: number;
|
||||
completed: number;
|
||||
pending: number;
|
||||
campaign_total?: number;
|
||||
};
|
||||
|
||||
type MyTasksTableProps = {
|
||||
items: MyAssignmentRow[];
|
||||
highlightId?: string | null;
|
||||
};
|
||||
|
||||
const COLS = ["30%", "12%", "8%", "10%", "14%", "auto", "6rem"];
|
||||
|
||||
export const MyTasksTable: React.FC<MyTasksTableProps> = ({ items, highlightId }) => (
|
||||
<CompactTableShell colWidths={COLS}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="py-2">批次</th>
|
||||
<th className="py-2">任务</th>
|
||||
<th className="py-2">项目</th>
|
||||
<th className="py-2">待标</th>
|
||||
<th className="py-2">进度</th>
|
||||
<th className="py-2">Campaign</th>
|
||||
<th className="py-2">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((row) => {
|
||||
const pct = row.assigned > 0 ? Math.round((row.completed / row.assigned) * 100) : 0;
|
||||
const highlighted = highlightId === row.campaign_id;
|
||||
return (
|
||||
<tr
|
||||
key={row.campaign_id}
|
||||
className={`align-middle ${highlighted ? "bg-blue-50/60" : ""}`}
|
||||
>
|
||||
<td className="py-2 max-w-0">
|
||||
<TruncatedText text={row.batch || "—"} className="font-medium text-gray-900" maxWidthClass="max-w-full" />
|
||||
</td>
|
||||
<td className="py-2 max-w-0">
|
||||
<TruncatedText text={displayTaskFields(row)} maxWidthClass="max-w-full" />
|
||||
</td>
|
||||
<td className="py-2 whitespace-nowrap text-sm text-gray-700">{displayProjectFields(row)}</td>
|
||||
<td className="py-2 whitespace-nowrap">
|
||||
<span className="text-sm font-medium text-amber-600 tabular-nums">{row.pending}</span>
|
||||
</td>
|
||||
<td className="py-2 whitespace-nowrap">
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
<div className="w-14 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${pct >= 100 ? "bg-green-500" : "bg-blue-500"}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 tabular-nums">{row.completed}/{row.assigned}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 max-w-0">
|
||||
<TruncatedText
|
||||
text={row.campaign_id || "—"}
|
||||
className="font-mono text-xs text-gray-500"
|
||||
maxWidthClass="max-w-full"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-2 whitespace-nowrap w-[6rem]">
|
||||
<Link to={`/labeling/annotate/${encodeURIComponent(row.campaign_id)}`}>
|
||||
<Button size="small" variant="primary" className="!px-2">
|
||||
{row.pending > 0 ? "标注" : "查看"}
|
||||
</Button>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</CompactTableShell>
|
||||
);
|
||||
@@ -0,0 +1,110 @@
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { StageBadge } from "@/components/ui/Badge";
|
||||
import { CompactTableShell } from "@/components/CompactTableShell";
|
||||
import { TruncatedText } from "@/components/TruncatedText";
|
||||
import { displayProject, displayTask } from "@/lib/labelingDisplay";
|
||||
import type { LabelingBatchRow } from "@/lib/types";
|
||||
|
||||
type ReviewProgress = {
|
||||
total: number;
|
||||
reviewed: number;
|
||||
good: number;
|
||||
fine: number;
|
||||
bad: number;
|
||||
pass_rate: number;
|
||||
complete: boolean;
|
||||
stage?: string;
|
||||
};
|
||||
|
||||
type ReviewBatchTableProps = {
|
||||
batches: LabelingBatchRow[];
|
||||
progressMap: Record<string, ReviewProgress>;
|
||||
};
|
||||
|
||||
const COLS = ["30%", "12%", "8%", "10%", "14%", "auto", "7.5rem"];
|
||||
|
||||
export const ReviewBatchTable: React.FC<ReviewBatchTableProps> = ({ batches, progressMap }) => (
|
||||
<CompactTableShell colWidths={COLS}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="py-2">批次</th>
|
||||
<th className="py-2">任务</th>
|
||||
<th className="py-2">项目</th>
|
||||
<th className="py-2">状态</th>
|
||||
<th className="py-2">进度</th>
|
||||
<th className="py-2">Campaign</th>
|
||||
<th className="py-2">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{batches.map((b) => {
|
||||
const cid = b.campaign_id || "";
|
||||
const prog = cid ? progressMap[cid] : undefined;
|
||||
const reviewPct = prog && prog.total > 0 ? Math.round((prog.reviewed / prog.total) * 100) : 0;
|
||||
const reviewDone = prog?.complete;
|
||||
const effectiveStage = reviewDone && prog?.stage ? prog.stage : b.stage;
|
||||
const passed = effectiveStage === "labeling_submitted" || (reviewDone && (prog?.pass_rate ?? 0) >= 80);
|
||||
return (
|
||||
<tr key={cid || b.batch} className="align-middle">
|
||||
<td className="py-2 max-w-0">
|
||||
<TruncatedText text={b.batch || "—"} className="font-medium text-gray-900" maxWidthClass="max-w-full" />
|
||||
</td>
|
||||
<td className="py-2 max-w-0">
|
||||
<TruncatedText text={displayTask(b)} maxWidthClass="max-w-full" />
|
||||
</td>
|
||||
<td className="py-2 whitespace-nowrap text-sm text-gray-700">{displayProject(b)}</td>
|
||||
<td className="py-2 whitespace-nowrap">
|
||||
<StageBadge stage={effectiveStage || ""} />
|
||||
</td>
|
||||
<td className="py-2 whitespace-nowrap">
|
||||
{prog && prog.total > 0 ? (
|
||||
<div className="flex items-center justify-center gap-1.5 min-w-0">
|
||||
<div className="w-14 h-1.5 bg-gray-100 rounded-full overflow-hidden shrink-0">
|
||||
<div
|
||||
className={`h-full rounded-full ${reviewDone ? "bg-green-500" : "bg-blue-500"}`}
|
||||
style={{ width: `${reviewPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 tabular-nums">{prog.reviewed}/{prog.total}</span>
|
||||
{reviewDone && (
|
||||
<span className={`text-xs ${passed ? "text-green-600" : "text-red-600"}`}>
|
||||
{prog.pass_rate}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 max-w-0">
|
||||
<TruncatedText text={cid || "—"} className="font-mono text-xs text-gray-500" maxWidthClass="max-w-full" />
|
||||
</td>
|
||||
<td className="py-2 px-2 whitespace-nowrap w-[7.5rem]">
|
||||
<div className="flex justify-center">
|
||||
{effectiveStage === "in_review" && cid && (
|
||||
<Link to={`/labeling/review/${cid}`}>
|
||||
<Button size="small" variant="primary" className="!px-2">
|
||||
{reviewDone ? "查看" : prog && prog.reviewed > 0 ? "继续" : "质检"}
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{effectiveStage === "labeling_submitted" && (
|
||||
<Link to="/labeling/export">
|
||||
<Button size="small" variant="success" className="!px-2">导出</Button>
|
||||
</Link>
|
||||
)}
|
||||
{effectiveStage === "review_rejected" && cid && (
|
||||
<Link to={`/labeling/review/${cid}`}>
|
||||
<Button size="small" variant="default" className="!px-2">记录</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</CompactTableShell>
|
||||
);
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
const BASE =
|
||||
"inline-flex items-center gap-0.5 font-medium rounded-lg transition-colors whitespace-nowrap shrink-0";
|
||||
|
||||
type TableIconActionProps = {
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
to?: string;
|
||||
tone?: "blue" | "gray" | "green" | "purple" | "danger";
|
||||
/** 略收紧的内边距,仍保留图标+文字 */
|
||||
dense?: boolean;
|
||||
};
|
||||
|
||||
const TONE: Record<NonNullable<TableIconActionProps["tone"]>, string> = {
|
||||
blue: "bg-blue-50 text-blue-700 hover:bg-blue-100",
|
||||
gray: "bg-gray-50 text-gray-600 hover:bg-gray-100",
|
||||
green: "bg-green-50 text-green-700 hover:bg-green-100",
|
||||
purple: "bg-purple-50 text-purple-700 hover:bg-purple-100",
|
||||
danger: "bg-red-50 text-red-700 hover:bg-red-100",
|
||||
};
|
||||
|
||||
const DISABLED = "bg-gray-50 text-gray-300 cursor-not-allowed pointer-events-none";
|
||||
|
||||
export const TableIconAction: React.FC<TableIconActionProps> = ({
|
||||
children,
|
||||
title,
|
||||
disabled,
|
||||
onClick,
|
||||
to,
|
||||
tone = "gray",
|
||||
dense = false,
|
||||
}) => {
|
||||
const cls = `${BASE} ${dense ? "px-1.5 py-1 text-[11px] leading-tight" : "px-2 py-1.5 text-xs"} ${
|
||||
disabled ? DISABLED : TONE[tone]
|
||||
}`;
|
||||
if (to && !disabled) {
|
||||
return (
|
||||
<Link to={to} className={cls} title={title}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button type="button" className={cls} title={title} disabled={disabled} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
116
platform/web/src/modules/labeling/components/TableRowMenu.tsx
Normal file
116
platform/web/src/modules/labeling/components/TableRowMenu.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export type TableRowMenuItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
onClick?: () => void;
|
||||
to?: string;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
const TONE: Record<string, string> = {
|
||||
blue: "bg-blue-50 text-blue-700 hover:bg-blue-100",
|
||||
green: "bg-green-50 text-green-700 hover:bg-green-100",
|
||||
purple: "bg-purple-50 text-purple-700 hover:bg-purple-100",
|
||||
gray: "bg-gray-50 text-gray-600 hover:bg-gray-100",
|
||||
};
|
||||
|
||||
type TableRowMenuProps = {
|
||||
primary?: TableRowMenuItem & { tone?: keyof typeof TONE };
|
||||
items?: TableRowMenuItem[];
|
||||
};
|
||||
|
||||
const PrimaryBtn: React.FC<{ item: TableRowMenuItem & { tone?: keyof typeof TONE } }> = ({ item }) => {
|
||||
const cls = `inline-flex items-center gap-1 px-2 py-1.5 text-xs font-medium rounded-lg transition-colors whitespace-nowrap shrink-0 ${
|
||||
item.disabled ? "bg-gray-50 text-gray-300 cursor-not-allowed pointer-events-none" : TONE[item.tone || "blue"]
|
||||
}`;
|
||||
const content = (
|
||||
<>
|
||||
{item.icon && <span>{item.icon}</span>}
|
||||
<span>{item.label}</span>
|
||||
</>
|
||||
);
|
||||
if (item.to && !item.disabled) {
|
||||
return <Link to={item.to} className={cls} title={item.title}>{content}</Link>;
|
||||
}
|
||||
return (
|
||||
<button type="button" className={cls} title={item.title} disabled={item.disabled} onClick={item.onClick}>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export const TableRowMenu: React.FC<TableRowMenuProps> = ({ primary, items = [] }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
}, [open]);
|
||||
|
||||
const renderMenuItem = (item: TableRowMenuItem) => {
|
||||
const cls = `flex w-full items-center gap-2 px-3 py-2 text-left text-xs transition-colors ${
|
||||
item.disabled ? "text-gray-300 cursor-not-allowed" : "text-gray-700 hover:bg-gray-50"
|
||||
}`;
|
||||
const content = (
|
||||
<>
|
||||
{item.icon && <span className="shrink-0">{item.icon}</span>}
|
||||
<span>{item.label}</span>
|
||||
</>
|
||||
);
|
||||
if (item.to && !item.disabled) {
|
||||
return (
|
||||
<Link key={item.key} to={item.to} className={cls} title={item.title} onClick={() => setOpen(false)}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className={cls}
|
||||
title={item.title}
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
if (item.disabled) return;
|
||||
item.onClick?.();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="relative inline-flex items-center justify-end gap-0.5">
|
||||
{primary && <PrimaryBtn item={primary} />}
|
||||
{items.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center w-7 h-7 text-xs font-medium rounded-lg bg-gray-50 text-gray-500 hover:bg-gray-100 shrink-0"
|
||||
title="更多操作"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
⋮
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full z-30 mt-1 min-w-[9.5rem] rounded-lg border border-gray-200 bg-white py-1 shadow-lg">
|
||||
{items.map(renderMenuItem)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
import React from "react";
|
||||
import { StageBadge } from "@/components/ui/Badge";
|
||||
import { CompactTableShell } from "@/components/CompactTableShell";
|
||||
import { TruncatedText } from "@/components/TruncatedText";
|
||||
import { TableIconAction } from "./TableIconAction";
|
||||
import { displayProject, displayTask } from "@/lib/labelingDisplay";
|
||||
import type { LabelingBatchRow } from "@/lib/types";
|
||||
|
||||
type WorkbenchBatchTableProps = {
|
||||
batches: LabelingBatchRow[];
|
||||
onOpenCampaign: (row: LabelingBatchRow) => void;
|
||||
onArchive?: (row: LabelingBatchRow) => void;
|
||||
archivingId?: string | null;
|
||||
};
|
||||
|
||||
const COLS = ["32%", "9%", "7%", "9%", "7%", "7%", "11rem"];
|
||||
|
||||
export const WorkbenchBatchTable: React.FC<WorkbenchBatchTableProps> = ({
|
||||
batches,
|
||||
onOpenCampaign,
|
||||
onArchive,
|
||||
archivingId,
|
||||
}) => (
|
||||
<CompactTableShell colWidths={COLS}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="py-2">批次</th>
|
||||
<th className="py-2">任务</th>
|
||||
<th className="py-2">项目</th>
|
||||
<th className="py-2">状态</th>
|
||||
<th className="py-2">图片</th>
|
||||
<th className="py-2">标注</th>
|
||||
<th className="py-2 w-[11rem]">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{batches.map((b) => {
|
||||
const cid = b.campaign_id || "";
|
||||
return (
|
||||
<tr key={`${b.project}/${b.task}/${b.batch}`} className="align-middle">
|
||||
<td className="py-2 max-w-0 overflow-hidden">
|
||||
<TruncatedText text={b.batch || "—"} className="font-medium text-gray-900" maxWidthClass="max-w-full" />
|
||||
</td>
|
||||
<td className="py-2 max-w-0 overflow-hidden">
|
||||
<TruncatedText text={displayTask(b)} maxWidthClass="max-w-full" />
|
||||
</td>
|
||||
<td className="py-2 whitespace-nowrap text-sm text-gray-700">{displayProject(b)}</td>
|
||||
<td className="py-2 whitespace-nowrap">
|
||||
<StageBadge stage={b.stage} />
|
||||
</td>
|
||||
<td className="py-2 text-sm text-gray-600 tabular-nums">{b.counts?.images ?? 0}</td>
|
||||
<td className="py-2 text-sm text-gray-600 tabular-nums">{b.counts?.labels ?? 0}</td>
|
||||
<td className="py-2 px-0.5 whitespace-nowrap w-[11rem]">
|
||||
<div className="inline-flex flex-nowrap items-center justify-center gap-0.5">
|
||||
{b.stage === "raw_pool" && (
|
||||
<>
|
||||
<TableIconAction dense tone="blue" onClick={() => onOpenCampaign(b)}>
|
||||
📂 开标
|
||||
</TableIconAction>
|
||||
{onArchive && (
|
||||
<TableIconAction
|
||||
dense
|
||||
tone="danger"
|
||||
disabled={archivingId === cid}
|
||||
onClick={() => onArchive(b)}
|
||||
>
|
||||
🗑 移除
|
||||
</TableIconAction>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{b.stage === "out_for_labeling" && cid && (
|
||||
<>
|
||||
<TableIconAction
|
||||
dense
|
||||
to={`/labeling/annotate/${encodeURIComponent(cid)}`}
|
||||
tone="blue"
|
||||
>
|
||||
✏️ 标注
|
||||
</TableIconAction>
|
||||
<TableIconAction dense to="/labeling/campaigns" tone="gray">
|
||||
📊 进度
|
||||
</TableIconAction>
|
||||
</>
|
||||
)}
|
||||
{b.stage === "returned" && (
|
||||
<TableIconAction dense to="/labeling/export" tone="gray">
|
||||
🏗 入库
|
||||
</TableIconAction>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</CompactTableShell>
|
||||
);
|
||||
@@ -110,7 +110,12 @@ export const AnnotationPage: React.FC = () => {
|
||||
}, [status?.cvat_job_url, activeFrame]);
|
||||
|
||||
const runSync = useCallback(async (silent = false): Promise<SyncResult | null> => {
|
||||
if (syncInFlight.current) return null;
|
||||
if (syncInFlight.current) {
|
||||
if (!silent) {
|
||||
setSyncHint("同步进行中,请稍候…");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
syncInFlight.current = true;
|
||||
if (!silent) setSyncing(true);
|
||||
try {
|
||||
@@ -129,9 +134,15 @@ export const AnnotationPage: React.FC = () => {
|
||||
const shapes = Number(data.shapes ?? 0);
|
||||
const hint = shapes > 0
|
||||
? `已同步 ${saved} 张 · ${shapes} 个标注 · ${new Date().toLocaleTimeString()}`
|
||||
: `已检查,暂无新标注 · ${new Date().toLocaleTimeString()}`;
|
||||
: `已检查,暂无新标注(请先在 CVAT 画布 Ctrl+S 保存)· ${new Date().toLocaleTimeString()}`;
|
||||
setSyncHint(hint);
|
||||
if (!silent && shapes > 0) alert(`标注已同步(${saved} 张,${shapes} 个对象)`);
|
||||
if (!silent) {
|
||||
if (shapes > 0) {
|
||||
alert(`标注已同步(${saved} 张,${shapes} 个对象)`);
|
||||
} else {
|
||||
alert(`同步完成:CVAT 侧暂无新标注。\n请确认已在画布中画框并保存(Ctrl+S),再点「立即同步」。`);
|
||||
}
|
||||
}
|
||||
void loadMyTasks();
|
||||
return data as SyncResult;
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { StageBadge } from "@/components/ui/Badge";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
import { ListPaginationBar } from "@/components/ListPaginationBar";
|
||||
import { LabelingListToolbar } from "../components/LabelingListToolbar";
|
||||
import { CampaignBatchTable } from "../components/CampaignBatchTable";
|
||||
import { AssignUserSelect } from "@/components/AssignUserSelect";
|
||||
import { AssignCountControl } from "@/components/AssignCountControl";
|
||||
import type { LabelingBatchRow } from "@/lib/types";
|
||||
@@ -28,8 +29,9 @@ export const CampaignsPage: React.FC = () => {
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
const [feishuSyncHint, setFeishuSyncHint] = useState<{ message: string; url?: string } | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
// assignees & assignment state
|
||||
const [total, setTotal] = useState(0);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [limit, setLimit] = useState(20);
|
||||
const [assignees, setAssignees] = useState<Assignee[]>([]);
|
||||
const [assigneesLoading, setAssigneesLoading] = useState(false);
|
||||
const [expandCampaign, setExpandCampaign] = useState<string | null>(null);
|
||||
@@ -37,27 +39,21 @@ export const CampaignsPage: React.FC = () => {
|
||||
const [assigning, setAssigning] = useState(false);
|
||||
const [progressMap, setProgressMap] = useState<Record<string, Record<string, unknown>>>({});
|
||||
|
||||
const filtered = batches.filter((b) => {
|
||||
if (!search) return true;
|
||||
const q = search.toLowerCase();
|
||||
return (
|
||||
(b.batch || "").toLowerCase().includes(q) ||
|
||||
(b.task || "").toLowerCase().includes(q) ||
|
||||
(b.campaign_id || "").toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const load = useCallback(async (newOffset: number, newLimit: number) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await hsapApi.labelingBatches({ stage: "out_for_labeling", limit: 100 });
|
||||
const q = search.trim() || undefined;
|
||||
const res = await hsapApi.labelingBatches({ stage: "out_for_labeling", offset: newOffset, limit: newLimit, q });
|
||||
setBatches((res.items || []) as LabelingBatchRow[]);
|
||||
setTotal(res.total ?? 0);
|
||||
setOffset(newOffset);
|
||||
setLimit(newLimit);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
}, [search]);
|
||||
|
||||
const loadAssignees = useCallback(async () => {
|
||||
setAssigneesLoading(true);
|
||||
@@ -97,20 +93,17 @@ export const CampaignsPage: React.FC = () => {
|
||||
setAssigneesLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
loadAssignees();
|
||||
}, [load, loadAssignees]);
|
||||
useEffect(() => { loadAssignees(); }, [loadAssignees]);
|
||||
useEffect(() => { void load(0, limit); }, [search]);
|
||||
|
||||
// load progress + refresh assignees when expanding assignment panel
|
||||
useEffect(() => {
|
||||
if (!expandCampaign) return;
|
||||
loadAssignees();
|
||||
hsapApi
|
||||
.campaignProgress(expandCampaign)
|
||||
.then((p) => setProgressMap((prev) => ({ ...prev, [expandCampaign!]: p })))
|
||||
.catch(() => {});
|
||||
}, [expandCampaign, loadAssignees]);
|
||||
}, [expandCampaign]);
|
||||
|
||||
const handleExport = async (campaignId: string) => {
|
||||
setInfo(null);
|
||||
@@ -125,7 +118,7 @@ export const CampaignsPage: React.FC = () => {
|
||||
const handleSubmit = async (campaignId: string) => {
|
||||
try {
|
||||
await hsapApi.submitLabelingCampaign(campaignId);
|
||||
load();
|
||||
load(offset, limit);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
@@ -188,7 +181,7 @@ export const CampaignsPage: React.FC = () => {
|
||||
// refresh progress
|
||||
const p = await hsapApi.campaignProgress(campaignId);
|
||||
setProgressMap((prev) => ({ ...prev, [campaignId]: p }));
|
||||
load();
|
||||
load(offset, limit);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
@@ -215,6 +208,98 @@ export const CampaignsPage: React.FC = () => {
|
||||
return Math.max(0, total - used);
|
||||
};
|
||||
|
||||
const renderAssignPanel = (b: LabelingBatchRow) => {
|
||||
const prog = (b.campaign_id ? progressMap[b.campaign_id] : null) as Record<string, unknown> | null;
|
||||
const byUser = (prog?.by_user || []) as { user_id: number; name: string; assigned: number; completed: number; percent: number }[];
|
||||
if (!b.campaign_id) return null;
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
{byUser.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">当前分配</h4>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
|
||||
{byUser.map((u) => (
|
||||
<div key={u.user_id} className="flex items-center gap-2 px-3 py-2 bg-white rounded-lg text-xs border border-gray-100">
|
||||
<span className="font-medium text-gray-700 truncate">{u.name}</span>
|
||||
<span className="text-gray-400">{u.completed}/{u.assigned}</span>
|
||||
<div className="flex-1 h-1 bg-gray-200 rounded-full max-w-[40px]">
|
||||
<div className="h-full rounded-full bg-blue-400" style={{ width: `${u.percent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-4">
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-800">新增分配</h4>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
从飞书通讯录选择成员,填写分配数量后提交
|
||||
{assignees.length > 0 && <span className="text-gray-400"> · 共 {assignees.length} 人可选</span>}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => loadAssignees()}
|
||||
disabled={assigneesLoading}
|
||||
className="shrink-0 px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-200 bg-white text-gray-600 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
同步通讯录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{assignees.length === 0 && !assigneesLoading ? (
|
||||
<div className="text-center py-6 text-sm text-gray-500">暂无可选成员,请同步通讯录</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-[10rem_1fr_2rem] gap-3 px-3 text-[11px] font-medium text-gray-400 uppercase tracking-wide">
|
||||
<span>成员</span>
|
||||
<span>分配数量</span>
|
||||
<span />
|
||||
</div>
|
||||
{assignLines.map((line, idx) => (
|
||||
<div key={idx} className="grid grid-cols-[10rem_1fr_2rem] gap-3 items-center px-3 py-2.5 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<AssignUserSelect
|
||||
value={line.userId}
|
||||
options={assignees}
|
||||
excludedIds={assignLines.filter((_, i) => i !== idx).map((l) => l.userId).filter((id) => id > 0)}
|
||||
onChange={(userId) => handleLineChange(idx, "userId", userId)}
|
||||
disabled={assigneesLoading || assignees.length === 0}
|
||||
/>
|
||||
<AssignCountControl
|
||||
value={line.count}
|
||||
max={maxCountForLine(b.campaign_id!, idx)}
|
||||
onChange={(count) => handleLineChange(idx, "count", count)}
|
||||
disabled={assigneesLoading}
|
||||
/>
|
||||
{assignLines.length > 1 ? (
|
||||
<button type="button" onClick={() => handleRemoveLine(idx)} className="text-gray-400 hover:text-red-500">×</button>
|
||||
) : <span />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<button type="button" onClick={handleAddLine} disabled={assignees.length === 0} className="text-xs text-blue-600 hover:underline disabled:text-gray-300">
|
||||
添加一行
|
||||
</button>
|
||||
{expandCampaign && unassignedCount(expandCampaign) != null && (
|
||||
<span className="text-xs text-gray-400">剩余未分配 {unassignedCount(expandCampaign)} 张</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<Button size="small" variant="primary" loading={assigning} disabled={assignees.length === 0} onClick={() => handleAssign(b.campaign_id!)}>
|
||||
确认分配
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header flex items-center justify-between">
|
||||
@@ -222,40 +307,17 @@ export const CampaignsPage: React.FC = () => {
|
||||
<h1>标注进度</h1>
|
||||
<p>查看和管理进行中的标注活动</p>
|
||||
</div>
|
||||
<Button size="small" variant="default" onClick={load}>
|
||||
<Button size="small" variant="default" onClick={() => load(offset, limit)}>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-3 mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 min-w-[200px] relative">
|
||||
<svg
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
className="w-full pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none"
|
||||
placeholder="搜索批次、任务或 Campaign ID..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 font-medium bg-gray-50 px-2.5 py-1 rounded-full">
|
||||
{filtered.length} 条
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<LabelingListToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder="搜索批次/任务/Campaign..."
|
||||
total={total}
|
||||
/>
|
||||
|
||||
{feishuSyncHint && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 mb-4 text-sm text-amber-900">
|
||||
@@ -279,234 +341,26 @@ export const CampaignsPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PageQueryState loading={loading} error={error} empty={filtered.length === 0} emptyMessage="暂无进行中的标注活动">
|
||||
<div className="space-y-3">
|
||||
{filtered.map((b) => {
|
||||
const pct =
|
||||
b.total_tasks && b.total_tasks > 0
|
||||
? Math.round(((b.completed_tasks || 0) / b.total_tasks) * 100)
|
||||
: 0;
|
||||
const isExpanded = expandCampaign === b.campaign_id;
|
||||
const prog = (b.campaign_id ? progressMap[b.campaign_id] : null) as Record<string, unknown> | null;
|
||||
const byUser = (prog?.by_user || []) as { user_id: number; name: string; assigned: number; completed: number; percent: number }[];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={b.campaign_id || b.batch}
|
||||
className={`card hover:shadow-sm transition-shadow ${isExpanded ? "ring-2 ring-blue-300" : ""}`}
|
||||
>
|
||||
{/* Main row */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-sm">{b.batch}</span>
|
||||
<span className="text-xs text-gray-400 font-mono">{b.task || "—"}</span>
|
||||
<StageBadge stage={b.stage} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-400">
|
||||
{b.campaign_id && (
|
||||
<span className="font-mono">{b.campaign_id.slice(0, 14)}...</span>
|
||||
)}
|
||||
{b.assigned_to_name && <span>👤 {b.assigned_to_name}</span>}
|
||||
</div>
|
||||
{b.total_tasks != null && b.total_tasks > 0 && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-gray-100 rounded-full overflow-hidden max-w-[200px]">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${pct >= 100 ? "bg-green-500" : "bg-blue-500"}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">
|
||||
{b.completed_tasks}/{b.total_tasks}
|
||||
</span>
|
||||
{b.assigned_tasks != null && (
|
||||
<span className="text-xs text-gray-400">
|
||||
已分配 {b.assigned_tasks}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{b.campaign_id && (
|
||||
<>
|
||||
<Link
|
||||
to={`/labeling/annotate/${encodeURIComponent(b.campaign_id)}`}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg bg-blue-50 text-blue-700 hover:bg-blue-100 transition-colors"
|
||||
>
|
||||
✏️ 标注
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleExport(b.campaign_id!)}
|
||||
disabled={!["labeling_submitted", "returned"].includes(b.stage || "")}
|
||||
title={!["labeling_submitted", "returned"].includes(b.stage || "") ? "质检通过后才可导出" : undefined}
|
||||
className={`inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
|
||||
["labeling_submitted", "returned"].includes(b.stage || "")
|
||||
? "bg-gray-50 text-gray-600 hover:bg-gray-100"
|
||||
: "bg-gray-50 text-gray-300 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
📤 导出
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSubmit(b.campaign_id!)}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg bg-green-50 text-green-700 hover:bg-green-100 transition-colors"
|
||||
>
|
||||
✅ 提交质检
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleExpand(b.campaign_id!)}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg bg-purple-50 text-purple-700 hover:bg-purple-100 transition-colors"
|
||||
>
|
||||
👥 {isExpanded ? "收起" : "分配"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded: task assignment panel */}
|
||||
{isExpanded && b.campaign_id && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
{/* ── current assignment progress ── */}
|
||||
{byUser.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">当前分配</h4>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
|
||||
{byUser.map((u) => (
|
||||
<div
|
||||
key={u.user_id}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-gray-50 rounded-lg text-xs"
|
||||
>
|
||||
<span className="font-medium text-gray-700 truncate">{u.name}</span>
|
||||
<span className="text-gray-400">
|
||||
{u.completed}/{u.assigned}
|
||||
</span>
|
||||
<div className="flex-1 h-1 bg-gray-200 rounded-full max-w-[40px]">
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-400"
|
||||
style={{ width: `${u.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── assign form ── */}
|
||||
<div className="rounded-xl border border-gray-100 bg-gray-50/60 p-4">
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-800">新增分配</h4>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
从飞书通讯录选择成员,填写分配数量后提交
|
||||
{assignees.length > 0 && (
|
||||
<span className="text-gray-400"> · 共 {assignees.length} 人可选</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => loadAssignees()}
|
||||
disabled={assigneesLoading}
|
||||
className="shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-gray-200 bg-white text-gray-600 hover:bg-gray-50 hover:border-gray-300 disabled:opacity-50"
|
||||
>
|
||||
<svg className={`w-3.5 h-3.5 ${assigneesLoading ? "animate-spin" : ""}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
同步通讯录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{assignees.length === 0 && !assigneesLoading ? (
|
||||
<div className="text-center py-8 px-4 bg-white rounded-lg border border-dashed border-gray-200">
|
||||
<p className="text-sm text-gray-500 mb-2">暂无可选成员</p>
|
||||
<p className="text-xs text-gray-400">请点击「同步通讯录」或联系管理员开通飞书通讯录权限</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-[10rem_1fr_2rem] gap-3 px-3 text-[11px] font-medium text-gray-400 uppercase tracking-wide">
|
||||
<span>成员</span>
|
||||
<span>分配数量</span>
|
||||
<span />
|
||||
</div>
|
||||
{assignLines.map((line, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="grid grid-cols-[10rem_1fr_2rem] gap-3 items-center px-3 py-2.5 bg-white rounded-lg border border-gray-200"
|
||||
>
|
||||
<AssignUserSelect
|
||||
value={line.userId}
|
||||
options={assignees}
|
||||
excludedIds={assignLines
|
||||
.filter((_, i) => i !== idx)
|
||||
.map((l) => l.userId)
|
||||
.filter((id) => id > 0)}
|
||||
onChange={(userId) => handleLineChange(idx, "userId", userId)}
|
||||
disabled={assigneesLoading || assignees.length === 0}
|
||||
/>
|
||||
<AssignCountControl
|
||||
value={line.count}
|
||||
max={maxCountForLine(b.campaign_id!, idx)}
|
||||
onChange={(count) => handleLineChange(idx, "count", count)}
|
||||
disabled={assigneesLoading}
|
||||
/>
|
||||
{assignLines.length > 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveLine(idx)}
|
||||
className="h-10 w-8 flex items-center justify-center rounded-lg text-gray-400 hover:text-red-500 hover:bg-red-50 transition-colors"
|
||||
title="移除"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddLine}
|
||||
disabled={assignees.length === 0}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg text-blue-600 hover:bg-blue-50 disabled:text-gray-300 disabled:hover:bg-transparent"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
添加一行
|
||||
</button>
|
||||
{expandCampaign && unassignedCount(expandCampaign) != null && (
|
||||
<span className="text-xs text-gray-400">
|
||||
剩余未分配 {unassignedCount(expandCampaign)} 张
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
size="small"
|
||||
variant="primary"
|
||||
loading={assigning}
|
||||
disabled={assignees.length === 0}
|
||||
onClick={() => handleAssign(b.campaign_id!)}
|
||||
>
|
||||
确认分配
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<PageQueryState loading={loading} error={error} empty={!loading && total === 0} emptyMessage="暂无进行中的标注活动">
|
||||
{total > 0 && (
|
||||
<>
|
||||
<CampaignBatchTable
|
||||
batches={batches}
|
||||
expandCampaign={expandCampaign}
|
||||
onToggleExpand={toggleExpand}
|
||||
onExport={handleExport}
|
||||
onSubmit={handleSubmit}
|
||||
renderExpand={renderAssignPanel}
|
||||
/>
|
||||
<ListPaginationBar
|
||||
total={total}
|
||||
offset={offset}
|
||||
limit={limit}
|
||||
onOffsetChange={(o) => load(o, limit)}
|
||||
onLimitChange={(l) => load(0, l)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -40,7 +40,6 @@ function readStoredScope(fallback: string): string {
|
||||
|
||||
export const CatalogPage: React.FC = () => {
|
||||
const [cat, setCat] = useState<CatalogReport | null>(null);
|
||||
const [dmsDetail, setDmsDetail] = useState<DmsTaskEntry | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState("");
|
||||
const [domain, setDomain] = useState<DomainTab>("dms");
|
||||
@@ -61,15 +60,8 @@ export const CatalogPage: React.FC = () => {
|
||||
const scope = parseCatalogScope(scopeKey);
|
||||
const isPackView = scope.project === "dms-pack";
|
||||
|
||||
const loadDmsDetail = async (refresh = false) => {
|
||||
if (!isDmsCatalogScope(scope) || !scope.task) return;
|
||||
try { setDmsDetail((await hsapApi.catalogDms(scope.task, refresh)) as DmsTaskEntry); }
|
||||
catch { setDmsDetail(null); }
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
useEffect(() => { if (!cat) return; setUi((prev) => { const next = selectionFromScopeKey(scopeKeyFromSelection(prev), cat); return next; }); }, [cat]);
|
||||
useEffect(() => { loadDmsDetail(); }, [scopeKey]);
|
||||
useEffect(() => { try { localStorage.setItem(SCOPE_STORAGE_KEY, scopeKey); } catch { /* */ } }, [scopeKey]);
|
||||
|
||||
// Filter tasks by domain
|
||||
@@ -151,7 +143,7 @@ export const CatalogPage: React.FC = () => {
|
||||
};
|
||||
|
||||
// DMS/Forward: use existing data paths
|
||||
const taskEntry: DmsTaskEntry | undefined = isDmsCatalogScope(scope) ? dmsDetail || (dmsData[scope.task] as DmsTaskEntry | undefined) : undefined;
|
||||
const taskEntry: DmsTaskEntry | undefined = isDmsCatalogScope(scope) ? (dmsData[scope.task] as DmsTaskEntry | undefined) : undefined;
|
||||
const packRow: DmsPackRow | undefined = isPackView && scope.pack ? findPackRow(taskEntry, scope.pack, scope.mode) : undefined;
|
||||
const activePackTask = packTasks.find((t) => t.key === packTaskKey(ui.task, ui.mode || undefined));
|
||||
const modePacks = scope.project === "dms" ? dmsPacks(taskEntry, scope.mode || "") : [];
|
||||
@@ -171,7 +163,7 @@ export const CatalogPage: React.FC = () => {
|
||||
<h1>数据目录</h1>
|
||||
<p>按训练包与任务查看统计分布</p>
|
||||
</div>
|
||||
<Button variant="default" size="small" onClick={() => { load(true); loadDmsDetail(true); }} disabled={loading}>刷新</Button>
|
||||
<Button variant="default" size="small" onClick={() => load(true)} disabled={loading}>刷新</Button>
|
||||
</div>
|
||||
|
||||
{err && <p className="text-red-500 text-sm mb-3">{err}</p>}
|
||||
|
||||
@@ -1,145 +1,364 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { StatusBadge } from "@/components/ui/Badge";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
import { ListPaginationBar } from "@/components/ListPaginationBar";
|
||||
import { CompactTableShell } from "@/components/CompactTableShell";
|
||||
import { LabelingListToolbar } from "../components/LabelingListToolbar";
|
||||
import { DeliveryTable } from "../components/DeliveryTable";
|
||||
import { DeliveryCreateModal, type DeliveryFormValues } from "../components/DeliveryCreateModal";
|
||||
import { DeliveryIntakePanel } from "../components/DeliveryIntakePanel";
|
||||
import { displayTaskFields } from "@/lib/labelingDisplay";
|
||||
import type { BatchDelivery } from "@/lib/types";
|
||||
import { useAuth } from "@/app/AuthContext";
|
||||
|
||||
type ScanItem = {
|
||||
project: string;
|
||||
task: string;
|
||||
batch_name: string;
|
||||
images: number;
|
||||
labels: number;
|
||||
stage_hint: string;
|
||||
in_ledger: boolean;
|
||||
in_workbench: boolean;
|
||||
needs_ledger: boolean;
|
||||
delivery_status?: string;
|
||||
collection_start?: string;
|
||||
data_path: string;
|
||||
};
|
||||
|
||||
const STATUS_FILTERS = [
|
||||
{ label: "全部", value: "" },
|
||||
{ label: "草稿", value: "draft" },
|
||||
{ label: "待审核", value: "pending_review" },
|
||||
{ label: "已入湖", value: "in_lake" },
|
||||
{ label: "入湖失败", value: "ingest_failed" },
|
||||
{ label: "已驳回", value: "rejected" },
|
||||
];
|
||||
|
||||
function parseApiError(e: unknown): string {
|
||||
const raw = String(e);
|
||||
try {
|
||||
const m = raw.match(/\{.*\}/s);
|
||||
if (m) {
|
||||
const j = JSON.parse(m[0]) as { detail?: string };
|
||||
if (j.detail) return j.detail;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return raw;
|
||||
}
|
||||
|
||||
export const DeliveriesPage: React.FC = () => {
|
||||
const { hasPermission } = useAuth();
|
||||
const canSubmit = hasPermission("write:delivery_submit");
|
||||
|
||||
const [deliveries, setDeliveries] = useState<BatchDelivery[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [limit, setLimit] = useState(20);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [registering, setRegistering] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showScan, setShowScan] = useState(false);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [scanItems, setScanItems] = useState<ScanItem[]>([]);
|
||||
const [scanMeta, setScanMeta] = useState<{ needs_ledger: number; needs_workbench: number; scanned_at?: string } | null>(null);
|
||||
|
||||
const load = async (newOffset = 0, newLimit = 20) => {
|
||||
setLoading(true); setError(null);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await hsapApi.listDeliveries({ status: statusFilter || undefined, offset: newOffset, limit: newLimit });
|
||||
let items = res.items || [];
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
items = items.filter((d) => (d.batch_name || "").toLowerCase().includes(q) || (d.task || "").toLowerCase().includes(q) || (d.owner_name || "").toLowerCase().includes(q));
|
||||
items = items.filter((d) =>
|
||||
(d.batch_name || "").toLowerCase().includes(q)
|
||||
|| (d.task || "").toLowerCase().includes(q)
|
||||
|| (d.owner_name || "").toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
setDeliveries(items);
|
||||
setTotal(search ? items.length : res.total);
|
||||
setOffset(newOffset); setLimit(newLimit);
|
||||
} catch (e) { setError(String(e)); }
|
||||
setOffset(newOffset);
|
||||
setLimit(newLimit);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [search, statusFilter]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const batchName = prompt("批次名称 (如 20260601_pilot):");
|
||||
if (!batchName) return;
|
||||
const dataPath = prompt("数据路径 (如 datasets/dms/inbox/ddaw/20260601_pilot):");
|
||||
if (!dataPath) return;
|
||||
const stats = useMemo(() => {
|
||||
const inLake = deliveries.filter((d) => d.status === "in_lake").length;
|
||||
const pending = deliveries.filter((d) => ["draft", "pending_review", "ingesting", "ingest_failed", "rejected"].includes(d.status)).length;
|
||||
return { inLake, pending };
|
||||
}, [deliveries]);
|
||||
|
||||
const handleScan = async () => {
|
||||
setScanning(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
const res = await hsapApi.scanDeliveries();
|
||||
const items = (res.items || []) as unknown as ScanItem[];
|
||||
setScanItems(items);
|
||||
setScanMeta({
|
||||
needs_ledger: res.needs_ledger ?? 0,
|
||||
needs_workbench: res.needs_workbench ?? 0,
|
||||
scanned_at: res.scanned_at,
|
||||
});
|
||||
setShowScan(true);
|
||||
} catch (e) {
|
||||
setActionError(parseApiError(e));
|
||||
}
|
||||
setScanning(false);
|
||||
};
|
||||
|
||||
const handleRegisterAll = async () => {
|
||||
const pending = scanItems.filter((i) => i.needs_ledger);
|
||||
if (pending.length === 0) {
|
||||
setInfo("所有 inbox 批次均已登记到台账");
|
||||
return;
|
||||
}
|
||||
if (!confirm(
|
||||
`将 ${pending.length} 个未登记批次写入台账,并同步到送标工作台?\n\n`
|
||||
+ "已在 inbox 的批次将直接标为「已入湖」,采集时间取目录修改日期。",
|
||||
)) return;
|
||||
setRegistering(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
const r = await hsapApi.registerScannedDeliveries(
|
||||
pending as unknown as Record<string, unknown>[],
|
||||
true,
|
||||
);
|
||||
setInfo(`台账新增 ${r.created} 条,更新 ${r.updated} 条,工作台同步 ${r.synced_workbench} 条`);
|
||||
setShowScan(false);
|
||||
await load(0, limit);
|
||||
} catch (e) {
|
||||
setActionError(parseApiError(e));
|
||||
}
|
||||
setRegistering(false);
|
||||
};
|
||||
|
||||
const handleCreateSubmit = async (values: DeliveryFormValues) => {
|
||||
setSaving(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
await hsapApi.createDelivery({
|
||||
project: "dms",
|
||||
batch_name: batchName,
|
||||
data_path: dataPath,
|
||||
project: values.project,
|
||||
task: values.project === "lane" ? values.task || "lane_v1" : values.task.trim() || null,
|
||||
mode: values.mode.trim() || null,
|
||||
batch_name: values.batch_name.trim(),
|
||||
data_path: values.data_path.trim(),
|
||||
source_type: values.source_type,
|
||||
collection_start: values.collection_start || null,
|
||||
collection_end: values.collection_end || null,
|
||||
estimated_count: values.estimated_count ? Number(values.estimated_count) : null,
|
||||
vehicle_scene: values.vehicle_scene.trim() || null,
|
||||
remark: values.remark.trim() || null,
|
||||
});
|
||||
load(0, limit);
|
||||
setShowCreate(false);
|
||||
await load(0, limit);
|
||||
setInfo("已保存草稿。请核对后点「提交」走审批入湖。");
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
setActionError(parseApiError(e));
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const handleSubmit = async (id: string) => {
|
||||
setActionError(null);
|
||||
try {
|
||||
await hsapApi.submitDelivery(id);
|
||||
load(offset, limit);
|
||||
await load(offset, limit);
|
||||
setInfo("已提交审批,请在审核管理批准入湖");
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
setActionError(parseApiError(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm("确定删除此送标记录?")) return;
|
||||
setActionError(null);
|
||||
try {
|
||||
await hsapApi.deleteDelivery(id);
|
||||
load(offset, limit);
|
||||
await load(offset, limit);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
setActionError(parseApiError(e));
|
||||
}
|
||||
};
|
||||
|
||||
const pendingScan = scanItems.filter((i) => i.needs_ledger);
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header flex items-center justify-between">
|
||||
<div className="page-header flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1>批次台账</h1>
|
||||
<p>管理数据送标申请与审核流程</p>
|
||||
<p>数据湖周期落盘 · NAS 外挂盘 · 统一登记采集周期 → 入湖 → 工作台开标</p>
|
||||
</div>
|
||||
{hasPermission("write:delivery_submit") && (
|
||||
<Button variant="primary" onClick={handleCreate}>新建送标</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search & Filter */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-3 mb-4">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex-1 min-w-[200px] relative">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input className="w-full pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none"
|
||||
placeholder="搜索批次/任务/提单人..." value={search} onChange={(e) => { setSearch(e.target.value); setOffset(0); }} />
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
{["全部", "草稿", "已提交", "已入湖", "已驳回"].map((label, i) => {
|
||||
const val = i === 0 ? "" : ["draft", "submitted", "ingested", "rejected"][i - 1];
|
||||
return <button key={val} onClick={() => { setStatusFilter(val); setOffset(0); }} className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${statusFilter === val ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"}`}>{label}</button>;
|
||||
})}
|
||||
</div>
|
||||
<Button size="small" variant="default" onClick={() => load(0, limit)}>刷新</Button>
|
||||
<span className="text-xs text-gray-500 font-medium bg-gray-50 px-2.5 py-1 rounded-full">{total} 条</span>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button variant="primary" size="small" onClick={handleScan} loading={scanning}>
|
||||
扫描数据湖
|
||||
</Button>
|
||||
{canSubmit && (
|
||||
<Button variant="default" size="small" onClick={() => setShowCreate(true)}>
|
||||
新建送标
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PageQueryState loading={loading} error={error} empty={deliveries.length === 0} emptyMessage="暂无送标记录">
|
||||
<div className="space-y-2">
|
||||
{deliveries.map((d) => (
|
||||
<div key={d.id} className="card hover:shadow-sm transition-shadow">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm">{d.batch_name}</span>
|
||||
<span className="text-xs text-gray-400">{d.project}/{d.task || "—"}</span>
|
||||
<StatusBadge status={d.status} />
|
||||
</div>
|
||||
<div className="flex gap-3 mt-1 text-xs text-gray-400">
|
||||
<span>🖼 {d.estimated_count ?? "—"}</span>
|
||||
<span>👤 {d.submitted_by_name || d.owner_name || "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{d.status === "draft" && (
|
||||
<>
|
||||
<button onClick={() => handleSubmit(d.id)} className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg bg-blue-50 text-blue-700 hover:bg-blue-100 transition-colors">✅ 提交审核</button>
|
||||
<button onClick={() => handleDelete(d.id)} className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg bg-red-50 text-red-600 hover:bg-red-100 transition-colors">🗑 删除</button>
|
||||
</>
|
||||
)}
|
||||
{d.approval_id && (
|
||||
<Link to={`/system/audit/${d.approval_id}`} className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg bg-gray-50 text-gray-600 hover:bg-gray-100 transition-colors">📋 审核</Link>
|
||||
)}
|
||||
{d.status === "ingested" && <span className="text-green-600 text-sm font-medium">✓ 已入湖</span>}
|
||||
</div>
|
||||
</div>
|
||||
<DeliveryIntakePanel
|
||||
total={total}
|
||||
inLake={stats.inLake}
|
||||
pending={stats.pending}
|
||||
scanPending={scanMeta?.needs_ledger}
|
||||
scanning={scanning}
|
||||
canCreate={canSubmit}
|
||||
onScan={handleScan}
|
||||
onCreate={() => setShowCreate(true)}
|
||||
/>
|
||||
|
||||
{info && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-3 mb-4 text-sm text-green-700">
|
||||
{info}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionError && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4 text-sm text-red-700">
|
||||
{actionError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showScan && (
|
||||
<div className="card mb-4 border-blue-200 bg-blue-50/30">
|
||||
<div className="card-header flex items-center justify-between gap-2">
|
||||
<span>
|
||||
数据湖扫描 — 共 {scanItems.length} 批
|
||||
{scanMeta && (
|
||||
<span className="text-gray-500 text-sm ml-2">
|
||||
未登记台账 {scanMeta.needs_ledger} · 未进工作台 {scanMeta.needs_workbench}
|
||||
{scanMeta.scanned_at && ` · ${scanMeta.scanned_at.slice(11, 19)}`}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
{pendingScan.length > 0 && canSubmit && (
|
||||
<Button size="small" variant="primary" loading={registering} onClick={handleRegisterAll}>
|
||||
登记到台账 ({pendingScan.length})
|
||||
</Button>
|
||||
)}
|
||||
<Button size="small" variant="default" onClick={() => setShowScan(false)}>收起</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{scanItems.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 p-4">inbox 暂无批次目录</p>
|
||||
) : (
|
||||
<CompactTableShell colWidths={["10%", "24%", "10%", "8%", "8%", "10%", "10%", "10%"]}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="py-2">项目</th>
|
||||
<th className="py-2">批次</th>
|
||||
<th className="py-2">任务</th>
|
||||
<th className="py-2">图片</th>
|
||||
<th className="py-2">采集</th>
|
||||
<th className="py-2">台账</th>
|
||||
<th className="py-2">工作台</th>
|
||||
<th className="py-2">阶段</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scanItems.slice(0, 50).map((item) => (
|
||||
<tr key={`${item.project}/${item.task}/${item.batch_name}`} className="align-middle">
|
||||
<td className="py-2 text-xs uppercase text-gray-600">{item.project}</td>
|
||||
<td className="py-2 text-sm font-medium truncate max-w-[12rem]" title={item.batch_name}>{item.batch_name}</td>
|
||||
<td className="py-2 text-sm">{displayTaskFields(item)}</td>
|
||||
<td className="py-2 text-sm tabular-nums">{item.images}</td>
|
||||
<td className="py-2 text-xs text-gray-500">{item.collection_start?.slice(0, 10) || "—"}</td>
|
||||
<td className="py-2">
|
||||
{item.in_ledger
|
||||
? <Badge variant="success" size="small">已登记</Badge>
|
||||
: <Badge variant="warning" size="small">待登记</Badge>}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
{item.in_workbench
|
||||
? <Badge variant="success" size="small">已同步</Badge>
|
||||
: <Badge variant="default" size="small">未同步</Badge>}
|
||||
</td>
|
||||
<td className="py-2 text-xs text-gray-500">{item.stage_hint === "returned" ? "待入库" : "待送标"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</CompactTableShell>
|
||||
)}
|
||||
{scanItems.length > 50 && (
|
||||
<p className="text-xs text-gray-400 p-2">仅展示前 50 条,登记时会处理全部未登记批次</p>
|
||||
)}
|
||||
</div>
|
||||
<ListPaginationBar total={total} offset={offset} limit={limit} onOffsetChange={(o) => load(o, limit)} onLimitChange={(l) => load(0, l)} />
|
||||
)}
|
||||
|
||||
<LabelingListToolbar
|
||||
search={search}
|
||||
onSearchChange={(v) => { setSearch(v); setOffset(0); }}
|
||||
placeholder="搜索批次/任务/提单人..."
|
||||
filters={STATUS_FILTERS}
|
||||
filterValue={statusFilter}
|
||||
onFilterChange={(v) => { setStatusFilter(v); setOffset(0); }}
|
||||
total={total}
|
||||
extra={<Button size="small" variant="default" onClick={() => load(0, limit)}>刷新</Button>}
|
||||
/>
|
||||
|
||||
<PageQueryState loading={loading} error={error} empty={false}>
|
||||
{!loading && deliveries.length === 0 ? (
|
||||
<div className="card py-16 text-center">
|
||||
<div className="text-4xl mb-3">📋</div>
|
||||
<p className="text-gray-600 font-medium mb-1">暂无送标记录</p>
|
||||
<p className="text-sm text-gray-400 mb-6 max-w-md mx-auto">
|
||||
数据湖 inbox 已有批次?先点「扫描数据湖」批量登记。
|
||||
NAS 外挂盘新数据?点「新建送标」填写路径与采集周期。
|
||||
</p>
|
||||
<div className="flex justify-center gap-3">
|
||||
<Button variant="primary" onClick={handleScan} loading={scanning}>扫描数据湖</Button>
|
||||
{canSubmit && <Button variant="default" onClick={() => setShowCreate(true)}>新建送标</Button>}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DeliveryTable
|
||||
deliveries={deliveries}
|
||||
canSubmit={canSubmit}
|
||||
onSubmit={handleSubmit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
<ListPaginationBar
|
||||
total={total}
|
||||
offset={offset}
|
||||
limit={limit}
|
||||
onOffsetChange={(o) => load(o, limit)}
|
||||
onLimitChange={(l) => load(0, l)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</PageQueryState>
|
||||
|
||||
<DeliveryCreateModal
|
||||
open={showCreate}
|
||||
saving={saving}
|
||||
onClose={() => setShowCreate(false)}
|
||||
onSubmit={handleCreateSubmit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,75 +3,67 @@ import { Link } from "react-router-dom";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
import { Badge } from "@/components/ui/Badge";
|
||||
import { ListPaginationBar } from "@/components/ListPaginationBar";
|
||||
import { LabelingListToolbar } from "../components/LabelingListToolbar";
|
||||
import { ExportPipelineFlow } from "../components/ExportPipelineFlow";
|
||||
import { ExportBatchTable } from "../components/ExportBatchTable";
|
||||
import { defaultBuildPack } from "@/lib/labelingDisplay";
|
||||
import type { LabelingBatchRow } from "@/lib/types";
|
||||
|
||||
export const ExportPage: React.FC = () => {
|
||||
const [batches, setBatches] = useState<LabelingBatchRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [limit, setLimit] = useState(20);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
const [importingId, setImportingId] = useState<string | null>(null);
|
||||
const [statsMap, setStatsMap] = useState<Record<string, Record<string, unknown>>>({});
|
||||
const [buildingId, setBuildingId] = useState<string | null>(null);
|
||||
const [fittingId, setFittingId] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [stageFilter, setStageFilter] = useState("");
|
||||
const [pendingExport, setPendingExport] = useState(0);
|
||||
const [pendingBuild, setPendingBuild] = useState(0);
|
||||
|
||||
const filtered = batches.filter((b) => {
|
||||
if (search && !(b.batch || "").toLowerCase().includes(search.toLowerCase()) && !(b.task || "").toLowerCase().includes(search.toLowerCase())) return false;
|
||||
if (stageFilter && b.stage !== stageFilter) return false;
|
||||
return true;
|
||||
});
|
||||
const loadStageCounts = useCallback(async () => {
|
||||
try {
|
||||
const [sub, ret] = await Promise.all([
|
||||
hsapApi.labelingBatches({ stage: "labeling_submitted", limit: 1, offset: 0 }),
|
||||
hsapApi.labelingBatches({ stage: "returned", limit: 1, offset: 0 }),
|
||||
]);
|
||||
setPendingExport(sub.total ?? 0);
|
||||
setPendingBuild(ret.total ?? 0);
|
||||
} catch { /* optional */ }
|
||||
}, []);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const load = useCallback(async (newOffset: number, newLimit: number) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const results: LabelingBatchRow[] = [];
|
||||
const [submitted, returned] = await Promise.allSettled([
|
||||
hsapApi.labelingBatches({ stage: "labeling_submitted", limit: 100 }),
|
||||
hsapApi.labelingBatches({ stage: "returned", limit: 100 }),
|
||||
]);
|
||||
if (submitted.status === "fulfilled") results.push(...((submitted.value.items || []) as LabelingBatchRow[]));
|
||||
if (returned.status === "fulfilled") results.push(...((returned.value.items || []) as LabelingBatchRow[]));
|
||||
if (submitted.status === "rejected" && returned.status === "rejected") setError(String(submitted.reason));
|
||||
setBatches(results);
|
||||
const adasReturned = results.filter((b) => b.stage === "returned" && b.project === "adas" && b.campaign_id);
|
||||
if (adasReturned.length) {
|
||||
const entries = await Promise.allSettled(
|
||||
adasReturned.map(async (b) => {
|
||||
const s = await hsapApi.labelingExportStats(b.campaign_id!);
|
||||
return [b.campaign_id!, s] as const;
|
||||
}),
|
||||
);
|
||||
const map: Record<string, Record<string, unknown>> = {};
|
||||
for (const e of entries) {
|
||||
if (e.status === "fulfilled") map[e.value[0]] = e.value[1];
|
||||
}
|
||||
setStatsMap(map);
|
||||
}
|
||||
const q = search.trim() || undefined;
|
||||
const res = await hsapApi.labelingBatches(
|
||||
stageFilter
|
||||
? { stage: stageFilter, offset: newOffset, limit: newLimit, q }
|
||||
: { stages: ["labeling_submitted", "returned"], offset: newOffset, limit: newLimit, q },
|
||||
);
|
||||
setBatches((res.items || []) as LabelingBatchRow[]);
|
||||
setTotal(res.total ?? 0);
|
||||
setOffset(newOffset);
|
||||
setLimit(newLimit);
|
||||
await loadStageCounts();
|
||||
} catch (e) { setError(String(e)); }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
}, [search, stageFilter, loadStageCounts]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
useEffect(() => { void load(0, limit); }, [search, stageFilter]);
|
||||
|
||||
const reloadCurrent = useCallback(() => load(offset, limit), [load, offset, limit]);
|
||||
|
||||
const handleExport = async (campaignId: string) => {
|
||||
try { await hsapApi.labelingExport(campaignId); setInfo("导出任务已提交"); load(); }
|
||||
try { await hsapApi.labelingExport(campaignId); setInfo("导出任务已提交"); reloadCurrent(); }
|
||||
catch (e) { setError(String(e)); }
|
||||
};
|
||||
|
||||
const handleCuboidFit = async (campaignId: string) => {
|
||||
setFittingId(campaignId);
|
||||
try {
|
||||
await hsapApi.cuboidFit(campaignId);
|
||||
setInfo("3D 拟合任务已提交");
|
||||
load();
|
||||
} catch (e) { setError(String(e)); }
|
||||
setFittingId(null);
|
||||
};
|
||||
|
||||
const handleSubmitBuild = async (b: LabelingBatchRow) => {
|
||||
if (!b.task || !b.batch) return;
|
||||
setBuildingId(b.campaign_id || b.batch);
|
||||
@@ -81,12 +73,12 @@ export const ExportPage: React.FC = () => {
|
||||
project: b.project || "dms",
|
||||
task: b.task,
|
||||
batch: b.batch,
|
||||
pack: b.pack || (b.project === "adas" ? "adas_moon3d_v1" : "dms_v2"),
|
||||
pack: defaultBuildPack(b),
|
||||
location: b.location || "inbox",
|
||||
note: `入库 ${b.batch}`,
|
||||
});
|
||||
setInfo("build 已提交至审核队列");
|
||||
load();
|
||||
setInfo("入库任务已提交至审核队列");
|
||||
reloadCurrent();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
@@ -100,125 +92,65 @@ export const ExportPage: React.FC = () => {
|
||||
const f = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!f) return;
|
||||
setImportingId(campaignId);
|
||||
try { await hsapApi.importVendorZip(campaignId, f); load(); }
|
||||
try { await hsapApi.importVendorZip(campaignId, f); reloadCurrent(); }
|
||||
catch (err) { setError(String(err)); }
|
||||
setImportingId(null);
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
const hasData = batches.length > 0;
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<h1>导出与入库</h1>
|
||||
<p>质检通过后的格式转换、供应商回标、build 入库</p>
|
||||
</div>
|
||||
|
||||
{info && <div className="bg-green-50 border border-green-200 rounded p-3 mb-4 text-sm text-green-700">{info}</div>}
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-3 mb-4">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex-1 min-w-[200px] relative">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input className="w-full pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none"
|
||||
placeholder="搜索批次/任务..." value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
{["全部", "待导出", "待 build"].map((label, i) => {
|
||||
const val = i === 0 ? "" : ["labeling_submitted", "returned"][i - 1];
|
||||
return <button key={val} onClick={() => setStageFilter(val)} className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${stageFilter === val ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"}`}>{label}</button>;
|
||||
})}
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 font-medium bg-gray-50 px-2.5 py-1 rounded-full">{filtered.length} 条</span>
|
||||
<div className="page-header flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1>导出与入库</h1>
|
||||
<p>质检通过后的格式转换、供应商回标、build 入库</p>
|
||||
</div>
|
||||
<Button size="small" variant="default" onClick={() => reloadCurrent()} disabled={loading}>
|
||||
刷新列表
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="card mb-4">
|
||||
<div className="card-header">流程说明</div>
|
||||
<div className="text-sm text-gray-600 space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-8 h-8 rounded-full bg-blue-100 text-blue-700 flex items-center justify-center text-xs font-bold">1</span>
|
||||
<span><strong>提交质检</strong> — 标注员完成标注后,在标注进度页点击「提交质检」</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-8 h-8 rounded-full bg-blue-100 text-blue-700 flex items-center justify-center text-xs font-bold">2</span>
|
||||
<span><strong>质检通过</strong> — 协调员在质检页审核,通过后批次进入「待导出」</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-8 h-8 rounded-full bg-orange-100 text-orange-700 flex items-center justify-center text-xs font-bold">3</span>
|
||||
<span>
|
||||
<strong>执行导出</strong> — 将 CVAT 标注转为训练格式(DMS→YOLO,ADAS→quaternion_json)
|
||||
{hasData && <span className="text-gray-400">(下表有待处理批次)</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-8 h-8 rounded-full bg-green-100 text-green-700 flex items-center justify-center text-xs font-bold">4</span>
|
||||
<span>
|
||||
<strong>提交 build</strong> — 导出完成后进入 <Badge variant="warning" size="small">待 build</Badge>,在此提交 build 并经审核队列批准后变为 <Badge variant="success" size="small">已入库</Badge>
|
||||
<span className="block text-xs text-gray-400 mt-0.5">「待 build」≠「已入库」;ingested 批次不会出现在本页</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{info && <div className="bg-green-50 border border-green-200 rounded-lg p-3 mb-4 text-sm text-green-700">{info}</div>}
|
||||
{error && <div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4 text-sm text-red-600">{error}</div>}
|
||||
|
||||
<PageQueryState loading={loading} error={error} empty={filtered.length === 0}>
|
||||
{filtered.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{filtered.map((b) => (
|
||||
<div key={b.campaign_id || b.batch} className="card hover:shadow-sm transition-shadow">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm">{b.batch}</span>
|
||||
<span className="text-xs text-gray-400">{b.project}/{b.task || "—"}</span>
|
||||
<Badge variant={b.stage === "returned" ? "warning" : "warning"}>{b.stage === "labeling_submitted" ? "待导出" : "待 build"}</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 font-mono mt-1">{b.campaign_id?.slice(0, 16) || "—"}</div>
|
||||
{b.stage === "returned" && b.project === "adas" && b.campaign_id && statsMap[b.campaign_id] && (
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
quaternion: {String(statsMap[b.campaign_id].quaternion_files ?? "—")} ·
|
||||
fit_ok: {((Number(statsMap[b.campaign_id].fit_ok_ratio) || 0) * 100).toFixed(0)}% ·
|
||||
pack: adas_moon3d_v1
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{b.campaign_id && b.stage === "labeling_submitted" && (
|
||||
<>
|
||||
<Button size="small" variant="primary" onClick={() => handleExport(b.campaign_id!)}>📤 执行导出</Button>
|
||||
<Button size="small" variant="default" loading={importingId === b.campaign_id} onClick={() => handleImportVendor(b.campaign_id!)}>📥 导入供应商</Button>
|
||||
</>
|
||||
)}
|
||||
{b.stage === "returned" && (
|
||||
<>
|
||||
{b.project === "adas" && b.campaign_id && (
|
||||
<Button size="small" variant="default" loading={fittingId === b.campaign_id} onClick={() => handleCuboidFit(b.campaign_id!)}>
|
||||
补全 3D
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
variant="primary"
|
||||
loading={buildingId === (b.campaign_id || b.batch)}
|
||||
onClick={() => handleSubmitBuild(b)}
|
||||
>
|
||||
🏗 提交 build
|
||||
</Button>
|
||||
<Link to="/system/audit" className="text-xs text-blue-600 hover:underline">审核队列 →</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ExportPipelineFlow pendingExport={pendingExport} pendingBuild={pendingBuild} />
|
||||
|
||||
<LabelingListToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
filters={[
|
||||
{ label: "全部", value: "" },
|
||||
{ label: "待导出", value: "labeling_submitted" },
|
||||
{ label: "待入库", value: "returned" },
|
||||
]}
|
||||
filterValue={stageFilter}
|
||||
onFilterChange={setStageFilter}
|
||||
total={total}
|
||||
/>
|
||||
|
||||
<PageQueryState loading={loading} error={null} empty={!loading && total === 0}>
|
||||
{total > 0 ? (
|
||||
<>
|
||||
<ExportBatchTable
|
||||
batches={batches}
|
||||
importingId={importingId}
|
||||
buildingId={buildingId}
|
||||
onExport={handleExport}
|
||||
onImportVendor={handleImportVendor}
|
||||
onSubmitBuild={handleSubmitBuild}
|
||||
/>
|
||||
<ListPaginationBar
|
||||
total={total}
|
||||
offset={offset}
|
||||
limit={limit}
|
||||
onOffsetChange={(o) => load(o, limit)}
|
||||
onLimitChange={(l) => load(0, l)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="card text-center py-12">
|
||||
<p className="text-gray-400 text-lg mb-3">暂无待导出或待 build 的批次</p>
|
||||
<p className="text-gray-400 text-lg mb-3">暂无待导出或待入库的批次</p>
|
||||
<p className="text-gray-400 text-sm mb-4">完成标注并质检通过后,批次会出现在此处</p>
|
||||
<Link to="/labeling/campaigns"><Button variant="default" size="small">去标注进度 →</Button></Link>
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
|
||||
interface MyAssignmentRow {
|
||||
campaign_id: string;
|
||||
batch: string;
|
||||
task: string;
|
||||
project?: string;
|
||||
status?: string;
|
||||
assigned: number;
|
||||
completed: number;
|
||||
pending: number;
|
||||
campaign_total?: number;
|
||||
annotate_url?: string;
|
||||
}
|
||||
import { MyTasksTable } from "../components/MyTasksTable";
|
||||
import type { MyAssignmentRow } from "../components/MyTasksTable";
|
||||
|
||||
export const MyTasksPage: React.FC = () => {
|
||||
const location = useLocation();
|
||||
@@ -66,59 +55,7 @@ export const MyTasksPage: React.FC = () => {
|
||||
empty={items.length === 0}
|
||||
emptyMessage="暂无分配给您的任务,请联系协调员分配或等待飞书通知"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{items.map((row) => {
|
||||
const pct = row.assigned > 0 ? Math.round((row.completed / row.assigned) * 100) : 0;
|
||||
const highlighted = highlightId === row.campaign_id;
|
||||
return (
|
||||
<div
|
||||
key={row.campaign_id}
|
||||
className={`card transition-shadow ${
|
||||
highlighted ? "ring-2 ring-blue-400 shadow-md" : "hover:shadow-sm"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-sm">{row.batch}</span>
|
||||
<span className="text-xs text-gray-400 font-mono">{row.task}</span>
|
||||
{row.project && (
|
||||
<span className="text-xs text-gray-400 uppercase">{row.project}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500">
|
||||
<span>
|
||||
待标 <strong className="text-amber-600">{row.pending}</strong>
|
||||
</span>
|
||||
<span>已完成 {row.completed}</span>
|
||||
<span>共分配 {row.assigned}</span>
|
||||
{row.campaign_total != null && (
|
||||
<span className="text-gray-400">批次共 {row.campaign_total} 张</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2 max-w-xs">
|
||||
<div className="flex-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${pct >= 100 ? "bg-green-500" : "bg-blue-500"}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">{pct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
to={`/labeling/annotate/${encodeURIComponent(row.campaign_id)}`}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Button size="small" variant="primary">
|
||||
{row.pending > 0 ? "继续标注" : "查看"}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<MyTasksTable items={items} highlightId={highlightId} />
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import React, { useEffect, useState, useCallback, useRef } from "react";
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { useParams, Link } from "react-router-dom";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { StageBadge } from "@/components/ui/Badge";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
import { ListPaginationBar } from "@/components/ListPaginationBar";
|
||||
import { LabelingListToolbar } from "../components/LabelingListToolbar";
|
||||
import { ReviewBatchTable } from "../components/ReviewBatchTable";
|
||||
import type { LabelingBatchRow } from "@/lib/types";
|
||||
|
||||
type ReviewItem = { id: string; image_path: string; fileName: string; score: string; has_label: boolean };
|
||||
type ScoreCounts = { good: number; fine: number; bad: number; pending: number };
|
||||
type ScoreKey = keyof ScoreCounts;
|
||||
type ReviewProgress = ScoreCounts & { total: number; reviewed: number; pass_rate: number; complete: boolean; stage?: string };
|
||||
|
||||
const SCORE_CFG: Record<ScoreKey, { label: string; color: string; bg: string; icon: string; btn: string }> = {
|
||||
good: { label: "合格", color: "text-green-600", bg: "bg-green-500", icon: "✓", btn: "bg-green-600 hover:bg-green-500 text-white" },
|
||||
@@ -17,45 +20,82 @@ const SCORE_CFG: Record<ScoreKey, { label: string; color: string; bg: string; ic
|
||||
pending: { label: "未评", color: "text-gray-400", bg: "bg-gray-300", icon: "○", btn: "" },
|
||||
};
|
||||
|
||||
const NAV_BTN =
|
||||
"inline-flex items-center justify-center gap-1.5 min-w-[5.5rem] px-3 py-2 rounded-lg text-sm font-medium transition-all " +
|
||||
"bg-gray-700/90 text-gray-100 border border-gray-600 " +
|
||||
"hover:bg-gray-600 hover:border-gray-500 active:scale-[0.98] " +
|
||||
"disabled:opacity-35 disabled:cursor-not-allowed disabled:hover:bg-gray-700/90 disabled:active:scale-100";
|
||||
|
||||
const STAGE_FILTERS = [
|
||||
{ label: "全部", value: "" },
|
||||
{ label: "质检中", value: "in_review" },
|
||||
{ label: "已通过", value: "labeling_submitted" },
|
||||
{ label: "已退回", value: "review_rejected" },
|
||||
];
|
||||
|
||||
// ── List ──
|
||||
const ReviewListPage: React.FC = () => {
|
||||
const [batches, setBatches] = useState<LabelingBatchRow[]>([]);
|
||||
const [filtered, setFiltered] = useState<LabelingBatchRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [limit, setLimit] = useState(20);
|
||||
const [progressMap, setProgressMap] = useState<Record<string, ReviewProgress>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [stageFilter, setStageFilter] = useState("");
|
||||
const [sort, setSort] = useState<"batch"|"task">("batch");
|
||||
const [rebuilding, setRebuilding] = useState(false);
|
||||
const [indexedAt, setIndexedAt] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError(null);
|
||||
const loadProgress = useCallback(async (rows: LabelingBatchRow[]) => {
|
||||
const ids = rows.map((b) => b.campaign_id).filter(Boolean) as string[];
|
||||
if (!ids.length) {
|
||||
setProgressMap({});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const results: LabelingBatchRow[] = [];
|
||||
const [inReview, approved, rejected] = await Promise.allSettled([
|
||||
hsapApi.labelingBatches({ stage: "in_review", limit: 100 }),
|
||||
hsapApi.labelingBatches({ stage: "labeling_submitted", limit: 50 }),
|
||||
hsapApi.labelingBatches({ stage: "review_rejected", limit: 50 }),
|
||||
]);
|
||||
if (inReview.status === "fulfilled") results.push(...((inReview.value.items || []) as LabelingBatchRow[]));
|
||||
if (approved.status === "fulfilled") results.push(...((approved.value.items || []) as LabelingBatchRow[]));
|
||||
if (rejected.status === "fulfilled") results.push(...((rejected.value.items || []) as LabelingBatchRow[]));
|
||||
setBatches(results);
|
||||
} catch (e) { setError(String(e)); }
|
||||
setLoading(false);
|
||||
const res = await hsapApi.reviewProgressBatch(ids);
|
||||
setProgressMap((res.items || {}) as Record<string, ReviewProgress>);
|
||||
} catch {
|
||||
setProgressMap({});
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
let result = [...batches];
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
result = result.filter((b) => (b.batch || "").toLowerCase().includes(q) || (b.task || "").toLowerCase().includes(q));
|
||||
const load = useCallback(async (newOffset: number, newLimit: number) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const q = search.trim() || undefined;
|
||||
const res = await hsapApi.labelingBatches(
|
||||
stageFilter
|
||||
? { stage: stageFilter, offset: newOffset, limit: newLimit, q }
|
||||
: { stages: ["in_review", "labeling_submitted", "review_rejected"], offset: newOffset, limit: newLimit, q },
|
||||
);
|
||||
const results = (res.items || []) as LabelingBatchRow[];
|
||||
setBatches(results);
|
||||
setTotal(res.total ?? 0);
|
||||
setOffset(newOffset);
|
||||
setLimit(newLimit);
|
||||
setIndexedAt(res.updated_at || null);
|
||||
await loadProgress(results);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
if (stageFilter) result = result.filter((b) => b.stage === stageFilter);
|
||||
result.sort((a, b) => (a[sort] || "").localeCompare(b[sort] || ""));
|
||||
setFiltered(result);
|
||||
}, [batches, search, stageFilter, sort]);
|
||||
setLoading(false);
|
||||
}, [search, stageFilter, loadProgress]);
|
||||
|
||||
useEffect(() => { void load(0, limit); }, [search, stageFilter]);
|
||||
|
||||
const handleRebuild = async () => {
|
||||
setRebuilding(true);
|
||||
try {
|
||||
await hsapApi.rebuildBatchIndex();
|
||||
await load(offset, limit);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
setRebuilding(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
@@ -64,72 +104,36 @@ const ReviewListPage: React.FC = () => {
|
||||
<h1>标注质检</h1>
|
||||
<p>审核标注质量 — 合格和可用的流入训练集,退回的返回标注员修正</p>
|
||||
</div>
|
||||
<Button size="small" variant="default" onClick={load}>刷新</Button>
|
||||
</div>
|
||||
{/* Search & Filter Bar */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-3 mb-4">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* Search */}
|
||||
<div className="flex-1 min-w-[200px] relative">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input className="w-full pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none"
|
||||
placeholder="搜索批次名称、任务名称..." value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</div>
|
||||
{/* Filter chips */}
|
||||
<div className="flex gap-1.5">
|
||||
{["全部", "质检中", "已通过", "已退回"].map((label, i) => {
|
||||
const val = i === 0 ? "" : ["in_review", "labeling_submitted", "review_rejected"][i - 1];
|
||||
return (
|
||||
<button key={val} onClick={() => setStageFilter(val)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||
stageFilter === val ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-600 hover:bg-gray-200"
|
||||
}`}>{label}</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className="text-gray-300">|</span>
|
||||
{/* Sort */}
|
||||
<select className="text-xs border border-gray-300 rounded-lg px-2.5 py-1.5 text-gray-600"
|
||||
value={sort} onChange={(e) => setSort(e.target.value as "batch"|"task")}>
|
||||
<option value="batch">按批次排序</option>
|
||||
<option value="task">按任务排序</option>
|
||||
</select>
|
||||
{/* Count */}
|
||||
<span className="text-xs text-gray-500 font-medium bg-gray-50 px-2.5 py-1 rounded-full">{filtered.length} 条</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{indexedAt && <span className="text-xs text-gray-400">索引 {indexedAt.slice(11, 19)}</span>}
|
||||
<Button size="small" variant="default" onClick={() => load(offset, limit)} disabled={loading}>刷新</Button>
|
||||
<Button size="small" variant="default" onClick={handleRebuild} loading={rebuilding}>同步磁盘</Button>
|
||||
</div>
|
||||
</div>
|
||||
<PageQueryState loading={loading} error={error} empty={filtered.length === 0} emptyMessage="暂无待质检的批次">
|
||||
<div className="space-y-2">
|
||||
{filtered.map((b) => {
|
||||
const pct = b.total_tasks && b.total_tasks > 0 ? Math.round(((b.completed_tasks || 0) / b.total_tasks) * 100) : 0;
|
||||
return (
|
||||
<div key={b.campaign_id || b.batch} className="card hover:shadow-sm transition-shadow">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm">{b.batch}</span>
|
||||
<span className="text-xs text-gray-400">{b.task || "—"}</span>
|
||||
<StageBadge stage={b.stage} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-gray-400">
|
||||
<span className="font-mono">{(b.campaign_id || "").slice(0, 14)}...</span>
|
||||
{pct > 0 && <span>{b.completed_tasks}/{b.total_tasks} ({pct}%)</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{b.stage === "in_review" && <Link to={`/labeling/review/${b.campaign_id}`}><Button size="small" variant="primary">▶ 开始质检</Button></Link>}
|
||||
{b.stage === "labeling_submitted" && (
|
||||
<Link to="/labeling/export"><Button size="small" variant="default">去导出 →</Button></Link>
|
||||
)}
|
||||
{b.stage === "review_rejected" && <span className="text-red-600 text-sm font-medium">✗ 已退回</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<LabelingListToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder="搜索批次/任务/项目..."
|
||||
filters={STAGE_FILTERS}
|
||||
filterValue={stageFilter}
|
||||
onFilterChange={setStageFilter}
|
||||
total={total}
|
||||
/>
|
||||
|
||||
<PageQueryState loading={loading} error={error} empty={!loading && total === 0} emptyMessage="暂无待质检的批次">
|
||||
{total > 0 && (
|
||||
<>
|
||||
<ReviewBatchTable batches={batches} progressMap={progressMap} />
|
||||
<ListPaginationBar
|
||||
total={total}
|
||||
offset={offset}
|
||||
limit={limit}
|
||||
onOffsetChange={(o) => load(o, limit)}
|
||||
onLimitChange={(l) => load(0, l)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
@@ -138,64 +142,105 @@ const ReviewListPage: React.FC = () => {
|
||||
// ── Detail ──
|
||||
const ReviewDetailPage: React.FC<{ campaignId: string }> = ({ campaignId }) => {
|
||||
const [items, setItems] = useState<ReviewItem[]>([]);
|
||||
const [totalImages, setTotalImages] = useState(0);
|
||||
const [scores, setScores] = useState<ScoreCounts>({ good: 0, fine: 0, bad: 0, pending: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentIdx, setCurrentIdx] = useState(0);
|
||||
const [localScores, setLocalScores] = useState<Record<string, string>>({});
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const [imageBlobUrl, setImageBlobUrl] = useState<string | null>(null);
|
||||
const [imgLoading, setImgLoading] = useState(false);
|
||||
const [finishState, setFinishState] = useState<"approved" | "rejected" | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const loadPage = useCallback(async (idx: number) => {
|
||||
setLoading(true); setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams({ offset: "0", limit: "2000" });
|
||||
const res = await fetch(`/api/v1/labeling/campaigns/${campaignId}/review-queue?${params}`, {
|
||||
headers: { Authorization: `Bearer ${hsapApi.getToken()}` }, cache: "no-store",
|
||||
}).then((r) => r.json());
|
||||
setItems((res.items || []) as ReviewItem[]);
|
||||
setScores((res.scores || { good: 0, fine: 0, bad: 0, pending: 0 }) as ScoreCounts);
|
||||
const data = await hsapApi.reviewQueue(campaignId, { offset: idx, limit: 1 });
|
||||
setItems((data.items || []) as ReviewItem[]);
|
||||
setTotalImages(Number(data.total) || 0);
|
||||
if (data.scores) setScores(data.scores as ScoreCounts);
|
||||
} catch (e) { setError(String(e)); }
|
||||
setLoading(false);
|
||||
}, [campaignId]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
useEffect(() => { loadPage(currentIdx); }, [currentIdx, loadPage]);
|
||||
|
||||
const currentImage = items[currentIdx];
|
||||
const imageUrl = currentImage
|
||||
? `/api/v1/labeling/campaigns/${campaignId}/review-image?path=${encodeURIComponent(currentImage.image_path)}`
|
||||
: "";
|
||||
const currentScore = currentImage ? (localScores[currentImage.image_path] || currentImage.score || "pending") : "pending";
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentImage) {
|
||||
setImageBlobUrl(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setImgLoading(true);
|
||||
setImgError(false);
|
||||
hsapApi.fetchReviewImageBlob(campaignId, currentImage.image_path)
|
||||
.then((url) => {
|
||||
if (cancelled) {
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
setImageBlobUrl((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return url;
|
||||
});
|
||||
setImgLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setImgError(true);
|
||||
setImageBlobUrl(null);
|
||||
setImgLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
setImageBlobUrl((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return null;
|
||||
});
|
||||
};
|
||||
}, [campaignId, currentImage?.image_path]);
|
||||
|
||||
const handleScore = (score: string) => {
|
||||
if (!currentImage) return;
|
||||
setLocalScores((prev) => ({ ...prev, [currentImage.image_path]: score }));
|
||||
fetch(`/api/v1/labeling/campaigns/${campaignId}/review-submit`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${hsapApi.getToken()}` },
|
||||
body: JSON.stringify({ scores: [{ image_path: currentImage.image_path, score }] }),
|
||||
}).catch(() => {});
|
||||
hsapApi.reviewSubmit(campaignId, [{ image_path: currentImage.image_path, score }])
|
||||
.then((data) => {
|
||||
if (data.auto_advanced) {
|
||||
setFinishState(data.stage === "review_approved" ? "approved" : "rejected");
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
setScores((prev) => {
|
||||
const oldScore = currentImage.score || "pending";
|
||||
const next = { ...prev };
|
||||
if (next[oldScore as ScoreKey] > 0) next[oldScore as ScoreKey]--;
|
||||
next[score as ScoreKey] = (next[score as ScoreKey] || 0) + 1;
|
||||
if (oldScore === "pending" && next.pending > 0) next.pending--;
|
||||
return next;
|
||||
});
|
||||
if (currentIdx < items.length - 1) setTimeout(() => setCurrentIdx((i) => i + 1), 250);
|
||||
if (currentIdx < totalImages - 1) setTimeout(() => setCurrentIdx((i) => i + 1), 250);
|
||||
};
|
||||
|
||||
const reviewComplete = scores.pending === 0 && totalImages > 0;
|
||||
const reviewPassed = finishState === "approved" || (reviewComplete && (scores.good + scores.fine) / totalImages >= 0.8);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
||||
if (e.key === "ArrowLeft") setCurrentIdx((i) => Math.max(0, i - 1));
|
||||
else if (e.key === "ArrowRight") setCurrentIdx((i) => Math.min(items.length - 1, i + 1));
|
||||
else if (e.key === "ArrowRight") setCurrentIdx((i) => Math.min(totalImages - 1, i + 1));
|
||||
else if (e.key === "g" || e.key === "G") handleScore("good");
|
||||
else if (e.key === "f" || e.key === "F") handleScore("fine");
|
||||
else if (e.key === "b" || e.key === "B") handleScore("bad");
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [currentIdx, items.length]);
|
||||
}, [currentIdx, totalImages]);
|
||||
|
||||
const reviewed = scores.good + scores.fine + scores.bad;
|
||||
const total = reviewed + scores.pending;
|
||||
@@ -203,7 +248,7 @@ const ReviewDetailPage: React.FC<{ campaignId: string }> = ({ campaignId }) => {
|
||||
const passRate = reviewed > 0 ? ((scores.good + scores.fine) / reviewed) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-gray-900 text-white">
|
||||
<div className="flex flex-col flex-1 min-h-0 h-full overflow-hidden bg-gray-900 text-white">
|
||||
{/* Top control bar */}
|
||||
<header className="flex items-center gap-4 px-4 py-2 bg-gray-800 border-b border-gray-700 shrink-0">
|
||||
<Link to="/labeling/review" className="text-blue-400 text-sm hover:underline">← 返回列表</Link>
|
||||
@@ -225,36 +270,78 @@ const ReviewDetailPage: React.FC<{ campaignId: string }> = ({ campaignId }) => {
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<PageQueryState loading={loading} error={error} empty={items.length === 0} emptyMessage="该批次暂无图片">
|
||||
{(reviewComplete || finishState) && (
|
||||
<div className={`shrink-0 px-4 py-2.5 flex items-center justify-between gap-3 text-sm ${
|
||||
reviewPassed ? "bg-green-900/40 border-b border-green-700/50" : "bg-red-900/40 border-b border-red-700/50"
|
||||
}`}>
|
||||
<span className={reviewPassed ? "text-green-300" : "text-red-300"}>
|
||||
{reviewPassed
|
||||
? `质检完成:合格+可用 ${scores.good + scores.fine}/${totalImages}(通过率 ${Math.round(((scores.good + scores.fine) / totalImages) * 100)}%),可进入导出入库`
|
||||
: `质检完成:退回 ${scores.bad} 张,批次已退回标注员修正`}
|
||||
</span>
|
||||
{reviewPassed ? (
|
||||
<Link to="/labeling/export">
|
||||
<Button size="small" variant="success">前往导出入库 →</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Link to="/labeling/review">
|
||||
<Button size="small" variant="ghost">返回列表</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PageQueryState loading={loading} error={error} empty={!loading && totalImages === 0} emptyMessage="该批次暂无图片">
|
||||
{currentImage && (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
{/* Image */}
|
||||
<div className="flex-1 flex items-center justify-center p-4 min-h-0">
|
||||
{imgError ? (
|
||||
<div className="flex-1 flex items-center justify-center p-4 min-h-0 overflow-hidden">
|
||||
{imgLoading ? (
|
||||
<div className="text-gray-400 text-center"><p className="text-lg mb-2">加载预览中...</p></div>
|
||||
) : imgError || !imageBlobUrl ? (
|
||||
<div className="text-gray-500 text-center"><p className="text-lg mb-2">图片加载失败</p><p className="text-sm">{currentImage.fileName}</p></div>
|
||||
) : (
|
||||
<img src={imageUrl} alt={currentImage.fileName} className="max-w-full max-h-full object-contain rounded-lg shadow-2xl"
|
||||
onError={() => setImgError(true)} onLoad={() => setImgError(false)} />
|
||||
<img src={imageBlobUrl} alt={currentImage.fileName} className="max-w-full max-h-full object-contain rounded-lg shadow-2xl" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom bar */}
|
||||
<div className="shrink-0 bg-gray-800 border-t border-gray-700 px-4 py-3">
|
||||
<div className="flex items-center gap-3 max-w-5xl mx-auto">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="default" size="small" onClick={() => setCurrentIdx((i) => Math.max(0, i - 1))} disabled={currentIdx <= 0}>←</Button>
|
||||
<span className="text-sm text-gray-400 w-20 text-center tabular-nums">{currentIdx + 1}/{items.length}</span>
|
||||
<Button variant="default" size="small" onClick={() => setCurrentIdx((i) => Math.min(items.length - 1, i + 1))} disabled={currentIdx >= items.length - 1}>→</Button>
|
||||
{/* Bottom bar — 始终贴在可视区域底部 */}
|
||||
<div className="shrink-0 bg-gray-800 border-t border-gray-700 px-4 py-3 safe-area-pb">
|
||||
<div className="flex flex-wrap items-center justify-center gap-2 sm:gap-3 max-w-5xl mx-auto">
|
||||
<div className="inline-flex items-center gap-1 p-1 rounded-xl bg-gray-900/70 border border-gray-700/80 shadow-inner">
|
||||
<button
|
||||
type="button"
|
||||
className={NAV_BTN}
|
||||
onClick={() => setCurrentIdx((i) => Math.max(0, i - 1))}
|
||||
disabled={currentIdx <= 0}
|
||||
aria-label="上一张"
|
||||
>
|
||||
<span aria-hidden>‹</span> 上一张
|
||||
</button>
|
||||
<span className="px-3 py-1.5 text-sm font-semibold text-gray-200 tabular-nums min-w-[4.5rem] text-center select-none">
|
||||
{currentIdx + 1}<span className="text-gray-500 font-normal mx-1">/</span>{totalImages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={NAV_BTN}
|
||||
onClick={() => setCurrentIdx((i) => Math.min(totalImages - 1, i + 1))}
|
||||
disabled={currentIdx >= totalImages - 1}
|
||||
aria-label="下一张"
|
||||
>
|
||||
下一张 <span aria-hidden>›</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<div className="hidden sm:block flex-1" />
|
||||
<div className="flex flex-wrap items-center justify-center gap-2 w-full sm:w-auto">
|
||||
{(["good", "fine", "bad"] as ScoreKey[]).map((s) => (
|
||||
<button key={s} onClick={() => handleScore(s)}
|
||||
className={`px-6 py-2.5 rounded-lg font-semibold text-sm transition-all ${SCORE_CFG[s].btn} ${
|
||||
className={`px-5 py-2.5 rounded-lg font-semibold text-sm transition-all ${SCORE_CFG[s].btn} ${
|
||||
currentScore === s ? "ring-2 ring-offset-2 ring-offset-gray-800 scale-105" : "hover:scale-105"
|
||||
}`}>
|
||||
{SCORE_CFG[s].icon} {SCORE_CFG[s].label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center mt-1.5 text-xs text-gray-500">
|
||||
{currentImage.fileName} · 标注: {currentImage.has_label ? "有" : "无"} · 快捷键: G/F/B 评分 ←→ 翻页
|
||||
|
||||
@@ -1,91 +1,66 @@
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { hsapApi } from "@/app/hsap-api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { StageBadge, Badge } from "@/components/ui/Badge";
|
||||
import { PageQueryState } from "@/components/PageQueryState";
|
||||
import { ListPaginationBar } from "@/components/ListPaginationBar";
|
||||
import { LabelingListToolbar } from "../components/LabelingListToolbar";
|
||||
import { WorkbenchBatchTable } from "../components/WorkbenchBatchTable";
|
||||
import { displayTaskFields } from "@/lib/labelingDisplay";
|
||||
import type { LabelingBatchRow } from "@/lib/types";
|
||||
|
||||
type ScanItem = { project: string; task: string; batch: string; path: string; images: number; labels: number; has_labels: boolean; stage_hint: string };
|
||||
|
||||
export const WorkbenchPage: React.FC = () => {
|
||||
const [batches, setBatches] = useState<LabelingBatchRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [limit, setLimit] = useState(20);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
const [indexedAt, setIndexedAt] = useState<string | null>(null);
|
||||
const [rebuilding, setRebuilding] = useState(false);
|
||||
const [stage, setStage] = useState("raw_pool");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const filteredBatches = batches.filter((b) => {
|
||||
if (!search) return true;
|
||||
const q = search.toLowerCase();
|
||||
return (b.batch || "").toLowerCase().includes(q) || (b.task || "").toLowerCase().includes(q);
|
||||
});
|
||||
const STAGE_FILTERS = [
|
||||
{ label: "待送标", value: "raw_pool" },
|
||||
{ label: "标中", value: "out_for_labeling" },
|
||||
{ label: "待入库", value: "returned" },
|
||||
];
|
||||
|
||||
// Scan inbox
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [scanItems, setScanItems] = useState<ScanItem[]>([]);
|
||||
const [showScan, setShowScan] = useState(false);
|
||||
const [registering, setRegistering] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async (filterStage: string) => {
|
||||
setLoading(true); setError(null);
|
||||
const load = useCallback(async (filterStage: string, newOffset: number, newLimit: number) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await hsapApi.labelingBatches({ stage: filterStage, limit: 100 });
|
||||
const q = search.trim() || undefined;
|
||||
const res = await hsapApi.labelingBatches({ stage: filterStage, offset: newOffset, limit: newLimit, q });
|
||||
setBatches((res.items || []) as LabelingBatchRow[]);
|
||||
} catch (e) { setError(String(e)); }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(stage); }, [stage, load]);
|
||||
|
||||
const handleScan = async () => {
|
||||
setScanning(true); setError(null);
|
||||
try {
|
||||
const [dms, adas] = await Promise.all([
|
||||
hsapApi.scanInbox("dms"),
|
||||
hsapApi.scanInbox("adas"),
|
||||
]);
|
||||
const items = [
|
||||
...((dms.items || []) as unknown as ScanItem[]),
|
||||
...((adas.items || []) as unknown as ScanItem[]),
|
||||
];
|
||||
setScanItems(items);
|
||||
setShowScan(true);
|
||||
} catch (e) { setError(String(e)); }
|
||||
setScanning(false);
|
||||
};
|
||||
|
||||
const handleRegister = async (item: ScanItem) => {
|
||||
setRegistering(item.batch);
|
||||
try {
|
||||
await hsapApi.registerBatch({
|
||||
project: item.project, task: item.task, batch: item.batch,
|
||||
stage: item.stage_hint, location: "inbox",
|
||||
});
|
||||
setScanItems((prev) => prev.filter((s) => s.batch !== item.batch));
|
||||
load(stage);
|
||||
setInfo(`已登记: ${item.task}/${item.batch}`);
|
||||
} catch (e) { setError(String(e)); }
|
||||
setRegistering(null);
|
||||
};
|
||||
|
||||
const handleRegisterAll = async () => {
|
||||
let count = 0;
|
||||
for (const item of scanItems) {
|
||||
try {
|
||||
await hsapApi.registerBatch({
|
||||
project: item.project, task: item.task, batch: item.batch,
|
||||
stage: item.stage_hint, location: "inbox",
|
||||
});
|
||||
count++;
|
||||
} catch { /* skip */ }
|
||||
setTotal(res.total ?? 0);
|
||||
setOffset(newOffset);
|
||||
setLimit(newLimit);
|
||||
setIndexedAt(res.updated_at || null);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
setScanItems([]); setShowScan(false);
|
||||
load(stage);
|
||||
setInfo(`已登记 ${count} 个批次`);
|
||||
setLoading(false);
|
||||
}, [search]);
|
||||
|
||||
const [archivingId, setArchivingId] = useState<string | null>(null);
|
||||
|
||||
const handleRebuildIndex = async () => {
|
||||
setRebuilding(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await hsapApi.rebuildBatchIndex();
|
||||
setInfo(`索引已更新 ${r.count} 条(${r.elapsed_ms}ms)`);
|
||||
await load(stage, offset, limit);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
setRebuilding(false);
|
||||
};
|
||||
|
||||
useEffect(() => { void load(stage, 0, limit); }, [stage, search]);
|
||||
|
||||
const handleOpenCampaign = async (row: LabelingBatchRow) => {
|
||||
if (!row.task) return;
|
||||
try {
|
||||
@@ -93,15 +68,31 @@ export const WorkbenchPage: React.FC = () => {
|
||||
project: row.project, task: row.task, batch: row.batch,
|
||||
mode: row.mode || null, pack: row.pack || null, location: row.location,
|
||||
});
|
||||
load(stage);
|
||||
load(stage, offset, limit);
|
||||
} catch (e) { setError(String(e)); }
|
||||
};
|
||||
|
||||
const STAGES = [
|
||||
{ key: "raw_pool", label: "待送标" },
|
||||
{ key: "out_for_labeling", label: "标中" },
|
||||
{ key: "returned", label: "待入库" },
|
||||
];
|
||||
const handleArchive = async (row: LabelingBatchRow) => {
|
||||
const cid = row.campaign_id;
|
||||
if (!cid) return;
|
||||
const label = `${displayTaskFields(row)} / ${row.batch}`;
|
||||
const ok = window.confirm(
|
||||
`确定从工作台移除「${label}」?\n\n`
|
||||
+ "不会删除磁盘上的图片与标注文件,仅从列表隐藏。\n"
|
||||
+ "之后可在批次台账重新扫描登记。",
|
||||
);
|
||||
if (!ok) return;
|
||||
setArchivingId(cid);
|
||||
setError(null);
|
||||
try {
|
||||
await hsapApi.archiveLabelingBatch(cid);
|
||||
setInfo(`已移除: ${row.batch}`);
|
||||
await load(stage, offset, limit);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
setArchivingId(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
@@ -110,105 +101,48 @@ export const WorkbenchPage: React.FC = () => {
|
||||
<h1>送标工作台</h1>
|
||||
<p>管理数据标注批次的全生命周期</p>
|
||||
</div>
|
||||
<Button variant="primary" size="small" onClick={handleScan} loading={scanning}>
|
||||
扫描入库
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="default" size="small" onClick={handleRebuildIndex} loading={rebuilding}>
|
||||
同步磁盘
|
||||
</Button>
|
||||
{indexedAt && <span className="text-xs text-gray-400">索引 {indexedAt.slice(11, 19)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{info && <div className="bg-green-50 border border-green-200 rounded p-3 mb-4 text-sm text-green-700">{info}</div>}
|
||||
|
||||
{/* Scan results panel */}
|
||||
{showScan && (
|
||||
<div className="card mb-4 border-blue-200 bg-blue-50/30">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<span>扫描结果 — 发现 {scanItems.length} 个未登记批次</span>
|
||||
<div className="flex gap-2">
|
||||
{scanItems.length > 0 && <Button size="small" variant="primary" onClick={handleRegisterAll}>全部登记</Button>}
|
||||
<Button size="small" variant="default" onClick={() => setShowScan(false)}>收起</Button>
|
||||
</div>
|
||||
</div>
|
||||
{scanItems.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">所有 inbox 数据均已登记</p>
|
||||
) : (
|
||||
<table className="table-auto">
|
||||
<thead>
|
||||
<tr><th>任务</th><th>批次</th><th>图片</th><th>标注</th><th>状态</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{scanItems.map((item) => (
|
||||
<tr key={`${item.task}/${item.batch}`}>
|
||||
<td className="font-medium">{item.task}</td>
|
||||
<td>{item.batch}</td>
|
||||
<td>{item.images}</td>
|
||||
<td>{item.has_labels ? <Badge variant="success" size="small">{item.labels} 个</Badge> : <span className="text-gray-400">无标注</span>}</td>
|
||||
<td><StageBadge stage={item.stage_hint} /></td>
|
||||
<td>
|
||||
<Button size="small" variant="primary" loading={registering === item.batch} onClick={() => handleRegister(item)}>
|
||||
登记入库
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stage filter + Search */}
|
||||
<div className="flex gap-2 mb-4 items-center flex-wrap">
|
||||
{STAGES.map((s) => (
|
||||
<button key={s.key} onClick={() => setStage(s.key)}
|
||||
className={`px-4 py-2 text-sm rounded-md transition-colors ${stage === s.key ? "bg-blue-700 text-white" : "bg-white border border-gray-300 text-gray-600 hover:bg-gray-50"}`}>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
<input className="form-input w-40" placeholder="搜索批次..." value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
<div className="bg-blue-50 border border-blue-100 rounded-lg px-4 py-2 mb-4 text-xs text-blue-800">
|
||||
新批次请先在 <a href="/labeling/deliveries" className="underline font-medium">批次台账</a> 扫描数据湖或登记 NAS 送标,入湖后再来此开标。
|
||||
</div>
|
||||
|
||||
<PageQueryState loading={loading} error={error} empty={filteredBatches.length === 0}>
|
||||
<div className="space-y-2">
|
||||
{filteredBatches.map((b) => (
|
||||
<div key={`${b.project}/${b.task}/${b.batch}`} className="card hover:shadow-sm transition-shadow">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-sm">{b.batch}</span>
|
||||
<span className="text-xs text-gray-400">{b.project}/{b.task || "—"}</span>
|
||||
<StageBadge stage={b.stage} />
|
||||
{(b as any).annotation_types?.map((t: string) => (
|
||||
<span key={t} className="text-[10px] px-1.5 py-0.5 rounded bg-indigo-100 text-indigo-700 font-medium">
|
||||
{{bbox: "2D框", keypoint: "关键点", polyline: "车道线", cuboid: "3D框"}[t] || t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-3 mt-1 text-xs text-gray-400">
|
||||
<span>🖼 {b.counts?.images ?? 0}</span>
|
||||
<span>🏷 {b.counts?.labels ?? 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{b.stage === "raw_pool" && (
|
||||
<Button size="small" variant="primary" onClick={() => handleOpenCampaign(b)}>开标</Button>
|
||||
)}
|
||||
{b.stage === "out_for_labeling" && b.campaign_id && (
|
||||
<Link to={`/labeling/annotate/${b.campaign_id}`}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg bg-blue-50 text-blue-700 hover:bg-blue-100 transition-colors">
|
||||
✏️ 进入标注
|
||||
</Link>
|
||||
)}
|
||||
{b.stage === "returned" && (
|
||||
<Link to="/labeling/export"
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium rounded-lg bg-orange-50 text-orange-700 hover:bg-orange-100 transition-colors">
|
||||
🏗 提交 build
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<LabelingListToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder="搜索批次/任务..."
|
||||
filters={STAGE_FILTERS}
|
||||
filterValue={stage}
|
||||
onFilterChange={setStage}
|
||||
total={total}
|
||||
/>
|
||||
|
||||
<PageQueryState loading={loading} error={error} empty={!loading && total === 0}>
|
||||
{total > 0 && (
|
||||
<>
|
||||
<WorkbenchBatchTable
|
||||
batches={batches}
|
||||
onOpenCampaign={handleOpenCampaign}
|
||||
onArchive={handleArchive}
|
||||
archivingId={archivingId}
|
||||
/>
|
||||
<ListPaginationBar
|
||||
total={total}
|
||||
offset={offset}
|
||||
limit={limit}
|
||||
onOffsetChange={(o) => load(stage, o, limit)}
|
||||
onLimitChange={(l) => load(stage, 0, l)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</PageQueryState>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,41 +2,44 @@ import React, { useState } from "react";
|
||||
import { useAuth } from "@/app/AuthContext";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
|
||||
const LOGIN_BG = "/login-bg.png";
|
||||
|
||||
export const LoginPage: React.FC = () => {
|
||||
const { authConfig, loginDev, loginFeishu, loading } = useAuth();
|
||||
const [devName, setDevName] = useState("开发用户");
|
||||
const [loggingIn, setLoggingIn] = useState(false);
|
||||
|
||||
const shellCls =
|
||||
"min-h-screen relative flex items-center justify-end bg-cover bg-center bg-no-repeat px-6 py-10 md:px-16";
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-gray-400 animate-pulse">加载中...</div>
|
||||
<div className={shellCls} style={{ backgroundImage: `url(${LOGIN_BG})` }}>
|
||||
<div className="absolute inset-0 bg-slate-900/25" />
|
||||
<div className="relative z-10 text-white/90 animate-pulse">加载中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-8 w-full max-w-sm">
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">HSAP</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">华胥 Sentinel 主动安全平台</p>
|
||||
<div className={shellCls} style={{ backgroundImage: `url(${LOGIN_BG})` }}>
|
||||
<div className="absolute inset-0 bg-gradient-to-l from-slate-900/55 via-slate-900/20 to-transparent" />
|
||||
|
||||
<div className="relative z-10 w-full max-w-md rounded-2xl border border-white/20 bg-white/92 backdrop-blur-md shadow-2xl p-8">
|
||||
<div className="mb-6">
|
||||
<p className="text-xs font-medium tracking-widest text-blue-600 uppercase">HSAP Platform</p>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mt-1">华胥 Sentinel</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">主动安全算法迭代平台</p>
|
||||
</div>
|
||||
|
||||
{/* Feishu login */}
|
||||
{authConfig?.feishu_enabled && (
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full mb-3"
|
||||
onClick={loginFeishu}
|
||||
>
|
||||
<Button variant="primary" className="w-full mb-3" onClick={loginFeishu}>
|
||||
飞书账号登录
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Dev login */}
|
||||
{authConfig?.dev_auth_enabled && (
|
||||
<div className="border-t pt-4 mt-4">
|
||||
<div className="border-t border-gray-200 pt-4 mt-4">
|
||||
<p className="text-xs text-gray-400 mb-2 text-center">开发模式</p>
|
||||
<input
|
||||
className="form-input mb-2"
|
||||
|
||||
@@ -26,8 +26,8 @@ html, body, #root {
|
||||
|
||||
/* ---- Sidebar ---- */
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
min-width: 240px;
|
||||
width: 188px;
|
||||
min-width: 188px;
|
||||
background: #ffffff;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
@@ -37,11 +37,19 @@ html, body, #root {
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
padding: 16px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
color: var(--lsf-primary-emphasis);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.sidebar-brand .text-gray-400 {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
@@ -51,9 +59,9 @@ html, body, #root {
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 12px 16px;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ---- Module Group ---- */
|
||||
@@ -64,10 +72,10 @@ html, body, #root {
|
||||
.module-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #4b5563;
|
||||
border: none;
|
||||
@@ -110,8 +118,8 @@ html, body, #root {
|
||||
|
||||
.module-group-items .nav-item {
|
||||
display: block;
|
||||
padding: 7px 16px 7px 40px;
|
||||
font-size: 13px;
|
||||
padding: 6px 12px 6px 32px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
@@ -147,6 +155,21 @@ html, body, #root {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.main-content > .module-area:has(.labeling-immersive) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.labeling-immersive {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---- Module Tab Bar ---- */
|
||||
@@ -155,13 +178,13 @@ html, body, #root {
|
||||
gap: 0;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
background: #ffffff;
|
||||
padding: 0 24px;
|
||||
padding: 0 16px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.module-tab {
|
||||
padding: 12px 20px;
|
||||
font-size: 14px;
|
||||
padding: 10px 14px;
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
text-decoration: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
@@ -182,8 +205,8 @@ html, body, #root {
|
||||
|
||||
/* ---- Shared UI ---- */
|
||||
.page-container {
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
padding: 20px;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
|
||||
Reference in New Issue
Block a user