mirror of
https://github.com/RhenCloud/Cloud-Index.git
synced 2025-12-06 07:06:41 +08:00
feat: 优化下载功能,支持路径编码和直接下载链接
This commit is contained in:
@@ -12,7 +12,12 @@ function attachDownloadButtonListeners() {
|
|||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
const key = button.dataset.downloadKey;
|
const key = button.dataset.downloadKey;
|
||||||
const name = button.dataset.downloadName;
|
const name = button.dataset.downloadName;
|
||||||
downloadFile(`/download/${key}`, name);
|
// 对路径分段编码,保留路径分隔符,避免 # ? 等字符破坏 URL
|
||||||
|
const encoded = key
|
||||||
|
.split("/")
|
||||||
|
.map((seg) => encodeURIComponent(seg))
|
||||||
|
.join("/");
|
||||||
|
downloadFile(`/download/${encoded}`, name);
|
||||||
});
|
});
|
||||||
button.dataset.listenerAttached = "true";
|
button.dataset.listenerAttached = "true";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,31 +166,19 @@ function downloadFile(url, filename) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.startsWith("/download/")) {
|
if (url.startsWith("/download/") || url.startsWith("/file/")) {
|
||||||
fetch(url)
|
// 让浏览器原生跟随服务器重定向(OneDrive 直链/共享链接),避免 fetch 对 3xx 的处理差异
|
||||||
.then((response) => {
|
const link = document.createElement("a");
|
||||||
if (!response.ok) {
|
link.href = url;
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
link.target = "_blank";
|
||||||
}
|
link.rel = "noopener";
|
||||||
return response.blob();
|
|
||||||
})
|
|
||||||
.then((blob) => {
|
|
||||||
const link = document.createElement("a");
|
|
||||||
const blobUrl = URL.createObjectURL(blob);
|
|
||||||
link.href = blobUrl;
|
|
||||||
link.download = filename || "file";
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
URL.revokeObjectURL(blobUrl);
|
|
||||||
|
|
||||||
const statusDiv = updateStatus(`✓ 开始下载: ${filename || ""}`, "success");
|
document.body.appendChild(link);
|
||||||
hideStatusLater(statusDiv);
|
link.click();
|
||||||
})
|
document.body.removeChild(link);
|
||||||
.catch((error) => {
|
|
||||||
console.error("Download error:", error);
|
const statusDiv = updateStatus(`✓ 开始下载: ${filename || ""}`, "success");
|
||||||
updateStatus(`✗ 下载失败: ${error.message}`, "error");
|
hideStatusLater(statusDiv);
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = url;
|
link.href = url;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
@@ -37,6 +38,27 @@ class OnedriveStorage(BaseStorage):
|
|||||||
self.access_token = None
|
self.access_token = None
|
||||||
self._refresh_token()
|
self._refresh_token()
|
||||||
|
|
||||||
|
def _item_path_url(self, key: str, action: str | None = None) -> str:
|
||||||
|
"""根据 folder_item_id 构造基于路径的 DriveItem URL,可附带动作后缀。
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- action=None => ...:/path:
|
||||||
|
- action='content' => ...:/path:/content
|
||||||
|
- action='createLink' => ...:/path:/createLink
|
||||||
|
- action='thumbnails' => ...:/path:/thumbnails
|
||||||
|
"""
|
||||||
|
key = key.strip("/")
|
||||||
|
# 对路径进行 URL 编码,但保留路径分隔符 '/'
|
||||||
|
key_quoted = quote(key, safe="/")
|
||||||
|
base = (
|
||||||
|
f"{self.graph_api_url}/me/drive/root:/{key_quoted}:"
|
||||||
|
if self.folder_item_id == "root"
|
||||||
|
else f"{self.graph_api_url}/me/drive/items/{self.folder_item_id}:/{key_quoted}:"
|
||||||
|
)
|
||||||
|
if action:
|
||||||
|
return base + f"/{action}"
|
||||||
|
return base
|
||||||
|
|
||||||
def _refresh_token(self) -> None:
|
def _refresh_token(self) -> None:
|
||||||
"""刷新 OneDrive 访问令牌"""
|
"""刷新 OneDrive 访问令牌"""
|
||||||
configured_scopes = getattr(Config, "ONEDRIVE_SCOPES", None)
|
configured_scopes = getattr(Config, "ONEDRIVE_SCOPES", None)
|
||||||
@@ -190,10 +212,16 @@ class OnedriveStorage(BaseStorage):
|
|||||||
# 直接基于路径列出,避免先查 ID 再列出造成的额外往返
|
# 直接基于路径列出,避免先查 ID 再列出造成的额外往返
|
||||||
select = "$select=name,size,lastModifiedDateTime,id,folder,file"
|
select = "$select=name,size,lastModifiedDateTime,id,folder,file"
|
||||||
if prefix:
|
if prefix:
|
||||||
|
# 对前缀进行 URL 编码,保留路径分隔符
|
||||||
|
from urllib.parse import quote as _quote
|
||||||
|
|
||||||
|
quoted_prefix = _quote(prefix.strip("/"), safe="/")
|
||||||
if self.folder_item_id == "root":
|
if self.folder_item_id == "root":
|
||||||
url = f"{self.graph_api_url}/me/drive/root:/{prefix}:/children?{select}"
|
url = f"{self.graph_api_url}/me/drive/root:/{quoted_prefix}:/children?{select}"
|
||||||
else:
|
else:
|
||||||
url = f"{self.graph_api_url}/me/drive/items/{self.folder_item_id}:/{prefix}:/children?{select}"
|
url = (
|
||||||
|
f"{self.graph_api_url}/me/drive/items/{self.folder_item_id}:/{quoted_prefix}:/children?{select}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
if self.folder_item_id == "root":
|
if self.folder_item_id == "root":
|
||||||
url = f"{self.graph_api_url}/me/drive/root/children?{select}"
|
url = f"{self.graph_api_url}/me/drive/root/children?{select}"
|
||||||
@@ -258,9 +286,7 @@ class OnedriveStorage(BaseStorage):
|
|||||||
对象元数据
|
对象元数据
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
key = key.strip("/")
|
url = self._item_path_url(key)
|
||||||
url = f"{self.graph_api_url}/me/drive/items/{self.folder_item_id}:/{key}:"
|
|
||||||
|
|
||||||
response = self._api_request("GET", url)
|
response = self._api_request("GET", url)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
@@ -285,11 +311,7 @@ class OnedriveStorage(BaseStorage):
|
|||||||
包含对象内容的字典
|
包含对象内容的字典
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
key = key.strip("/")
|
url = self._item_path_url(key, "content")
|
||||||
|
|
||||||
# 获取下载 URL
|
|
||||||
url = f"{self.graph_api_url}/me/drive/items/{self.folder_item_id}:/{key}:/content"
|
|
||||||
|
|
||||||
response = self._api_request("GET", url)
|
response = self._api_request("GET", url)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
@@ -313,25 +335,61 @@ class OnedriveStorage(BaseStorage):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
expires = expires or Config.PRESIGNED_URL_EXPIRES
|
expires = expires or Config.PRESIGNED_URL_EXPIRES
|
||||||
key = key.strip("/")
|
url = self._item_path_url(key, "createLink")
|
||||||
|
|
||||||
# OneDrive 会自动处理预签名 URL
|
body = {
|
||||||
url = f"{self.graph_api_url}/me/drive/items/{self.folder_item_id}:/{key}:/createLink"
|
|
||||||
|
|
||||||
request_body = {
|
|
||||||
"type": "view",
|
"type": "view",
|
||||||
|
"scope": "anonymous",
|
||||||
"expirationDateTime": (datetime.utcnow() + timedelta(seconds=expires)).isoformat() + "Z",
|
"expirationDateTime": (datetime.utcnow() + timedelta(seconds=expires)).isoformat() + "Z",
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.post(url, headers=self._headers(), json=request_body)
|
response = requests.post(url, headers=self._headers(), json=body, timeout=15)
|
||||||
|
|
||||||
if response.status_code == 201:
|
if response.status_code in (200, 201):
|
||||||
return response.json().get("link", {}).get("webUrl")
|
data = response.json() or {}
|
||||||
|
link = data.get("link") or {}
|
||||||
|
web = link.get("webUrl")
|
||||||
|
if web:
|
||||||
|
# 强制下载提示(视 OneDrive 行为而定)
|
||||||
|
sep = "&" if "?" in web else "?"
|
||||||
|
return f"{web}{sep}download=1"
|
||||||
|
|
||||||
return None
|
return None
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _get_direct_download_url(self, key: str) -> Optional[str]:
|
||||||
|
"""从 DriveItem 元数据中获取临时直链(@microsoft.graph.downloadUrl)。"""
|
||||||
|
try:
|
||||||
|
url = self._item_path_url(key)
|
||||||
|
resp = self._api_request("GET", url)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json() or {}
|
||||||
|
# Graph 返回的预签名直链属性
|
||||||
|
return data.get("@microsoft.graph.downloadUrl") or data.get("@microsoft.graph.downloadurl")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
def generate_download_response(self, key: str) -> Dict[str, Any]:
|
||||||
|
"""优先返回 OneDrive 的临时直链(直接文件内容),避免跳转到预览页。"""
|
||||||
|
# 1) 直链(最佳体验:直接获取文件内容,不经过 OneDrive 预览页)
|
||||||
|
direct = self._get_direct_download_url(key)
|
||||||
|
if direct:
|
||||||
|
return {"type": "redirect", "url": direct}
|
||||||
|
|
||||||
|
# 2) 匿名分享链接(添加 download=1 提示下载)
|
||||||
|
presigned = self.generate_presigned_url(key)
|
||||||
|
if presigned:
|
||||||
|
return {"type": "redirect", "url": presigned}
|
||||||
|
|
||||||
|
# 3) 回退到 webUrl(可能需要登录)
|
||||||
|
public_url = self.get_public_url(key)
|
||||||
|
if public_url:
|
||||||
|
return {"type": "redirect", "url": public_url}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
def get_public_url(self, key: str) -> str:
|
def get_public_url(self, key: str) -> str:
|
||||||
"""
|
"""
|
||||||
生成对象的公共访问 URL
|
生成对象的公共访问 URL
|
||||||
@@ -343,13 +401,11 @@ class OnedriveStorage(BaseStorage):
|
|||||||
公共 URL,未配置返回 None
|
公共 URL,未配置返回 None
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
key = key.strip("/")
|
# 直接获取 DriveItem 元数据并读取 webUrl 字段
|
||||||
# OneDrive 的共享 URL
|
url = self._item_path_url(key)
|
||||||
url = f"{self.graph_api_url}/me/drive/items/{self.folder_item_id}:/{key}:/webUrl"
|
response = self._api_request("GET", url)
|
||||||
|
|
||||||
response = requests.get(url, headers=self._headers())
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
return response.json().get("webUrl")
|
return (response.json() or {}).get("webUrl")
|
||||||
|
|
||||||
return None
|
return None
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -368,16 +424,15 @@ class OnedriveStorage(BaseStorage):
|
|||||||
上传成功返回 True,失败返回 False
|
上传成功返回 True,失败返回 False
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
key = key.strip("/")
|
url = self._item_path_url(key, "content")
|
||||||
url = f"{self.graph_api_url}/me/drive/items/{self.folder_item_id}:/{key}:/content"
|
|
||||||
|
|
||||||
# 自定义头部(因为我们需要指定 Content-Type)
|
# 自定义头部(需要指定 Content-Type)
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"Bearer {self.access_token}",
|
"Authorization": f"Bearer {self.access_token}",
|
||||||
"Content-Type": content_type or "application/octet-stream",
|
"Content-Type": content_type or "application/octet-stream",
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.put(url, headers=headers, data=file_data)
|
response = requests.put(url, headers=headers, data=file_data, timeout=30)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
return response.status_code == 200 or response.status_code == 201
|
return response.status_code == 200 or response.status_code == 201
|
||||||
@@ -397,9 +452,7 @@ class OnedriveStorage(BaseStorage):
|
|||||||
删除成功返回 True,失败返回 False
|
删除成功返回 True,失败返回 False
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
key = key.strip("/")
|
url = self._item_path_url(key)
|
||||||
url = f"{self.graph_api_url}/me/drive/items/{self.folder_item_id}:/{key}:"
|
|
||||||
|
|
||||||
response = self._api_request("DELETE", url)
|
response = self._api_request("DELETE", url)
|
||||||
return response.status_code == 204
|
return response.status_code == 204
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -451,12 +504,8 @@ class OnedriveStorage(BaseStorage):
|
|||||||
缩略图字节数据
|
缩略图字节数据
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
file_path = file_path.strip("/")
|
url = self._item_path_url(file_path, "thumbnails")
|
||||||
|
response = requests.get(url, headers=self._headers(), timeout=15)
|
||||||
# 获取缩略图 URL
|
|
||||||
url = f"{self.graph_api_url}/me/drive/items/{self.folder_item_id}:/{file_path}:/thumbnails"
|
|
||||||
|
|
||||||
response = requests.get(url, headers=self._headers())
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
thumbnails = response.json().get("value", [])
|
thumbnails = response.json().get("value", [])
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>{% block title %}Cloud Index{% endblock %}</title>
|
<title>{% block title %}Cloud Index{% endblock %}</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}" />
|
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}" />
|
||||||
|
<!-- 资源性能优化:预连接第三方 CDN,减少 TLS 握手耗时 -->
|
||||||
|
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin />
|
||||||
|
<link rel="dns-prefetch" href="//cdnjs.cloudflare.com" />
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css" />
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css" />
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/index.css') }}" />
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/index.css') }}" />
|
||||||
{% block head_extra %}{% endblock %}
|
{% block head_extra %}{% endblock %}
|
||||||
|
|||||||
@@ -195,7 +195,10 @@ current_prefix }}"{% endblock %} {% block content %}
|
|||||||
<div class="grid-thumb" onclick="openPreview('{{ entry.file_url }}', '{{ entry.name }}')">
|
<div class="grid-thumb" onclick="openPreview('{{ entry.file_url }}', '{{ entry.name }}')">
|
||||||
<img
|
<img
|
||||||
style="width: 100%; height: 100%; object-fit: cover; border-radius: 6px"
|
style="width: 100%; height: 100%; object-fit: cover; border-radius: 6px"
|
||||||
src="{{ entry.file_url }}"
|
src="/thumb/{{ entry.key }}"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
fetchpriority="low"
|
||||||
alt="{{ entry.name }}"
|
alt="{{ entry.name }}"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -281,4 +284,74 @@ current_prefix }}"{% endblock %} {% block content %}
|
|||||||
<div id="previewInfo" class="preview-info"></div>
|
<div id="previewInfo" class="preview-info"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endblock %} {% block scripts %}
|
||||||
|
<script>
|
||||||
|
// 客户端分页:初始只渲染部分条目,点击“加载更多”逐步显示,减少首屏 DOM 与渲染压力
|
||||||
|
(function () {
|
||||||
|
const PAGE_CHUNK = 200; // 每次显示的条目数(表格与网格分别按该步长展开)
|
||||||
|
function hideBeyond(nodeList) {
|
||||||
|
let hidden = 0;
|
||||||
|
for (let i = PAGE_CHUNK; i < nodeList.length; i++) {
|
||||||
|
const el = nodeList[i];
|
||||||
|
if (!el.hasAttribute("hidden")) {
|
||||||
|
el.setAttribute("hidden", "");
|
||||||
|
hidden++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hidden;
|
||||||
|
}
|
||||||
|
function revealNext(nodeList, count) {
|
||||||
|
let revealed = 0;
|
||||||
|
for (let i = 0; i < nodeList.length && revealed < count; i++) {
|
||||||
|
const el = nodeList[i];
|
||||||
|
if (el.hasAttribute("hidden")) {
|
||||||
|
el.removeAttribute("hidden");
|
||||||
|
revealed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return revealed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupLoadMore() {
|
||||||
|
const tableRows = document.querySelectorAll("table.files-table tbody tr");
|
||||||
|
const gridCards = document.querySelectorAll("#gridContainer .grid-card");
|
||||||
|
let hiddenCount = 0;
|
||||||
|
hiddenCount += hideBeyond(tableRows);
|
||||||
|
hiddenCount += hideBeyond(gridCards);
|
||||||
|
|
||||||
|
if (hiddenCount === 0) return;
|
||||||
|
|
||||||
|
// 创建“加载更多”按钮
|
||||||
|
const moreWrap = document.createElement("div");
|
||||||
|
moreWrap.style.display = "flex";
|
||||||
|
moreWrap.style.justifyContent = "center";
|
||||||
|
moreWrap.style.margin = "16px 0 24px";
|
||||||
|
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.className = "view-toggle";
|
||||||
|
btn.type = "button";
|
||||||
|
const info = document.createElement("span");
|
||||||
|
info.style.marginLeft = "8px";
|
||||||
|
info.textContent = hiddenCount;
|
||||||
|
btn.textContent = "加载更多";
|
||||||
|
btn.appendChild(info);
|
||||||
|
moreWrap.appendChild(btn);
|
||||||
|
|
||||||
|
const container = document.querySelector(".container");
|
||||||
|
container && container.appendChild(moreWrap);
|
||||||
|
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
const shown = revealNext(tableRows, PAGE_CHUNK) + revealNext(gridCards, PAGE_CHUNK);
|
||||||
|
hiddenCount = Math.max(0, hiddenCount - shown);
|
||||||
|
if (hiddenCount <= 0) {
|
||||||
|
moreWrap.remove();
|
||||||
|
} else {
|
||||||
|
info.textContent = hiddenCount;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", setupLoadMore);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user