|
<!DOCTYPE html> |
|
<html lang="zh-CN"> |
|
|
|
<head> |
|
<meta charset="UTF-8"> |
|
<title>目录浏览 SPA</title> |
|
|
|
<!-- Import Map: 模块别名 --> |
|
<script type="importmap"> |
|
{ |
|
"imports": { |
|
"vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.prod.js", |
|
"vue-router": "https://unpkg.com/vue-router@5/dist/vue-router.esm-browser.prod.js" |
|
} |
|
} |
|
</script> |
|
<style> |
|
:root { |
|
color-scheme: light dark; |
|
|
|
font-synthesis: none; |
|
text-rendering: optimizeLegibility; |
|
-webkit-font-smoothing: antialiased; |
|
-moz-osx-font-smoothing: grayscale; |
|
-webkit-text-size-adjust: 100%; |
|
} |
|
|
|
html, |
|
body, |
|
#app { |
|
margin: 0; |
|
padding: 0; |
|
width: 100%; |
|
height: 100%; |
|
display: block; |
|
} |
|
|
|
h2 { |
|
margin: 0; |
|
} |
|
|
|
.wrapper { |
|
width: 100%; |
|
height: 100%; |
|
box-sizing: border-box; |
|
overflow: auto; |
|
padding: 1rem; |
|
gap: 1rem; |
|
display: flex; |
|
flex-direction: column; |
|
} |
|
|
|
.name-wrapper { |
|
display: inline-flex; |
|
align-items: center; |
|
gap: 0.5rem; |
|
font-size: 16px; |
|
width: 100%; |
|
} |
|
|
|
.name-wrapper a { |
|
width: 100%; |
|
} |
|
|
|
/* 基础表格 */ |
|
table { |
|
width: 100%; |
|
border-collapse: collapse; |
|
/* 每行连续 */ |
|
font-family: sans-serif; |
|
} |
|
|
|
/* 表头 */ |
|
th { |
|
text-align: center; |
|
padding: 10px; |
|
background: #f5f5f5; |
|
border-bottom: 2px solid #ccc; |
|
} |
|
|
|
/* 表格单元格 */ |
|
td { |
|
padding: 10px; |
|
text-align: left; |
|
border-bottom: 1px solid #ddd; |
|
/* 轻微分隔 */ |
|
} |
|
|
|
td:nth-child(2), |
|
td:nth-child(3) { |
|
text-align: center; |
|
} |
|
|
|
/* Hover 整行高亮 */ |
|
tbody tr:hover { |
|
background-color: var(--hover-bg); |
|
cursor: pointer; |
|
transition: background 0.2s; |
|
} |
|
|
|
/* 深浅色模式变量 */ |
|
:root { |
|
--bg-header: #f5f5f5; |
|
--border-color: #ccc; |
|
--hover-bg: #e0f0ff; |
|
} |
|
|
|
th { |
|
background: var(--bg-header); |
|
border-bottom-color: var(--border-color); |
|
} |
|
|
|
td { |
|
border-bottom-color: var(--border-color); |
|
} |
|
|
|
/* 深色模式 */ |
|
@media (prefers-color-scheme: dark) { |
|
:root { |
|
--bg-header: #2a2a2a; |
|
--border-color: #444; |
|
--hover-bg: #3a3a3a; |
|
} |
|
|
|
tbody tr:hover { |
|
background-color: var(--hover-bg); |
|
} |
|
} |
|
</style> |
|
</head> |
|
|
|
<body> |
|
|
|
<div id="app"> |
|
<router-view></router-view> <!-- Vue Router 占位符 --> |
|
</div> |
|
<script type="text/html" id="fileTemplate"> |
|
<template v-if="status.inited"> |
|
<div v-if="!status.is404" class="wrapper"> |
|
<h2>目录: <a href="../" @click="exit">{{ parentPath }}</a>{{ currentPathName }}/</h2> |
|
<div v-if="!status.files.length">目录为空或不存在</div> |
|
<table v-else> |
|
<thead> |
|
<tr> |
|
<th>名称</th> |
|
<th>修改时间</th> |
|
<th>大小</th> |
|
</tr> |
|
</thead> |
|
<tbody> |
|
<tr v-for="item in status.files" :key="item.name"> |
|
<td> |
|
<div class="name-wrapper"> |
|
<span v-if="item.type === 'directory'">📁</span> |
|
<span v-else>📄</span> |
|
<a :href="item.link" :title="item.name" @click="enter($event, item)">{{ item.name }}</a> |
|
</div> |
|
</td> |
|
<td>{{ item.mtime?.toLocaleString() }}</td> |
|
<td>{{ item.size > 0 ? `${(item.size / 1024).toFixed(2)} KB` : '-' }}</td> |
|
</tr> |
|
</tbody> |
|
</table> |
|
</div> |
|
<span v-else>404 Not Found</span> |
|
</div> |
|
</script> |
|
|
|
<script type="module"> |
|
import { createApp, onMounted, watch, reactive, computed } from "vue" |
|
import { createRouter, createWebHistory, useRoute, useRouter } from "vue-router" |
|
|
|
const template = document.getElementById('fileTemplate') |
|
|
|
// 文件列表组件 |
|
const FileList = { |
|
template: template.innerHTML, |
|
setup() { |
|
const route = useRoute() |
|
const router = useRouter() |
|
const status = reactive({ |
|
files: [], |
|
is404: false, |
|
inited: false |
|
}) |
|
|
|
const parentPath = computed(() => { |
|
if (route.path === '/') { |
|
return '' |
|
} else { |
|
const tmp = route.path.split('/') |
|
tmp.splice(tmp.length - 2, 1) |
|
return tmp.join('/') |
|
} |
|
}) |
|
const currentPathName = computed(() => { |
|
if (route.path === '/') { |
|
return '' |
|
} else { |
|
const tmp = route.path.split('/') |
|
return tmp[tmp.length - 2] |
|
} |
|
}) |
|
|
|
const fetchData = async () => { |
|
try { |
|
const res = await fetch(`/api/list${route.path}`) |
|
if (res.status === 404) { |
|
status.files = [] |
|
status.is404 = true |
|
} else { |
|
const data = await res.json() |
|
let fileList = (data || []).map(({ name, mtime, size, type }) => { |
|
return { |
|
name, |
|
mtime: mtime ? new Date(mtime) : null, |
|
size, |
|
type, |
|
link: type === "directory" ? `./${name}/` : `./${name}`, |
|
} |
|
}) |
|
if (route.path === '/') { |
|
fileList = fileList.filter(file => file.name !== 'index.html') |
|
} else { |
|
if (route.path !== '/') { |
|
fileList.unshift({ |
|
name: '..', |
|
mtime: null, |
|
size: 0, |
|
type: 'directory', |
|
link: '../', |
|
}) |
|
} |
|
} |
|
status.files = fileList |
|
} |
|
} catch (e) { |
|
status.files = [] |
|
status.is404 = true |
|
} finally { |
|
status.inited = true |
|
} |
|
} |
|
|
|
onMounted(fetchData) |
|
|
|
// 监听 URL query.path 变化 |
|
watch(() => route.path, () => { |
|
fetchData() |
|
}) |
|
|
|
const enter = (e, item) => { |
|
if (item.type === "directory") { |
|
router.push(item.link) |
|
e.preventDefault() |
|
return false |
|
} |
|
} |
|
const exit = (e) => { |
|
e.preventDefault() |
|
if (route.path !== '/') { |
|
router.push('../') |
|
} |
|
} |
|
|
|
return { status, route, enter, exit, parentPath, currentPathName } |
|
} |
|
} |
|
|
|
// 路由配置 |
|
const routes = [ |
|
{ path: '/:catchAll(.*)', component: FileList } |
|
] |
|
|
|
const router = createRouter({ |
|
history: createWebHistory(), |
|
routes, |
|
scrollBehavior(to, from, savedPosition) { |
|
return savedPosition || { top: 0 } |
|
} |
|
}) |
|
|
|
// 创建 Vue 应用 |
|
createApp({}).use(router).mount('#app') |
|
</script> |
|
|
|
</body> |
|
|
|
</html> |