Files
FileShare/docs/compose/plans/2026-07-09-vue-router.md
T
admin777 6bdcb48c97 feat: Vue Router 集成 + 压缩密码编码修复
前端:
- 引入 vue-router,HTML5 History 模式,干净 URL
- ClientPage.vue 拆分为 5 个独立路由视图组件
- 提取共享状态到 composables(useLibraryRoots/useMediaPlayer/useViewMode/useModals)
- 新增 DefaultLayout 统一布局
- 导航链接改为 router-link
- FileCard 网格视图修复删除/压缩按钮 hover 不显示问题

后端:
- 压缩密码编码修复:DotNetZip/SharpZipLib 不支持 UTF-8 密码
- 替换为 SharpZipLib + 中文字符替换为 # 的 ASCII 安全密码方案
2026-07-10 15:22:09 +08:00

13 KiB

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
cd FileShare-Web-VUE && npm install vue-router
  • Step 2: Create src/router/index.ts
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
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
<script setup lang="ts">
</script>

<template>
  <router-view />
</template>
  • Step 5: Verify build
npx vue-tsc --noEmit

Task 2: Create Composables

  • Step 1: Create src/composables/useLibraryRoots.ts

Singleton module-level state for roots list.

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.

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
import { ref } from 'vue'

const viewMode = ref<'list' | 'grid'>('list')

export function useViewMode() {
  return { viewMode }
}
  • Step 4: Create src/composables/useModals.ts
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
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.

<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
<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.


  • 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
dotnet build FileShare-API/FileShare-API.csproj
cd FileShare-Web-VUE && npx vue-tsc --noEmit

Task 5: Cleanup

  • Step 1: Delete ClientPage.vue
rm src/components/ClientPage.vue
  • Step 2: Final verification
npx vue-tsc --noEmit