466 lines
13 KiB
Markdown
466 lines
13 KiB
Markdown
# Vue Router Integration Plan
|
|||
|
|
|
||
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use compose:subagent (recommended) or compose:execute to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
|
|
||
|
|
**Goal:** Add vue-router with HTML5 history mode, splitting ClientPage.vue into independent route components while preserving all UI and behavior.
|
||
|
|
|
||
|
|
**Architecture:** Extract shared state into composables, create route view components from ClientPage.vue template sections, wrap in DefaultLayout with shared header/modals.
|
||
|
|
|
||
|
|
**Tech Stack:** Vue 3.5, vue-router 4, TypeScript 6
|
||
|
|
|
||
|
|
## Global Constraints
|
||
|
|
|
||
|
|
- No changes to existing child component styles or logic (FileCard, FileListItem, BrowseToolbar, etc.)
|
||
|
|
- HTML5 history mode (clean URLs like `/browse/1`)
|
||
|
|
- Backend already has `MapFallbackToFile("index.html")` — no backend changes needed
|
||
|
|
|
||
|
|
## File Map
|
||
|
|
|
||
|
|
**New files (~12):**
|
||
|
|
- `src/router/index.ts`
|
||
|
|
- `src/layouts/DefaultLayout.vue`
|
||
|
|
- `src/views/RootPickerView.vue`
|
||
|
|
- `src/views/RecentAddedView.vue`
|
||
|
|
- `src/views/RecentPlayedView.vue`
|
||
|
|
- `src/views/BrowseView.vue`
|
||
|
|
- `src/views/SearchView.vue`
|
||
|
|
- `src/composables/useLibraryRoots.ts`
|
||
|
|
- `src/composables/useMediaPlayer.ts`
|
||
|
|
- `src/composables/useViewMode.ts`
|
||
|
|
- `src/composables/useModals.ts`
|
||
|
|
|
||
|
|
**Modify files (~5):**
|
||
|
|
- `package.json` — add vue-router
|
||
|
|
- `src/main.ts` — register router
|
||
|
|
- `src/App.vue` — router-view
|
||
|
|
- `src/components/client/ClientHeader.vue` — router-link
|
||
|
|
- `src/components/AdminPage.vue` — router-link
|
||
|
|
|
||
|
|
**Delete files (1):**
|
||
|
|
- `src/components/ClientPage.vue`
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 1: Install vue-router + Create Router Config
|
||
|
|
|
||
|
|
- [ ] **Step 1: Install vue-router**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
cd FileShare-Web-VUE && npm install vue-router
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Create src/router/index.ts**
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import { createRouter, createWebHistory } from 'vue-router'
|
||
|
|
|
||
|
|
const router = createRouter({
|
||
|
|
history: createWebHistory(),
|
||
|
|
routes: [
|
||
|
|
{
|
||
|
|
path: '/',
|
||
|
|
component: () => import('../layouts/DefaultLayout.vue'),
|
||
|
|
children: [
|
||
|
|
{ path: '', name: 'home', component: () => import('../views/RootPickerView.vue') },
|
||
|
|
{ path: 'recent/added', name: 'recent-added', component: () => import('../views/RecentAddedView.vue') },
|
||
|
|
{ path: 'recent/played', name: 'recent-played', component: () => import('../views/RecentPlayedView.vue') },
|
||
|
|
{ path: 'browse/:rootId', name: 'browse', component: () => import('../views/BrowseView.vue'), props: true },
|
||
|
|
{ path: 'browse/:rootId/*', name: 'browse-path', component: () => import('../views/BrowseView.vue'), props: true },
|
||
|
|
{ path: 'search', name: 'search', component: () => import('../views/SearchView.vue') },
|
||
|
|
],
|
||
|
|
},
|
||
|
|
{
|
||
|
|
path: '/admin',
|
||
|
|
name: 'admin',
|
||
|
|
component: () => import('../components/AdminPage.vue'),
|
||
|
|
},
|
||
|
|
],
|
||
|
|
})
|
||
|
|
|
||
|
|
export default router
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Register router in src/main.ts**
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import './assets/main.css'
|
||
|
|
import { createApp } from 'vue'
|
||
|
|
import App from './App.vue'
|
||
|
|
import router from './router'
|
||
|
|
|
||
|
|
createApp(App).use(router).mount('#app')
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Update src/App.vue**
|
||
|
|
|
||
|
|
```vue
|
||
|
|
<script setup lang="ts">
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<template>
|
||
|
|
<router-view />
|
||
|
|
</template>
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Verify build**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
npx vue-tsc --noEmit
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 2: Create Composables
|
||
|
|
|
||
|
|
- [ ] **Step 1: Create src/composables/useLibraryRoots.ts**
|
||
|
|
|
||
|
|
Singleton module-level state for roots list.
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import { ref, computed } from 'vue'
|
||
|
|
import { api, type LibraryRootDto } from '../api'
|
||
|
|
|
||
|
|
const roots = ref<LibraryRootDto[]>([])
|
||
|
|
const loading = ref(false)
|
||
|
|
const errorMessage = ref('')
|
||
|
|
|
||
|
|
const activeRoots = computed(() => roots.value.filter(r => r.isEnabled && r.isAvailable))
|
||
|
|
|
||
|
|
export function useLibraryRoots() {
|
||
|
|
async function loadRoots() {
|
||
|
|
loading.value = true
|
||
|
|
errorMessage.value = ''
|
||
|
|
try {
|
||
|
|
roots.value = await api.getRoots()
|
||
|
|
} catch (error) {
|
||
|
|
errorMessage.value = error instanceof Error ? error.message : '加载失败'
|
||
|
|
} finally {
|
||
|
|
loading.value = false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return { roots, loading, errorMessage, activeRoots, loadRoots }
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Create src/composables/useMediaPlayer.ts**
|
||
|
|
|
||
|
|
Singleton state for selected file, media player, playback control. Contains all the video progress saving, resume logic from ClientPage lines 324-458.
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import { ref, computed, onBeforeUnmount } from 'vue'
|
||
|
|
import { api, type FileRecordDto } from '../api'
|
||
|
|
|
||
|
|
type MediaPlayerHandle = {
|
||
|
|
getVideoElement: () => HTMLVideoElement | null
|
||
|
|
playVideo: () => Promise<void> | undefined
|
||
|
|
resetVideo: () => void
|
||
|
|
}
|
||
|
|
|
||
|
|
const selectedFile = ref<FileRecordDto | null>(null)
|
||
|
|
const textPreview = ref<import('../api').TextPreviewDto | null>(null)
|
||
|
|
const mediaPlayer = ref<MediaPlayerHandle | null>(null)
|
||
|
|
const resumePosition = ref(0)
|
||
|
|
const showResumePrompt = ref(false)
|
||
|
|
const resumeRequested = ref(false)
|
||
|
|
let lastPositionSave = 0
|
||
|
|
|
||
|
|
function getVideoElement() {
|
||
|
|
return mediaPlayer.value?.getVideoElement() ?? null
|
||
|
|
}
|
||
|
|
|
||
|
|
function setMediaPlayer(el: unknown) {
|
||
|
|
mediaPlayer.value = (el as MediaPlayerHandle) ?? null
|
||
|
|
}
|
||
|
|
|
||
|
|
function setError(error: unknown) {
|
||
|
|
// Will be wired to layout error state
|
||
|
|
}
|
||
|
|
|
||
|
|
function updatePlaybackPosition(id: number, position: number) {
|
||
|
|
if (selectedFile.value?.id === id) {
|
||
|
|
selectedFile.value.playbackPosition = position
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function saveVideoProgress(position?: number) {
|
||
|
|
if (!selectedFile.value || selectedFile.value.mediaType !== 'video') return
|
||
|
|
const video = getVideoElement()
|
||
|
|
if (!video) return
|
||
|
|
const rawPosition = position ?? video.currentTime
|
||
|
|
if (!Number.isFinite(rawPosition)) return
|
||
|
|
const nextPosition = Math.floor(rawPosition)
|
||
|
|
if (!Number.isFinite(nextPosition) || nextPosition < 0) return
|
||
|
|
const fileId = selectedFile.value.id
|
||
|
|
api.saveFileProgress(fileId, nextPosition)
|
||
|
|
.then(() => updatePlaybackPosition(fileId, nextPosition))
|
||
|
|
.catch(() => {})
|
||
|
|
}
|
||
|
|
|
||
|
|
function flushVideoProgress() {
|
||
|
|
const video = getVideoElement()
|
||
|
|
if (!video || video.currentTime === 0 || !Number.isFinite(video.currentTime) || video.ended) return
|
||
|
|
saveVideoProgress()
|
||
|
|
}
|
||
|
|
|
||
|
|
function resetResume() {
|
||
|
|
resumePosition.value = 0
|
||
|
|
resumeRequested.value = false
|
||
|
|
showResumePrompt.value = false
|
||
|
|
}
|
||
|
|
|
||
|
|
function tryResume(file: FileRecordDto) {
|
||
|
|
if (file.mediaType !== 'video' || !file.playbackPosition || file.playbackPosition <= 0) return
|
||
|
|
resumePosition.value = file.playbackPosition
|
||
|
|
resumeRequested.value = false
|
||
|
|
showResumePrompt.value = true
|
||
|
|
}
|
||
|
|
|
||
|
|
function seekToResumePosition() {
|
||
|
|
const video = getVideoElement()
|
||
|
|
if (!video || !resumeRequested.value || resumePosition.value <= 0 || video.readyState < 1) return
|
||
|
|
const maxPosition = Number.isFinite(video.duration) && video.duration > 1
|
||
|
|
? Math.max(0, video.duration - 1) : resumePosition.value
|
||
|
|
video.currentTime = Math.min(resumePosition.value, maxPosition)
|
||
|
|
resumeRequested.value = false
|
||
|
|
}
|
||
|
|
|
||
|
|
function resumePlayback() {
|
||
|
|
showResumePrompt.value = false
|
||
|
|
resumeRequested.value = true
|
||
|
|
seekToResumePosition()
|
||
|
|
mediaPlayer.value?.playVideo()?.catch(() => {})
|
||
|
|
}
|
||
|
|
|
||
|
|
function dismissResume() {
|
||
|
|
mediaPlayer.value?.resetVideo()
|
||
|
|
saveVideoProgress(0)
|
||
|
|
resetResume()
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleVideoPlay() {
|
||
|
|
if (showResumePrompt.value && resumePosition.value > 0) {
|
||
|
|
resumeRequested.value = true
|
||
|
|
}
|
||
|
|
seekToResumePosition()
|
||
|
|
showResumePrompt.value = false
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleVideoTimeUpdate() {
|
||
|
|
const video = getVideoElement()
|
||
|
|
if (!video || video.paused || video.currentTime === 0 || !Number.isFinite(video.currentTime)) return
|
||
|
|
const now = Date.now()
|
||
|
|
if (now - lastPositionSave < 5000) return
|
||
|
|
lastPositionSave = now
|
||
|
|
saveVideoProgress()
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleVideoPause() { flushVideoProgress() }
|
||
|
|
function handleVideoEnded() { saveVideoProgress(0) }
|
||
|
|
|
||
|
|
async function selectFile(file: FileRecordDto) {
|
||
|
|
flushVideoProgress()
|
||
|
|
resetResume()
|
||
|
|
lastPositionSave = 0
|
||
|
|
selectedFile.value = file
|
||
|
|
textPreview.value = null
|
||
|
|
if (file.mediaType === 'video' || file.mediaType === 'audio') {
|
||
|
|
api.markFilePlayed(file.id).catch(() => {})
|
||
|
|
if (file.mediaType === 'video') tryResume(file)
|
||
|
|
}
|
||
|
|
if (file.mediaType === 'text') {
|
||
|
|
try { textPreview.value = await api.getTextPreview(file.id) } catch {}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const selectedMediaUrl = computed(() => selectedFile.value ? api.mediaUrl(selectedFile.value.streamUrl) : '')
|
||
|
|
const selectedThumbnailUrl = computed(() => selectedFile.value?.thumbnailUrl ? api.thumbnailUrl(selectedFile.value.thumbnailUrl) : '')
|
||
|
|
|
||
|
|
export function useMediaPlayer() {
|
||
|
|
return {
|
||
|
|
selectedFile, textPreview, mediaPlayer, resumePosition, showResumePrompt, resumeRequested,
|
||
|
|
setMediaPlayer, flushVideoProgress, selectFile, seekToResumePosition,
|
||
|
|
resumePlayback, dismissResume, handleVideoPlay, handleVideoTimeUpdate, handleVideoPause, handleVideoEnded,
|
||
|
|
selectedMediaUrl, selectedThumbnailUrl,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Create src/composables/useViewMode.ts**
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import { ref } from 'vue'
|
||
|
|
|
||
|
|
const viewMode = ref<'list' | 'grid'>('list')
|
||
|
|
|
||
|
|
export function useViewMode() {
|
||
|
|
return { viewMode }
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Create src/composables/useModals.ts**
|
||
|
|
|
||
|
|
```typescript
|
||
|
|
import { ref } from 'vue'
|
||
|
|
import type ConfirmModal from '../components/ConfirmModal.vue'
|
||
|
|
import type QrCodeModal from '../components/QrCodeModal.vue'
|
||
|
|
|
||
|
|
const confirmModal = ref<InstanceType<typeof ConfirmModal> | null>(null)
|
||
|
|
const qrModal = ref<InstanceType<typeof QrCodeModal> | null>(null)
|
||
|
|
|
||
|
|
export function useModals() {
|
||
|
|
return { confirmModal, qrModal }
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Verify build**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
npx vue-tsc --noEmit
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 3: Create DefaultLayout + View Components
|
||
|
|
|
||
|
|
- [ ] **Step 1: Create src/layouts/DefaultLayout.vue**
|
||
|
|
|
||
|
|
Contains ClientHeader + router-view + ConfirmModal + QrCodeModal + error banner. Handles search submission (navigates to /search), back navigation, root loading.
|
||
|
|
|
||
|
|
```vue
|
||
|
|
<script setup lang="ts">
|
||
|
|
import { ref, onMounted, watch } from 'vue'
|
||
|
|
import { useRoute, useRouter } from 'vue-router'
|
||
|
|
import ClientHeader from '../components/client/ClientHeader.vue'
|
||
|
|
import QrCodeModal from '../components/QrCodeModal.vue'
|
||
|
|
import ConfirmModal from '../components/ConfirmModal.vue'
|
||
|
|
import { useLibraryRoots } from '../composables/useLibraryRoots'
|
||
|
|
import { useModals } from '../composables/useModals'
|
||
|
|
|
||
|
|
const route = useRoute()
|
||
|
|
const router = useRouter()
|
||
|
|
const { activeRoots, loadRoots } = useLibraryRoots()
|
||
|
|
const { confirmModal, qrModal } = useModals()
|
||
|
|
|
||
|
|
const searchQuery = ref('')
|
||
|
|
const errorMessage = ref('')
|
||
|
|
|
||
|
|
const isSearching = ref(false)
|
||
|
|
const isBrowsingRoots = ref(true)
|
||
|
|
|
||
|
|
function updateTitle() {
|
||
|
|
isBrowsingRoots.value = route.path === '/' || route.path.startsWith('/recent')
|
||
|
|
isSearching.value = route.name === 'search'
|
||
|
|
}
|
||
|
|
watch(() => route.path, updateTitle, { immediate: true })
|
||
|
|
|
||
|
|
// ... clientTitle computed, doSearch, backToRoots, exitSearch
|
||
|
|
// All navigation uses router.push
|
||
|
|
|
||
|
|
onMounted(loadRoots)
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<template>
|
||
|
|
<main class="client-shell">
|
||
|
|
<ClientHeader
|
||
|
|
v-model:search-query="searchQuery"
|
||
|
|
:title="clientTitle"
|
||
|
|
:active-tab="..."
|
||
|
|
:recent-count="..."
|
||
|
|
:active-roots-count="activeRoots.length"
|
||
|
|
:is-browsing-roots="isBrowsingRoots"
|
||
|
|
:is-searching="isSearching"
|
||
|
|
:browse-data="null"
|
||
|
|
@search="doSearch"
|
||
|
|
@back="backToRoots"
|
||
|
|
@exit-search="exitSearch"
|
||
|
|
@open-qr="qrModal?.open()"
|
||
|
|
/>
|
||
|
|
<p v-if="errorMessage" class="error-banner">{{ errorMessage }}</p>
|
||
|
|
<router-view />
|
||
|
|
</main>
|
||
|
|
<QrCodeModal ref="qrModal" />
|
||
|
|
<ConfirmModal ref="confirmModal" />
|
||
|
|
</template>
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Create src/views/RootPickerView.vue**
|
||
|
|
|
||
|
|
```vue
|
||
|
|
<script setup lang="ts">
|
||
|
|
import { onMounted } from 'vue'
|
||
|
|
import { useRouter } from 'vue-router'
|
||
|
|
import RootTabs from '../components/client/RootTabs.vue'
|
||
|
|
import RootPicker from '../components/client/RootPicker.vue'
|
||
|
|
import { useLibraryRoots } from '../composables/useLibraryRoots'
|
||
|
|
|
||
|
|
const router = useRouter()
|
||
|
|
const { activeRoots, loadRoots } = useLibraryRoots()
|
||
|
|
|
||
|
|
onMounted(() => { if (activeRoots.value.length === 0) loadRoots() })
|
||
|
|
|
||
|
|
function enterRoot(id: number) {
|
||
|
|
router.push(`/browse/${id}`)
|
||
|
|
}
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<template>
|
||
|
|
<RootTabs active-tab="libraries" @switch-tab="(tab) => {
|
||
|
|
if (tab === 'recent-added') router.push('/recent/added')
|
||
|
|
else if (tab === 'recent-played') router.push('/recent/played')
|
||
|
|
}" />
|
||
|
|
<RootPicker :roots="activeRoots" @enter-root="enterRoot" />
|
||
|
|
</template>
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Create src/views/RecentAddedView.vue**
|
||
|
|
|
||
|
|
Extracts the recent-added section from ClientPage.vue (lines 571-639 with activeTab='recent-added').
|
||
|
|
|
||
|
|
- [ ] **Step 4: Create src/views/RecentPlayedView.vue**
|
||
|
|
|
||
|
|
Same pattern, for recent-played.
|
||
|
|
|
||
|
|
- [ ] **Step 5: Create src/views/BrowseView.vue**
|
||
|
|
|
||
|
|
Extracts the browse section (lines 647-761). Uses route params for rootId and path.
|
||
|
|
|
||
|
|
- [ ] **Step 6: Create src/views/SearchView.vue**
|
||
|
|
|
||
|
|
Extracts the search section (lines 504-569). Reads `route.query.q` on mount to trigger search.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 4: Update Navigation Links
|
||
|
|
|
||
|
|
- [ ] **Step 1: Update ClientHeader.vue**
|
||
|
|
|
||
|
|
Replace `<a href="/admin">` with `<router-link to="/admin">`.
|
||
|
|
|
||
|
|
- [ ] **Step 2: Update AdminPage.vue**
|
||
|
|
|
||
|
|
Replace `<a href="/">` with `<router-link to="/">`.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Verify build**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
dotnet build FileShare-API/FileShare-API.csproj
|
||
|
|
cd FileShare-Web-VUE && npx vue-tsc --noEmit
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 5: Cleanup
|
||
|
|
|
||
|
|
- [ ] **Step 1: Delete ClientPage.vue**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
rm src/components/ClientPage.vue
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Final verification**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
npx vue-tsc --noEmit
|
||
|
|
```
|