Files
vue-driven-cloud-storage/frontend/share.html
WanWanYun 136d45dee7 优化: 分享所有文件时支持点击文件图标放大查看,并可返回列表
功能描述:
- 在分享所有文件(目录分享)时,点击文件图标可以放大查看
- 显示效果与分享单个文件时相同(居中大图标显示)
- 左上角显示"返回列表"按钮,点击可返回文件列表
- 单个文件分享时不显示返回按钮(避免混淆)

实现内容:
1. 添加状态管理
   - viewingFile: 当前正在查看的文件(null表示在列表视图)

2. 新增方法
   - handleFileClick(file): 处理文件点击
     * 图片/视频/音频 → 打开媒体预览(保持原有行为)
     * 其他文件 → 显示详情页面
   - viewFileDetail(file): 设置viewingFile显示详情
   - backToList(): 清除viewingFile返回列表

3. 修改HTML模板
   - 标题添加返回按钮(条件:viewingFile存在且不是单文件分享)
   - 单文件显示条件:viewingFile || files.length === 1
   - 网格/列表视图条件:添加 !viewingFile 限制
   - 单文件显示内容:使用 viewingFile || files[0]

4. 防止bug设计
   - shareInfo.share_type !== 'file' 确保单文件分享不显示返回按钮
   - handleFileClick 区分媒体文件和普通文件
   - viewingFile 为null时正常显示文件列表

用户体验提升:
 点击文件图标即可放大查看详情
 查看体验与单文件分享一致
 返回按钮清晰明了
 媒体文件仍然打开预览
 单文件分享不显示返回按钮(避免混淆)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-12 18:24:29 +08:00

