Files
momentry_portal/src/views/IdentityDetailView.vue

187 lines
7.2 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="space-y-6">
<!-- Header -->
<div class="flex items-center space-x-4">
<button @click="$router.back()" class="text-gray-400 hover:text-white">
返回
</button>
<div>
<h2 class="text-2xl font-bold">{{ profile.name || '未命名身份' }}</h2>
<p class="text-sm text-gray-400">全域身份 ID: {{ identityId }}</p>
</div>
</div>
<!-- Loading -->
<div v-if="loading" class="text-center py-12 text-gray-500">載入中...</div>
<!-- Content -->
<div v-else-if="detail" class="grid gap-6">
<!-- Profile Card -->
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
<h3 class="text-lg font-semibold text-blue-400 mb-4">人物檔案</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<span class="text-gray-500 text-sm">本名</span>
<p class="text-white text-lg">{{ profile.name || '-' }}</p>
</div>
<div>
<span class="text-gray-500 text-sm">角色名</span>
<p class="text-white text-lg">{{ profile.character_name || '-' }}</p>
</div>
<div>
<span class="text-gray-500 text-sm">別名 (Aliases)</span>
<div class="flex flex-wrap gap-2 mt-1">
<span v-for="(alias, idx) in profile.aliases" :key="idx" class="bg-gray-700 text-gray-300 px-2 py-1 rounded text-sm">
{{ alias }}
</span>
<span v-if="!profile.aliases || profile.aliases.length === 0" class="text-gray-600 text-sm"></span>
</div>
</div>
<div>
<span class="text-gray-500 text-sm">Speaker ID</span>
<p class="text-white text-lg font-mono">{{ profile.speaker_id || '-' }}</p>
</div>
<div>
<span class="text-gray-500 text-sm">性別</span>
<p class="text-white">{{ profile.gender || '-' }}</p>
</div>
<div>
<span class="text-gray-500 text-sm">年齡</span>
<p class="text-white">{{ profile.age || '-' }}</p>
</div>
</div>
</div>
<!-- Videos List -->
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
<h3 class="text-lg font-semibold text-green-400 mb-4">出現影片 ({{ videos.length }})</h3>
<div class="overflow-x-auto">
<table class="w-full text-sm text-left text-gray-400">
<thead class="text-xs text-gray-500 uppercase bg-gray-700">
<tr>
<th scope="col" class="px-6 py-3 rounded-l-lg">影片名稱</th>
<th scope="col" class="px-6 py-3">出現次數</th>
<th scope="col" class="px-6 py-3 rounded-r-lg">首次出現</th>
</tr>
</thead>
<tbody>
<tr v-for="video in videos" :key="video.file_uuid" class="bg-gray-800 border-b border-gray-700 hover:bg-gray-750">
<td class="px-6 py-4 font-medium text-white">{{ video.file_name }}</td>
<td class="px-6 py-4">{{ video.appearance_count }}</td>
<td class="px-6 py-4">{{ video.first_appearance?.toFixed(2) || '-' }}s</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 3D Face Viewer -->
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
<h3 class="text-lg font-semibold mb-4 text-blue-400">3D 臉部</h3>
<p class="text-sm text-gray-400 mb-3">立體臉部網格MediaPipe Face Mesh468 landmarks</p>
<div class="h-[350px]">
<Face3DViewer v-if="faceLandmarks.length" :landmarks="faceLandmarks" />
<div v-else class="flex items-center justify-center h-full text-gray-500 text-sm">
{{ faceLoading ? '正在取得臉部資料...' : '尚無臉部資料' }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import Face3DViewer from '@/components/Face3DViewer.vue'
import { useRoute } from 'vue-router'
import { httpFetch, getCurrentConfig } from '@/api/client'
const route = useRoute()
const identityId = ref('')
const loading = ref(false)
const detail = ref<any>(null)
const profile = ref<any>({})
const videos = ref<any[]>([])
const faceLandmarks = ref<number[][]>([])
const faceLoading = ref(false)
const loadDetail = async () => {
identityId.value = route.params.identity_uuid as string
loading.value = true
try {
const config = getCurrentConfig()
const result = await httpFetch<any>(`${config.api_base_url}/api/v1/identity/${identityId.value}`)
detail.value = result
profile.value = result.profile || {}
videos.value = result.videos || []
} catch {
// Fallback: some API servers crash on /api/v1/identity/:uuid
try {
const config = getCurrentConfig()
const result = await httpFetch<any>(`${config.api_base_url}/api/v1/identities?uuid=${identityId.value}&page_size=1`)
const identity = (result.identities || [])[0]
if (identity) {
detail.value = identity
const meta = identity.metadata || {}
profile.value = {
name: identity.name,
character_name: meta.tmdb_character,
aliases: meta.tmdb_aliases,
gender: meta.tmdb_gender === 1 ? 'Female' : meta.tmdb_gender === 2 ? 'Male' : undefined,
age: meta.tmdb_birthday ? `${new Date().getFullYear() - new Date(meta.tmdb_birthday).getFullYear()} yrs` : undefined,
}
videos.value = []
} else {
throw new Error('Identity not found')
}
} catch (fallbackError) {
console.error('Failed to load identity detail:', fallbackError)
}
} finally {
loading.value = false
}
}
async function loadFaceLandmarks() {
faceLoading.value = true
try {
const config = getCurrentConfig()
// Use first video's file_uuid for thumbnail (identity UUID doesn't work for thumbnails)
const firstFileUuid = videos.value?.[0]?.file_uuid || identityId.value
const thumbUrl = `${config.api_base_url}/api/v1/file/${firstFileUuid}/thumbnail?frame=1`
const thumbResp = await fetch(thumbUrl, {
headers: config.api_key ? { 'X-API-Key': config.api_key } : {}
})
const blob = await thumbResp.blob()
const reader = new FileReader()
reader.onload = async () => {
const b64 = (reader.result as string).split(',')[1]
try {
const lmResp = await fetch('http://localhost:11437/v1/face/landmarks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: b64 })
})
const data = await lmResp.json()
if (data?.landmarks?.length) {
faceLandmarks.value = data.landmarks.map((lm: any) => [lm.x, lm.y, lm.z])
}
} catch {
// Fallback to sample
const fallback = await fetch('/sample_face_landmarks.json')
const fbData = await fallback.json()
if (fbData?.landmarks?.length) faceLandmarks.value = fbData.landmarks
}
}
reader.readAsDataURL(blob)
} catch { /* ignore */ }
faceLoading.value = false
}
onMounted(() => {
loadDetail()
loadFaceLandmarks()
})
</script>