<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>本地文件映射</title>
<style>
html, body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: transparent;
}
* {
box-sizing: border-box;
}
body {
font-family: "Microsoft YaHei", sans-serif;
color: #edf5ff;
}
.card {
width: 100%;
height: 100%;
box-sizing: border-box;
border-radius: 18px;
overflow: hidden;
position: relative;
background:
radial-gradient(circle at 18% 8%, rgba(96, 165, 250, 0.22), transparent 32%),
radial-gradient(circle at 86% 92%, rgba(45, 212, 191, 0.13), transparent 36%),
linear-gradient(145deg, rgba(12, 18, 30, 0.96), rgba(18, 25, 39, 0.94) 52%, rgba(8, 13, 24, 0.96));
border: 1px solid rgba(139, 190, 255, 0.28);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.08);
}
.shell {
height: 100%;
display: grid;
grid-template-rows: auto auto 1fr;
padding: 14px;
gap: 10px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-height: 34px;
}
.title {
min-width: 0;
}
.title-main {
font-size: 16px;
font-weight: 700;
letter-spacing: 0.5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.title-sub {
margin-top: 2px;
font-size: 11px;
color: rgba(203, 218, 238, 0.68);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.actions {
display: flex;
align-items: center;
gap: 7px;
flex-shrink: 0;
}
button {
border: 1px solid rgba(148, 190, 255, 0.28);
background: rgba(30, 42, 64, 0.78);
color: #edf5ff;
border-radius: 12px;
height: 30px;
padding: 0 10px;
font-family: inherit;
font-size: 12px;
cursor: pointer;
outline: none;
}
button:hover {
background: rgba(48, 66, 98, 0.9);
border-color: rgba(154, 210, 255, 0.55);
}
button:active {
transform: translateY(1px);
}
button.primary {
background: rgba(68, 119, 190, 0.72);
border-color: rgba(155, 210, 255, 0.55);
}
.path-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
}
.path-box {
min-width: 0;
height: 30px;
line-height: 30px;
border-radius: 12px;
padding: 0 10px;
color: rgba(230, 240, 255, 0.86);
background: rgba(7, 12, 22, 0.44);
border: 1px solid rgba(130, 170, 220, 0.18);
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.content {
min-height: 0;
display: grid;
grid-template-columns: minmax(180px, 42%) 1fr;
gap: 10px;
}
.panel {
min-height: 0;
border-radius: 15px;
background: rgba(6, 11, 20, 0.38);
border: 1px solid rgba(130, 170, 220, 0.16);
overflow: hidden;
}
.list-panel {
display: grid;
grid-template-rows: auto 1fr;
}
.toolbar {
height: 34px;
display: flex;
align-items: center;
gap: 8px;
padding: 7px 9px;
border-bottom: 1px solid rgba(130, 170, 220, 0.13);
}
.search {
flex: 1;
min-width: 0;
height: 24px;
border: 0;
outline: none;
border-radius: 10px;
padding: 0 9px;
background: rgba(255,255,255,0.07);
color: #edf5ff;
font-family: inherit;
font-size: 12px;
}
.search::placeholder {
color: rgba(220, 232, 250, 0.38);
}
.count {
font-size: 11px;
color: rgba(210, 225, 245, 0.58);
flex-shrink: 0;
}
.list {
min-height: 0;
overflow: auto;
padding: 6px;
}
.item {
display: grid;
grid-template-columns: 25px 1fr;
gap: 6px;
align-items: center;
min-height: 34px;
padding: 6px 7px;
border-radius: 11px;
cursor: pointer;
border: 1px solid transparent;
}
.item:hover {
background: rgba(96, 165, 250, 0.10);
border-color: rgba(141, 193, 255, 0.18);
}
.item.active {
background: rgba(96, 165, 250, 0.18);
border-color: rgba(141, 193, 255, 0.35);
}
.icon {
width: 25px;
height: 25px;
display: grid;
place-items: center;
border-radius: 9px;
background: rgba(255,255,255,0.07);
font-size: 14px;
}
.item-name {
min-width: 0;
font-size: 12px;
color: rgba(242, 247, 255, 0.92);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-meta {
margin-top: 2px;
font-size: 10px;
color: rgba(204, 220, 240, 0.48);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.preview {
min-height: 0;
display: grid;
grid-template-rows: auto 1fr auto;
}
.preview-head {
min-height: 46px;
padding: 9px 10px;
border-bottom: 1px solid rgba(130, 170, 220, 0.13);
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.preview-title {
min-width: 0;
}
.preview-name {
font-size: 13px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.preview-meta {
margin-top: 3px;
font-size: 10px;
color: rgba(204, 220, 240, 0.52);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.preview-body {
min-height: 0;
overflow: auto;
padding: 10px;
}
.empty {
height: 100%;
display: grid;
place-items: center;
text-align: center;
color: rgba(215, 228, 247, 0.56);
font-size: 12px;
line-height: 1.75;
}
pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: Consolas, "Microsoft YaHei", monospace;
font-size: 12px;
line-height: 1.55;
color: rgba(235, 244, 255, 0.9);
}
.image-preview {
max-width: 100%;
max-height: 100%;
border-radius: 12px;
display: block;
margin: 0 auto;
}
.audio-box {
display: grid;
gap: 12px;
align-content: center;
height: 100%;
}
audio {
width: 100%;
}
.footer {
height: 34px;
border-top: 1px solid rgba(130, 170, 220, 0.13);
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 10px;
}
.status {
min-width: 0;
font-size: 11px;
color: rgba(205, 222, 244, 0.58);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.small-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.small-actions button {
height: 25px;
padding: 0 8px;
font-size: 11px;
border-radius: 10px;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
background: rgba(150, 190, 240, 0.22);
border-radius: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
@media (max-width: 520px), (max-height: 320px) {
.shell {
padding: 10px;
gap: 8px;
}
.content {
grid-template-columns: 1fr;
grid-template-rows: minmax(120px, 46%) 1fr;
}
.title-sub {
display: none;
}
.actions button {
padding: 0 8px;
}
}
</style>
</head>
<body>
<div class="card">
<div class="shell">
<div class="header">
<div class="title">
<div class="title-main">本地文件映射</div>
<div class="title-sub" id="subTitle">选择文件夹后,可浏览、预览并打开本地文件</div>
</div>
<div class="actions">
<button class="primary" id="selectFolderBtn">选择文件夹</button>
<button id="selectFileBtn">选择文件</button>
</div>
</div>
<div class="path-row">
<div class="path-box" id="pathBox">未选择文件夹</div>
<button id="refreshBtn">刷新</button>
</div>
<div class="content">
<div class="panel list-panel">
<div class="toolbar">
<input class="search" id="searchInput" placeholder="过滤文件名">
<div class="count" id="countText">0 项</div>
</div>
<div class="list" id="fileList">
<div class="empty">点击“选择文件夹”开始<br>需要宿主支持 fs.selectFolder / fs.list</div>
</div>
</div>
<div class="panel preview">
<div class="preview-head">
<div class="preview-title">
<div class="preview-name" id="previewName">预览</div>
<div class="preview-meta" id="previewMeta">支持文本、图片、音频;其他文件可直接打开</div>
</div>
<button id="openBtn">打开</button>
</div>
<div class="preview-body" id="previewBody">
<div class="empty">选择左侧文件查看预览</div>
</div>
<div class="footer">
<div class="status" id="statusText">就绪</div>
<div class="small-actions">
<button id="copyPathBtn">复制路径</button>
<button id="saveStateBtn">保存映射</button>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
(function () {
var folderPath = '';
var items = [];
var filteredItems = [];
var selectedItem = null;
var pathBox = document.getElementById('pathBox');
var fileList = document.getElementById('fileList');
var countText = document.getElementById('countText');
var searchInput = document.getElementById('searchInput');
var statusText = document.getElementById('statusText');
var previewName = document.getElementById('previewName');
var previewMeta = document.getElementById('previewMeta');
var previewBody = document.getElementById('previewBody');
function host(method, args) {
if (!window.fudao || !window.fudao.invoke) {
return Promise.reject(new Error('宿主能力未就绪:' + method));
}
return window.fudao.invoke(method, args || {});
}
function setStatus(text) {
statusText.textContent = text || '';
}
function escapeHtml(text) {
return String(text == null ? '' : text)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
}
function formatSize(size) {
if (!size || size <= 0) return '';
if (size < 1024) return size + ' B';
if (size < 1024 * 1024) return (size / 1024).toFixed(1) + ' KB';
return (size / 1024 / 1024).toFixed(1) + ' MB';
}
function extOf(name) {
var i = name.lastIndexOf('.');
return i >= 0 ? name.substring(i).toLowerCase() : '';
}
function iconOf(item) {
if (item.type === 'folder') return '📁';
var ext = extOf(item.name || '');
if (['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'].indexOf(ext) >= 0) return '🖼️';
if (['.mp3', '.wav', '.m4a', '.aac', '.wma', '.ogg'].indexOf(ext) >= 0) return '🔊';
if (['.txt', '.md', '.json', '.csv', '.log', '.xml', '.html', '.css', '.js'].indexOf(ext) >= 0) return '📄';
return '📦';
}
function isTextFile(item) {
var ext = extOf(item.name || '');
return ['.txt', '.md', '.json', '.csv', '.log', '.xml', '.html', '.css', '.js', '.ini'].indexOf(ext) >= 0;
}
function isImageFile(item) {
var ext = extOf(item.name || '');
return ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'].indexOf(ext) >= 0;
}
function isAudioFile(item) {
var ext = extOf(item.name || '');
return ['.mp3', '.wav', '.m4a', '.aac', '.wma', '.ogg'].indexOf(ext) >= 0;
}
function audioMime(item) {
var ext = extOf(item.name || '');
if (ext === '.mp3') return 'audio/mpeg';
if (ext === '.wav') return 'audio/wav';
if (ext === '.m4a') return 'audio/mp4';
if (ext === '.aac') return 'audio/aac';
if (ext === '.ogg') return 'audio/ogg';
if (ext === '.wma') return 'audio/x-ms-wma';
return 'application/octet-stream';
}
function imageMime(item) {
var ext = extOf(item.name || '');
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
if (ext === '.png') return 'image/png';
if (ext === '.gif') return 'image/gif';
if (ext === '.webp') return 'image/webp';
if (ext === '.bmp') return 'image/bmp';
return 'application/octet-stream';
}
async function loadState() {
try {
var r = await host('state.read', {
key: 'folderPath',
defaultValue: ''
});
folderPath = r.value || '';
if (folderPath) {
pathBox.textContent = folderPath;
await loadFolder(folderPath);
}
} catch (e) {
setStatus('读取状态失败:' + e.message);
}
}
async function saveState() {
await host('state.write', {
key: 'folderPath',
value: folderPath || ''
});
setStatus('映射路径已保存');
}
async function selectFolder() {
try {
var r = await host('fs.selectFolder', {
title: '选择要映射的文件夹',
initialDir: folderPath || '',
showNewFolderButton: true
});
if (!r.ok || r.cancelled) {
setStatus('已取消选择文件夹');
return;
}
folderPath = r.path;
pathBox.textContent = folderPath;
await saveState();
await loadFolder(folderPath);
} catch (e) {
setStatus('选择文件夹失败:' + e.message);
}
}
async function selectFile() {
try {
var r = await host('fs.selectFile', {
title: '选择要预览的文件',
filter: '常见文件|*.txt;*.md;*.json;*.csv;*.log;*.xml;*.html;*.css;*.js;*.png;*.jpg;*.jpeg;*.gif;*.webp;*.bmp;*.mp3;*.wav;*.m4a;*.aac;*.wma;*.ogg|所有文件|*.*',
initialDir: folderPath || ''
});
if (!r.ok || r.cancelled) {
setStatus('已取消选择文件');
return;
}
selectedItem = {
name: r.name,
path: r.path,
type: 'file',
size: r.size || 0,
modified: r.modified || ''
};
renderSelection();
await previewItem(selectedItem);
} catch (e) {
setStatus('选择文件失败:' + e.message);
}
}
async function loadFolder(path) {
if (!path) return;
setStatus('正在读取文件夹...');
try {
var r = await host('fs.list', {
path: path,
pattern: '*.*',
recursive: false,
maxItems: 300
});
if (!r.ok && !r.items) {
setStatus('读取失败');
return;
}
items = r.items || [];
selectedItem = null;
applyFilter();
clearPreview();
setStatus('读取完成');
} catch (e) {
setStatus('读取文件夹失败:' + e.message);
}
}
function applyFilter() {
var kw = (searchInput.value || '').toLowerCase();
filteredItems = [];
for (var i = 0; i < items.length; i++) {
var it = items[i];
if (!kw || String(it.name || '').toLowerCase().indexOf(kw) >= 0) {
filteredItems.push(it);
}
}
renderList();
}
function renderList() {
countText.textContent = filteredItems.length + ' 项';
if (!filteredItems.length) {
fileList.innerHTML = '<div class="empty">没有匹配的项目</div>';
return;
}
var html = '';
for (var i = 0; i < filteredItems.length; i++) {
var it = filteredItems[i];
var active = selectedItem && selectedItem.path === it.path ? ' active' : '';
var meta = it.type === 'folder'
? '文件夹 · ' + (it.modified || '')
: [formatSize(it.size), it.modified || ''].filter(Boolean).join(' · ');
html += '<div class="item' + active + '" data-index="' + i + '">' +
'<div class="icon">' + iconOf(it) + '</div>' +
'<div>' +
'<div class="item-name">' + escapeHtml(it.name) + '</div>' +
'<div class="item-meta">' + escapeHtml(meta) + '</div>' +
'</div>' +
'</div>';
}
fileList.innerHTML = html;
var nodes = fileList.querySelectorAll('.item');
for (var j = 0; j < nodes.length; j++) {
nodes[j].addEventListener('click', function () {
var idx = parseInt(this.getAttribute('data-index'), 10);
selectedItem = filteredItems[idx];
renderSelection();
previewItem(selectedItem);
});
nodes[j].addEventListener('dblclick', function () {
var idx = parseInt(this.getAttribute('data-index'), 10);
openItem(filteredItems[idx]);
});
}
}
function renderSelection() {
renderList();
}
function clearPreview() {
previewName.textContent = '预览';
previewMeta.textContent = '选择左侧文件查看预览';
previewBody.innerHTML = '<div class="empty">选择左侧文件查看预览</div>';
}
async function previewItem(item) {
if (!item) return;
previewName.textContent = item.name || '未命名';
previewMeta.textContent = item.path || '';
if (item.type === 'folder') {
previewBody.innerHTML = '<div class="empty">这是一个文件夹<br>双击或点击“打开”进入系统文件管理器</div>';
setStatus('已选择文件夹');
return;
}
if (isTextFile(item)) {
await previewText(item);
return;
}
if (isImageFile(item)) {
await previewImage(item);
return;
}
if (isAudioFile(item)) {
await previewAudio(item);
return;
}
previewBody.innerHTML = '<div class="empty">该类型暂不预览<br>可以点击“打开”交给系统处理</div>';
setStatus('已选择文件');
}
async function previewText(item) {
setStatus('正在读取文本...');
try {
var r = await host('fs.readText', {
path: item.path,
maxBytes: 1048576
});
previewBody.innerHTML = '<pre>' + escapeHtml(r.text || '') + '</pre>';
setStatus(r.truncated ? '文本已截断显示' : '文本预览完成');
} catch (e) {
previewBody.innerHTML = '<div class="empty">文本读取失败<br>' + escapeHtml(e.message) + '</div>';
setStatus('文本读取失败');
}
}
async function previewImage(item) {
setStatus('正在读取图片...');
try {
var r = await host('fs.readBase64', {
path: item.path,
maxBytes: 10485760
});
previewBody.innerHTML = '<img class="image-preview" src="data:' + (r.mime || imageMime(item)) + ';base64,' + r.base64 + '">';
setStatus('图片预览完成');
} catch (e) {
previewBody.innerHTML = '<div class="empty">图片读取失败<br>' + escapeHtml(e.message) + '</div>';
setStatus('图片读取失败');
}
}
async function previewAudio(item) {
setStatus('正在读取音频...');
try {
var r = await host('fs.readBase64', {
path: item.path,
maxBytes: 15728640
});
previewBody.innerHTML =
'<div class="audio-box">' +
'<div class="empty" style="height:auto">本地音频已载入</div>' +
'<audio controls src="data:' + (r.mime || audioMime(item)) + ';base64,' + r.base64 + '"></audio>' +
'</div>';
setStatus('音频预览完成');
} catch (e) {
previewBody.innerHTML = '<div class="empty">音频读取失败<br>' + escapeHtml(e.message) + '</div>';
setStatus('音频读取失败');
}
}
async function openItem(item) {
if (!item) {
setStatus('未选择项目');
return;
}
try {
await host('path.open', { path: item.path });
setStatus('已请求打开:' + item.name);
} catch (e) {
setStatus('打开失败:' + e.message);
}
}
async function copyPath() {
if (!selectedItem) {
setStatus('未选择项目');
return;
}
try {
await host('clipboard.write', { text: selectedItem.path });
setStatus('路径已复制');
} catch (e) {
setStatus('复制失败:' + e.message);
}
}
document.getElementById('selectFolderBtn').addEventListener('click', selectFolder);
document.getElementById('selectFileBtn').addEventListener('click', selectFile);
document.getElementById('refreshBtn').addEventListener('click', function () { loadFolder(folderPath); });
document.getElementById('openBtn').addEventListener('click', function () { openItem(selectedItem); });
document.getElementById('copyPathBtn').addEventListener('click', copyPath);
document.getElementById('saveStateBtn').addEventListener('click', saveState);
searchInput.addEventListener('input', applyFilter);
loadState();
})();
</script>
</body>
</html>