Unify API port to 3002 and add video playback to search page

- Change localhost:3003 to localhost:3002 in demo pages
- Add video playback modal to page-api-demo-query.php
- Enhance search results with clickable video chunks
- Update search functions to support identity and universal search
This commit is contained in:
OpenCode
2026-05-30 19:32:46 +08:00
parent 48ef06a07a
commit 39b9fd7da8
4 changed files with 335 additions and 22 deletions

View File

@@ -165,6 +165,24 @@ get_header();
</div>
</div>
<!-- 视频播放模态窗口 -->
<div id="video-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<span class="close-btn">&times;</span>
</div>
<div class="modal-body">
<video id="video-player" controls width="100%">
<source src="" type="video/mp4">
</video>
<div class="video-info">
<div class="chunk-time"></div>
<div class="chunk-text"></div>
</div>
</div>
</div>
</div>
<style>
.workflow-container {
margin-top: 1rem;
@@ -337,10 +355,68 @@ get_header();
padding: 1rem;
border-radius: 4px;
}
/* 视频播放模态窗口 */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.8);
}
.modal-content {
margin: 5% auto;
width: 80%;
max-width: 1200px;
background: #1a1a1a;
border-radius: 8px;
overflow: hidden;
}
.modal-header {
padding: 15px;
background: #2d2d2d;
display: flex;
justify-content: space-between;
}
.close-btn {
color: #fff;
font-size: 28px;
cursor: pointer;
}
#video-player {
width: 100%;
max-height: 70vh;
}
.video-info {
padding: 15px;
background: #2d2d2d;
color: #fff;
}
.chunk-item.clickable {
cursor: pointer;
transition: background 0.2s;
}
.chunk-item.clickable:hover {
background: #dbeafe;
}
.play-btn {
margin-top: 8px;
padding: 4px 12px;
background: var(--primary-color);
color: white;
border-radius: 4px;
font-size: 0.8rem;
border: none;
cursor: pointer;
}
</style>
<script>
const API_BASE = 'http://localhost:3003/api/v1';
const API_BASE = 'http://localhost:3002/api/v1';
const API_KEY = 'muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69';
let workflowFileUuid = null;
function showResult(elementId, data) {
@@ -349,6 +425,60 @@ function showResult(elementId, data) {
el.classList.add('has-content');
}
function playVideoChunk(fileUuid, startTime, endTime, text) {
const videoUrl = `${API_BASE}/file/${fileUuid}/video?api_key=${API_KEY}&start_time=${startTime}&end_time=${endTime}`;
const modal = document.getElementById('video-modal');
const video = document.getElementById('video-player');
video.pause();
video.src = videoUrl;
video.load();
document.querySelector('.chunk-time').textContent =
`${startTime.toFixed(1)}s - ${endTime.toFixed(1)}s`;
document.querySelector('.chunk-text').textContent = text || '';
modal.style.display = 'block';
video.onloadeddata = () => {
video.play().catch(e => console.warn('playVideoChunk: autoplay blocked', e));
};
video.onerror = () => {
console.error('playVideoChunk: video error', video.error);
};
}
function closeVideoModal() {
const video = document.getElementById('video-player');
document.getElementById('video-modal').style.display = 'none';
video.pause();
video.removeAttribute('src');
video.load();
}
document.addEventListener('DOMContentLoaded', function() {
document.querySelector('#video-modal .close-btn').onclick = closeVideoModal;
window.onclick = (event) => {
if (event.target === document.getElementById('video-modal')) {
closeVideoModal();
}
};
document.getElementById('search_details_content').addEventListener('click', function(e) {
const item = e.target.closest('.chunk-item.clickable');
if (!item) return;
const fu = item.dataset.fileUuid;
const st = parseFloat(item.dataset.startTime);
const et = parseFloat(item.dataset.endTime);
const txt = item.dataset.text || '';
if (fu && !isNaN(st) && !isNaN(et)) {
playVideoChunk(fu, st, et, txt);
}
});
});
function updateStep(step, status, isCompleted = false) {
const stepEl = document.getElementById(`step${step}`);
const statusEl = document.getElementById(`step${step}_status`);
@@ -569,10 +699,10 @@ async function searchWorkflow() {
if (!query) return alert('請輸入搜尋查詢');
try {
const res = await fetch(`${API_BASE}/search`, {
const res = await fetch(`${API_BASE}/search/universal?api_key=${API_KEY}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, type: 'chunk' })
body: JSON.stringify({ query, types: ['chunk'] })
});
const data = await res.json();
@@ -582,13 +712,19 @@ async function searchWorkflow() {
<p>涵蓋 ${data.files_count || 0} 個檔案</p>
`;
document.getElementById('search_details_content').innerHTML = (data.results || [])
.map(r => `
<div class="chunk-item">
<div class="chunk-time">${r.file_name} | ${r.start_time?.toFixed(1)}s - ${r.end_time?.toFixed(1)}s</div>
<div>${r.text_content || r.description || '無內容'}</div>
<div class="badge">相似度: ${(r.similarity * 100).toFixed(1)}%</div>
</div>
`).join('') || '<p>沒有結果</p>';
.map(r => {
const textAttr = (r.text || '').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return `<div class="chunk-item clickable"
data-file-uuid="${r.file_uuid}"
data-start-time="${r.start_time || 0}"
data-end-time="${r.end_time || (r.start_time || 0) + 10}"
data-text="${textAttr}">
<div class="chunk-time">${r.file_uuid} | ${(r.start_time || 0).toFixed(1)}s - ${(r.end_time || (r.start_time || 0) + 10).toFixed(1)}s</div>
<div>${r.text || '無內容'}</div>
<div class="badge">相似度: ${(r.score * 100).toFixed(1)}%</div>
<button class="play-btn">▶ 播放片段</button>
</div>`;
}).join('') || '<p>沒有結果</p>';
} catch (e) {
alert('搜尋失敗: ' + e.message);
}

View File

@@ -207,7 +207,7 @@ get_header();
</style>
<script>
const API_BASE = 'http://localhost:3003/api/v1';
const API_BASE = 'http://localhost:3002/api/v1';
async function loadDashboard() {
const uuid = document.getElementById('dashboard_uuid').value.trim();

View File

@@ -171,7 +171,7 @@ get_header();
</style>
<script>
const API_BASE = 'http://localhost:3003/api/v1';
const API_BASE = 'http://localhost:3002/api/v1';
function showResult(elementId, data) {
const el = document.getElementById(elementId);

View File

@@ -104,6 +104,24 @@ get_header();
</div>
</div>
<!-- 视频播放模态窗口 -->
<div id="video-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<span class="close-btn">&times;</span>
</div>
<div class="modal-body">
<video id="video-player" controls width="100%">
<source src="" type="video/mp4">
</video>
<div class="video-info">
<div class="chunk-time"></div>
<div class="chunk-text"></div>
</div>
</div>
</div>
</div>
<style>
.api-nav {
display: flex;
@@ -223,10 +241,75 @@ get_header();
.badge-status.processing { background: #dbeafe; color: #1e40af; }
.badge-status.completed { background: #d1fae5; color: #065f46; }
.badge-status.error { background: #fee2e2; color: #991b1b; }
/* 视频播放模态窗口 */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.8);
}
.modal-content {
margin: 5% auto;
width: 80%;
max-width: 1200px;
background: #1a1a1a;
border-radius: 8px;
overflow: hidden;
}
.modal-header {
padding: 15px;
background: #2d2d2d;
display: flex;
justify-content: space-between;
}
.close-btn {
color: #fff;
font-size: 28px;
cursor: pointer;
}
#video-player {
width: 100%;
max-height: 70vh;
}
.video-info {
padding: 15px;
background: #2d2d2d;
color: #fff;
}
.chunk-item.clickable {
cursor: pointer;
transition: background 0.2s;
padding: 0.75rem;
background: #f8fafc;
border-radius: 4px;
margin-bottom: 0.5rem;
}
.chunk-item.clickable:hover {
background: #dbeafe;
}
.play-btn {
margin-top: 8px;
padding: 4px 12px;
background: var(--primary-color);
color: white;
border-radius: 4px;
font-size: 0.8rem;
border: none;
cursor: pointer;
}
</style>
<script>
const API_BASE = 'http://localhost:3002/api/v1';
const API_KEY = 'muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69';
const URL_WITH_KEY = `${API_BASE}?api_key=${API_KEY}`;
const URL_WITHOUT_KEY = API_BASE;
function showResult(elementId, data) {
const el = document.getElementById(elementId);
@@ -241,6 +324,60 @@ function showError(elementId, message) {
el.style.color = '#ef4444';
}
function playVideoChunk(fileUuid, startTime, endTime, text) {
const videoUrl = `${API_BASE}/file/${fileUuid}/video?api_key=${API_KEY}&start_time=${startTime}&end_time=${endTime}`;
const modal = document.getElementById('video-modal');
const video = document.getElementById('video-player');
video.pause();
video.src = videoUrl;
video.load();
document.querySelector('.chunk-time').textContent =
`${startTime.toFixed(1)}s - ${endTime.toFixed(1)}s`;
document.querySelector('.chunk-text').textContent = text || '';
modal.style.display = 'block';
video.onloadeddata = () => {
video.play().catch(e => console.warn('playVideoChunk: autoplay blocked', e));
};
video.onerror = () => {
console.error('playVideoChunk: video error', video.error);
};
}
function closeVideoModal() {
const video = document.getElementById('video-player');
document.getElementById('video-modal').style.display = 'none';
video.pause();
video.removeAttribute('src');
video.load();
}
document.addEventListener('DOMContentLoaded', function() {
document.querySelector('#video-modal .close-btn').onclick = closeVideoModal;
window.onclick = (event) => {
if (event.target === document.getElementById('video-modal')) {
closeVideoModal();
}
};
document.getElementById('search_result').addEventListener('click', function(e) {
const item = e.target.closest('.chunk-item.clickable');
if (!item) return;
const fu = item.dataset.fileUuid;
const st = parseFloat(item.dataset.startTime);
const et = parseFloat(item.dataset.endTime);
const txt = item.dataset.text || '';
if (fu && !isNaN(st) && !isNaN(et)) {
playVideoChunk(fu, st, et, txt);
}
});
});
async function queryFile() {
const uuid = document.getElementById('file_uuid').value.trim();
if (!uuid) return alert('請輸入 file_uuid');
@@ -320,18 +457,58 @@ async function queryFileHistory() {
async function semanticSearch() {
const query = document.getElementById('search_query').value.trim();
const type = document.getElementById('search_type').value;
if (!query) return alert('請輸入搜尋查');
if (!query) return alert('請輸入搜尋查');
const resultEl = document.getElementById('search_result');
resultEl.innerHTML = '<p style="padding: 1rem;">搜尋中,請稍候...(身份搜尋可能需要較長時間)</p>';
resultEl.classList.add('has-content');
try {
const res = await fetch(`${API_BASE}/search`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, type })
});
const data = await res.json();
showResult('search_result', data);
let resultsHtml = '';
if (type === 'identity') {
const res = await fetch(`${API_BASE}/identities/search?q=${encodeURIComponent(query)}&api_key=${API_KEY}`);
const data = await res.json();
const results = data.results || [];
resultsHtml = results.map(i => {
const textAttr = (i.text_content || '').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return `<div class="chunk-item clickable"
data-file-uuid="${i.file_uuid}"
data-start-time="${i.start_time || 0}"
data-end-time="${i.end_time || (i.start_time || 0) + 10}"
data-text="${textAttr}">
<div style="font-weight: 600; color: #1e40af;">${i.name} (${i.source || 'unknown'})</div>
<div class="chunk-time">${i.file_uuid} | ${(i.start_time || 0).toFixed(1)}s - ${(i.end_time || (i.start_time || 0) + 10).toFixed(1)}s</div>
<div style="margin-top: 4px;">${i.text_content || '無內容'}</div>
<button class="play-btn">▶ 播放片段</button>
</div>`;
}).join('');
resultEl.innerHTML = `<p style="padding: 0.5rem; background: #d1fae5; border-radius: 4px; margin-bottom: 0.5rem;">找到 ${data.total || 0} 個身份相關片段</p>${resultsHtml || '<p>沒有結果</p>'}`;
} else {
const typesParam = type === 'face' ? ['frame'] : ['chunk'];
const res = await fetch(`${API_BASE}/search/universal?api_key=${API_KEY}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, types: typesParam })
});
const data = await res.json();
const results = data.results || [];
resultsHtml = results.map(r => {
const textAttr = (r.text || '').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return `<div class="chunk-item clickable"
data-file-uuid="${r.file_uuid}"
data-start-time="${r.start_time || 0}"
data-end-time="${r.end_time || (r.start_time || 0) + 10}"
data-text="${textAttr}">
<div class="chunk-time">${r.file_uuid} | ${(r.start_time || 0).toFixed(1)}s - ${(r.end_time || (r.start_time || 0) + 10).toFixed(1)}s</div>
<div style="margin-top: 4px;">${r.text || '無內容'}</div>
<div class="badge-status completed" style="margin-top: 4px;">相似度: ${(r.score * 100).toFixed(1)}%</div>
<button class="play-btn">▶ 播放片段</button>
</div>`;
}).join('');
resultEl.innerHTML = `<p style="padding: 0.5rem; background: #d1fae5; border-radius: 4px; margin-bottom: 0.5rem;">找到 ${data.total || 0} 個片段</p>${resultsHtml || '<p>沒有結果</p>'}`;
}
} catch (e) {
showError('search_result', e.message);
resultEl.innerHTML = '<p style="color:#ef4444; padding: 1rem;">錯誤: ' + e.message + '</p>';
}
}
</script>