Initial commit: Momentry Studio v0.1.0
This commit is contained in:
57
src/App.vue
Normal file
57
src/App.vue
Normal 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>
|
||||
488
src/assets/momentry-base.css
Normal file
488
src/assets/momentry-base.css
Normal 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
1044
src/assets/momentry-full.css
Normal file
File diff suppressed because one or more lines are too long
0
src/assets/sidebar-nav.css
Normal file
0
src/assets/sidebar-nav.css
Normal file
1044
src/assets/wordpress-exact.css
Normal file
1044
src/assets/wordpress-exact.css
Normal file
File diff suppressed because one or more lines are too long
199
src/components/VideoPlayer.vue
Normal file
199
src/components/VideoPlayer.vue
Normal 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
7
src/main.ts
Normal 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
18
src/router/index.ts
Normal 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
292
src/views/LibraryView.vue
Normal 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
142
src/views/PeopleView.vue
Normal 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
126
src/views/SearchView.vue
Normal 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>
|
||||
Reference in New Issue
Block a user