Initial commit: Momentry Studio v0.1.0

This commit is contained in:
2026-06-13 17:49:02 +08:00
commit 79e0a862d4
14019 changed files with 1129062 additions and 0 deletions

57
src/App.vue Normal file
View File

@@ -0,0 +1,57 @@
<template>
<div id="app" class="ms-app" :data-current-login="'guest'">
<aside class="ms-side">
<div class="gs-logo">Workspace</div>
<nav class="gs-nav">
<router-link to="/search" class="gs-nav-item" active-class="active">
<img class="gs-nav-icon" src="/icons/Search.png" alt="">
<span>Search</span>
</router-link>
<router-link to="/library" class="gs-nav-item" active-class="active">
<img class="gs-nav-icon" src="/icons/Library.png" alt="">
<span>Library</span>
</router-link>
<router-link to="/people" class="gs-nav-item" active-class="active">
<img class="gs-nav-icon" src="/icons/People.png" alt="">
<span>People</span>
</router-link>
</nav>
<div class="gs-divider"></div>
<div class="gs-footer">
<div class="gs-theme-switcher">
<button class="gs-theme-btn active"></button>
<button class="gs-theme-btn">🌙</button>
<button class="gs-theme-btn">🌗</button>
</div>
<div class="gs-account"><span class="gs-account-name">Demo</span></div>
</div>
</aside>
<main class="ms-main">
<div class="ms-content"><router-view /></div>
</main>
</div>
</template>
<style>
@import './assets/wordpress-exact.css';
* { 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; }
#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; }
.gs-nav { flex: 1; padding: 8px 0; overflow-y: auto; }
.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-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; }
.gs-theme-btn { width: 32px; height: 32px; border: 1px solid #dadce0; background: #fff; border-radius: 8px; cursor: pointer; font-size: 14px; }
.gs-theme-btn:hover { background: #f1f3f4; }
.gs-theme-btn.active { border-color: #1967d2; background: #e8f0fe; }
.gs-account-name { font-size: 12px; color: #80868b; }
.ms-main { margin-left: 260px; flex: 1; min-height: 100vh; background: #fff; }
.ms-content { padding: 24px 32px; max-width: 1200px; }
</style>

View File

@@ -0,0 +1,488 @@
:root {
--primary-color: #2563eb;
--secondary-color: #64748b;
--success-color: #22c55e;
--warning-color: #f59e0b;
--error-color: #ef4444;
--background-color: #f8fafc;
--card-background: #ffffff;
--text-primary: #1e293b;
--text-secondary: #64748b;
--border-radius: 8px;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: var(--background-color);
color: var(--text-primary);
line-height: 1.6;
}
.site-header {
background: var(--card-background);
border-bottom: 1px solid #e2e8f0;
padding: 1rem 2rem;
box-shadow: var(--shadow);
}
.site-title {
font-size: 1.5rem;
font-weight: 700;
margin: 0;
}
.site-title a {
text-decoration: none;
color: var(--primary-color);
}
.site-content {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.identity-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.identity-card {
background: var(--card-background);
border-radius: var(--border-radius);
padding: 1.5rem;
box-shadow: var(--shadow);
transition: transform 0.2s, box-shadow 0.2s;
cursor: pointer;
}
.identity-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.identity-name {
font-size: 1.125rem;
font-weight: 600;
margin: 0 0 0.5rem 0;
}
.identity-meta {
display: flex;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
.badge.source-tmdb {
background: #dbeafe;
color: #1e40af;
}
.badge.source-manual {
background: #fef3c7;
color: #92400e;
}
.badge.source-auto_trace {
background: #d1fae5;
color: #065f46;
}
.quality-score {
position: relative;
height: 24px;
background: #e2e8f0;
border-radius: 4px;
margin-bottom: 0.75rem;
overflow: hidden;
}
.score-bar {
position: absolute;
left: 0;
top: 0;
height: 100%;
background: linear-gradient(90deg, var(--warning-color), var(--success-color));
border-radius: 4px;
}
.score-value {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
font-size: 0.75rem;
font-weight: 600;
}
.trace-stats {
display: flex;
gap: 1rem;
font-size: 0.875rem;
color: var(--text-secondary);
margin-bottom: 0.75rem;
}
.identity-actions {
display: flex;
gap: 0.5rem;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.btn:hover {
opacity: 0.9;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.angle-coverage-btn {
background: var(--primary-color);
color: white;
}
.body-actions-btn {
background: var(--secondary-color);
color: white;
}
.pagination-container {
display: flex;
justify-content: center;
margin-top: 2rem;
}
.pagination {
display: flex;
align-items: center;
gap: 1rem;
}
.pagination .prev,
.pagination .next {
background: var(--primary-color);
color: white;
}
.page-info {
font-size: 0.875rem;
color: var(--text-secondary);
}
.modal-container {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: none;
justify-content: center;
align-items: center;
z-index: 1000;
}
.identity-detail-modal,
.angle-coverage-modal,
.body-actions-modal {
background: var(--card-background);
border-radius: var(--border-radius);
padding: 2rem;
max-width: 600px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
}
.detail-section {
margin-bottom: 1.5rem;
}
.detail-section h3 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.coverage-score {
font-size: 1.5rem;
font-weight: 700;
color: var(--primary-color);
margin-bottom: 1rem;
}
.angles-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.angle-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
border-radius: 4px;
background: #f1f5f9;
}
.angle-item.dominant {
background: #d1fae5;
}
.angle-item.present {
background: #dbeafe;
}
.angle-item.missing {
background: #fef3c7;
}
.status-icon {
font-size: 1.25rem;
}
.angle-name {
flex: 1;
font-weight: 500;
text-transform: capitalize;
}
.count {
font-size: 0.875rem;
color: var(--text-secondary);
}
.recommendation {
margin-top: 1rem;
padding: 0.75rem;
background: #fef3c7;
border-radius: 4px;
font-size: 0.875rem;
}
.actions-container {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1.5rem;
}
.action-category h4 {
font-size: 0.875rem;
font-weight: 600;
margin-bottom: 0.5rem;
text-transform: capitalize;
}
.action-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.action-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
background: #e2e8f0;
font-size: 0.75rem;
}
.close-modal {
background: var(--secondary-color);
color: white;
width: 100%;
}
.error {
color: var(--error-color);
padding: 1rem;
background: #fee2e2;
border-radius: 4px;
}
.site-footer {
text-align: center;
padding: 2rem;
margin-top: 2rem;
border-top: 1px solid #e2e8f0;
color: var(--text-secondary);
}
.site-footer a {
color: var(--primary-color);
text-decoration: none;
}
/* API Demo Pages - Shared Styles */
.page-description {
color: var(--text-secondary);
margin-bottom: 1.5rem;
}
.api-nav {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
padding: 1rem;
background: var(--card-background);
border-radius: var(--border-radius);
box-shadow: var(--shadow);
flex-wrap: wrap;
}
.api-nav .nav-item {
padding: 0.5rem 1rem;
border-radius: 4px;
text-decoration: none;
color: var(--text-secondary);
font-weight: 500;
transition: background-color 0.2s;
}
.api-nav .nav-item:hover {
background: #f1f5f9;
}
.api-nav .nav-item.active {
background: var(--primary-color);
color: white;
}
.api-section {
background: var(--card-background);
border-radius: var(--border-radius);
padding: 1.5rem;
margin-bottom: 1.5rem;
box-shadow: var(--shadow);
}
.api-section h2 {
margin-top: 0;
color: var(--primary-color);
font-size: 1.25rem;
}
.api-tester {
margin: 1rem 0;
}
.input-group {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
margin-bottom: 0.5rem;
}
.input-group label {
font-weight: 500;
min-width: 120px;
font-size: 0.875rem;
}
.input-group input,
.input-group select,
.input-group textarea {
padding: 0.5rem;
border: 1px solid #e2e8f0;
border-radius: 4px;
font-size: 0.875rem;
flex: 1;
min-width: 200px;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
}
.btn:hover {
opacity: 0.9;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--primary-color);
color: white;
}
.btn-secondary {
background: var(--secondary-color);
color: white;
}
.response-panel {
margin-top: 1rem;
padding: 1rem;
background: #f1f5f9;
border-radius: 4px;
font-family: monospace;
font-size: 0.75rem;
max-height: 400px;
overflow-y: auto;
white-space: pre-wrap;
display: none;
}
.response-panel.has-content {
display: block;
}
.badge-status {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 600;
}
.badge-status.pending { background: #fef3c7; color: #92400e; }
.badge-status.processing { background: #dbeafe; color: #1e40af; }
.badge-status.completed { background: #d1fae5; color: #065f46; }
.badge-status.error { background: #fee2e2; color: #991b1b; }
.file-item {
padding: 0.75rem;
background: #f8fafc;
border-radius: 4px;
margin-bottom: 0.5rem;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
}
.file-item:hover {
background: #e2e8f0;
}
.file-item .uuid {
font-family: monospace;
font-size: 0.75rem;
color: var(--primary-color);
}
.file-item .name {
font-weight: 500;
}
.chunk-item {
padding: 0.75rem;
background: #f1f5f9;
border-radius: 4px;
margin-bottom: 0.5rem;
border-left: 3px solid var(--primary-color);
}
.chunk-time {
font-size: 0.75rem;
color: var(--text-secondary);
font-family: monospace;
}

1044
src/assets/momentry-full.css Normal file

File diff suppressed because one or more lines are too long

View File

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,199 @@
<template>
<div v-if="visible" class="video-player-modal" @click.self="close">
<div class="video-player-box">
<button class="close-btn" @click="close">×</button>
<video ref="videoEl" class="video" controls autoplay @loadedmetadata="onLoaded" @timeupdate="onTimeUpdate">
<source :src="videoUrl" type="video/mp4" />
</video>
<div class="video-info">
<h3>{{ title }}</h3>
<div class="time-display">
<span>{{ formatTime(currentTime) }}</span>
<span class="separator">/</span>
<span>{{ formatTime(duration) }}</span>
<span v-if="hasRange" class="range">({{ formatTime(rangeStart) }} - {{ formatTime(rangeEnd) }})</span>
</div>
</div>
<div v-if="hasRange" class="segment-controls">
<button @click="seekToStart" class="seg-btn"> 跳到起點</button>
<button @click="togglePlay" class="seg-btn">{{ isPlaying ? '⏸ 暫停' : '▶ 播放' }}</button>
<button @click="seekToEnd" class="seg-btn">跳到結尾 </button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
const props = defineProps<{
fileUuid: string
startTime?: number
endTime?: number
title?: string
}>()
const emit = defineEmits(['close'])
const videoEl = ref<HTMLVideoElement | null>(null)
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 hasRange = computed(() => {
return props.startTime !== undefined && props.endTime !== undefined
})
const rangeStart = computed(() => props.startTime ?? 0)
const rangeEnd = computed(() => props.endTime ?? 0)
function close() {
visible.value = false
emit('close')
}
function onLoaded() {
if (videoEl.value && props.startTime) {
videoEl.value.currentTime = props.startTime
videoEl.value.play()
isPlaying.value = true
}
}
function onTimeUpdate() {
if (videoEl.value) {
currentTime.value = videoEl.value.currentTime
duration.value = videoEl.value.duration || 0
// 如果播放到 range end暫停
if (hasRange.value && videoEl.value.currentTime >= (props.endTime ?? 0)) {
videoEl.value.pause()
isPlaying.value = false
}
}
}
function seekToStart() {
if (videoEl.value && props.startTime) {
videoEl.value.currentTime = props.startTime
videoEl.value.play()
isPlaying.value = true
}
}
function seekToEnd() {
if (videoEl.value && props.endTime) {
videoEl.value.currentTime = props.endTime - 1
videoEl.value.play()
isPlaying.value = true
}
}
function togglePlay() {
if (videoEl.value) {
if (videoEl.value.paused) {
videoEl.value.play()
isPlaying.value = true
} else {
videoEl.value.pause()
isPlaying.value = false
}
}
}
function formatTime(sec: number): string {
if (!sec || isNaN(sec)) return '0:00'
const m = Math.floor(sec / 60)
const s = Math.floor(sec % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}
// 鍵盤快捷鍵
function onKeydown(e: KeyboardEvent) {
if (!visible.value) return
switch (e.key) {
case 'Escape':
close()
break
case ' ':
e.preventDefault()
togglePlay()
break
case 'ArrowLeft':
if (videoEl.value) videoEl.value.currentTime = Math.max(0, videoEl.value.currentTime - 5)
break
case 'ArrowRight':
if (videoEl.value) videoEl.value.currentTime = Math.min(duration.value, videoEl.value.currentTime + 5)
break
}
}
onMounted(() => {
document.addEventListener('keydown', onKeydown)
})
onUnmounted(() => {
document.removeEventListener('keydown', onKeydown)
})
</script>
<style scoped>
.video-player-modal {
position: fixed; inset: 0;
background: rgba(0,0,0,0.85);
display: flex; align-items: center; justify-content: center;
z-index: 2000;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.video-player-box {
background: #111;
border-radius: 16px;
padding: 20px;
max-width: 90vw;
position: relative;
animation: slideUp 0.3s ease;
}
@keyframes slideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
.close-btn {
position: absolute; top: -12px; right: -12px;
width: 36px; height: 36px; border-radius: 50%;
background: #fff; border: none; font-size: 1.5rem;
cursor: pointer; z-index: 10;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.close-btn:hover { background: #f3f4f6; }
.video {
max-width: 80vw; max-height: 60vh;
border-radius: 8px; display: block;
}
.video-info {
color: #fff; margin-top: 16px; text-align: center;
}
.video-info h3 { font-size: 1rem; margin-bottom: 8px; font-weight: 500; }
.time-display { font-size: 0.85rem; color: #9ca3af; display: flex; align-items: center; justify-content: center; gap: 6px; }
.range { color: #4f46e5; }
.segment-controls {
display: flex; justify-content: center; gap: 12px; margin-top: 12px;
}
.seg-btn {
padding: 8px 16px; background: #1f2937; color: #fff;
border: 1px solid #374151; border-radius: 8px;
cursor: pointer; font-size: 0.85rem; transition: background 0.15s;
}
.seg-btn:hover { background: #374151; }
</style>

7
src/main.ts Normal file
View File

@@ -0,0 +1,7 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(router)
app.mount('#app')

18
src/router/index.ts Normal file
View File

@@ -0,0 +1,18 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import SearchView from '../views/SearchView.vue'
import LibraryView from '../views/LibraryView.vue'
import PeopleView from '../views/PeopleView.vue'
const routes: RouteRecordRaw[] = [
{ path: '/', redirect: '/search' },
{ path: '/search', name: 'Search', component: SearchView },
{ path: '/library', name: 'Library', component: LibraryView },
{ path: '/people', name: 'People', component: PeopleView }
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router

292
src/views/LibraryView.vue Normal file
View File

@@ -0,0 +1,292 @@
<template>
<div id="ms-files-page">
<section class="mp-panel is-active">
<div class="mp-toolbar">
<div class="mp-toolbar-left">
<button class="mp-btn mp-btn-primary" type="button" @click="addToPeople">Register</button>
<button class="mp-btn mp-btn-icon" type="button" title="Refresh" @click="loadFiles">
<span class="mp-refresh-icon"></span>
</button>
<label class="mp-radio-row">
<input type="radio" name="mp-display-filter" value="all" v-model="displayFilter"> All
</label>
<label class="mp-radio-row">
<input type="radio" name="mp-display-filter" value="video" v-model="displayFilter"> All Videos
</label>
<label class="mp-radio-row">
<input type="radio" name="mp-display-filter" value="photo" v-model="displayFilter"> All Photos
</label>
</div>
<div class="mp-toolbar-right">
<div class="mp-search-wrap">
<input type="text" class="mp-search" v-model="filterText" placeholder="Search files / docs / videos">
</div>
<button class="mp-filter-btn" type="button" @click="showFilter = !showFilter"></button>
</div>
</div>
<!-- Filter Popup -->
<div class="mp-filter-pop" :class="{ show: showFilter }">
<div class="mp-filter-title">排序方式</div>
<label class="mp-radio-row"><input type="radio" name="mp-sort" value="time_desc" v-model="sortBy" checked> 依時間由近到遠</label>
<label class="mp-radio-row"><input type="radio" name="mp-sort" value="time_asc" v-model="sortBy"> 依時間由遠到近</label>
<label class="mp-radio-row"><input type="radio" name="mp-sort" value="size_desc" v-model="sortBy"> 依大小由大到小</label>
<label class="mp-radio-row"><input type="radio" name="mp-sort" value="size_asc" v-model="sortBy"> 依大小由小到大</label>
<label class="mp-radio-row"><input type="radio" name="mp-sort" value="name_asc" v-model="sortBy"> 依檔名 A Z</label>
<label class="mp-radio-row"><input type="radio" name="mp-sort" value="name_desc" v-model="sortBy"> 依檔名 Z A</label>
<div class="mp-filter-title">過濾方式</div>
<label class="mp-check-row"><input type="checkbox" v-model="filterUnregistered"> 未註冊</label>
<label class="mp-check-row"><input type="checkbox" v-model="filterRegistered"> 已註冊</label>
<label class="mp-check-row"><input type="checkbox" v-model="onlyVideos"> 僅顯示影片</label>
<label class="mp-check-row"><input type="checkbox" v-model="onlyPhotos"> 僅顯示照片</label>
<div class="mp-filter-title">大小</div>
<div class="mp-range-row">
<input type="number" class="mp-filter-number" v-model.number="sizeMin" min="0" placeholder="最小 MB">
<span></span>
<input type="number" class="mp-filter-number" v-model.number="sizeMax" min="0" placeholder="最大 MB">
</div>
<div class="mp-filter-title">影片時長</div>
<div class="mp-range-row">
<input type="number" class="mp-filter-number" v-model.number="durationMin" min="0" placeholder="最小分鐘">
<span></span>
<input type="number" class="mp-filter-number" v-model.number="durationMax" min="0" placeholder="最大分鐘">
</div>
<button type="button" class="mp-filter-reset" @click="resetFilters">清除篩選</button>
</div>
<div class="mp-status" :class="{ show: loading || statusText }">{{ statusText }}</div>
<div class="mp-grid">
<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">
<div v-else class="mp-doc-thumb">
<span class="mp-doc-icon">📄</span>
<span class="mp-doc-ext">{{ getFileExt(f.file_name) }}</span>
</div>
<button v-if="isVideo(f)" class="mp-play-btn" @click.stop="playVideo(f)"></button>
<div class="mp-complete-mark" v-if="f.is_registered"></div>
</div>
<div class="mp-meta">
<div class="mp-name">{{ f.file_name }}</div>
<div class="mp-source">{{ formatSize(f.file_size) }} · {{ formatDate(f.modified_time) }}</div>
</div>
</div>
</div>
</section>
<VideoPlayer v-if="playing" :file-uuid="currentVideo.fileUuid" :start-time="currentVideo.startTime" :end-time="currentVideo.endTime" :title="currentVideo.title" @close="playing = false" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import VideoPlayer from '../components/VideoPlayer.vue'
const files = ref<any[]>([])
const loading = ref(false)
const statusText = ref('')
const filterText = ref('')
const displayFilter = ref('all')
const showFilter = ref(false)
const selectedFiles = ref<string[]>([])
const playing = ref(false)
const currentVideo = ref({ fileUuid: '', startTime: 0, endTime: 0, title: '' })
// Sort & Filter
const sortBy = ref('time_desc')
const filterUnregistered = ref(false)
const filterRegistered = ref(false)
const onlyVideos = ref(false)
const onlyPhotos = ref(false)
const sizeMin = ref<number | null>(null)
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'
const sortedFilteredFiles = computed(() => {
let result = [...files.value]
// Display filter
if (displayFilter.value === 'video') result = result.filter(isVideo)
else if (displayFilter.value === 'photo') result = result.filter(isPhoto)
// Text search
if (filterText.value) {
const f = filterText.value.toLowerCase()
result = result.filter((file: any) => file.file_name.toLowerCase().includes(f))
}
// 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 (onlyVideos.value) result = result.filter(isVideo)
if (onlyPhotos.value) result = result.filter(isPhoto)
// Size filter
if (sizeMin.value) result = result.filter((f: any) => (f.file_size || 0) >= sizeMin.value! * 1048576)
if (sizeMax.value) result = result.filter((f: any) => (f.file_size || 0) <= sizeMax.value! * 1048576)
// Duration filter (estimated from file size for now)
if (durationMin.value || durationMax.value) {
result = result.filter((f: any) => {
if (!isVideo(f)) return !durationMin.value && !durationMax.value
const estDuration = (f.file_size || 0) / 50000000 // rough estimate
if (durationMin.value && estDuration < durationMin.value * 60) return false
if (durationMax.value && estDuration > durationMax.value * 60) return false
return true
})
}
// Sort
result.sort((a: any, b: any) => {
switch (sortBy.value) {
case 'time_desc': return new Date(b.modified_time || 0).getTime() - new Date(a.modified_time || 0).getTime()
case 'time_asc': return new Date(a.modified_time || 0).getTime() - new Date(b.modified_time || 0).getTime()
case 'size_desc': return (b.file_size || 0) - (a.file_size || 0)
case 'size_asc': return (a.file_size || 0) - (b.file_size || 0)
case 'name_asc': return (a.file_name || '').localeCompare(b.file_name || '')
case 'name_desc': return (b.file_name || '').localeCompare(a.file_name || '')
default: return 0
}
})
return result
})
onMounted(() => loadFiles())
async function loadFiles() {
loading.value = true
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)
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 })))
}
statusText.value = `${files.value.length} files`
} catch (e: any) {
console.error('Failed to load files:', e)
statusText.value = 'Failed: ' + (e.message || e)
} finally {
loading.value = false
}
}
function isVideo(f: any) {
const ext = (f.file_name || '').split('.').pop().toLowerCase()
return ['mp4', 'mov', 'mkv', 'avi', 'webm'].includes(ext)
}
function isPhoto(f: any) {
const ext = (f.file_name || '').split('.').pop().toLowerCase()
return ['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext)
}
function getFileExt(name: string) {
return (name || '').split('.').pop().toUpperCase()
}
function formatSize(bytes: number) {
return (bytes / 1048576).toFixed(1) + ' MB'
}
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`
}
function handleThumbError(e: Event) {
const img = e.target as HTMLImageElement
img.style.display = 'none'
}
function toggleSelect(f: any) {
const idx = selectedFiles.value.indexOf(f.file_uuid)
if (idx >= 0) selectedFiles.value.splice(idx, 1)
else selectedFiles.value.push(f.file_uuid)
}
function playVideo(f: any) {
currentVideo.value = { fileUuid: f.file_uuid, startTime: 0, endTime: 99999, title: f.file_name }
playing.value = true
}
function addToPeople() {
if (!selectedFiles.value.length) { alert('Please select files first') }
alert(`Register ${selectedFiles.value.length} file(s)`)
}
function resetFilters() {
sortBy.value = 'time_desc'
filterUnregistered.value = false
filterRegistered.value = false
onlyVideos.value = false
onlyPhotos.value = false
sizeMin.value = null
sizeMax.value = null
durationMin.value = null
durationMax.value = null
displayFilter.value = 'all'
filterText.value = ''
}
</script>
<style scoped>
#ms-files-page { width: 100%; max-width: 1200px; margin: 0 auto; color: #202124; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans TC', sans-serif; position: relative; }
#ms-files-page * { box-sizing: border-box; }
.mp-panel { display: block; position: relative; overflow: visible; }
.mp-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 22px; flex-wrap: wrap; position: relative; z-index: 2; }
.mp-toolbar-left { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; }
.mp-toolbar-right { display: flex; align-items: center; gap: 12px; margin-left: auto; }
.mp-btn { border: 1px solid #d8dce3; background: #fff; color: #202124; border-radius: 18px; font-size: 14px; padding: 10px 18px; cursor: pointer; box-shadow: 0 2px 6px rgba(0,0,0,.04); font-family: inherit; }
.mp-btn-primary { font-weight: 600; background: #1967d2; color: #fff; border-color: #1967d2; }
.mp-btn-icon { width: 40px; height: 40px; padding: 0; display: flex; align-items: center; justify-content: center; border-radius: 50%; box-shadow: 0 4px 10px rgba(0,0,0,.08); }
.mp-refresh-icon { font-size: 22px; line-height: 1; font-weight: 500; transform: translateY(-1px); }
.mp-filter-btn { width: 42px; height: 42px; padding: 0; display: flex; align-items: center; justify-content: center; border-radius: 50%; border: 1px solid #d8dce3; background: #fff; cursor: pointer; font-size: 16px; }
.mp-radio-row, .mp-check-row { font-size: 13px; color: #5f6368; display: flex; align-items: center; gap: 6px; cursor: pointer; min-height: 24px; margin-bottom: 4px; }
.mp-search-wrap { width: 240px; }
.mp-search { width: 100%; height: 42px; border: 1px solid #e1e5ea; border-radius: 14px; padding: 0 14px; font-size: 14px; background: #fff; outline: none; font-family: inherit; }
.mp-status { font-size: 13px; color: #7a7f87; margin: 4px 0 14px; }
.mp-status.show { display: block; }
.mp-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 16px; }
.mp-file-card { border-radius: 14px; cursor: pointer; position: relative; }
.mp-thumb-wrap { position: relative; border-radius: 12px; overflow: hidden; background: #eef2f7; aspect-ratio: 4 / 3; border: 1px solid #e6ebf1; box-shadow: 0 0 0 2px #d9dde3 inset; }
.lt-thumb { width: 100%; height: 100%; object-fit: cover; display: block; }
.mp-doc-thumb { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 8px; background: linear-gradient(180deg, #f7f9fc, #eef2f7); color: #6b7280; text-align: center; padding: 14px; }
.mp-doc-icon { font-size: 38px; line-height: 1; }
.mp-doc-ext { font-size: 11px; font-weight: 700; letter-spacing: .5px; color: #7a818c; text-transform: uppercase; }
.mp-badge-type { position: absolute; top: 8px; left: 8px; font-size: 10px; line-height: 1; padding: 4px 6px; border-radius: 999px; background: rgba(17,24,39,.75); color: #fff; letter-spacing: .3px; }
.mp-complete-mark { position: absolute; right: 10px; bottom: 10px; width: 28px; height: 28px; border-radius: 50%; background: rgba(17,24,39,.78); color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 700; z-index: 9; }
.mp-file-card.is-selected .mp-thumb-wrap { border-color: #7db9ff !important; background: #eaf4ff !important; box-shadow: 0 0 0 3px rgba(125,185,255,.55) inset !important; }
.mp-file-card.is-selected .mp-doc-thumb { background: linear-gradient(180deg, #eef7ff, #dceeff) !important; }
.mp-meta { padding-top: 6px; }
.mp-source { font-size: 11px; color: #8a919c; margin-bottom: 2px; }
.mp-name { font-size: 12px; color: #42474f; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.mp-play-btn { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); width: 42px; height: 42px; border-radius: 50%; border: none; background: rgba(0,0,0,.58); color: #fff; font-size: 18px; display: flex; align-items: center; justify-content: center; cursor: pointer; z-index: 8; }
.mp-filter-pop { position: absolute; top: 64px; right: 0; width: 250px; background: #fff; border: 1px solid #eceff3; border-radius: 24px; box-shadow: 0 16px 36px rgba(0,0,0,.08); padding: 18px 18px 16px; z-index: 999; display: none; max-height: 560px; overflow-y: auto; }
.mp-filter-pop.show { display: block; }
.mp-filter-title { font-size: 15px; font-weight: 700; color: #202124; margin: 10px 0 10px; }
.mp-filter-title:first-child { margin-top: 0; }
.mp-range-row { display: flex; align-items: center; gap: 8px; margin: 4px 0 12px; }
.mp-filter-number { width: 100%; height: 32px; border: 1px solid #e1e5ea; border-radius: 10px; padding: 0 9px; font-size: 12px; color: #42474f; background: #fff; outline: none; font-family: inherit; }
.mp-filter-reset { width: 100%; height: 34px; margin-top: 8px; border: 1px solid #d8dce3; background: #fff; color: #5f6368; border-radius: 12px; font-size: 13px; cursor: pointer; font-family: inherit; }
.mp-filter-reset:hover { background: #f6f7f9; }
</style>

142
src/views/PeopleView.vue Normal file
View File

@@ -0,0 +1,142 @@
<template>
<div class="people-view">
<h1>People</h1>
<input v-model="search" class="search-input" placeholder="Search people..." />
<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>
<p class="name">{{ p.name }}</p>
</div>
</div>
<div v-if="selected" class="detail-modal" @click.self="selected = null">
<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>
</div>
<div class="detail-content">
<div class="section"><h3>Faces ({{ faces.length }})</h3>
<div class="face-strip">
<div v-for="f in faces.slice(0, 20)" :key="f.id" class="face-thumb">
<div class="face-placeholder">{{ Math.round(f.confidence * 100) }}%</div>
</div>
</div>
</div>
<div class="section"><h3>Traces ({{ traces.length }})</h3>
<div class="trace-list">
<div v-for="t in traces.slice(0, 20)" :key="t.trace_id" class="trace-item" @click="playTrace(t)">
<div class="trace-thumb"></div>
<div class="trace-info">
<span class="trace-time">{{ formatTime(t.first_sec) }} - {{ formatTime(t.last_sec) }}</span>
<span class="trace-conf">{{ (t.avg_confidence * 100).toFixed(0) }}%</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<VideoPlayer
v-if="playing"
:file-uuid="currentVideo.fileUuid"
:start-time="currentVideo.startTime"
:end-time="currentVideo.endTime"
:title="currentVideo.title"
@close="playing = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import VideoPlayer from '../components/VideoPlayer.vue'
const people = ref<any[]>([])
const loading = ref(true)
const search = ref('')
const selected = ref<any>(null)
const faces = ref<any[]>([])
const traces = ref<any[]>([])
const playing = ref(false)
const currentVideo = ref({ fileUuid: '', startTime: 0, endTime: 0, title: '' })
const filteredPeople = computed(() => {
if (!search.value) return people.value
const s = search.value.toLowerCase()
return people.value.filter((p: any) => p.name.toLowerCase().includes(s))
})
onMounted(async () => {
try {
const result: any = await invoke('get_people', { page: 1, perPage: 1000 })
people.value = Array.isArray(result) ? result : []
} catch (e) {
console.error('Failed to load people:', e)
} finally {
loading.value = false
}
})
async function selectPerson(p: any) {
selected.value = p
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 })
faces.value = Array.isArray(fResult) ? fResult : []
traces.value = Array.isArray(tResult) ? tResult : []
} catch (e) {
console.error('Failed to load details:', e)
}
}
function formatTime(sec: number): string {
const m = Math.floor(sec / 60)
const s = Math.floor(sec % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}
function playTrace(t: any) {
currentVideo.value = {
fileUuid: t.file_uuid,
startTime: t.first_sec,
endTime: t.last_sec,
title: `${selected.value?.name} - ${formatTime(t.first_sec)}-${formatTime(t.last_sec)}`
}
playing.value = true
}
</script>
<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; }
.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; }
.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; }
.section { margin-bottom: 24px; }
.section h3 { margin-bottom: 12px; font-size: 1rem; }
.face-strip { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 8px; }
.face-thumb { width: 52px; height: 52px; border-radius: 8px; background: #e5e7eb; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.face-placeholder { font-size: 0.6rem; color: #6b7280; }
.trace-list { display: flex; flex-direction: column; gap: 8px; }
.trace-item { display: flex; align-items: center; gap: 12px; padding: 8px 12px; background: #f9fafb; border-radius: 8px; cursor: pointer; }
.trace-item:hover { background: #eef2ff; }
.trace-thumb { color: #4f46e5; }
.trace-info { display: flex; gap: 12px; flex: 1; }
.trace-time { color: #374151; font-size: 0.85rem; }
.trace-conf { color: #4f46e5; font-weight: 500; font-size: 0.85rem; }
</style>

126
src/views/SearchView.vue Normal file
View File

@@ -0,0 +1,126 @@
<template>
<div class="search-view">
<div class="search-header">
<h1>Search</h1>
<div class="mode-selector">
<button v-for="m in modes" :key="m.value"
:class="['mode-btn', { active: mode === m.value }]"
@click="mode = m.value">
{{ m.label }}
</button>
</div>
</div>
<div class="search-bar">
<input v-model="query" @keyup.enter="search"
placeholder="Enter search query..."
:disabled="loading" />
<button @click="search" :disabled="loading || !query">
{{ loading ? 'Searching...' : 'Search' }}
</button>
</div>
<div class="results" v-if="results.length">
<div v-for="(r, i) in results" :key="i" class="result-card" @click="playVideo(r)">
<div class="result-thumb">
<div class="play-icon"></div>
</div>
<div class="result-info">
<div class="result-meta">
<span class="score">{{ (r.similarity * 100).toFixed(0) }}%</span>
<span class="time">{{ formatTime(r.start_time) }} - {{ formatTime(r.end_time) }}</span>
</div>
<p class="summary">{{ r.summary }}</p>
<p class="file-name">{{ r.file_name }}</p>
</div>
</div>
</div>
<p v-else-if="searched" class="no-results">No results found</p>
<VideoPlayer
v-if="playing"
:file-uuid="currentVideo.fileUuid"
:start-time="currentVideo.startTime"
:end-time="currentVideo.endTime"
:title="currentVideo.title"
@close="playing = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import VideoPlayer from '../components/VideoPlayer.vue'
const modes = [
{ label: 'Keyword', value: 'keyword' },
{ label: 'Semantic', value: 'semantic' },
{ label: 'Agent', value: 'agent' }
]
const mode = ref('semantic')
const query = ref('')
const results = ref<any[]>([])
const loading = ref(false)
const searched = ref(false)
const playing = ref(false)
const currentVideo = ref({ fileUuid: '', startTime: 0, endTime: 0, title: '' })
async function search() {
if (!query.value) return
loading.value = true
searched.value = false
try {
results.value = await invoke('search_llm_smart', { query: query.value, limit: 20 })
searched.value = true
} catch (e) {
console.error('Search failed:', e)
searched.value = true
} finally {
loading.value = false
}
}
function formatTime(sec: number): string {
const m = Math.floor(sec / 60)
const s = Math.floor(sec % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}
function playVideo(r: any) {
currentVideo.value = {
fileUuid: r.file_uuid,
startTime: r.start_time,
endTime: r.end_time,
title: r.summary?.slice(0, 60) || r.file_name || 'Video'
}
playing.value = true
}
</script>
<style scoped>
.search-view { max-width: 900px; }
.search-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; }
h1 { font-size: 1.5rem; }
.mode-selector { display: flex; gap: 8px; }
.mode-btn { padding: 8px 16px; border: 1px solid #d1d5db; background: #fff; border-radius: 8px; cursor: pointer; font-size: 0.85rem; }
.mode-btn.active { background: #4f46e5; color: #fff; border-color: #4f46e5; }
.search-bar { display: flex; gap: 10px; margin-bottom: 24px; }
.search-bar input { flex: 1; padding: 12px 16px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 1rem; }
.search-bar input:disabled { background: #f3f4f6; }
.search-bar button { padding: 12px 24px; background: #4f46e5; color: #fff; border: none; border-radius: 8px; cursor: pointer; font-weight: 500; }
.search-bar button:disabled { opacity: 0.6; cursor: not-allowed; }
.result-card { display: flex; gap: 16px; background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; padding: 16px; margin-bottom: 12px; cursor: pointer; transition: border-color 0.15s, box-shadow 0.15s; }
.result-card:hover { border-color: #4f46e5; box-shadow: 0 4px 12px rgba(79,70,229,0.1); }
.result-thumb { width: 120px; height: 68px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 8px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.play-icon { color: #fff; font-size: 1.5rem; opacity: 0.8; }
.result-info { flex: 1; }
.result-meta { display: flex; gap: 12px; margin-bottom: 6px; }
.score { color: #4f46e5; font-weight: 600; font-size: 0.85rem; }
.time { color: #6b7280; font-size: 0.85rem; }
.summary { color: #374151; margin-bottom: 4px; }
.file-name { color: #9ca3af; font-size: 0.8rem; }
.no-results { text-align: center; padding: 40px; color: #6b7280; }
</style>