All API through Rust proxy, fix unregistered files null uuid, People enhancements

This commit is contained in:
2026-06-14 11:05:23 +08:00
parent a700cc983e
commit 3ba2120c8e
8 changed files with 265 additions and 45 deletions

View File

@@ -34,9 +34,11 @@
<style>
@import './assets/wordpress-exact.css';
</style>
<style scoped>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'DM Sans', 'Noto Sans TC', -apple-system, BlinkMacSystemFont, sans-serif; background: #fff; color: #202124; font-size: 14px; }
body { font-family: 'DM Sans', 'Noto Sans TC', -apple-system, BlinkMacSystemFont, sans-serif; background: #fff; color: #202124; }
#app { display: flex; min-height: 100vh; }
.ms-side { width: 260px; background: #fff; border-right: 1px solid #e8eaed; display: flex; flex-direction: column; position: fixed; top: 0; left: 0; bottom: 0; z-index: 100; }
.gs-logo { padding: 16px 20px; font-size: 16px; font-weight: 700; border-bottom: 1px solid #e8eaed; }
@@ -44,7 +46,7 @@ body { font-family: 'DM Sans', 'Noto Sans TC', -apple-system, BlinkMacSystemFont
.gs-nav-item { display: flex; align-items: center; gap: 12px; padding: 10px 20px; color: #5f6368; text-decoration: none; font-size: 13px; font-weight: 500; border-left: 3px solid transparent; cursor: pointer; }
.gs-nav-item:hover { background: #f1f3f4; color: #202124; }
.gs-nav-item.active { background: #e8f0fe; color: #1967d2; border-left-color: #1967d2; font-weight: 600; }
.gs-nav-icon { width: 24px; height: 24px; object-fit: contain; flex-shrink: 0; }
.gs-nav-icon { width: 24px; height: 24px; object-fit: contain; }
.gs-divider { height: 1px; background: #e8eaed; margin: 4px 0; }
.gs-footer { padding: 12px 20px; border-top: 1px solid #e8eaed; }
.gs-theme-switcher { display: flex; gap: 6px; margin-bottom: 10px; }

View File

@@ -1,3 +1,4 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;
img:is([sizes=auto i],[sizes^="auto," i]){contain-intrinsic-size:3000px 1500px}
/*# sourceURL=wp-img-auto-sizes-contain-inline-css */
@@ -822,7 +823,7 @@ padding:0 14px 0 32px !important; margin:2px 0 !important;
.ms-app #lt-sidebar, .ms-app ~ #lt-sidebar { display: none !important; }
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Noto+Sans+TC:wght@400;500;600;700&display=swap');
500;600;700&family=Noto+Sans+TC:wght@400;500;600;700&display=swap');
#msPplViewList, #msPplViewDetail {
font-family: 'DM Sans', 'Noto Sans TC', -apple-system, sans-serif;

View File

@@ -4,7 +4,7 @@
<button class="close-btn" @click="close">×</button>
<video ref="videoEl" class="video" controls autoplay @loadedmetadata="onLoaded" @timeupdate="onTimeUpdate">
<source :src="videoUrl" type="video/mp4" />
<source :src="videoSrc" type="video/mp4" />
</video>
<div class="video-info">
@@ -28,6 +28,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { invoke } from '@tauri-apps/api/core'
const props = defineProps<{
fileUuid: string
@@ -43,15 +44,8 @@ const visible = ref(true)
const currentTime = ref(0)
const duration = ref(0)
const isPlaying = ref(false)
const CORE_API = 'http://localhost:3002'
const API_KEY = 'muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69'
const videoUrl = computed(() => {
const start = props.startTime ?? 0
const end = props.endTime ?? 99999
return `${CORE_API}/api/v1/file/${props.fileUuid}/video?api_key=${API_KEY}&start_time=${start}&end_time=${end}`
})
const videoSrc = ref('')
const videoLoading = ref(true)
const hasRange = computed(() => {
return props.startTime !== undefined && props.endTime !== undefined
@@ -141,8 +135,19 @@ function onKeydown(e: KeyboardEvent) {
}
}
onMounted(() => {
onMounted(async () => {
document.addEventListener('keydown', onKeydown)
try {
videoSrc.value = await invoke('get_video_stream', {
uuid: props.fileUuid,
startTime: props.startTime ?? 0,
endTime: props.endTime ?? 99999
})
} catch (e: any) {
console.error('Video stream failed:', e)
} finally {
videoLoading.value = false
}
})
onUnmounted(() => {

View File

@@ -64,7 +64,7 @@
<div v-for="f in sortedFilteredFiles" :key="f.file_uuid" class="mp-file-card" :class="{ 'is-completed': f.is_registered, 'is-selected': selectedFiles.includes(f.file_uuid) }" @click="toggleSelect(f)">
<div class="mp-thumb-wrap">
<div class="mp-badge-type">{{ isVideo(f) ? 'VIDEO' : (isPhoto(f) ? 'PHOTO' : 'DOC') }}</div>
<img v-if="isPhoto(f) || isVideo(f)" class="lt-thumb" :src="getThumbUrl(f.file_uuid)" alt="" loading="lazy" @error="handleThumbError">
<img v-if="isPhoto(f) || isVideo(f)" class="lt-thumb" :src="thumbnails[f.file_uuid]" alt="" loading="lazy" @error="handleThumbError" @vue:mounted="loadThumbnail(f.file_uuid)">
<div v-else class="mp-doc-thumb">
<span class="mp-doc-icon">📄</span>
<span class="mp-doc-ext">{{ getFileExt(f.file_name) }}</span>
@@ -85,7 +85,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import VideoPlayer from '../components/VideoPlayer.vue'
@@ -99,10 +99,12 @@ const selectedFiles = ref<string[]>([])
const playing = ref(false)
const currentVideo = ref({ fileUuid: '', startTime: 0, endTime: 0, title: '' })
// Sort & Filter
const thumbnails = ref<Record<string, string>>({})
const thumbnailLoading = ref<Set<string>>(new Set())
const sortBy = ref('time_desc')
const filterUnregistered = ref(false)
const filterRegistered = ref(false)
const filterRegistered = ref(true)
const onlyVideos = ref(false)
const onlyPhotos = ref(false)
const sizeMin = ref<number | null>(null)
@@ -110,9 +112,7 @@ const sizeMax = ref<number | null>(null)
const durationMin = ref<number | null>(null)
const durationMax = ref<number | null>(null)
const CORE_API = 'http://localhost:3002'
const API_KEY = 'muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69'
// Sort & Filter
const sortedFilteredFiles = computed(() => {
let result = [...files.value]
@@ -127,8 +127,8 @@ const sortedFilteredFiles = computed(() => {
}
// Checkbox filters
if (filterUnregistered.value && !filterRegistered.value) result = result.filter((f: any) => !f.is_registered)
else if (filterRegistered.value && !filterUnregistered.value) result = result.filter((f: any) => f.is_registered)
if (filterUnregistered.value && !filterRegistered.value) result = result.filter((f: any) => !f.isRegistered)
else if (filterRegistered.value && !filterUnregistered.value) result = result.filter((f: any) => f.isRegistered)
// 兩者都不勾選或都勾選:顯示全部
if (onlyVideos.value) result = result.filter(isVideo)
@@ -172,8 +172,8 @@ async function loadFiles() {
statusText.value = 'Loading...'
try {
console.log('Calling get_files...')
files.value = await invoke('get_files', { page_size: 500 })
console.log('Files loaded:', files.value.length)
files.value = await invoke('get_files', { args: { pageSize: 500 } })
console.log('Files loaded:', files.value.length); console.log('First file:', JSON.stringify(files.value[0]))
if (files.value.length > 0) {
console.log('First file:', files.value[0])
console.log('is_registered sample:', files.value.slice(0,5).map((f:any) => ({ name: f.file_name?.slice(0,20), is_registered: f.is_registered, type: typeof f.is_registered })))
@@ -209,8 +209,16 @@ function formatDate(date: string) {
return new Date(date).toISOString().slice(0, 10)
}
function getThumbUrl(uuid: string) {
return `${CORE_API}/api/v1/file/${uuid}/thumbnail?api_key=${API_KEY}&frame=30`
async function loadThumbnail(uuid: string) {
if (thumbnails.value[uuid] || thumbnailLoading.value.has(uuid)) return
thumbnailLoading.value.add(uuid)
try {
thumbnails.value[uuid] = await invoke('get_thumbnail', { uuid, frame: 30 })
} catch (e: any) {
console.error('Thumbnail load failed:', e)
} finally {
thumbnailLoading.value.delete(uuid)
}
}
function handleThumbError(e: Event) {

View File

@@ -1,11 +1,14 @@
<template>
<div class="people-view">
<h1>People</h1>
<input v-model="search" class="search-input" placeholder="Search people..." />
<div class="toolbar">
<h1>People</h1>
<input v-model="search" class="search-input" placeholder="Search people..." />
</div>
<div v-if="loading" class="loading">Loading...</div>
<div v-else class="grid">
<div v-for="p in filteredPeople" :key="p.identity_uuid" class="person-card" @click="selectPerson(p)">
<div class="avatar">{{ p.name?.[0]?.toUpperCase() || '?' }}</div>
<img v-if="profiles[p.identity_uuid]" class="avatar-img" :src="profiles[p.identity_uuid]" alt="" loading="lazy" @vue:mounted="loadProfile(p.identity_uuid)">
<div v-else class="avatar" @vue:mounted="loadProfile(p.identity_uuid)">{{ p.name?.[0]?.toUpperCase() || '?' }}</div>
<p class="name">{{ p.name }}</p>
</div>
</div>
@@ -13,8 +16,19 @@
<div class="detail-box">
<button class="close-btn" @click="selected = null">×</button>
<div class="detail-header">
<div class="detail-avatar">{{ selected.name?.[0]?.toUpperCase() || '?' }}</div>
<div><h2>{{ selected.name }}</h2><p class="uuid">{{ selected.identity_uuid }}</p></div>
<img v-if="selectedProfile" class="detail-avatar-img" :src="selectedProfile" alt="">
<div v-else class="detail-avatar">{{ selected.name?.[0]?.toUpperCase() || '?' }}</div>
<div class="detail-info">
<div class="name-row">
<input v-if="editingName" v-model="editName" class="name-input" @keyup.enter="saveName" @blur="saveName" />
<h2 v-else @click="startEditName">{{ selected.name }}</h2>
<button class="edit-btn" @click="startEditName" title="Edit name"></button>
</div>
<p class="uuid">{{ selected.identity_uuid }}</p>
<div class="detail-actions">
<button class="action-btn delete" @click="confirmDelete">Delete</button>
</div>
</div>
</div>
<div class="detail-content">
<div class="section"><h3>Faces ({{ faces.length }})</h3>
@@ -64,6 +78,11 @@ const traces = ref<any[]>([])
const playing = ref(false)
const currentVideo = ref({ fileUuid: '', startTime: 0, endTime: 0, title: '' })
const profiles = ref<Record<string, string>>({})
const selectedProfile = ref<string>('')
const editingName = ref(false)
const editName = ref('')
const filteredPeople = computed(() => {
if (!search.value) return people.value
const s = search.value.toLowerCase()
@@ -71,9 +90,13 @@ const filteredPeople = computed(() => {
})
onMounted(async () => {
console.log('PeopleView mounted')
try {
const result: any = await invoke('get_people', { page: 1, perPage: 1000 })
console.log('Calling get_people...')
const result: any = await invoke('get_people', { page: 1, per_page: 1000 })
console.log('get_people result:', result)
people.value = Array.isArray(result) ? result : []
console.log('People count:', people.value.length)
} catch (e) {
console.error('Failed to load people:', e)
} finally {
@@ -81,11 +104,21 @@ onMounted(async () => {
}
})
async function loadProfile(uuid: string) {
if (profiles.value[uuid]) return
try {
profiles.value[uuid] = await invoke('get_identity_profile', { uuid })
} catch {
// keep default avatar
}
}
async function selectPerson(p: any) {
selected.value = p
selectedProfile.value = profiles.value[p.identity_uuid] || ''
try {
const fResult: any = await invoke('get_faces', { uuid: p.identity_uuid, perPage: 100 })
const tResult: any = await invoke('get_traces', { uuid: p.identity_uuid, perPage: 100 })
const fResult: any = await invoke('get_faces', { uuid: p.identity_uuid, per_page: 100 })
const tResult: any = await invoke('get_traces', { uuid: p.identity_uuid, per_page: 100 })
faces.value = Array.isArray(fResult) ? fResult : []
traces.value = Array.isArray(tResult) ? tResult : []
} catch (e) {
@@ -93,6 +126,35 @@ async function selectPerson(p: any) {
}
}
function startEditName() {
editingName.value = true
editName.value = selected.value.name
}
async function saveName() {
editingName.value = false
if (editName.value === selected.value.name) return
try {
await invoke('update_identity_name', { uuid: selected.value.identity_uuid, name: editName.value })
selected.value.name = editName.value
const idx = people.value.findIndex((p: any) => p.identity_uuid === selected.value.identity_uuid)
if (idx >= 0) people.value[idx].name = editName.value
} catch (e) {
console.error('Failed to update name:', e)
}
}
async function confirmDelete() {
if (!confirm(`Delete "${selected.value.name}"?`)) return
try {
await invoke('delete_identity', { uuid: selected.value.identity_uuid })
people.value = people.value.filter((p: any) => p.identity_uuid !== selected.value.identity_uuid)
selected.value = null
} catch (e) {
console.error('Failed to delete:', e)
}
}
function formatTime(sec: number): string {
const m = Math.floor(sec / 60)
const s = Math.floor(sec % 60)
@@ -112,21 +174,32 @@ function playTrace(t: any) {
<style scoped>
.people-view { max-width: 1200px; }
h1 { margin-bottom: 20px; }
.search-input { padding: 10px 14px; border: 1px solid #d1d5db; border-radius: 8px; width: 300px; margin-bottom: 20px; }
.toolbar { display: flex; align-items: center; gap: 20px; margin-bottom: 20px; }
h1 { margin: 0; }
.search-input { padding: 10px 14px; border: 1px solid #d1d5db; border-radius: 8px; width: 300px; }
.loading { text-align: center; padding: 40px; color: #6b7280; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 16px; }
.person-card { background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; padding: 16px; text-align: center; cursor: pointer; }
.person-card:hover { border-color: #4f46e5; }
.avatar { width: 56px; height: 56px; border-radius: 50%; background: #eef2ff; color: #4f46e5; display: flex; align-items: center; justify-content: center; font-size: 1.3rem; font-weight: 600; margin: 0 auto 8px; }
.avatar, .avatar-img { width: 56px; height: 56px; border-radius: 50%; background: #eef2ff; color: #4f46e5; display: flex; align-items: center; justify-content: center; font-size: 1.3rem; font-weight: 600; margin: 0 auto 8px; object-fit: cover; }
.name { font-size: 0.75rem; color: #374151; }
.detail-modal { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.detail-box { background: #fff; border-radius: 20px; padding: 32px; width: min(700px, 90vw); max-height: 80vh; overflow-y: auto; position: relative; }
.close-btn { position: absolute; top: 16px; right: 16px; width: 32px; height: 32px; border-radius: 50%; background: #f3f4f6; border: none; font-size: 1.2rem; cursor: pointer; }
.detail-header { display: flex; align-items: center; gap: 20px; margin-bottom: 24px; }
.detail-avatar { width: 80px; height: 80px; border-radius: 20px; background: #eef2ff; color: #4f46e5; display: flex; align-items: center; justify-content: center; font-size: 2rem; font-weight: 700; }
.detail-header h2 { margin-bottom: 4px; }
.uuid { color: #9ca3af; font-size: 0.8rem; font-family: monospace; }
.detail-avatar, .detail-avatar-img { width: 80px; height: 80px; border-radius: 20px; background: #eef2ff; color: #4f46e5; display: flex; align-items: center; justify-content: center; font-size: 2rem; font-weight: 700; object-fit: cover; flex-shrink: 0; }
.detail-info { flex: 1; }
.name-row { display: flex; align-items: center; gap: 8px; }
.name-row h2 { margin: 0; cursor: pointer; }
.name-row h2:hover { text-decoration: underline; }
.name-input { font-size: 1.25rem; font-weight: 700; border: 1px solid #d1d5db; border-radius: 6px; padding: 4px 8px; width: 200px; }
.edit-btn { background: none; border: none; cursor: pointer; font-size: 1rem; color: #6b7280; padding: 4px; }
.edit-btn:hover { color: #4f46e5; }
.uuid { color: #9ca3af; font-size: 0.8rem; font-family: monospace; margin: 4px 0 8px; }
.detail-actions { display: flex; gap: 8px; }
.action-btn { padding: 6px 14px; border-radius: 8px; border: 1px solid #d1d5db; background: #fff; cursor: pointer; font-size: 0.85rem; }
.action-btn.delete { color: #dc2626; border-color: #fca5a5; }
.action-btn.delete:hover { background: #fef2f2; }
.section { margin-bottom: 24px; }
.section h3 { margin-bottom: 12px; font-size: 1rem; }
.face-strip { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 8px; }