feat: add face filter components and integration guide

- useFaceFilter composable: manages face filtering state and API calls
- FaceFilterPanel component: UI for filtering controls
- FaceCard component: text-only face display card
- Integration guide: step-by-step guide for integrating into PeopleView

Implements hybrid filtering mode with:
- Instant mode: client-side filtering (5-10ms)
- Backend mode: server-side complex filtering
- Hybrid mode: backend + instant filtering (AND)

Uses actual Core API endpoints:
- GET /file/{uuid}/faces
- POST /file/{uuid}/faces/filter
- GET /file/{uuid}/face/{faceId}/filter-report
- GET /file/{uuid}/filter-stats

Preferences stored in localStorage.
This commit is contained in:
2026-08-09 21:24:52 +08:00
parent 97a5fef0b2
commit 7b60ca3d2d
11 changed files with 1533 additions and 4892 deletions

195
src/components/FaceCard.vue Normal file
View File

@@ -0,0 +1,195 @@
<template>
<div
class="face-card"
:class="{
'face-card-selected': selected,
'status-accept': face.filter_features.backend_filter_status === 'accept',
'status-reject': face.filter_features.backend_filter_status === 'reject',
'status-review': face.filter_features.backend_filter_status === 'review'
}"
@click="$emit('click', face)"
>
<!-- Status Badge -->
<div v-if="face.filter_features.backend_filter_status" class="status-badge" :class="face.filter_features.backend_filter_status">
{{ face.filter_features.backend_filter_status }}
</div>
<!-- Face Info -->
<div class="face-info">
<div class="info-row">
<span class="info-label">Face ID:</span>
<span class="info-value">{{ face.face_id }}</span>
</div>
<div class="info-row">
<span class="info-label">Frame:</span>
<span class="info-value">{{ face.frame }}</span>
</div>
<div class="info-row">
<span class="info-label">Confidence:</span>
<span class="info-value">{{ (face.primary_confidence * 100).toFixed(1) }}%</span>
</div>
<div class="info-row">
<span class="info-label">Size:</span>
<span class="info-value">{{ face.filter_features.bbox_size }}px</span>
</div>
<div class="info-row">
<span class="info-label">Source:</span>
<span class="info-value source-badge" :class="face.primary_source">
{{ face.primary_source === 'apple_vision' ? 'AV' : 'MP' }}
</span>
</div>
</div>
<!-- Checkbox for selection -->
<div v-if="showCheckbox" class="face-checkbox" @click.stop="$emit('select', face)">
<span v-if="selected">✓</span>
</div>
</div>
</template>
<script setup lang="ts">
import type { FaceMetadata } from '../composables/useFaceFilter';
defineProps<{
face: FaceMetadata;
selected?: boolean;
showCheckbox?: boolean;
}>();
defineEmits<{
click: [face: FaceMetadata];
select: [face: FaceMetadata];
}>();
</script>
<style scoped>
.face-card {
position: relative;
border: 1px solid var(--border-color, #ddd);
border-radius: 4px;
padding: 10px;
background: var(--bg-color, #fff);
cursor: pointer;
transition: all 0.2s;
min-height: 120px;
}
.face-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border-color: var(--primary-color, #007bff);
}
.face-card-selected {
border-color: var(--primary-color, #007bff);
background: var(--selected-bg, #e3f2fd);
}
/* Status-based styling */
.status-accept {
border-left: 3px solid #4caf50;
}
.status-reject {
border-left: 3px solid #f44336;
opacity: 0.7;
}
.status-review {
border-left: 3px solid #ff9800;
}
.status-badge {
position: absolute;
top: 8px;
right: 8px;
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
}
.status-badge.accept {
background: #e8f5e9;
color: #2e7d32;
}
.status-badge.reject {
background: #ffebee;
color: #c62828;
}
.status-badge.review {
background: #fff3e0;
color: #e65100;
}
.face-info {
display: flex;
flex-direction: column;
gap: 6px;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
}
.info-label {
color: #666;
font-weight: 500;
}
.info-value {
color: #333;
font-weight: 600;
}
.source-badge {
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
font-weight: 700;
}
.source-badge.apple_vision {
background: #e3f2fd;
color: #1976d2;
}
.source-badge.mediapipe {
background: #f3e5f5;
color: #7b1fa2;
}
.face-checkbox {
position: absolute;
top: 8px;
left: 8px;
width: 20px;
height: 20px;
border: 2px solid var(--border-color, #ddd);
border-radius: 3px;
background: var(--bg-color, #fff);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
.face-checkbox:hover {
border-color: var(--primary-color, #007bff);
}
.face-checkbox span {
color: var(--primary-color, #007bff);
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,406 @@
<template>
<div class="face-filter-panel">
<!-- Toolbar Toggle -->
<div class="face-filter-toolbar">
<button
class="face-filter-toggle-btn"
:class="{ active: showPanel }"
@click="togglePanel"
>
🔍 Face Filter
<span class="toggle-dot" :class="{ on: showPanel }"></span>
</button>
<!-- Stats Summary -->
<div v-if="stats" class="face-filter-stats-inline">
<span>Total: {{ stats.total }}</span>
<span>Filtered: {{ stats.filtered }}</span>
<span v-if="stats.removed > 0" class="removed">Removed: {{ stats.removed }}</span>
</div>
</div>
<!-- Filter Panel -->
<div v-if="showPanel" class="face-filter-content">
<!-- Mode Selection -->
<div class="filter-row">
<label>Mode:</label>
<div class="mode-buttons">
<button
class="mode-btn"
:class="{ active: mode === 'instant' }"
@click="setMode('instant')"
title="Instant client-side filtering (5-10ms)"
>
Instant
</button>
<button
class="mode-btn"
:class="{ active: mode === 'backend' }"
@click="setMode('backend')"
title="Backend complex filtering (landmark, embedding, temporal)"
>
Backend
</button>
<button
class="mode-btn"
:class="{ active: mode === 'hybrid' }"
@click="setMode('hybrid')"
title="Hybrid: backend + instant filtering"
>
Hybrid
</button>
</div>
</div>
<!-- Filter Controls -->
<div class="filter-row">
<label>Confidence:</label>
<input
type="number"
v-model.number="rules.min_confidence"
@change="onRuleChange"
min="0"
max="1"
step="0.05"
class="filter-input"
placeholder="Min confidence"
/>
<span class="filter-hint">(0-1)</span>
</div>
<div class="filter-row">
<label>Bbox Size:</label>
<input
type="number"
v-model.number="rules.min_bbox_size"
@change="onRuleChange"
min="0"
step="10"
class="filter-input"
placeholder="Min size (px)"
/>
<span class="filter-hint">(px)</span>
</div>
<div class="filter-row">
<label>Source:</label>
<select v-model="rules.source" @change="onRuleChange" class="filter-select">
<option value="all">All</option>
<option value="apple_vision">Apple Vision</option>
<option value="mediapipe">MediaPipe</option>
</select>
</div>
<div class="filter-row">
<label>Backend Status:</label>
<select v-model="rules.backend_status" @change="onRuleChange" class="filter-select">
<option value="all">All</option>
<option value="accept">Accept</option>
<option value="review">Review</option>
<option value="reject">Reject</option>
</select>
</div>
<!-- Action Buttons -->
<div class="filter-actions">
<button
class="action-btn action-btn-primary"
@click="handleApplyBackend"
:disabled="loading || mode === 'instant'"
>
{{ loading ? 'Applying...' : 'Apply Backend Rules' }}
</button>
<button
class="action-btn"
@click="handleReset"
>
Reset
</button>
</div>
<!-- Error Message -->
<div v-if="error" class="error-message">
❌ {{ error }}
</div>
<!-- Filter Stats (Backend Mode) -->
<div v-if="filterStats && (mode === 'backend' || mode === 'hybrid')" class="filter-stats">
<h4>Filter Statistics</h4>
<div class="stats-grid">
<div class="stat-item">
<label>Total Faces:</label>
<span>{{ filterStats.total_faces }}</span>
</div>
<div class="stat-item">
<label>Apple Vision:</label>
<span>{{ filterStats.by_category?.by_source?.apple_vision || 0 }}</span>
</div>
<div class="stat-item">
<label>MediaPipe:</label>
<span>{{ filterStats.by_category?.by_source?.mediapipe || 0 }}</span>
</div>
<div class="stat-item">
<label>MediaPipe Verified:</label>
<span>{{ filterStats.by_category?.by_quality?.mediapipe_verified || 0 }}</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useFaceFilter, type QuickFilterRules } from '../composables/useFaceFilter';
const props = defineProps<{
fileUuid: string;
}>();
const {
mode,
rules,
filterResult,
filterStats,
loading,
error,
stats,
setMode,
updateRules,
applyBackendFilter,
resetToDefault
} = useFaceFilter();
const showPanel = ref(false);
function togglePanel() {
showPanel.value = !showPanel.value;
}
function onRuleChange() {
updateRules(rules.value);
}
async function handleApplyBackend() {
if (!props.fileUuid) return;
await applyBackendFilter(props.fileUuid, null);
}
function handleReset() {
resetToDefault();
}
// Expose for parent
defineExpose({
showPanel,
stats
});
</script>
<style scoped>
.face-filter-panel {
border: 1px solid var(--border-color, #ddd);
border-radius: 4px;
background: var(--bg-color, #fff);
margin-bottom: 10px;
}
.face-filter-toolbar {
display: flex;
align-items: center;
gap: 15px;
padding: 8px 12px;
border-bottom: 1px solid var(--border-color, #ddd);
}
.face-filter-toggle-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 1px solid var(--border-color, #ddd);
border-radius: 4px;
background: var(--bg-color, #fff);
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.face-filter-toggle-btn:hover {
background: var(--hover-bg, #f5f5f5);
}
.face-filter-toggle-btn.active {
background: var(--primary-color, #007bff);
color: white;
border-color: var(--primary-color, #007bff);
}
.toggle-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #ccc;
transition: background 0.2s;
}
.toggle-dot.on {
background: #4caf50;
}
.face-filter-stats-inline {
display: flex;
gap: 10px;
font-size: 13px;
color: #666;
}
.face-filter-stats-inline .removed {
color: #f44336;
}
.face-filter-content {
padding: 12px;
}
.filter-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.filter-row label {
min-width: 120px;
font-size: 14px;
font-weight: 500;
}
.mode-buttons {
display: flex;
gap: 5px;
}
.mode-btn {
padding: 6px 12px;
border: 1px solid var(--border-color, #ddd);
border-radius: 4px;
background: var(--bg-color, #fff);
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.mode-btn:hover {
background: var(--hover-bg, #f5f5f5);
}
.mode-btn.active {
background: var(--primary-color, #007bff);
color: white;
border-color: var(--primary-color, #007bff);
}
.filter-input {
width: 100px;
padding: 4px 8px;
border: 1px solid var(--border-color, #ddd);
border-radius: 4px;
font-size: 14px;
}
.filter-select {
padding: 4px 8px;
border: 1px solid var(--border-color, #ddd);
border-radius: 4px;
font-size: 14px;
background: var(--bg-color, #fff);
}
.filter-hint {
font-size: 12px;
color: #999;
}
.filter-actions {
display: flex;
gap: 10px;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid var(--border-color, #ddd);
}
.action-btn {
padding: 8px 16px;
border: 1px solid var(--border-color, #ddd);
border-radius: 4px;
background: var(--bg-color, #fff);
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.action-btn:hover:not(:disabled) {
background: var(--hover-bg, #f5f5f5);
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.action-btn-primary {
background: var(--primary-color, #007bff);
color: white;
border-color: var(--primary-color, #007bff);
}
.action-btn-primary:hover:not(:disabled) {
background: var(--primary-hover, #0056b3);
}
.error-message {
margin-top: 10px;
padding: 8px 12px;
background: #ffebee;
border: 1px solid #f44336;
border-radius: 4px;
color: #c62828;
font-size: 13px;
}
.filter-stats {
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid var(--border-color, #ddd);
}
.filter-stats h4 {
margin: 0 0 10px 0;
font-size: 14px;
font-weight: 600;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.stat-item {
display: flex;
justify-content: space-between;
padding: 6px 10px;
background: var(--hover-bg, #f5f5f5);
border-radius: 4px;
font-size: 13px;
}
.stat-item label {
color: #666;
}
.stat-item span {
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,389 @@
import { ref, computed } from 'vue';
// Types
export interface FaceMetadata {
face_id: string;
frame: number;
primary_source: 'apple_vision' | 'mediapipe';
primary_confidence: number;
detection_sources: Array<{
source_type: string;
confidence: number;
}>;
filter_features: {
face_id: string;
frame: number;
bbox_size: number;
apple_vision_confidence: number;
mediapipe_confidence?: number;
eye_ratio?: number;
nose_ratio?: number;
pose_score?: number;
mediapipe_detected?: boolean;
mediapipe_iou?: number;
embedding_similarity?: number;
consecutive_frames?: number;
total_frames_detected?: number;
backend_filter_status?: 'accept' | 'reject' | 'review';
};
}
export interface QuickFilterRules {
min_confidence?: number;
max_confidence?: number;
min_bbox_size?: number;
max_bbox_size?: number;
source?: 'apple_vision' | 'mediapipe' | 'all';
backend_status?: 'accept' | 'reject' | 'review' | 'all';
}
export interface FilterResult {
total_faces: number;
passed: number;
filtered: number;
passed_faces: Array<{
face_id: string;
frame: number;
filter_result: {
overall_confidence: number;
recommendation: 'accept' | 'review' | 'reject';
reasons: string[];
};
}>;
filtered_faces: Array<{
face_id: string;
frame: number;
filter_result: {
overall_confidence: number;
recommendation: 'accept' | 'review' | 'reject';
reasons: string[];
};
}>;
filter_stats: {
landmark?: number;
mediapipe?: number;
embedding?: number;
temporal?: number;
};
}
export interface FilterStats {
total_faces: number;
by_category: {
total_faces: number;
by_source: {
apple_vision: number;
mediapipe: number;
};
by_quality: {
avg_eye_ratio?: number;
avg_nose_ratio?: number;
avg_pose_score?: number;
mediapipe_verified: number;
avg_embedding_similarity?: number;
avg_consecutive_frames?: number;
};
};
}
// Default rules
const defaultRules: QuickFilterRules = {
min_confidence: 0.5,
min_bbox_size: 80,
source: 'all',
backend_status: 'accept'
};
const STORAGE_KEY = 'momentry_face_filter_preferences';
// API helpers
const apiBase = '/api/v1';
async function fetchFaces(fileUuid: string, params?: { page?: number; page_size?: number; source?: string }) {
const queryParams = new URLSearchParams();
if (params?.page) queryParams.set('page', params.page.toString());
if (params?.page_size) queryParams.set('page_size', params.page_size.toString());
if (params?.source) queryParams.set('source', params.source);
const response = await fetch(`${apiBase}/file/${fileUuid}/faces?${queryParams}`);
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
return response.json();
}
async function applyFilter(fileUuid: string, rules?: any) {
const response = await fetch(`${apiBase}/file/${fileUuid}/faces/filter`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rules: rules || null })
});
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
return response.json();
}
async function getFilterStats(fileUuid: string) {
const response = await fetch(`${apiBase}/file/${fileUuid}/filter-stats`);
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
return response.json();
}
async function getFaceReport(fileUuid: string, faceId: string) {
const response = await fetch(`${apiBase}/file/${fileUuid}/face/${faceId}/filter-report`);
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
return response.json();
}
// Quick filter function (client-side)
function quickFilter(faces: FaceMetadata[], rules: QuickFilterRules): FaceMetadata[] {
return faces.filter(face => {
// 1. Confidence 過濾
if (rules.min_confidence && face.primary_confidence < rules.min_confidence) {
return false;
}
if (rules.max_confidence && face.primary_confidence > rules.max_confidence) {
return false;
}
// 2. Bbox size 過濾
if (rules.min_bbox_size && face.filter_features.bbox_size < rules.min_bbox_size) {
return false;
}
if (rules.max_bbox_size && face.filter_features.bbox_size > rules.max_bbox_size) {
return false;
}
// 3. Source 過濾
if (rules.source && rules.source !== 'all' && face.primary_source !== rules.source) {
return false;
}
// 4. Backend status 過濾
if (rules.backend_status && rules.backend_status !== 'all' &&
face.filter_features.backend_filter_status !== rules.backend_status) {
return false;
}
return true;
});
}
// Hybrid filter function (backend + frontend)
function hybridFilter(faces: FaceMetadata[], rules: QuickFilterRules): FaceMetadata[] {
// 1. 前端過濾
const frontendFiltered = quickFilter(faces, rules);
// 2. 後端已標示 backend_filter_status
// 3. 兩條件 AND
return frontendFiltered.filter(face =>
face.filter_features.backend_filter_status === 'accept'
);
}
// Main composable
export function useFaceFilter() {
const mode = ref<'instant' | 'backend' | 'hybrid'>('instant');
const rules = ref<QuickFilterRules>({ ...defaultRules });
const allFaces = ref<FaceMetadata[]>([]);
const filteredFaces = ref<FaceMetadata[]>([]);
const filterResult = ref<FilterResult | null>(null);
const filterStats = ref<FilterStats | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
// Computed stats
const stats = computed(() => ({
total: allFaces.value.length,
filtered: filteredFaces.value.length,
removed: allFaces.value.length - filteredFaces.value.length
}));
// Load preferences from localStorage
function loadPreferences() {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const prefs = JSON.parse(stored);
mode.value = prefs.mode || 'instant';
rules.value = prefs.rules || { ...defaultRules };
}
} catch (err) {
console.error('Failed to load preferences:', err);
}
}
// Save preferences to localStorage
function savePreferences() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
mode: mode.value,
rules: rules.value
}));
} catch (err) {
console.error('Failed to save preferences:', err);
}
}
// Initialize: load faces and apply filter
async function initialize(fileUuid: string) {
loading.value = true;
error.value = null;
try {
// 1. 載入臉部元數據
const data = await fetchFaces(fileUuid, { page: 1, page_size: 1000 });
allFaces.value = data.data?.faces || data.faces || [];
// 2. 載入後端過濾結果(混合模式需要)
if (mode.value === 'hybrid' || mode.value === 'backend') {
const result = await applyFilter(fileUuid, null);
filterResult.value = result;
// 合併後端過濾結果到元數據
mergeBackendResults(result);
}
// 3. 載入統計
filterStats.value = await getFilterStats(fileUuid);
// 4. 套用過濾
applyLocalFilter();
// 5. 載入偏好
loadPreferences();
} catch (err: any) {
error.value = err.message || 'Failed to initialize';
console.error('Failed to initialize face filter:', err);
} finally {
loading.value = false;
}
}
// Merge backend filter results into metadata
function mergeBackendResults(result: FilterResult) {
result.passed_faces.forEach(face => {
const metadata = allFaces.value.find(f => f.face_id === face.face_id);
if (metadata) {
metadata.filter_features.backend_filter_status = face.filter_result.recommendation as any;
}
});
result.filtered_faces.forEach(face => {
const metadata = allFaces.value.find(f => f.face_id === face.face_id);
if (metadata) {
metadata.filter_features.backend_filter_status = face.filter_result.recommendation as any;
}
});
}
// Apply local filter based on mode
function applyLocalFilter() {
switch (mode.value) {
case 'instant':
filteredFaces.value = quickFilter(allFaces.value, rules.value);
break;
case 'backend':
filteredFaces.value = allFaces.value.filter(face =>
face.filter_features.backend_filter_status === 'accept'
);
break;
case 'hybrid':
filteredFaces.value = hybridFilter(allFaces.value, rules.value);
break;
}
}
// Update rules and re-filter
function updateRules(newRules: Partial<QuickFilterRules>) {
rules.value = { ...rules.value, ...newRules };
applyLocalFilter();
savePreferences();
}
// Change mode and re-filter
function setMode(newMode: 'instant' | 'backend' | 'hybrid') {
mode.value = newMode;
applyLocalFilter();
savePreferences();
}
// Apply backend filter
async function applyBackendFilter(fileUuid: string, customRules?: any) {
loading.value = true;
error.value = null;
try {
const result = await applyFilter(fileUuid, customRules);
filterResult.value = result;
// 合併後端過濾結果到元數據
mergeBackendResults(result);
// 重新套用過濾
applyLocalFilter();
return result;
} catch (err: any) {
error.value = err.message || 'Failed to apply backend filter';
console.error('Failed to apply backend filter:', err);
return null;
} finally {
loading.value = false;
}
}
// Get face report
async function getFaceReportData(fileUuid: string, faceId: string) {
try {
return await getFaceReport(fileUuid, faceId);
} catch (err: any) {
console.error('Failed to get face report:', err);
return null;
}
}
// Refresh stats
async function refreshStats(fileUuid: string) {
try {
filterStats.value = await getFilterStats(fileUuid);
} catch (err: any) {
console.error('Failed to refresh stats:', err);
}
}
// Reset to default
function resetToDefault() {
rules.value = { ...defaultRules };
applyLocalFilter();
savePreferences();
}
return {
// State
mode,
rules,
allFaces,
filteredFaces,
filterResult,
filterStats,
loading,
error,
stats,
// Actions
initialize,
updateRules,
setMode,
applyBackendFilter,
getFaceReportData,
refreshStats,
resetToDefault,
applyLocalFilter,
// API helpers (exposed for direct use)
fetchFaces,
applyFilter,
getFilterStats,
getFaceReport,
quickFilter,
hybridFilter
};
}

View File

@@ -1,85 +0,0 @@
<template>
<div id="ms-view-admin">
<div class="admin-header">
<h1>Admin Panel</h1>
<div class="admin-tabs">
<button class="admin-tab" :class="{ active: activeTab === 'dashboard' }" @click="activeTab = 'dashboard'">Dashboard</button>
<button class="admin-tab" :class="{ active: activeTab === 'users' }" @click="activeTab = 'users'">Users</button>
<button class="admin-tab" :class="{ active: activeTab === 'shares' }" @click="activeTab = 'shares'">Shares</button>
</div>
</div>
<div class="admin-content">
<div v-if="activeTab === 'dashboard'" class="dashboard-panel">
<div class="stats-grid">
<div class="stat-card"><div class="stat-label">CPU</div><div class="stat-value">{{ stats.cpu }}%</div></div>
<div class="stat-card"><div class="stat-label">Memory</div><div class="stat-value">{{ stats.memory }}%</div></div>
<div class="stat-card"><div class="stat-label">Disk</div><div class="stat-value">{{ stats.disk }}%</div></div>
<div class="stat-card"><div class="stat-label">Users</div><div class="stat-value">{{ users.length }}</div></div>
<div class="stat-card"><div class="stat-label">Shares</div><div class="stat-value">{{ shares.length }}</div></div>
</div>
</div>
<div v-if="activeTab === 'users'" class="users-panel">
<table class="admin-table">
<thead><tr><th>Username</th><th>Home Dir</th><th>Status</th></tr></thead>
<tbody>
<tr v-for="u in users" :key="u.username">
<td>{{ u.username }}</td><td>{{ u.homeDir }}</td><td>{{ u.status === 1 ? 'Active' : 'Disabled' }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="activeTab === 'shares'" class="shares-panel">
<table class="admin-table">
<thead><tr><th>Name</th><th>Port</th><th>Status</th></tr></thead>
<tbody>
<tr v-for="s in shares" :key="s.name">
<td>{{ s.name }}</td><td>{{ s.port }}</td><td>{{ s.status }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { apiCall } from '../api'
const activeTab = ref('dashboard')
const stats = ref({ cpu: 0, memory: 0, disk: 0 })
const users = ref<any[]>([])
const shares = ref<any[]>([])
async function loadDashboard() {
try { stats.value = await apiCall('get_system_stats', {}) || { cpu: 0, memory: 0, disk: 0 } } catch (e) { console.error(e) }
}
async function loadUsers() {
try { users.value = await apiCall('list_admin_users', {}) || [] } catch (e) { console.error(e) }
}
async function loadShares() {
try { shares.value = await apiCall('list_shares', {}) || [] } catch (e) { console.error(e) }
}
onMounted(async () => { await loadDashboard(); await loadUsers(); await loadShares() })
</script>
<style scoped>
#ms-view-admin { padding: 20px; }
.admin-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.admin-tabs { display: flex; gap: 10px; }
.admin-tab { padding: 8px 16px; border: none; background: #f0f0f0; cursor: pointer; border-radius: 4px; }
.admin-tab.active { background: #2563eb; color: white; }
.admin-content { background: white; border-radius: 8px; padding: 20px; }
.stats-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 15px; }
.stat-card { background: #f8f9fa; padding: 15px; border-radius: 8px; text-align: center; }
.stat-label { font-size: 12px; color: #666; }
.stat-value { font-size: 24px; font-weight: bold; }
.admin-table { width: 100%; border-collapse: collapse; }
.admin-table th, .admin-table td { padding: 10px; border: 1px solid #ddd; text-align: left; }
</style>

View File

@@ -1,84 +0,0 @@
<template>
<div id="ms-view-client">
<div class="client-header">
<h1>File Manager</h1>
<div class="client-toolbar">
<button @click="goUp">Up</button>
<button @click="createFolder">New Folder</button>
<button @click="deleteSelected" :disabled="!selectedFile">Delete</button>
<span class="current-path">{{ currentPath }}</span>
</div>
</div>
<div class="client-content">
<table class="file-table">
<thead><tr><th>Name</th><th>Size</th><th>Type</th></tr></thead>
<tbody>
<tr v-for="f in files" :key="f.name" @click="selectFile(f)" :class="{ selected: selectedFile?.name === f.name }">
<td><span :class="f.isDir ? 'folder-icon' : 'file-icon'">{{ f.isDir ? '📁' : '📄' }}</span> {{ f.name }}</td>
<td>{{ f.isDir ? '-' : formatSize(f.size) }}</td>
<td>{{ f.isDir ? 'Folder' : 'File' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { apiCall } from '../api'
const currentPath = ref('/')
const files = ref<any[]>([])
const selectedFile = ref<any>(null)
async function loadFiles() {
try {
const data = await apiCall('list_client_files', { path: currentPath.value })
files.value = (data || []).sort((a: any, b: any) => a.isDir === b.isDir ? a.name.localeCompare(b.name) : a.isDir ? -1 : 1)
} catch (e) { console.error(e); files.value = [] }
}
function goUp() {
const parts = currentPath.value.split('/').filter(p => p)
parts.pop()
currentPath.value = '/' + parts.join('/')
loadFiles()
}
function selectFile(f: any) {
if (f.isDir) { currentPath.value = currentPath.value === '/' ? '/' + f.name : currentPath.value + '/' + f.name; loadFiles() }
else { selectedFile.value = f }
}
async function createFolder() {
const name = prompt('Folder name:')
if (!name) return
const path = currentPath.value === '/' ? '/' + name : currentPath.value + '/' + name
try { await apiCall('mkdir_client', { path }); loadFiles() } catch (e) { alert('Failed: ' + e) }
}
async function deleteSelected() {
if (!selectedFile.value || !confirm('Delete ' + selectedFile.value.name + '?')) return
const path = currentPath.value === '/' ? '/' + selectedFile.value.name : currentPath.value + '/' + selectedFile.value.name
try { await apiCall('rm_client', { path }); selectedFile.value = null; loadFiles() } catch (e) { alert('Failed: ' + e) }
}
function formatSize(n: number) { return n < 1024 ? n + 'B' : n < 1024*1024 ? (n/1024).toFixed(1) + 'KB' : (n/1024/1024).toFixed(1) + 'MB' }
onMounted(loadFiles)
</script>
<style scoped>
#ms-view-client { padding: 20px; }
.client-header { margin-bottom: 15px; }
.client-toolbar { display: flex; gap: 10px; align-items: center; margin-top: 10px; }
.client-toolbar button { padding: 6px 12px; border: 1px solid #ddd; background: white; cursor: pointer; border-radius: 4px; }
.client-toolbar button:disabled { opacity: 0.5; }
.current-path { margin-left: 20px; font-weight: bold; }
.file-table { width: 100%; border-collapse: collapse; }
.file-table th, .file-table td { padding: 8px; border: 1px solid #eee; }
.file-table tr.selected { background: #e3f2fd; }
.file-table tr:hover { background: #f5f5f5; cursor: pointer; }
</style>