feat: 优化下载功能,支持路径编码和直接下载链接

This commit is contained in:
2025-11-15 14:19:07 +08:00
parent d0bd44c526
commit 9d5f753621
5 changed files with 181 additions and 63 deletions

View File

@@ -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";
} }

View File

@@ -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;

View File

@@ -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", [])

View File

@@ -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 %}

View File

@@ -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 %}