feat: Library 批量注册 + PeopleView 修复 + 国际化

Library:
- 添加 checkbox 和鼠标框选功能
- Unregistered 卡片添加快速注册按钮
- 状态标签移至左下方,人脸图标移至左侧中间
- Deep Scan 改为 unregister -> register -> process
- 完整国际化(日语、繁体中文、英语)

PeopleView:
- 修复卡片不显示问题(使用 get_file_traces API)
- 移除对 identity_id 字段的依赖

其他:
- 添加日语语言包
- 添加 useDragSelect composable
- 新增文档:Face Bbox Null Bug Report
This commit is contained in:
2026-10-01 00:42:13 +08:00
parent 7b60ca3d2d
commit f3d75ebce8
35 changed files with 8054 additions and 961 deletions

View File

@@ -0,0 +1,149 @@
import { ref, type Ref } from 'vue'
export interface DragSelectBox {
x1: number
y1: number
x2: number
y2: number
}
export interface DragSelectOptions {
getSelected: () => string[]
setSelected: (keys: string[]) => void
keyOf?: (el: HTMLElement) => string
onBox?: (box: DragSelectBox | null) => void
}
const DRAG_THRESHOLD = 4
function overlaps(ax1: number, ay1: number, ax2: number, ay2: number, bx1: number, by1: number, bx2: number, by2: number) {
return ax1 < bx2 && ax2 > bx1 && ay1 < by2 && ay2 > by1
}
export function useDragSelect(containerRef: Ref<HTMLElement | null>, opts: DragSelectOptions) {
const box = ref<DragSelectBox | null>(null)
function setBox(b: DragSelectBox | null) {
box.value = b
opts.onBox?.(b)
}
let startX = 0
let startY = 0
let curX = 0
let curY = 0
let moved = false
let lastWasDrag = false
function onMousedown(e: MouseEvent) {
if (e.button !== 0) return
if (e.ctrlKey || e.metaKey || e.shiftKey) return
const target = e.target as HTMLElement
if (target.closest('button, a, input, textarea, select, .ms-ppl-card-checkbox, .ms-fm-play-btn')) return
startX = curX = e.clientX
startY = curY = e.clientY
moved = false
lastWasDrag = false
setBox({ x1: startX, y1: startY, x2: startX, y2: startY })
window.addEventListener('mousemove', onMousemove)
window.addEventListener('mouseup', onMouseup)
}
function onMousemove(e: MouseEvent) {
curX = e.clientX
curY = e.clientY
if (!moved && Math.abs(curX - startX) < DRAG_THRESHOLD && Math.abs(curY - startY) < DRAG_THRESHOLD) {
setBox({ x1: startX, y1: startY, x2: startX, y2: startY })
return
}
if (!moved) {
moved = true
document.body.style.userSelect = 'none'
}
setBox({
x1: Math.min(startX, curX),
y1: Math.min(startY, curY),
x2: Math.max(startX, curX),
y2: Math.max(startY, curY),
})
}
function onMouseup(e: MouseEvent) {
window.removeEventListener('mousemove', onMousemove)
window.removeEventListener('mouseup', onMouseup)
if (!moved) {
setBox(null)
return
}
lastWasDrag = true
const rect = box.value
setBox(null)
if (document.body.style.userSelect === 'none') {
document.body.style.userSelect = ''
}
const container = containerRef.value
if (!container || !rect) {
return
}
const bx1 = rect.x1
const by1 = rect.y1
const bx2 = rect.x2
const by2 = rect.y2
const keyOf = opts.keyOf ?? ((el: HTMLElement) => el.dataset.dragKey || '')
const intersecting = new Set<string>()
const cards = container.querySelectorAll<HTMLElement>('[data-drag-key]')
cards.forEach((card) => {
const r = card.getBoundingClientRect()
if (overlaps(r.left, r.top, r.right, r.bottom, bx1, by1, bx2, by2)) {
const key = keyOf(card)
if (key) intersecting.add(key)
}
})
const prev = opts.getSelected()
if (e.shiftKey) {
const next = new Set(prev)
intersecting.forEach((k) => next.add(k))
opts.setSelected(Array.from(next))
} else if (e.ctrlKey || e.metaKey) {
const next = new Set(prev)
intersecting.forEach((k) => {
if (next.has(k)) next.delete(k)
else next.add(k)
})
opts.setSelected(Array.from(next))
} else {
opts.setSelected(Array.from(intersecting))
}
}
function onClickCapture(e: MouseEvent) {
if (lastWasDrag) {
e.preventDefault()
e.stopPropagation()
lastWasDrag = false
}
}
return {
box,
onMousedown,
onClickCapture,
}
}
export function dragSelectRectStyle(b: DragSelectBox) {
return {
left: b.x1 + 'px',
top: b.y1 + 'px',
width: Math.max(0, b.x2 - b.x1) + 'px',
height: Math.max(0, b.y2 - b.y1) + 'px',
}
}

