用AI写的PHP列表文件管理程序
用AI写的PHP列表文件管理程序,通过简单对话调整了功能实现和问题修改。
不知道还有没有其它bug和漏洞...
建议慎用,哈哈哈,我自己都不太放心这个AI做出来的东西.
代码如下,另存为filevault.php,网页打开执行
<?php
/**
* FileVault — 单文件 PHP 目录程序
* 将此文件放在任意目录即可浏览该目录下的所有文件
* 支持:目录浏览、文件下载、图片预览、搜索、排序、新建文件夹、上传、删除、重命名
*/
// ─── 配置 ───────────────────────────────────────────────
$config = [
'root' => __DIR__, // 根目录(当前目录)
'title' => 'FileVault', // 站点标题
'allow_upload' => true, // 允许上传
'allow_delete' => true, // 允许删除
'allow_rename' => true, // 允许重命名
'allow_mkdir' => true, // 允许新建文件夹
'allow_download' => true, // 允许下载
'show_hidden' => false, // 显示隐藏文件(.开头)
'exclude' => ['filevault.php'], // 排除的文件名
'image_exts' => ['jpg','jpeg','png','gif','webp','svg','bmp','ico'],
'video_exts' => ['mp4','webm','ogg','mov','avi','mkv'],
'audio_exts' => ['mp3','wav','ogg','flac','aac','m4a'],
'text_exts' => ['txt','md','json','xml','yaml','yml','ini','conf','log','sh','bat','py','js','ts','css','html','htm','php','java','c','cpp','h','go','rs','rb','sql'],
'password' => '', // 留空不启用密码,填写后需要登录
];
// ─── 路径安全处理 ────────────────────────────────────────
function safe_path(string $rel, string $root): string|false {
$rel = str_replace(['../', '..\\', "\0"], '', $rel);
$full = realpath($root . DIRECTORY_SEPARATOR . ltrim($rel, '/\\'));
if ($full === false || strpos($full, realpath($root)) !== 0) return false;
return $full;
}
function rel_path(string $abs, string $root): string {
return ltrim(str_replace(realpath($root), '', $abs), DIRECTORY_SEPARATOR);
}
function format_size(int $bytes): string {
if ($bytes < 1024) return $bytes . ' B';
if ($bytes < 1048576) return round($bytes / 1024, 1) . ' KB';
if ($bytes < 1073741824) return round($bytes / 1048576, 1) . ' MB';
return round($bytes / 1073741824, 2) . ' GB';
}
function get_ext(string $name): string {
return strtolower(pathinfo($name, PATHINFO_EXTENSION));
}
function file_type(string $name, array $cfg): string {
$ext = get_ext($name);
if (in_array($ext, $cfg['image_exts'])) return 'image';
if (in_array($ext, $cfg['video_exts'])) return 'video';
if (in_array($ext, $cfg['audio_exts'])) return 'audio';
if (in_array($ext, $cfg['text_exts'])) return 'text';
if (in_array($ext, ['zip','rar','tar','gz','7z','bz2'])) return 'zip';
if (in_array($ext, ['pdf'])) return 'pdf';
if (in_array($ext, ['doc','docx','xls','xlsx','ppt','pptx','odt','ods'])) return 'office';
return 'other';
}
// ─── 密码验证 ────────────────────────────────────────────
session_start();
if (!empty($config['password'])) {
if (isset($_POST['_pwd'])) {
if ($_POST['_pwd'] === $config['password']) {
$_SESSION['fv_auth'] = true;
} else {
$login_error = true;
}
}
if (empty($_SESSION['fv_auth'])) {
show_login(isset($login_error));
exit;
}
}
if (isset($_GET['logout'])) {
session_destroy();
header('Location: ?');
exit;
}
// ─── 当前路径 ────────────────────────────────────────────
$rel = isset($_GET['p']) ? trim($_GET['p'], '/') : '';
$dir = safe_path($rel, $config['root']);
if (!$dir || !is_dir($dir)) { header('Location: ?'); exit; }
// ─── AJAX 操作 ───────────────────────────────────────────
if (isset($_GET['action'])) {
$action = $_GET['action'];
// 下载(直接输出文件,不设置 JSON header)
if ($action === 'download' && $config['allow_download']) {
$f = safe_path($_GET['file'] ?? '', $config['root']);
if ($f && is_file($f)) {
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . rawurlencode(basename($f)) . '"; filename*=UTF-8\'\'' . rawurlencode(basename($f)));
header('Content-Length: ' . filesize($f));
header('Cache-Control: no-cache');
ob_clean(); flush();
readfile($f); exit;
}
header('HTTP/1.1 404 Not Found'); exit;
}
// 图片/视频/音频预览(直接输出文件,不设置 JSON header)
if ($action === 'preview') {
$f = safe_path($_GET['file'] ?? '', $config['root']);
if ($f && is_file($f)) {
$ext = get_ext(basename($f));
$type = file_type(basename($f), $config);
if ($type === 'image') {
$mime = ['svg'=>'image/svg+xml','gif'=>'image/gif','png'=>'image/png','webp'=>'image/webp','ico'=>'image/x-icon'][$ext] ?? 'image/jpeg';
header("Content-Type: $mime");
header('Content-Length: ' . filesize($f));
ob_clean(); flush();
readfile($f); exit;
}
if ($type === 'video') {
$mime = ['webm'=>'video/webm','ogg'=>'video/ogg'][$ext] ?? 'video/mp4';
header("Content-Type: $mime");
header('Content-Length: ' . filesize($f));
ob_clean(); flush();
readfile($f); exit;
}
if ($type === 'audio') {
$mime = ['ogg'=>'audio/ogg','wav'=>'audio/wav','flac'=>'audio/flac','m4a'=>'audio/mp4'][$ext] ?? 'audio/mpeg';
header("Content-Type: $mime");
header('Content-Length: ' . filesize($f));
ob_clean(); flush();
readfile($f); exit;
}
// 文本类返回 JSON
header('Content-Type: application/json; charset=utf-8');
if ($type === 'text') {
$content = file_get_contents($f);
echo json_encode(['ok'=>true,'content'=>$content]); exit;
}
echo json_encode(['ok'=>false,'msg'=>'不支持预览']); exit;
}
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok'=>false,'msg'=>'文件不存在']); exit;
}
// 以下操作均返回 JSON
header('Content-Type: application/json; charset=utf-8');
// 目录列表(JSON)
if ($action === 'list') {
echo json_encode(dir_data($dir, $rel, $config));
exit;
}
// 新建文件夹
if ($action === 'mkdir' && $config['allow_mkdir']) {
$name = preg_replace('/[^\w\-. \x80-\xff]/', '', $_POST['name'] ?? '');
if ($name && strlen($name) <= 100) {
$target = $dir . DIRECTORY_SEPARATOR . $name;
if (!file_exists($target)) {
mkdir($target, 0755);
echo json_encode(['ok'=>true]);
} else {
echo json_encode(['ok'=>false,'msg'=>'已存在']);
}
} else {
echo json_encode(['ok'=>false,'msg'=>'名称无效']);
}
exit;
}
// 删除
if ($action === 'delete' && $config['allow_delete']) {
$f = safe_path($_POST['file'] ?? '', $config['root']);
if ($f && $f !== realpath($config['root'])) {
if (is_dir($f)) {
rmdir_recursive($f);
echo json_encode(['ok'=>true]);
} elseif (is_file($f)) {
unlink($f);
echo json_encode(['ok'=>true]);
} else {
echo json_encode(['ok'=>false,'msg'=>'不存在']);
}
} else {
echo json_encode(['ok'=>false,'msg'=>'非法路径']);
}
exit;
}
// 重命名
if ($action === 'rename' && $config['allow_rename']) {
$f = safe_path($_POST['file'] ?? '', $config['root']);
$name = preg_replace('/[\/\\\\:\*\?"<>\|]/', '', $_POST['name'] ?? '');
if ($f && $name && file_exists($f)) {
$dest = dirname($f) . DIRECTORY_SEPARATOR . $name;
if (!file_exists($dest)) {
rename($f, $dest);
echo json_encode(['ok'=>true]);
} else {
echo json_encode(['ok'=>false,'msg'=>'目标已存在']);
}
} else {
echo json_encode(['ok'=>false,'msg'=>'参数无效']);
}
exit;
}
// 上传
if ($action === 'upload' && $config['allow_upload']) {
if (!empty($_FILES['files']['name'])) {
$ok = 0; $fail = 0;
$names = (array)$_FILES['files']['name'];
$tmps = (array)$_FILES['files']['tmp_name'];
$errs = (array)$_FILES['files']['error'];
foreach ($names as $i => $name) {
if ($errs[$i] !== UPLOAD_ERR_OK) { $fail++; continue; }
$name = basename($name);
$target = $dir . DIRECTORY_SEPARATOR . $name;
// 重名自动编号
if (file_exists($target)) {
$pi = pathinfo($name);
$base = $pi['filename']; $ext = isset($pi['extension']) ? '.'.$pi['extension'] : '';
$n = 1;
while (file_exists($dir . DIRECTORY_SEPARATOR . $base . "_$n" . $ext)) $n++;
$target = $dir . DIRECTORY_SEPARATOR . $base . "_$n" . $ext;
}
move_uploaded_file($tmps[$i], $target) ? $ok++ : $fail++;
}
echo json_encode(['ok'=>true,'uploaded'=>$ok,'failed'=>$fail]);
} else {
echo json_encode(['ok'=>false,'msg'=>'无文件']);
}
exit;
}
echo json_encode(['ok'=>false,'msg'=>'未知操作']);
exit;
}
// ─── 目录数据 ────────────────────────────────────────────
function dir_data(string $dir, string $rel, array $cfg): array {
$items = [];
$entries = scandir($dir);
foreach ($entries as $name) {
if ($name === '.' || $name === '..') continue;
if (!$cfg['show_hidden'] && $name[0] === '.') continue;
if (in_array($name, $cfg['exclude'])) continue;
$full = $dir . DIRECTORY_SEPARATOR . $name;
$isDir = is_dir($full);
$ext = $isDir ? '' : get_ext($name);
$ftype = $isDir ? 'folder' : file_type($name, $cfg);
$frel = ($rel ? $rel . '/' : '') . $name;
$items[] = [
'name' => $name,
'rel' => $frel,
'is_dir'=> $isDir,
'type' => $ftype,
'ext' => $ext,
'size' => $isDir ? 0 : (int)@filesize($full),
'mtime' => (int)@filemtime($full),
'count' => $isDir ? count(array_diff(scandir($full), ['.','..'])) : 0,
];
}
return $items;
}
// 将 php.ini 尺寸字符串(如 128M)转为字节数
function return_bytes(string $val): int {
$val = trim($val);
$last = strtolower($val[strlen($val)-1]);
$num = (int)$val;
switch($last){
case 'g': $num *= 1024;
case 'm': $num *= 1024;
case 'k': $num *= 1024;
}
return $num;
}
function rmdir_recursive(string $dir): void {
foreach (scandir($dir) as $f) {
if ($f === '.' || $f === '..') continue;
$p = $dir . DIRECTORY_SEPARATOR . $f;
is_dir($p) ? rmdir_recursive($p) : unlink($p);
}
rmdir($dir);
}
// ─── 登录页 ──────────────────────────────────────────────
function show_login(bool $error = false): void {
global $config;
?><!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title><?= htmlspecialchars($config['title']) ?> · 登录</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'DM Mono',monospace;background:#0e0f11;color:#e8eaf0;min-height:100vh;display:flex;align-items:center;justify-content:center}
.box{background:#15171a;border:1px solid #2a2d35;border-radius:14px;padding:36px 32px;width:320px}
h1{font-size:18px;font-weight:700;color:#e8ff47;margin-bottom:24px;letter-spacing:-0.3px}
input{width:100%;height:40px;background:#1c1f24;border:1px solid #2a2d35;border-radius:7px;color:#e8eaf0;font-size:13px;padding:0 12px;outline:none;margin-bottom:12px;transition:.15s}
input:focus{border-color:#47d9ff}
button{width:100%;height:40px;background:#e8ff47;border:none;border-radius:7px;color:#000;font-size:13px;font-weight:600;cursor:pointer;transition:.15s}
button:hover{background:#d4e83d}
.err{color:#ff5f57;font-size:12px;margin-bottom:10px}
</style></head><body>
<div class="box">
<h1>🔐 <?= htmlspecialchars($config['title']) ?></h1>
<?php if($error): ?><div class="err">密码错误,请重试</div><?php endif; ?>
<form method="post">
<input type="password" name="_pwd" placeholder="请输入密码" autofocus>
<button type="submit">登录</button>
</form>
</div>
</body></html>
<?php
}
// ─── 构建面包屑数据 ──────────────────────────────────────
$crumbs = [['name'=>'根目录','path'=>'']];
if ($rel) {
$parts = explode('/', $rel);
$built = '';
foreach ($parts as $p) {
$built = $built ? $built . '/' . $p : $p;
$crumbs[] = ['name'=>$p,'path'=>$built];
}
}
// ─── 页面输出 ────────────────────────────────────────────
$allow_flags = json_encode([
'upload' => $config['allow_upload'],
'delete' => $config['allow_delete'],
'rename' => $config['allow_rename'],
'mkdir' => $config['allow_mkdir'],
'download' => $config['allow_download'],
]);
$init_data = json_encode(dir_data($dir, $rel, $config));
?><!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($config['title']) ?></title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Syne:wght@400;600;700;800&display=swap" rel="stylesheet">
<style>
:root {
--bg:#0e0f11; --bg2:#15171a; --bg3:#1c1f24;
--border:#2a2d35; --border2:#343840;
--accent:#e8ff47; --accent2:#47d9ff;
--text:#e8eaf0; --muted:#6b7280; --muted2:#4b5563;
--hover:#1e2128; --danger:#ff5f57; --green:#28ca42; --orange:#ffbd2e;
--sidebar:220px; --header:52px;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%;overflow:hidden}
body{font-family:'DM Mono',monospace;background:var(--bg);color:var(--text);display:flex;flex-direction:column}
/* scrollbar */
::-webkit-scrollbar{width:5px;height:5px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:var(--border2);border-radius:3px}
/* ── HEADER ── */
header{height:var(--header);background:var(--bg2);border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 14px;gap:10px;flex-shrink:0;z-index:50}
.logo{font-family:'Syne',sans-serif;font-weight:800;font-size:16px;color:var(--accent);letter-spacing:-.5px;white-space:nowrap;display:flex;align-items:center;gap:6px;flex-shrink:0}
.logo svg{width:18px;height:18px}
.sep-v{width:1px;height:20px;background:var(--border);flex-shrink:0}
.breadcrumb{display:flex;align-items:center;gap:2px;flex:1;font-size:12px;color:var(--muted);overflow:hidden;min-width:0}
.breadcrumb a{color:var(--muted);text-decoration:none;padding:2px 5px;border-radius:4px;transition:.12s;white-space:nowrap;cursor:pointer}
.breadcrumb a:hover{color:var(--accent);background:rgba(232,255,71,.07)}
.breadcrumb .crumb-sep{color:var(--muted2);font-size:11px}
.breadcrumb a.active{color:var(--text)}
.header-right{display:flex;align-items:center;gap:7px;flex-shrink:0}
.search-wrap{position:relative}
.search-wrap svg{position:absolute;left:8px;top:50%;transform:translateY(-50%);width:12px;height:12px;color:var(--muted);pointer-events:none}
#search{height:28px;padding:0 8px 0 26px;background:var(--bg3);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:'DM Mono',monospace;font-size:11px;outline:none;width:160px;transition:.15s}
#search:focus{border-color:var(--accent2);width:200px}
#search::placeholder{color:var(--muted2)}
.btn{height:28px;padding:0 10px;border-radius:6px;border:1px solid var(--border);background:transparent;color:var(--muted);font-family:'DM Mono',monospace;font-size:11px;cursor:pointer;display:inline-flex;align-items:center;gap:5px;transition:.15s;white-space:nowrap}
.btn:hover{border-color:var(--accent2);color:var(--accent2)}
.btn.accent{background:var(--accent);color:#000;border-color:var(--accent);font-weight:600}
.btn.accent:hover{background:#d4e83d}
.btn.danger:hover{border-color:var(--danger);color:var(--danger)}
.btn svg{width:11px;height:11px}
/* ── LAYOUT ── */
.layout{display:flex;flex:1;min-height:0;overflow:hidden}
/* ── SIDEBAR ── */
aside{width:var(--sidebar);background:var(--bg2);border-right:1px solid var(--border);display:flex;flex-direction:column;flex-shrink:0;overflow:hidden}
.sidebar-label{padding:10px 10px 4px;font-size:10px;color:var(--muted2);letter-spacing:1px;font-family:'Syne',sans-serif;font-weight:600;text-transform:uppercase}
.tree{flex:1;overflow-y:auto;padding-bottom:8px}
.tree-item{display:flex;align-items:center;gap:5px;padding:5px 10px;font-size:11px;color:var(--muted);cursor:pointer;transition:.1s;border-left:2px solid transparent;user-select:none}
.tree-item:hover{color:var(--text);background:var(--hover)}
.tree-item.active{color:var(--accent);background:rgba(232,255,71,.05);border-left-color:var(--accent)}
.tree-item svg{width:12px;height:12px;flex-shrink:0}
.tree-item.sub{padding-left:22px}
.tree-item.sub2{padding-left:34px}
.sidebar-bottom{border-top:1px solid var(--border);padding:10px 12px}
.disk-info{display:flex;justify-content:space-between;font-size:10px;color:var(--muted2);margin-bottom:5px}
.disk-bar{height:3px;background:var(--border);border-radius:2px;overflow:hidden}
.disk-fill{height:100%;background:linear-gradient(90deg,var(--accent2),var(--accent));border-radius:2px;transition:width 1s}
/* ── MAIN ── */
main{flex:1;display:flex;flex-direction:column;min-width:0;overflow:hidden}
.toolbar{height:38px;background:var(--bg2);border-bottom:1px solid var(--border);display:flex;align-items:center;padding:0 14px;gap:8px;flex-shrink:0}
.sort-btn{font-size:11px;color:var(--muted);cursor:pointer;padding:3px 7px;border-radius:4px;transition:.1s;display:flex;align-items:center;gap:3px;user-select:none}
.sort-btn:hover{color:var(--text);background:var(--hover)}
.sort-btn.active{color:var(--accent)}
.spacer{flex:1}
.count{font-size:10px;color:var(--muted2)}
.view-tog{display:flex;gap:2px;background:var(--bg3);border:1px solid var(--border);border-radius:5px;padding:2px}
.vbtn{width:24px;height:20px;display:flex;align-items:center;justify-content:center;border-radius:3px;cursor:pointer;color:var(--muted);transition:.1s}
.vbtn:hover{color:var(--text)}
.vbtn.active{background:var(--bg);color:var(--accent)}
.vbtn svg{width:12px;height:12px}
/* ── FILE AREA ── */
.file-area{flex:1;overflow-y:auto;padding:12px 14px}
/* grid */
.file-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:8px}
.file-card{background:var(--bg2);border:1px solid var(--border);border-radius:9px;padding:12px 8px 8px;cursor:pointer;transition:all .14s;display:flex;flex-direction:column;align-items:center;gap:6px;position:relative;animation:fadeUp .18s ease both}
.file-card:hover{border-color:var(--accent2);background:var(--hover);transform:translateY(-1px);box-shadow:0 5px 18px rgba(0,0,0,.3)}
.file-card.sel{border-color:var(--accent);background:rgba(232,255,71,.04)}
.ficon{width:44px;height:44px;border-radius:7px;display:flex;align-items:center;justify-content:center;font-size:20px}
.fname{font-size:10px;text-align:center;color:var(--text);word-break:break-word;line-height:1.3;max-height:2.6em;overflow:hidden;width:100%}
.fmeta{font-size:9px;color:var(--muted2);text-align:center}
/* list */
.file-list{display:flex;flex-direction:column;gap:1px}
.list-hd{display:grid;grid-template-columns:28px 1fr 70px 80px 90px 28px;gap:10px;padding:4px 10px 6px;font-size:9px;color:var(--muted2);text-transform:uppercase;letter-spacing:.5px;border-bottom:1px solid var(--border);margin-bottom:2px}
.file-row{display:grid;grid-template-columns:28px 1fr 70px 80px 90px 28px;align-items:center;gap:10px;padding:6px 10px;border-radius:5px;cursor:pointer;transition:.1s;animation:fadeUp .14s ease both;border:1px solid transparent}
.file-row:hover{background:var(--hover)}
.file-row.sel{background:rgba(232,255,71,.03);border-color:rgba(232,255,71,.12)}
.frow-icon{font-size:16px;text-align:center}
.frow-name{font-size:11px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.frow-type{font-size:10px;color:var(--muted);text-transform:uppercase}
.frow-size{font-size:10px;color:var(--muted);text-align:right}
.frow-date{font-size:10px;color:var(--muted2);text-align:right}
.frow-act{display:flex;gap:4px;justify-content:flex-end;opacity:0;transition:.1s}
.file-row:hover .frow-act{opacity:1}
.frow-act button{width:20px;height:20px;border-radius:4px;border:1px solid var(--border);background:transparent;cursor:pointer;color:var(--muted);display:flex;align-items:center;justify-content:center;transition:.1s;padding:0}
.frow-act button:hover{color:var(--accent2);border-color:var(--accent2)}
.frow-act button.del:hover{color:var(--danger);border-color:var(--danger)}
.frow-act button svg{width:11px;height:11px}
/* type colors */
.t-folder{background:rgba(255,189,46,.12)}
.t-image{background:rgba(71,217,255,.12)}
.t-video{background:rgba(175,82,222,.12)}
.t-audio{background:rgba(40,202,66,.12)}
.t-text,.t-office{background:rgba(71,113,255,.12)}
.t-zip{background:rgba(255,95,87,.12)}
.t-pdf{background:rgba(255,152,0,.12)}
.t-other{background:rgba(107,114,128,.12)}
/* ── DETAIL PANEL ── */
.detail{width:220px;background:var(--bg2);border-left:1px solid var(--border);flex-shrink:0;display:flex;flex-direction:column;transition:width .18s;overflow:hidden}
.detail.hidden{width:0;border-left:none}
.detail-hd{padding:12px 12px 8px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
.detail-title{font-family:'Syne',sans-serif;font-weight:600;font-size:11px;color:var(--text)}
.detail-close{width:18px;height:18px;display:flex;align-items:center;justify-content:center;cursor:pointer;color:var(--muted);border-radius:3px;transition:.1s}
.detail-close:hover{color:var(--danger)}
.detail-close svg{width:11px;height:11px}
.detail-body{padding:12px;flex:1;overflow-y:auto}
.d-icon{width:52px;height:52px;border-radius:10px;font-size:24px;display:flex;align-items:center;justify-content:center;margin:0 auto 10px}
.d-name{font-size:11px;font-weight:500;text-align:center;color:var(--text);margin-bottom:12px;word-break:break-word}
.d-rows{display:flex;flex-direction:column;gap:5px}
.d-row{display:flex;justify-content:space-between;gap:6px;font-size:10px}
.d-lbl{color:var(--muted2);flex-shrink:0}
.d-val{color:var(--text);text-align:right;word-break:break-all}
.detail-acts{padding:10px 12px;border-top:1px solid var(--border);display:flex;flex-direction:column;gap:5px;flex-shrink:0}
.d-btn{padding:6px 10px;border-radius:5px;border:1px solid var(--border);background:transparent;color:var(--muted);font-family:'DM Mono',monospace;font-size:10px;cursor:pointer;text-align:left;display:flex;align-items:center;gap:5px;transition:.1s;width:100%}
.d-btn:hover{border-color:var(--accent2);color:var(--accent2)}
.d-btn.red:hover{border-color:var(--danger);color:var(--danger)}
.d-btn svg{width:11px;height:11px;flex-shrink:0}
/* ── EMPTY ── */
.empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;color:var(--muted2);font-size:12px;padding:40px}
.empty svg{width:40px;height:40px;opacity:.25}
/* ── MODAL ── */
.overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);backdrop-filter:blur(4px);z-index:200;display:none;align-items:center;justify-content:center}
.overlay.show{display:flex}
.modal{background:var(--bg2);border:1px solid var(--border);border-radius:12px;padding:22px;width:320px;box-shadow:0 16px 50px rgba(0,0,0,.6);animation:modalIn .14s ease}
.modal-title{font-family:'Syne',sans-serif;font-weight:700;font-size:14px;margin-bottom:14px}
.modal input{width:100%;height:36px;background:var(--bg3);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:'DM Mono',monospace;font-size:12px;padding:0 10px;outline:none;margin-bottom:12px;transition:.14s}
.modal input:focus{border-color:var(--accent2)}
.modal-btns{display:flex;gap:7px;justify-content:flex-end}
/* ── CONTEXT MENU ── */
.ctxmenu{position:fixed;background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:4px;min-width:150px;z-index:500;box-shadow:0 8px 28px rgba(0,0,0,.5);display:none;animation:menuIn .1s ease}
.ctxmenu.show{display:block}
.ctx-item{display:flex;align-items:center;gap:7px;padding:6px 9px;font-size:11px;color:var(--muted);cursor:pointer;border-radius:4px;transition:.1s}
.ctx-item:hover{background:var(--hover);color:var(--text)}
.ctx-item.red:hover{color:var(--danger);background:rgba(255,95,87,.07)}
.ctx-item svg{width:12px;height:12px}
.ctx-sep{height:1px;background:var(--border);margin:3px 4px}
/* ── UPLOAD ── */
.drop-mask{position:fixed;inset:0;background:rgba(14,15,17,.9);border:3px dashed var(--accent);z-index:300;display:none;align-items:center;justify-content:center;flex-direction:column;gap:14px;pointer-events:none;font-family:'Syne',sans-serif;font-size:18px;font-weight:700;color:var(--accent)}
.drop-mask.show{display:flex}
.drop-mask svg{width:50px;height:50px}
/* ── PREVIEW MODAL ── */
#preview-overlay{position:fixed;inset:0;background:rgba(0,0,0,.85);backdrop-filter:blur(6px);z-index:400;display:none;align-items:center;justify-content:center;flex-direction:column;gap:12px}
#preview-overlay.show{display:flex}
#preview-overlay img,#preview-overlay video,#preview-overlay audio{max-width:90vw;max-height:80vh;border-radius:8px;border:1px solid var(--border)}
#preview-overlay .p-name{font-size:12px;color:var(--muted);font-family:'DM Mono',monospace}
#preview-overlay pre{background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:16px;max-width:80vw;max-height:75vh;overflow:auto;font-size:12px;color:var(--text);white-space:pre-wrap;word-break:break-word}
.p-close{position:fixed;top:16px;right:20px;width:34px;height:34px;border-radius:50%;background:var(--bg2);border:1px solid var(--border);color:var(--muted);cursor:pointer;display:flex;align-items:center;justify-content:center;transition:.12s;z-index:401}
.p-close:hover{color:var(--danger);border-color:var(--danger)}
.p-close svg{width:14px;height:14px}
/* ── TOAST ── */
.toasts{position:fixed;bottom:18px;right:18px;z-index:600;display:flex;flex-direction:column;gap:6px;pointer-events:none}
.toast{background:var(--bg2);border:1px solid var(--border);border-left:3px solid var(--green);border-radius:7px;padding:9px 13px;font-size:11px;color:var(--text);box-shadow:0 4px 16px rgba(0,0,0,.4);animation:toastIn .18s ease,toastOut .18s ease 2.7s forwards}
.toast.err{border-left-color:var(--danger)}
.toast.warn{border-left-color:var(--orange)}
/* ── ANIMATIONS ── */
@keyframes fadeUp{from{opacity:0;transform:translateY(5px)}to{opacity:1;transform:translateY(0)}}
@keyframes menuIn{from{opacity:0;transform:scale(.95)}to{opacity:1;transform:scale(1)}}
@keyframes modalIn{from{opacity:0;transform:scale(.96) translateY(8px)}to{opacity:1;transform:scale(1) translateY(0)}}
@keyframes toastIn{from{opacity:0;transform:translateX(16px)}to{opacity:1;transform:translateX(0)}}
@keyframes toastOut{to{opacity:0;transform:translateX(16px)}}
/* responsive */
@media(max-width:600px){
aside{display:none}
.detail{display:none}
#search{width:120px}
#search:focus{width:140px}
}
</style>
</head>
<body>
<!-- HEADER -->
<header>
<div class="logo">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
<?= htmlspecialchars($config['title']) ?>
</div>
<div class="sep-v"></div>
<nav class="breadcrumb" id="breadcrumb">
<?php foreach($crumbs as $i=>$c): $isLast=($i===count($crumbs)-1); ?>
<?php if($i>0): ?><span class="crumb-sep">/</span><?php endif; ?>
<a class="<?= $isLast?'active':'' ?>" <?= $isLast?'':'href="?p='.urlencode($c['path']).'"' ?>>
<?= htmlspecialchars($c['name']) ?>
</a>
<?php endforeach; ?>
</nav>
<div class="header-right">
<div class="search-wrap">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
<input type="text" id="search" placeholder="搜索文件…">
</div>
<?php if($config['allow_mkdir']): ?>
<button class="btn" id="btn-mkdir">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/><line x1="12" y1="11" x2="12" y2="17"/><line x1="9" y1="14" x2="15" y2="14"/></svg>
新建
</button>
<?php endif; ?>
<?php if($config['allow_upload']): ?>
<button class="btn accent" id="btn-upload">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
上传
</button>
<?php endif; ?>
<?php if(!empty($config['password'])): ?>
<a href="?logout=1" class="btn" title="退出登录">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
</a>
<?php endif; ?>
</div>
</header>
<div class="layout">
<!-- SIDEBAR -->
<aside>
<div class="sidebar-label">目录</div>
<div class="tree" id="tree">
<!-- 由 JS 渲染 -->
</div>
<div class="sidebar-bottom">
<div class="disk-info"><span>存储</span><span id="disk-used">—</span></div>
<div class="disk-bar"><div class="disk-fill" id="disk-fill" style="width:0%"></div></div>
</div>
</aside>
<!-- MAIN -->
<main>
<div class="toolbar">
<span class="sort-btn active" data-s="name" id="sort-name">名称</span>
<span class="sort-btn" data-s="size" id="sort-size">大小</span>
<span class="sort-btn" data-s="mtime" id="sort-date">日期</span>
<span class="sort-btn" data-s="type" id="sort-type">类型</span>
<div class="spacer"></div>
<span class="count" id="count"></span>
<div class="view-tog">
<div class="vbtn active" id="vbtn-grid" title="网格">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/></svg>
</div>
<div class="vbtn" id="vbtn-list" title="列表">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
</div>
</div>
</div>
<div class="file-area" id="file-area"></div>
</main>
<!-- DETAIL PANEL -->
<div class="detail hidden" id="detail">
<div class="detail-hd">
<span class="detail-title">详情</span>
<div class="detail-close" id="detail-close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</div>
</div>
<div class="detail-body" id="detail-body"></div>
<div class="detail-acts" id="detail-acts"></div>
</div>
</div>
<!-- CONTEXT MENU -->
<div class="ctxmenu" id="ctxmenu"></div>
<!-- MODAL -->
<div class="overlay" id="modal-overlay">
<div class="modal">
<div class="modal-title" id="modal-title">新建文件夹</div>
<input type="text" id="modal-input" placeholder="输入名称">
<div class="modal-btns">
<button class="btn" id="modal-cancel">取消</button>
<button class="btn accent" id="modal-ok">确定</button>
</div>
</div>
</div>
<!-- UPLOAD INPUT -->
<input type="file" id="file-input" multiple style="display:none">
<!-- DROP MASK -->
<div class="drop-mask" id="drop-mask">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
松开以上传文件
</div>
<!-- PREVIEW -->
<div id="preview-overlay">
<div class="p-close" id="p-close"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></div>
<div id="preview-inner"></div>
<div class="p-name" id="preview-name"></div>
</div>
<!-- TOASTS -->
<div class="toasts" id="toasts"></div>
<script>
// ─── CONFIG FROM PHP ─────────────────────────────────────
const ALLOW = <?= $allow_flags ?>;
const BASE_URL = '<?= htmlspecialchars($_SERVER['PHP_SELF']) ?>';
const CURRENT_REL = '<?= htmlspecialchars($rel) ?>';
// 文件直链根路径(filevault.php 所在目录的 Web 路径)
const ROOT_URL = '<?= rtrim(dirname($_SERVER['PHP_SELF']), '/') ?>/';
// ─── STATE ───────────────────────────────────────────────
let items = <?= $init_data ?>;
let viewMode = localStorage.getItem('fv_view') || 'grid';
let sortKey = 'name';
let sortAsc = true;
let selected = null; // item object
let searchQ = '';
let modalMode = ''; // 'mkdir' | 'rename'
let renameTarget = null;
// ─── ICONS ───────────────────────────────────────────────
const ICONS = {
folder:'📁', image:'🖼️', video:'🎬', audio:'🎵',
text:'📝', office:'📊', zip:'📦', pdf:'📕', other:'📎'
};
function fmtSize(b){
if(!b) return '—';
if(b<1024) return b+' B';
if(b<1048576) return (b/1024).toFixed(1)+' KB';
if(b<1073741824) return (b/1048576).toFixed(1)+' MB';
return (b/1073741824).toFixed(2)+' GB';
}
function fmtDate(ts){
if(!ts) return '—';
const d=new Date(ts*1000);
return d.getFullYear()+'-'+(d.getMonth()+1).toString().padStart(2,'0')+'-'+d.getDate().toString().padStart(2,'0');
}
function url(p){ return BASE_URL+'?p='+encodeURIComponent(p); }
function apiUrl(action){ return BASE_URL+'?action='+action+'&p='+encodeURIComponent(CURRENT_REL); }
// ─── SORT + FILTER ───────────────────────────────────────
function getSorted(){
let arr = items.filter(it=>{
if(!searchQ) return true;
return it.name.toLowerCase().includes(searchQ.toLowerCase());
});
arr.sort((a,b)=>{
// folders first
if(a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
let cmp=0;
if(sortKey==='name') cmp=a.name.localeCompare(b.name,'zh');
if(sortKey==='size') cmp=a.size-b.size;
if(sortKey==='mtime') cmp=a.mtime-b.mtime;
if(sortKey==='type') cmp=a.type.localeCompare(b.type);
return sortAsc ? cmp : -cmp;
});
return arr;
}
// ─── RENDER ──────────────────────────────────────────────
function render(){
renderTree();
renderFiles();
updateSortBtns();
}
function renderFiles(){
const area=document.getElementById('file-area');
const arr=getSorted();
document.getElementById('count').textContent=arr.length+' 个项目';
if(!arr.length){
area.innerHTML=`<div class="empty"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg><span>${searchQ?'没有匹配文件':'此文件夹为空'}</span></div>`;
return;
}
if(viewMode==='grid'){
area.innerHTML='<div class="file-grid" id="fg"></div>';
const fg=document.getElementById('fg');
arr.forEach((it,i)=>{
const el=document.createElement('div');
el.className='file-card'+(selected===it?' sel':'');
el.style.animationDelay=Math.min(i*.022,.3)+'s';
el.innerHTML=`<div class="ficon t-${it.type}">${ICONS[it.type]||'📎'}</div>
<div class="fname" title="${esc(it.name)}">${esc(it.name)}</div>
<div class="fmeta">${it.is_dir?(it.count+' 项'):fmtSize(it.size)}</div>`;
el.addEventListener('click',e=>{e.stopPropagation();selectItem(it)});
el.addEventListener('dblclick',()=>openItem(it));
el.addEventListener('contextmenu',e=>showCtx(e,it));
fg.appendChild(el);
});
} else {
area.innerHTML=`<div class="list-hd"><div></div><div>名称</div><div>类型</div><div>大小</div><div>修改日期</div><div></div></div><div class="file-list" id="fl"></div>`;
const fl=document.getElementById('fl');
arr.forEach((it,i)=>{
const el=document.createElement('div');
el.className='file-row'+(selected===it?' sel':'');
el.style.animationDelay=Math.min(i*.018,.25)+'s';
el.innerHTML=`<div class="frow-icon">${ICONS[it.type]||'📎'}</div>
<div class="frow-name" title="${esc(it.name)}">${esc(it.name)}</div>
<div class="frow-type">${it.is_dir?'文件夹':(it.ext||it.type)}</div>
<div class="frow-size">${it.is_dir?(it.count+' 项'):fmtSize(it.size)}</div>
<div class="frow-date">${fmtDate(it.mtime)}</div>
<div class="frow-act">
${it.is_dir?`<button title="打开" onclick="openItem(getItem('${esc(it.name)}'));event.stopPropagation()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg></button>`
:`${ALLOW.download?`<button title="下载" onclick="downloadFile('${esc(it.rel)}');event.stopPropagation()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></button>`:''}
<button title="预览" onclick="previewItem(getItem('${esc(it.name)}'));event.stopPropagation()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>`}
${ALLOW.delete?`<button class="del" title="删除" onclick="deleteItem(getItem('${esc(it.name)}'));event.stopPropagation()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg></button>`:''}
</div>`;
el.addEventListener('click',e=>{e.stopPropagation();selectItem(it)});
el.addEventListener('dblclick',()=>openItem(it));
el.addEventListener('contextmenu',e=>showCtx(e,it));
fl.appendChild(el);
});
}
}
function getItem(name){ return items.find(it=>it.name===name)||null; }
function esc(s){ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"') }
// ─── TREE SIDEBAR ────────────────────────────────────────
function renderTree(){
const tree=document.getElementById('tree');
// build simple tree from current breadcrumb
const crumbs=[{name:'根目录',path:''}];
if(CURRENT_REL){
const parts=CURRENT_REL.split('/');
let built='';
parts.forEach(p=>{ built=built?built+'/'+p:p; crumbs.push({name:p,path:built}) });
}
tree.innerHTML=crumbs.map((c,i)=>{
const depth=i===0?'':i===1?' sub':' sub2';
const active=c.path===CURRENT_REL?' active':'';
return `<div class="tree-item${depth}${active}" onclick="location.href='${url(c.path)}'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
${esc(c.name)}
</div>`;
}).join('');
// add subdirs from current listing
items.filter(it=>it.is_dir).forEach(it=>{
const depth=CURRENT_REL?' sub2':' sub';
tree.innerHTML+=`<div class="tree-item${depth}" onclick="location.href='${url(it.rel)}'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
${esc(it.name)}
</div>`;
});
// disk usage (approximate from file sizes)
const total=items.reduce((s,it)=>s+it.size,0);
document.getElementById('disk-used').textContent=fmtSize(total);
document.getElementById('disk-fill').style.width=Math.min(total/1e8*100,100)+'%';
}
// ─── SORT BUTTONS ────────────────────────────────────────
function updateSortBtns(){
document.querySelectorAll('.sort-btn').forEach(b=>{
const s=b.dataset.s;
b.classList.toggle('active',s===sortKey);
b.textContent={name:'名称',size:'大小',mtime:'日期',type:'类型'}[s]+(s===sortKey?(sortAsc?' ↑':' ↓'):'');
});
}
document.querySelectorAll('.sort-btn').forEach(b=>{
b.addEventListener('click',()=>{
if(sortKey===b.dataset.s) sortAsc=!sortAsc;
else { sortKey=b.dataset.s; sortAsc=true; }
renderFiles(); updateSortBtns();
});
});
// ─── VIEW TOGGLE ─────────────────────────────────────────
document.getElementById('vbtn-grid').addEventListener('click',()=>setView('grid'));
document.getElementById('vbtn-list').addEventListener('click',()=>setView('list'));
function setView(v){
viewMode=v; localStorage.setItem('fv_view',v);
document.getElementById('vbtn-grid').classList.toggle('active',v==='grid');
document.getElementById('vbtn-list').classList.toggle('active',v==='list');
renderFiles();
}
setView(viewMode);
// ─── SEARCH ──────────────────────────────────────────────
document.getElementById('search').addEventListener('input',e=>{
searchQ=e.target.value; renderFiles();
});
// ─── SELECT + DETAIL ─────────────────────────────────────
function selectItem(it){
selected=it;
renderFiles();
showDetail(it);
}
function showDetail(it){
const panel=document.getElementById('detail');
panel.classList.remove('hidden');
const body=document.getElementById('detail-body');
const acts=document.getElementById('detail-acts');
body.innerHTML=`
<div class="d-icon t-${it.type}">${ICONS[it.type]||'📎'}</div>
<div class="d-name">${esc(it.name)}</div>
<div class="d-rows">
<div class="d-row"><span class="d-lbl">类型</span><span class="d-val">${it.is_dir?'文件夹':(it.ext?it.ext.toUpperCase():it.type)}</span></div>
<div class="d-row"><span class="d-lbl">${it.is_dir?'包含':'大小'}</span><span class="d-val">${it.is_dir?(it.count+' 项'):fmtSize(it.size)}</span></div>
<div class="d-row"><span class="d-lbl">修改</span><span class="d-val">${fmtDate(it.mtime)}</span></div>
<div class="d-row"><span class="d-lbl">路径</span><span class="d-val">/${esc(it.rel)}</span></div>
</div>`;
const btns=[];
if(it.is_dir){
btns.push(`<button class="d-btn" onclick="location.href='${url(it.rel)}'"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>打开文件夹</button>`);
} else {
if(['image','video','audio','text'].includes(it.type))
btns.push(`<button class="d-btn" onclick="previewItem(selected)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>预览</button>`);
if(ALLOW.download)
btns.push(`<button class="d-btn" onclick="downloadFile('${esc(it.rel)}')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>下载</button>`);
}
if(ALLOW.rename) btns.push(`<button class="d-btn" onclick="openRename(selected)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>重命名</button>`);
if(ALLOW.delete) btns.push(`<button class="d-btn red" onclick="deleteItem(selected)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>删除</button>`);
acts.innerHTML=btns.join('');
}
document.getElementById('detail-close').addEventListener('click',()=>{
document.getElementById('detail').classList.add('hidden');
selected=null; renderFiles();
});
document.addEventListener('click',()=>{
// deselect on bg click
});
// ─── OPEN ────────────────────────────────────────────────
function openItem(it){
if(!it) return;
if(it.is_dir){ location.href=url(it.rel); return; }
previewItem(it);
}
// ─── PREVIEW ─────────────────────────────────────────────
function previewItem(it){
if(!it || it.is_dir) return;
const po=document.getElementById('preview-overlay');
const pi=document.getElementById('preview-inner');
const pn=document.getElementById('preview-name');
pn.textContent=it.name;
pi.innerHTML='<div style="color:var(--muted);font-size:12px">加载中…</div>';
po.classList.add('show');
const src=BASE_URL+'?action=preview&file='+encodeURIComponent(it.rel);
if(it.type==='image'){
pi.innerHTML=`<img src="${src}" alt="${esc(it.name)}" onload="" onerror="this.alt='加载失败'">`;
} else if(it.type==='video'){
pi.innerHTML=`<video src="${src}" controls autoplay></video>`;
} else if(it.type==='audio'){
pi.innerHTML=`<audio src="${src}" controls autoplay></audio>`;
} else if(it.type==='text'){
fetch(src).then(r=>r.json()).then(d=>{
if(d.ok) pi.innerHTML=`<pre>${esc(d.content)}</pre>`;
else pi.innerHTML=`<div style="color:var(--danger)">无法读取文件</div>`;
});
} else {
pi.innerHTML=`<div style="color:var(--muted);font-size:13px">此类型暂不支持预览</div>`;
}
}
document.getElementById('p-close').addEventListener('click',()=>{
document.getElementById('preview-overlay').classList.remove('show');
document.getElementById('preview-inner').innerHTML='';
});
document.getElementById('preview-overlay').addEventListener('click',e=>{
if(e.target===e.currentTarget) document.getElementById('p-close').click();
});
// ─── DOWNLOAD ────────────────────────────────────────────
// 这些扩展名会被服务端解析执行,必须走 PHP 代理强制 attachment 下载
const SERVER_EXEC_EXTS = new Set([
'php','php3','php4','php5','php7','php8','phtml','phar',
'html','htm','xhtml','shtml',
'asp','aspx','ashx','asmx',
'jsp','jspx',
'cgi','pl','py','rb','lua',
'cfm','cfml',
'htaccess','htpasswd',
]);
function downloadFile(rel){
const ext = rel.split('.').pop().toLowerCase();
if(SERVER_EXEC_EXTS.has(ext)){
// 服务端可执行类型 → PHP 代理,强制 attachment
window.location.href = BASE_URL+'?action=download&file='+encodeURIComponent(rel);
} else {
// 其余类型 → 直链,让浏览器 / Web 服务器直接处理
const directUrl = ROOT_URL + rel.split('/').map(encodeURIComponent).join('/');
const a = document.createElement('a');
a.href = directUrl;
a.download = rel.split('/').pop(); // 提示浏览器作为下载而非导航
document.body.appendChild(a);
a.click();
a.remove();
}
}
// ─── DELETE ──────────────────────────────────────────────
function deleteItem(it){
if(!it) return;
if(!confirm(`确定删除 "${it.name}"?${it.is_dir?'\n(将删除文件夹及其所有内容)':''}`)) return;
apiFetch('delete',{file:it.rel}).then(d=>{
if(d.ok){
toast('已删除: '+it.name);
items=items.filter(i=>i.name!==it.name);
selected=null;
document.getElementById('detail').classList.add('hidden');
renderFiles(); renderTree();
} else toast(d.msg||'删除失败','err');
});
}
// ─── RENAME ──────────────────────────────────────────────
function openRename(it){
if(!it) return;
renameTarget=it;
modalMode='rename';
document.getElementById('modal-title').textContent='重命名';
document.getElementById('modal-input').value=it.name;
document.getElementById('modal-overlay').classList.add('show');
setTimeout(()=>{
const inp=document.getElementById('modal-input');
inp.focus();
const dot=it.name.lastIndexOf('.');
inp.setSelectionRange(0, dot>0&&!it.is_dir?dot:it.name.length);
},50);
}
// ─── MKDIR ───────────────────────────────────────────────
document.getElementById('btn-mkdir')&&document.getElementById('btn-mkdir').addEventListener('click',()=>{
modalMode='mkdir'; renameTarget=null;
document.getElementById('modal-title').textContent='新建文件夹';
document.getElementById('modal-input').value='新建文件夹';
document.getElementById('modal-overlay').classList.add('show');
setTimeout(()=>{ document.getElementById('modal-input').select(); },50);
});
// ─── MODAL LOGIC ─────────────────────────────────────────
function closeModal(){ document.getElementById('modal-overlay').classList.remove('show'); }
document.getElementById('modal-cancel').addEventListener('click',closeModal);
document.getElementById('modal-overlay').addEventListener('click',e=>{ if(e.target===e.currentTarget) closeModal(); });
document.getElementById('modal-ok').addEventListener('click',doModal);
document.getElementById('modal-input').addEventListener('keydown',e=>{ if(e.key==='Enter') doModal(); if(e.key==='Escape') closeModal(); });
function doModal(){
const val=document.getElementById('modal-input').value.trim();
if(!val) return;
closeModal();
if(modalMode==='mkdir'){
apiFetch('mkdir',{name:val}).then(d=>{
if(d.ok){ toast('文件夹已创建'); reloadItems(); }
else toast(d.msg||'创建失败','err');
});
} else if(modalMode==='rename' && renameTarget){
apiFetch('rename',{file:renameTarget.rel,name:val}).then(d=>{
if(d.ok){ toast('已重命名'); reloadItems(); }
else toast(d.msg||'重命名失败','err');
});
}
}
// ─── UPLOAD ──────────────────────────────────────────────
document.getElementById('btn-upload')&&document.getElementById('btn-upload').addEventListener('click',()=>{
document.getElementById('file-input').click();
});
document.getElementById('file-input').addEventListener('change',function(){
if(this.files.length) uploadFiles(this.files);
this.value='';
});
function uploadFiles(files){
const fd=new FormData();
Array.from(files).forEach(f=>fd.append('files[]',f));
toast('上传中…','warn');
fetch(apiUrl('upload'),{method:'POST',body:fd})
.then(r=>r.json()).then(d=>{
if(d.ok){
toast(`上传完成:${d.uploaded} 个文件${d.failed?','+d.failed+' 个失败':''}`);
reloadItems();
} else toast(d.msg||'上传失败','err');
}).catch(()=>toast('上传出错','err'));
}
// Drag & drop upload
const dropMask=document.getElementById('drop-mask');
let dragCnt=0;
document.addEventListener('dragenter',e=>{ e.preventDefault(); dragCnt++; if(dragCnt===1) dropMask.classList.add('show'); });
document.addEventListener('dragleave',e=>{ dragCnt--; if(dragCnt===0) dropMask.classList.remove('show'); });
document.addEventListener('dragover',e=>e.preventDefault());
document.addEventListener('drop',e=>{
e.preventDefault(); dragCnt=0; dropMask.classList.remove('show');
if(ALLOW.upload && e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files);
});
// ─── CONTEXT MENU ────────────────────────────────────────
const ctxMenu=document.getElementById('ctxmenu');
function showCtx(e,it){
e.preventDefault(); e.stopPropagation();
selectItem(it);
const x=Math.min(e.clientX,window.innerWidth-165);
const y=Math.min(e.clientY,window.innerHeight-200);
ctxMenu.style.cssText=`left:${x}px;top:${y}px`;
ctxMenu.innerHTML=`
${it.is_dir?`<div class="ctx-item" onclick="openItem(selected);closeCtx()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>打开</div>`
:`${['image','video','audio','text'].includes(it.type)?`<div class="ctx-item" onclick="previewItem(selected);closeCtx()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>预览</div>`:''}
${ALLOW.download?`<div class="ctx-item" onclick="downloadFile(selected.rel);closeCtx()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>下载</div>`:''}`}
<div class="ctx-item" onclick="showDetail(selected);closeCtx()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>属性</div>
<div class="ctx-sep"></div>
${ALLOW.rename?`<div class="ctx-item" onclick="openRename(selected);closeCtx()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>重命名</div>`:''}
${ALLOW.delete?`<div class="ctx-sep"></div><div class="ctx-item red" onclick="deleteItem(selected);closeCtx()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>删除</div>`:''}`;
ctxMenu.classList.add('show');
}
function closeCtx(){ ctxMenu.classList.remove('show'); }
document.addEventListener('click',closeCtx);
document.addEventListener('keydown',e=>{ if(e.key==='Escape'){ closeCtx(); document.getElementById('preview-overlay').classList.remove('show'); } });
// ─── API HELPERS ─────────────────────────────────────────
function apiFetch(action, data={}){
const fd=new FormData();
Object.entries(data).forEach(([k,v])=>fd.append(k,v));
return fetch(apiUrl(action),{method:'POST',body:fd}).then(r=>r.json());
}
function reloadItems(){
fetch(apiUrl('list')).then(r=>r.json()).then(d=>{
items=d; selected=null;
document.getElementById('detail').classList.add('hidden');
renderFiles(); renderTree();
});
}
// ─── TOAST ───────────────────────────────────────────────
function toast(msg, type='ok'){
const el=document.createElement('div');
el.className='toast'+(type==='err'?' err':type==='warn'?' warn':'');
el.textContent=msg;
document.getElementById('toasts').appendChild(el);
setTimeout(()=>el.remove(), 3100);
}
// ─── KEYBOARD ────────────────────────────────────────────
document.addEventListener('keydown',e=>{
if(e.target.tagName==='INPUT') return;
if(e.key==='Backspace'||e.key==='ArrowLeft'){
const parts=CURRENT_REL?CURRENT_REL.split('/'):[]; parts.pop();
location.href=url(parts.join('/'));
}
if(e.key==='F2' && selected && ALLOW.rename) openRename(selected);
if(e.key==='Delete' && selected && ALLOW.delete) deleteItem(selected);
if((e.key==='f'||e.key==='F') && !e.ctrlKey && !e.metaKey){
e.preventDefault();
document.getElementById('search').focus();
}
});
// ─── INIT ────────────────────────────────────────────────
render();
</script>
</body>
</html>本作品采用 知识共享署名-相同方式共享 4.0 国际许可协议 进行许可。
小菜鸡
评论已关闭