本地文件映射

经验创意 · 164 次浏览
困困君 创建于 2026-05-20 21:48

<!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, '&amp;')
            .replace(/</g, '&lt;')
            .replace(/>/g, '&gt;');
    }

    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>

困困君 最后更新于 2026/5/20

回复内容
暂无回复
回复主贴