View File

@@ -86,6 +86,26 @@ export interface FilterStats {
};
}
export interface SingleFrameFSSettings {
single_frame_trace_vlm_enabled: boolean;
single_frame_trace_group_enabled: boolean;
single_frame_trace_unassigned_enabled: boolean;
single_trace_group_filter_enabled: boolean;
}
export interface FileProfileResponse {
file_uuid: string;
file_name: string;
file_path: string;
status: string;
duration: number;
width: number;
height: number;
fps: number;
total_frames: number;
face_filter: SingleFrameFSSettings;
}
// Default rules
const defaultRules: QuickFilterRules = {
min_confidence: 0.5,
@@ -105,13 +125,13 @@ async function fetchFaces(fileUuid: string, params?: { page?: number; page_size?
if (params?.page_size) queryParams.set('page_size', params.page_size.toString());
if (params?.source) queryParams.set('source', params.source);
const response = await fetch(`${apiBase}/file/${fileUuid}/faces?${queryParams}`);
const response = await fetch(`${apiBase}/file/${fileUuid}/face-filter/faces?${queryParams}`);
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
return response.json();
}
async function applyFilter(fileUuid: string, rules?: any) {
const response = await fetch(`${apiBase}/file/${fileUuid}/faces/filter`, {
const response = await fetch(`${apiBase}/file/${fileUuid}/face-filter/apply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rules: rules || null })
@@ -121,17 +141,32 @@ async function applyFilter(fileUuid: string, rules?: any) {
}
async function getFilterStats(fileUuid: string) {
const response = await fetch(`${apiBase}/file/${fileUuid}/filter-stats`);
const response = await fetch(`${apiBase}/file/${fileUuid}/face-filter/stats`);
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
return response.json();
}
async function getFaceReport(fileUuid: string, faceId: string) {
const response = await fetch(`${apiBase}/file/${fileUuid}/face/${faceId}/filter-report`);
const response = await fetch(`${apiBase}/file/${fileUuid}/face-filter/${faceId}/report`);
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
return response.json();
}
async function getFileProfile(fileUuid: string): Promise<FileProfileResponse> {
const response = await fetch(`${apiBase}/file-profile?file_uuid=${fileUuid}`);
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
return response.json();
}
async function updateFileProfile(fileUuid: string, faceFilter: Partial<SingleFrameFSSettings>): Promise<boolean> {
const response = await fetch(`${apiBase}/file-profile`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_uuid: fileUuid, face_filter: faceFilter })
});
return response.ok;
}
// Quick filter function (client-side)
function quickFilter(faces: FaceMetadata[], rules: QuickFilterRules): FaceMetadata[] {
return faces.filter(face => {
@@ -189,6 +224,16 @@ export function useFaceFilter() {
const loading = ref(false);
const error = ref<string | null>(null);
const singleFrameSettings = ref<SingleFrameFSSettings>({
single_frame_trace_vlm_enabled: true,
single_frame_trace_group_enabled: true,
single_frame_trace_unassigned_enabled: true,
single_trace_group_filter_enabled: true,
});
const singleFrameSettingsLoading = ref(false);
const singleFrameSettingsError = ref<string | null>(null);
const singleFrameSettingsModified = ref(false);
// Computed stats
const stats = computed(() => ({
total: allFaces.value.length,
@@ -356,6 +401,60 @@ export function useFaceFilter() {
savePreferences();
}
async function loadSingleFrameSettings(fileUuid: string) {
singleFrameSettingsLoading.value = true;
singleFrameSettingsError.value = null;
try {
const profile = await getFileProfile(fileUuid);
singleFrameSettings.value = {
single_frame_trace_vlm_enabled: profile.face_filter.single_frame_trace_vlm_enabled ?? true,
single_frame_trace_group_enabled: profile.face_filter.single_frame_trace_group_enabled ?? true,
single_frame_trace_unassigned_enabled: profile.face_filter.single_frame_trace_unassigned_enabled ?? true,
single_trace_group_filter_enabled: profile.face_filter.single_trace_group_filter_enabled ?? true,
};
singleFrameSettingsModified.value = false;
} catch (err: any) {
singleFrameSettingsError.value = err.message || 'Failed to load settings';
console.error('Failed to load single frame settings:', err);
} finally {
singleFrameSettingsLoading.value = false;
}
}
async function saveSingleFrameSettings(fileUuid: string): Promise<boolean> {
singleFrameSettingsLoading.value = true;
singleFrameSettingsError.value = null;
try {
const success = await updateFileProfile(fileUuid, singleFrameSettings.value);
if (success) {
singleFrameSettingsModified.value = false;
}
return success;
} catch (err: any) {
singleFrameSettingsError.value = err.message || 'Failed to save settings';
console.error('Failed to save single frame settings:', err);
return false;
} finally {
singleFrameSettingsLoading.value = false;
}
}
function resetSingleFrameSettings() {
singleFrameSettings.value = {
single_frame_trace_vlm_enabled: true,
single_frame_trace_group_enabled: true,
single_frame_trace_unassigned_enabled: true,
single_trace_group_filter_enabled: true,
};
singleFrameSettingsModified.value = true;
}
function markSingleFrameSettingsModified() {
singleFrameSettingsModified.value = true;
}
return {
// State
mode,
@@ -367,6 +466,10 @@ export function useFaceFilter() {
loading,
error,
stats,
singleFrameSettings,
singleFrameSettingsLoading,
singleFrameSettingsError,
singleFrameSettingsModified,
// Actions
initialize,
@@ -377,6 +480,10 @@ export function useFaceFilter() {
refreshStats,
resetToDefault,
applyLocalFilter,
loadSingleFrameSettings,
saveSingleFrameSettings,
resetSingleFrameSettings,
markSingleFrameSettingsModified,
// API helpers (exposed for direct use)
fetchFaces,

View File

@@ -1,4 +1,5 @@
import { reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { apiCall } from '@/api'
import { invalidatePeople, ensurePeople } from '@/store'
@@ -29,6 +30,7 @@ interface ActionHistoryItem {
}
export function useUndoRedo() {
const { t } = useI18n()
const counts = reactive<Record<string, HistoryCounts>>({})
const mergeHistory = reactive<MergeHistoryItem[]>([])
const recentActions = reactive<ActionHistoryItem[]>([])
@@ -82,7 +84,7 @@ export function useUndoRedo() {
const patchItems = patchHist?.history || patchHist?.records || []
for (const r of Array.isArray(patchItems) ? patchItems : []) {
const op = r.operation ?? r.op ?? ''
const label = op === 'delete' ? '刪除人物' : op === 'update' ? '更新人物' : op
const label = op === 'delete' ? t('people.action.delete_person') : op === 'update' ? t('people.action.update_person') : op
actions.push({
type: op === 'delete' ? 'delete' : 'patch',
label,
@@ -95,7 +97,7 @@ export function useUndoRedo() {
const bindItems = bindHist?.history || bindHist?.records || []
for (const r of Array.isArray(bindItems) ? bindItems : []) {
const op = r.operation ?? r.op ?? ''
const label = op === 'bind' ? '綁定面部' : op === 'unbind' ? '解綁面部' : op === 'bind_trace' ? '綁定軌跡' : op
const label = op === 'bind' ? t('people.action.bind_face') : op === 'unbind' ? t('people.action.unbind_face') : op === 'bind_trace' ? t('people.action.bind_trace') : op
actions.push({
type: 'bind',
label,