797 lines
23 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>文件分享 - 玩玩云</title>
<script src="libs/vue.global.prod.js"></script>
<script src="libs/axios.min.js"></script>
<link rel="stylesheet" href="libs/fontawesome/css/all.min.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1000px;
margin: 0 auto;
}
.card {
background: white;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
padding: 30px;
margin-bottom: 20px;
}
.title {
font-size: 24px;
font-weight: 700;
color: #667eea;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
}
.form-group { margin-bottom: 20px; }
.form-label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #555;
}
.form-input {
width: 100%;
padding: 12px 16px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 14px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover { background: #5568d3; }
.alert { padding: 12px; border-radius: 8px; margin-bottom: 15px; }
.alert-error { background: #f8d7da; color: #721c24; }
.file-list {
list-style: none;
}
.file-item {
padding: 15px;
border-bottom: 1px solid #eee;
display: flex;
align-items: center;
justify-content: space-between;
}
.file-item:hover {
background: #f5f5f5;
}
.file-info {
display: flex;
align-items: center;
gap: 15px;
}
.file-icon {
font-size: 24px;
}
.loading {
text-align: center;
padding: 40px;
color: #999;
}
/* 视图切换按钮 */
.view-controls {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-bottom: 20px;
}
.btn-secondary {
background: #e0e0e0;
color: #333;
}
.btn-secondary:hover {
background: #d0d0d0;
}
/* 大图标视图 */
.file-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 25px;
padding: 10px 0;
}
.file-grid-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 15px;
border: 2px solid #e8e8e8;
border-radius: 12px;
cursor: pointer;
transition: all 0.3s;
background: #fff;
}
.file-grid-item:hover {
background: #f8f9fa;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
border-color: #667eea;
transform: translateY(-3px);
}
.file-grid-icon {
font-size: 56px;
margin-bottom: 12px;
}
.file-grid-name {
font-size: 14px;
font-weight: 500;
text-align: center;
word-break: break-all;
margin-bottom: 8px;
max-width: 100%;
color: #333;
/* 固定显示2行超出显示省略号 */
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.4;
min-height: 39px;
max-height: 39px;
}
.file-grid-size {
font-size: 12px;
color: #999;
margin-bottom: 12px;
}
/* 单文件居中显示 */
.single-file-container {
display: flex;
justify-content: center;
align-items: center;
padding: 40px 20px;
min-height: 300px;
}
.single-file-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 50px 40px;
border: 2px solid #e0e0e0;
border-radius: 16px;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
max-width: 500px;
width: 100%;
transition: all 0.3s;
}
.single-file-card:hover {
transform: translateY(-5px);
box-shadow: 0 12px 32px rgba(0,0,0,0.18);
}
.single-file-icon {
font-size: 120px;
margin-bottom: 25px;
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-10px); }
}
.single-file-name {
font-size: 20px;
font-weight: 600;
text-align: center;
word-break: break-all;
margin-bottom: 15px;
color: #333;
}
.single-file-size {
font-size: 16px;
color: #666;
margin-bottom: 30px;
}
.single-file-download {
padding: 15px 40px;
font-size: 16px;
font-weight: 600;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 30px;
cursor: pointer;
transition: all 0.3s;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.single-file-download:hover {
transform: scale(1.05);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
}
/* 响应式设计 */
@media (max-width: 768px) {
.single-file-card {
padding: 30px 20px;
}
.single-file-icon {
font-size: 80px;
}
.single-file-name {
font-size: 16px;
}
.single-file-size {
font-size: 14px;
}
.single-file-download {
padding: 12px 30px;
font-size: 14px;
}
}
/* 分享不存在提示 */
.share-not-found {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
text-align: center;
}
.share-not-found-icon {
font-size: 100px;
color: #ccc;
margin-bottom: 30px;
animation: fadeIn 0.5s;
}
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.8); }
to { opacity: 1; transform: scale(1); }
}
.share-not-found-title {
font-size: 24px;
font-weight: 600;
color: #666;
margin-bottom: 15px;
}
.share-not-found-message {
font-size: 16px;
color: #999;
line-height: 1.6;
}
/* 移动端适配 */
@media (max-width: 768px) {
body {
padding: 10px;
}
.container {
max-width: 100%;
}
.card {
padding: 20px;
border-radius: 8px;
}
.title {
font-size: 20px;
margin-bottom: 15px;
}
/* 视图切换按钮移动端优化 */
.view-controls {
margin-bottom: 15px;
}
.view-controls .btn {
padding: 8px 14px;
font-size: 13px;
}
/* 表单移动端优化 */
.form-group {
margin-bottom: 15px;
}
.form-label {
font-size: 14px;
margin-bottom: 6px;
}
.form-input {
padding: 10px 12px;
font-size: 14px;
}
.btn {
padding: 8px 16px;
font-size: 14px;
}
/* 文件网格视图移动端优化 */
.file-grid {
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
gap: 15px;
}
.file-grid-item {
padding: 15px 10px;
}
.file-grid-icon {
font-size: 44px !important;
}
.file-grid-name {
font-size: 12px;
min-height: 34px;
max-height: 34px;
margin-bottom: 6px;
}
.file-grid-size {
font-size: 11px;
margin-bottom: 10px;
}
.file-grid-item .btn {
padding: 6px 10px;
font-size: 12px;
}
/* 列表视图移动端优化 */
.file-list {
padding: 0;
}
.file-item {
padding: 12px 8px;
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.file-info {
gap: 10px;
width: 100%;
}
.file-icon {
font-size: 20px !important;
}
.file-item .btn {
width: 100%;
padding: 8px 12px;
}
/* 单文件显示移动端优化 */
.single-file-container {
padding: 20px 10px;
min-height: 250px;
}
.single-file-card {
padding: 30px 20px;
max-width: 100%;
}
.single-file-icon {
font-size: 80px !important;
margin-bottom: 20px;
}
.single-file-name {
font-size: 16px;
margin-bottom: 12px;
}
.single-file-size {
font-size: 14px;
margin-bottom: 20px;
}
.single-file-download {
padding: 12px 30px;
font-size: 14px;
}
/* 分享不存在提示移动端优化 */
.share-not-found {
padding: 40px 15px;
}
.share-not-found-icon {
font-size: 70px;
margin-bottom: 20px;
}
.share-not-found-title {
font-size: 20px;
margin-bottom: 12px;
}
.share-not-found-message {
font-size: 14px;
}
/* 加载状态移动端优化 */
.loading {
padding: 30px 15px;
}
}
/* 超小屏幕优化 (手机竖屏) */
@media (max-width: 480px) {
.card {
padding: 15px;
}
.title {
font-size: 18px;
}
/* 文件网格更紧凑 */
.file-grid {
grid-template-columns: repeat(auto-fill, minmax(75px, 1fr));
gap: 10px;
}
.file-grid-icon {
font-size: 36px !important;
}
.file-grid-name {
font-size: 11px;
min-height: 31px;
max-height: 31px;
}
.file-grid-size {
font-size: 10px;
}
/* 单文件显示更紧凑 */
.single-file-icon {
font-size: 60px !important;
}
.single-file-name {
font-size: 14px;
}
.single-file-size {
font-size: 13px;
}
.single-file-download {
padding: 10px 24px;
font-size: 13px;
}
/* 视图切换按钮 */
.view-controls .btn {
padding: 6px 10px;
font-size: 12px;
}
}
</style>
</head>
<body>
<div id="app">
<div class="container">
<div class="card">
<div class="title">
<!-- 返回按钮:仅在查看单个文件详情且不是单文件分享时显示 -->
<button v-if="viewingFile && shareInfo.share_type !== 'file'" class="btn btn-secondary" @click="backToList" style="margin-right: 10px;">
<i class="fas fa-arrow-left"></i> 返回列表
</button>
<i class="fas fa-cloud"></i>
文件分享
</div>
<!-- 通用错误显示 -->
<div v-if="errorMessage && !needPassword && !verified && !shareNotFound" class="share-not-found">
<i class="fas fa-exclamation-circle share-not-found-icon" style="color: #dc3545;"></i>
<div class="share-not-found-title">访问失败</div>
<div class="share-not-found-message">{{ errorMessage }}</div>
</div>
<!-- 密码验证 -->
<div v-if="needPassword && !verified && !shareNotFound">
<div v-if="errorMessage" class="alert alert-error">{{ errorMessage }}</div>
<div class="form-group">
<label class="form-label">请输入分享密码</label>
<input type="password" class="form-input" v-model="password" @keyup.enter="verifyShare" placeholder="输入密码">
</div>
<button class="btn btn-primary" @click="verifyShare">
<i class="fas fa-unlock"></i> 验证
</button>
</div>
<!-- 分享不存在 -->
<div v-else-if="shareNotFound" class="share-not-found">
<i class="fas fa-inbox share-not-found-icon"></i>
<div class="share-not-found-title">来晚了~</div>
<div class="share-not-found-message">
分享的内容已经取消了<br>
或者该分享链接已过期
</div>
</div>
<!-- 文件列表 -->
<div v-else-if="verified">
<p style="color: #666; margin-bottom: 20px;">
分享者: <strong>{{ shareInfo.username }}</strong> |
创建时间: {{ formatDate(shareInfo.created_at) }}
</p>
<!-- 视图切换按钮 (多文件时才显示) -->
<div v-if="files.length > 1" class="view-controls">
<button class="btn" :class="viewMode === 'grid' ? 'btn-primary' : 'btn-secondary'" @click="viewMode = 'grid'">
<i class="fas fa-th-large"></i> 大图标
</button>
<button class="btn" :class="viewMode === 'list' ? 'btn-primary' : 'btn-secondary'" @click="viewMode = 'list'">
<i class="fas fa-list"></i> 列表
</button>
</div>
<div v-if="loading" class="loading">
<div class="spinner"></div>
<p>加载中...</p>
</div>
<!-- 大图标视图 - 单文件居中显示 -->
<!-- 单文件居中显示 -->
<div v-else-if="viewingFile || files.length === 1" class="single-file-container">
<div class="single-file-card">
<i class="single-file-icon fas" :class="getFileIcon(viewingFile || files[0])" :style="getIconColor(viewingFile || files[0])"></i>
<div class="single-file-name">{{ (viewingFile || files[0]).name }}</div>
<div class="single-file-size">{{ (viewingFile || files[0]).sizeFormatted }}</div>
<button v-if="!(viewingFile || files[0]).isDirectory" class="btn single-file-download" @click="downloadFile(viewingFile || files[0])">
<i class="fas fa-download"></i> 下载文件
</button>
</div>
</div>
<!-- 大图标视图 - 多文件网格显示 -->
<div v-else-if="!viewingFile && viewMode === 'grid'" class="file-grid">
<div v-for="file in files" :key="file.name" class="file-grid-item"
@click="handleFileClick(file)"
@contextmenu="showFileContextMenu($event, file)"
@touchstart="startLongPress($event, file)"
@touchend="cancelLongPress"
@touchmove="cancelLongPress">
<i class="file-grid-icon fas" :class="getFileIcon(file)" :style="getIconColor(file)"></i>
<div class="file-grid-name" :title="file.name">{{ file.name }}</div>
<div class="file-grid-size">{{ file.sizeFormatted }}</div>
<button v-if="!file.isDirectory" class="btn btn-primary" @click.stop="downloadFile(file)" style="width: 100%;">
<i class="fas fa-download"></i> 下载
</button>
</div>
</div>
<!-- 列表视图 -->
<ul v-else-if="!viewingFile" class="file-list">
<li v-for="file in files" :key="file.name" class="file-item">
<div class="file-info">
<i class="file-icon fas" :class="getFileIcon(file)" :style="getIconColor(file)"></i>
<div>
<div style="font-weight: 500;">{{ file.name }}</div>
<div style="font-size: 12px; color: #999;">{{ file.sizeFormatted }}</div>
</div>
</div>
<button v-if="!file.isDirectory" class="btn btn-primary" @click.stop="downloadFile(file)">
<i class="fas fa-download"></i> 下载
</button>
</li>
</ul>
<p v-if="files.length === 0" style="text-align: center; color: #999; padding: 40px;">
暂无文件
</p>
</div>
<!-- 加载中 -->
<div v-else-if="loading" class="loading">
<div class="spinner"></div>
<p>加载中...</p>
</div>
</div>
</div>
</div>
<script>
const { createApp } = Vue;
createApp({
data() {
return {
// API配置 - 通过nginx代理访问
apiBase: window.location.protocol + '//' + window.location.host,
shareCode: '',
password: '',
needPassword: false,
verified: false,
shareNotFound: false,
shareInfo: null,
files: [],
loading: true,
errorMessage: '',
viewMode: "grid", // 视图模式: grid 大图标, list 列表(默认大图标)
// 媒体预览
showImageViewer: false,
showVideoPlayer: false,
showAudioPlayer: false,
currentMediaUrl: '',
currentMediaName: '',
currentMediaType: '', // 'image', 'video', 'audio'
// 右键菜单
showContextMenu: false,
contextMenuX: 0,
contextMenuY: 0,
contextMenuFile: null,
// 长按支持(移动端)
longPressTimer: null,
longPressFile: null,
// 查看单个文件详情(用于多文件分享时点击查看)
viewingFile: null
};
},
methods: {
async init() {
const urlParams = new URLSearchParams(window.location.search);
this.shareCode = urlParams.get('code');
if (!this.shareCode) {
this.errorMessage = '无效的分享链接';
this.loading = false;
return;
}
// 尝试验证分享
await this.verifyShare();
},
async verifyShare() {
this.errorMessage = '';
this.loading = true;
try {
const response = await axios.post(`${this.apiBase}/api/share/${this.shareCode}/verify`, {
password: this.password
});
if (response.data.success) {
this.verified = true;
this.shareInfo = response.data.share;
// 如果是单文件分享且后端已返回文件信息,直接使用,无需再次请求
if (response.data.file) {
this.files = [response.data.file];
this.loading = false;
} else {
// 目录分享,需要加载文件列表
await this.loadFiles();
}
}
} catch (error) {
// 404错误 - 分享不存在
if (error.response?.status === 404) {
this.shareNotFound = true;
this.loading = false;
}
// 需要密码
else if (error.response?.data?.needPassword) {
this.needPassword = true;
this.loading = false;
}
// 其他错误
else {
this.errorMessage = error.response?.data?.message || '验证失败';
this.loading = false;
}
}
},
async loadFiles() {
try {
const response = await axios.post(`${this.apiBase}/api/share/${this.shareCode}/list`, {
password: this.password,
path: ''
});
if (response.data.success) {
this.files = response.data.items;
}
} catch (error) {
console.error('加载文件失败:', error);
this.errorMessage = '加载文件失败';
} finally {
this.loading = false;
}
},
// 处理文件点击 - 可预览的文件打开预览,其他文件查看详情
handleFileClick(file) {
// 如果是图片/视频/音频,打开媒体预览
const isImage = /\.(jpg|jpeg|png|gif|bmp|webp|svg)$/i.test(file.name);
const isVideo = /\.(mp4|webm|ogg|mov)$/i.test(file.name);
const isAudio = /\.(mp3|wav|ogg|m4a|flac)$/i.test(file.name);
if (isImage || isVideo || isAudio) {
this.previewMedia(file);
} else {
// 其他文件类型,显示详情页面
this.viewFileDetail(file);
}
},
// 查看文件详情(放大显示)
viewFileDetail(file) {
this.viewingFile = file;
},
// 返回文件列表
backToList() {
this.viewingFile = null;
},
downloadFile(file) {
console.log("[分享下载] 文件:", file);
// 记录下载次数(异步,不等待)
axios.post(`${this.apiBase}/api/share/${this.shareCode}/download`)
.catch(err => console.error('记录下载次数失败:', err));
if (file.httpDownloadUrl) {
// 如果配置了HTTP下载URL使用HTTP直接下载
console.log("[分享下载] 使用HTTP下载:", file.httpDownloadUrl);
window.open(file.httpDownloadUrl, '_blank');
} else {
// 如果没有配置HTTP URL通过后端SFTP下载
console.log("[分享下载] 使用SFTP下载");
// 构建文件路径
let filePath;
if (this.shareInfo.share_type === 'file') {
// 单文件分享,使用 share_path
filePath = this.shareInfo.share_path;
} else {
// 目录分享,组合路径
const basePath = this.shareInfo.share_path;
filePath = basePath === '/' ? `/${file.name}` : `${basePath}/${file.name}`;
}
// 使用分享下载API公开API不需要认证
let downloadUrl = `${this.apiBase}/api/share/${this.shareCode}/download-file?path=${encodeURIComponent(filePath)}`;
// 如果有密码,附加密码参数
if (this.password) {
downloadUrl += `&password=${encodeURIComponent(this.password)}`;
}
window.open(downloadUrl, '_blank');
}
},
getFileIcon(file) {
if (file.isDirectory) return 'fa-folder';
if (file.name.match(/\.(jpg|jpeg|png|gif|bmp|svg)$/i)) return 'fa-file-image';
if (file.name.match(/\.(mp4|avi|mov|wmv|flv|mkv)$/i)) return 'fa-file-video';
if (file.name.match(/\.(mp3|wav|flac|aac|ogg)$/i)) return 'fa-file-audio';
if (file.name.match(/\.(pdf)$/i)) return 'fa-file-pdf';
if (file.name.match(/\.(doc|docx)$/i)) return 'fa-file-word';
if (file.name.match(/\.(xls|xlsx)$/i)) return 'fa-file-excel';
if (file.name.match(/\.(zip|rar|7z|tar|gz)$/i)) return 'fa-file-archive';
return 'fa-file';
},
getIconColor(file) {
if (file.isDirectory) return 'color: #FFC107;';
if (file.name.match(/\.(jpg|jpeg|png|gif|bmp|svg)$/i)) return 'color: #4CAF50;';
if (file.name.match(/\.(mp4|avi|mov|wmv|flv|mkv)$/i)) return 'color: #9C27B0;';
if (file.name.match(/\.(mp3|wav|flac|aac|ogg)$/i)) return 'color: #FF5722;';
if (file.name.match(/\.(pdf)$/i)) return 'color: #F44336;';
if (file.name.match(/\.(doc|docx)$/i)) return 'color: #2196F3;';
if (file.name.match(/\.(xls|xlsx)$/i)) return 'color: #4CAF50;';
if (file.name.match(/\.(zip|rar|7z|tar|gz)$/i)) return 'color: #795548;';
return 'color: #9E9E9E;';
},
formatDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleString('zh-CN');
}
},
mounted() {
this.init();
}
}).mount('#app');
</script>
</body>
</html>