187 lines
7.2 KiB
Vue
187 lines
7.2 KiB
Vue
<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 Mesh,468 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>
|