feat: bind/unbind by id, video streaming proxy, unmute default, library refresh, hamburger click-outside

- Core API: bind/unbind accept id (integer PK) when face_id is null
- Tauri proxy: stream video responses instead of buffering for seek bar
- VideoPlayer: remove muted attribute, unmute by default
- LibraryView: call invalidateFiles() before ensureFiles() on register/unregister
- PeopleView: close sort panel on click-outside, not just hamburger toggle
- bind_face: prefer face_id, fallback to id when face_id is null
This commit is contained in:
2026-06-18 22:24:03 +08:00
parent 97af4da331
commit 7855983dc1
19 changed files with 5321 additions and 793 deletions

View File

@@ -0,0 +1,138 @@
import { ref } from 'vue'
import { apiCall } from '@/api'
export interface HistoryItem {
id: string
query: string
title: string
chat_state: string | null
mode: string | null
pinned: boolean
created_at: string | null
updated_at: string | null
}
export function useSearchHistory() {
const history = ref<HistoryItem[]>([])
const loading = ref(false)
async function loadHistory() {
loading.value = true
try {
const result = await apiCall('get_search_history', { limit: 30 })
history.value = Array.isArray(result) ? result : []
} catch (e) {
console.error('Failed to load history:', e)
} finally {
loading.value = false
}
}
async function saveToHistory(query: string, chatState: string, mode: string) {
const id = `h_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
try {
await apiCall('save_search_history', {
id,
query,
title: query,
chatState,
mode,
})
await loadHistory()
} catch (e) {
console.error('Failed to save history:', e)
}
}
async function renameHistory(id: string, title: string) {
try {
await apiCall('rename_search_history', { id, title })
await loadHistory()
} catch (e) {
console.error('Failed to rename history:', e)
}
}
async function pinHistory(id: string, pinned: boolean) {
try {
await apiCall('pin_search_history', { id, pinned })
await loadHistory()
} catch (e) {
console.error('Failed to pin history:', e)
}
}
async function deleteHistory(id: string) {
try {
await apiCall('delete_search_history', { id })
await loadHistory()
} catch (e) {
console.error('Failed to delete history:', e)
}
}
function restoreFromHistory(item: HistoryItem): any[] | null {
if (!item.chat_state) return null
try {
return JSON.parse(item.chat_state)
} catch {
return null
}
}
return {
history,
loading,
loadHistory,
saveToHistory,
renameHistory,
pinHistory,
deleteHistory,
restoreFromHistory,
}
}
export interface BookmarkItem {
id: number
label: string
history_id: string | null
created_at: string | null
}
export function useBookmarks() {
const bookmarks = ref<BookmarkItem[]>([])
async function loadBookmarks() {
try {
const result = await apiCall('get_bookmarks', {})
bookmarks.value = Array.isArray(result) ? result : []
} catch (e) {
console.error('Failed to load bookmarks:', e)
}
}
async function saveBookmark(label: string, historyId?: string) {
try {
await apiCall('save_bookmark', { label, historyId: historyId || null })
await loadBookmarks()
} catch (e) {
console.error('Failed to save bookmark:', e)
}
}
async function deleteBookmark(id: number) {
try {
await apiCall('delete_bookmark', { id })
await loadBookmarks()
} catch (e) {
console.error('Failed to delete bookmark:', e)
}
}
return {
bookmarks,
loadBookmarks,
saveBookmark,
deleteBookmark,
}
}

View File

@@ -0,0 +1,253 @@
import { reactive } from 'vue'
import { apiCall } from '@/api'
import { invalidatePeople, ensurePeople } from '@/store'
interface HistoryCounts {
patchUndo: number
patchRedo: number
bindUndo: number
bindRedo: number
}
interface MergeHistoryItem {
mergeId: string
sourceUuid: string
targetUuid: string
sourceName: string
targetName: string
undone: boolean
createdAt: string
}
interface ActionHistoryItem {
type: 'patch' | 'bind' | 'merge' | 'delete'
label: string
uuid: string
timestamp: string
canUndo: boolean
canRedo: boolean
}
export function useUndoRedo() {
const counts = reactive<Record<string, HistoryCounts>>({})
const mergeHistory = reactive<MergeHistoryItem[]>([])
const recentActions = reactive<ActionHistoryItem[]>([])
async function refreshCounts(uuid: string) {
try {
const [patchHist, bindHist] = await Promise.all([
apiCall('identity_history', { uuid, page: 1, pageSize: 1 }),
apiCall('identity_bind_history', { uuid, page: 1, pageSize: 1 }),
])
if (!counts[uuid]) counts[uuid] = { patchUndo: 0, patchRedo: 0, bindUndo: 0, bindRedo: 0 }
counts[uuid].patchUndo = patchHist?.undo_stack_count ?? patchHist?.undoStackCount ?? 0
counts[uuid].patchRedo = patchHist?.redo_stack_count ?? patchHist?.redoStackCount ?? 0
counts[uuid].bindUndo = bindHist?.undo_stack_count ?? bindHist?.undoStackCount ?? 0
counts[uuid].bindRedo = bindHist?.redo_stack_count ?? bindHist?.redoStackCount ?? 0
} catch (e) {
console.error('Failed to refresh undo counts:', e)
}
}
async function refreshMergeHistory(uuid?: string) {
try {
const params: Record<string, any> = { pageSize: 10 }
if (uuid) params.sourceUuid = uuid
const data = await apiCall('merge_history', params)
const items = data?.history || data?.merges || data || []
mergeHistory.splice(0, mergeHistory.length)
for (const m of Array.isArray(items) ? items : []) {
mergeHistory.push({
mergeId: m.merge_id ?? m.mergeId ?? '',
sourceUuid: m.source_uuid ?? m.sourceUuid ?? '',
targetUuid: m.target_uuid ?? m.targetUuid ?? '',
sourceName: m.source_name ?? m.sourceName ?? '',
targetName: m.target_name ?? m.targetName ?? '',
undone: m.is_undone ?? m.isUndone ?? false,
createdAt: m.created_at ?? m.createdAt ?? '',
})
}
} catch (e) {
console.error('Failed to refresh merge history:', e)
}
}
async function refreshActionHistory(uuid: string) {
try {
const [patchHist, bindHist] = await Promise.all([
apiCall('identity_history', { uuid, page: 1, pageSize: 10 }),
apiCall('identity_bind_history', { uuid, page: 1, pageSize: 10 }),
])
const actions: ActionHistoryItem[] = []
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
actions.push({
type: op === 'delete' ? 'delete' : 'patch',
label,
uuid,
timestamp: r.created_at ?? r.createdAt ?? r.timestamp ?? '',
canUndo: !r.is_undone && !r.isUndone,
canRedo: !!(r.is_undone || r.isUndone),
})
}
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
actions.push({
type: 'bind',
label,
uuid,
timestamp: r.created_at ?? r.createdAt ?? r.timestamp ?? '',
canUndo: !(r.is_undone ?? r.isUndone),
canRedo: !!(r.is_undone ?? r.isUndone),
})
}
actions.sort((a, b) => b.timestamp.localeCompare(a.timestamp))
recentActions.splice(0, recentActions.length, ...actions.slice(0, 20))
} catch (e) {
console.error('Failed to refresh action history:', e)
}
}
async function undo(uuid: string, steps = 1) {
try {
await apiCall('identity_undo', { uuid, steps })
await refreshCounts(uuid)
await refreshActionHistory(uuid)
invalidatePeople()
await ensurePeople()
} catch (e) {
console.error('Undo failed:', e)
throw e
}
}
async function redo(uuid: string, steps = 1) {
try {
await apiCall('identity_redo', { uuid, steps })
await refreshCounts(uuid)
await refreshActionHistory(uuid)
invalidatePeople()
await ensurePeople()
} catch (e) {
console.error('Redo failed:', e)
throw e
}
}
async function bindUndo(uuid: string, steps = 1) {
try {
await apiCall('identity_bind_undo', { uuid, steps })
await refreshCounts(uuid)
await refreshActionHistory(uuid)
invalidatePeople()
await ensurePeople()
} catch (e) {
console.error('Bind undo failed:', e)
throw e
}
}
async function bindRedo(uuid: string, steps = 1) {
try {
await apiCall('identity_bind_redo', { uuid, steps })
await refreshCounts(uuid)
await refreshActionHistory(uuid)
invalidatePeople()
await ensurePeople()
} catch (e) {
console.error('Bind redo failed:', e)
throw e
}
}
async function mergeUndo(mergeId: string) {
try {
await apiCall('merge_undo', { mergeId })
invalidatePeople()
await ensurePeople()
} catch (e) {
console.error('Merge undo failed:', e)
throw e
}
}
async function mergeRedo(mergeId: string) {
try {
await apiCall('merge_redo', { mergeId })
invalidatePeople()
await ensurePeople()
} catch (e) {
console.error('Merge redo failed:', e)
throw e
}
}
function canUndo(uuid: string): boolean {
const c = counts[uuid]
if (!c) return false
return c.patchUndo > 0 || c.bindUndo > 0
}
function canRedo(uuid: string): boolean {
const c = counts[uuid]
if (!c) return false
return c.patchRedo > 0 || c.bindRedo > 0
}
async function undoAction(uuid: string, type: 'patch' | 'bind') {
try {
if (type === 'bind') {
await apiCall('identity_bind_undo', { uuid, steps: 1 })
} else {
await apiCall('identity_undo', { uuid, steps: 1 })
}
await refreshCounts(uuid)
await refreshActionHistory(uuid)
invalidatePeople()
await ensurePeople()
} catch (e) {
console.error('Undo action failed:', e)
throw e
}
}
async function redoAction(uuid: string, type: 'patch' | 'bind') {
try {
if (type === 'bind') {
await apiCall('identity_bind_redo', { uuid, steps: 1 })
} else {
await apiCall('identity_redo', { uuid, steps: 1 })
}
await refreshCounts(uuid)
await refreshActionHistory(uuid)
invalidatePeople()
await ensurePeople()
} catch (e) {
console.error('Redo action failed:', e)
throw e
}
}
return {
counts,
mergeHistory,
recentActions,
refreshCounts,
refreshMergeHistory,
refreshActionHistory,
undo,
redo,
bindUndo,
bindRedo,
mergeUndo,
mergeRedo,
canUndo,
canRedo,
undoAction,
redoAction,
}
}