Deploy v3.9.2 unified download manager
Browse files- README.md +1 -1
- VERSION +1 -1
- routes/downloads.py +102 -0
- static/css/app.css +20 -0
- static/js/app.js +193 -22
- static/js/project-package.js +8 -1
- tests/test_download_progress_v391.py +27 -0
- tests/test_history_sidebar_v373.py +1 -1
- tests/test_p0_finish_v380.py +1 -1
- tests/test_p0_phase2_v371.py +1 -1
- tests/test_p0_phase3_v372.py +1 -1
- tests/test_quality_center_v362.py +1 -1
- tests/test_release_stability.py +1 -1
- tests/test_runtime_hotfix_v376.py +1 -1
- tests/test_sidebar_collapse_v374.py +1 -1
- tests/test_sidebar_partial_collapse_v375.py +1 -1
- tests/test_unified_download_manager_v392.py +38 -0
- tests/test_unified_query_v390.py +3 -2
- tests/test_v381_navigation_ocean_intent.py +1 -1
- ui_server.py +2 -0
README.md
CHANGED
|
@@ -9,7 +9,7 @@ app_port: 7860
|
|
| 9 |
|
| 10 |
# Global Marine Foundation Data Agent
|
| 11 |
|
| 12 |
-
Current UI release: **v3.9.
|
| 13 |
|
| 14 |
|
| 15 |
## v3.4.0 稳定性重构(第一阶段)
|
|
|
|
| 9 |
|
| 10 |
# Global Marine Foundation Data Agent
|
| 11 |
|
| 12 |
+
Current UI release: **v3.9.2**.
|
| 13 |
|
| 14 |
|
| 15 |
## v3.4.0 稳定性重构(第一阶段)
|
VERSION
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
3.9.
|
|
|
|
| 1 |
+
3.9.2
|
routes/downloads.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from urllib.parse import urlparse
|
| 5 |
+
|
| 6 |
+
import httpx
|
| 7 |
+
from fastapi import HTTPException, Request
|
| 8 |
+
from fastapi.responses import StreamingResponse
|
| 9 |
+
|
| 10 |
+
def _inject(context):
|
| 11 |
+
globals().update(context)
|
| 12 |
+
|
| 13 |
+
def _host_allowed(host: str) -> bool:
|
| 14 |
+
host=(host or "").lower().strip(".")
|
| 15 |
+
if not host:
|
| 16 |
+
return False
|
| 17 |
+
|
| 18 |
+
dynamic=set()
|
| 19 |
+
for value in (MARINE_API_URL, MEMORY_API_URL):
|
| 20 |
+
try:
|
| 21 |
+
parsed=urlparse(value)
|
| 22 |
+
if parsed.hostname:
|
| 23 |
+
dynamic.add(parsed.hostname.lower())
|
| 24 |
+
except Exception:
|
| 25 |
+
pass
|
| 26 |
+
|
| 27 |
+
extra={
|
| 28 |
+
x.strip().lower()
|
| 29 |
+
for x in os.environ.get("DOWNLOAD_PROXY_EXTRA_HOSTS","").split(",")
|
| 30 |
+
if x.strip()
|
| 31 |
+
}
|
| 32 |
+
if host in dynamic or host in extra:
|
| 33 |
+
return True
|
| 34 |
+
|
| 35 |
+
trusted_suffixes=(
|
| 36 |
+
"huggingface.co",
|
| 37 |
+
"hf.co",
|
| 38 |
+
"xethub.hf.co",
|
| 39 |
+
)
|
| 40 |
+
return any(host==suffix or host.endswith("."+suffix) for suffix in trusted_suffixes)
|
| 41 |
+
|
| 42 |
+
async def proxy_download(request: Request):
|
| 43 |
+
await resolve_request_user(request)
|
| 44 |
+
url=str(request.query_params.get("url") or "").strip()
|
| 45 |
+
if not url:
|
| 46 |
+
raise HTTPException(400,"missing url")
|
| 47 |
+
|
| 48 |
+
try:
|
| 49 |
+
parsed=urlparse(url)
|
| 50 |
+
except Exception as exc:
|
| 51 |
+
raise HTTPException(400,"invalid url") from exc
|
| 52 |
+
|
| 53 |
+
if parsed.scheme not in {"http","https"}:
|
| 54 |
+
raise HTTPException(400,"unsupported url scheme")
|
| 55 |
+
if not _host_allowed(parsed.hostname or ""):
|
| 56 |
+
raise HTTPException(403,"external download host is not allowlisted")
|
| 57 |
+
|
| 58 |
+
client=httpx.AsyncClient(
|
| 59 |
+
follow_redirects=True,
|
| 60 |
+
timeout=httpx.Timeout(connect=15.0,read=None,write=30.0,pool=15.0),
|
| 61 |
+
)
|
| 62 |
+
try:
|
| 63 |
+
upstream=await client.send(
|
| 64 |
+
client.build_request("GET",url,headers={"User-Agent":"Global-Marine-Data-Agent/3.9.2"}),
|
| 65 |
+
stream=True,
|
| 66 |
+
)
|
| 67 |
+
except Exception:
|
| 68 |
+
await client.aclose()
|
| 69 |
+
raise
|
| 70 |
+
|
| 71 |
+
if upstream.status_code>=400:
|
| 72 |
+
body=await upstream.aread()
|
| 73 |
+
await upstream.aclose()
|
| 74 |
+
await client.aclose()
|
| 75 |
+
raise HTTPException(upstream.status_code,body[:500].decode("utf-8","replace"))
|
| 76 |
+
|
| 77 |
+
async def body_iter():
|
| 78 |
+
try:
|
| 79 |
+
async for chunk in upstream.aiter_bytes():
|
| 80 |
+
if chunk:
|
| 81 |
+
yield chunk
|
| 82 |
+
finally:
|
| 83 |
+
await upstream.aclose()
|
| 84 |
+
await client.aclose()
|
| 85 |
+
|
| 86 |
+
headers={}
|
| 87 |
+
for key in ("content-length","content-type","content-disposition","etag","last-modified"):
|
| 88 |
+
value=upstream.headers.get(key)
|
| 89 |
+
if value:
|
| 90 |
+
headers[key]=value
|
| 91 |
+
headers["cache-control"]="no-store"
|
| 92 |
+
headers["x-download-proxied"]="1"
|
| 93 |
+
|
| 94 |
+
return StreamingResponse(
|
| 95 |
+
body_iter(),
|
| 96 |
+
media_type=upstream.headers.get("content-type","application/octet-stream"),
|
| 97 |
+
headers=headers,
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
def register_download_routes(app, context):
|
| 101 |
+
_inject(context)
|
| 102 |
+
app.add_api_route("/api/download/proxy",proxy_download,methods=["GET"])
|
static/css/app.css
CHANGED
|
@@ -664,3 +664,23 @@ body.sidebar-tools-collapsed .primary-tools{
|
|
| 664 |
.uq-row span{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#9fc0d8;font-size:11.5px}
|
| 665 |
.uq-row small{color:#7191aa}
|
| 666 |
@media(max-width:900px){.uq-plan-grid{grid-template-columns:1fr 1fr}.uq-row{grid-template-columns:1fr}.uq-row span{white-space:normal}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 664 |
.uq-row span{min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#9fc0d8;font-size:11.5px}
|
| 665 |
.uq-row small{color:#7191aa}
|
| 666 |
@media(max-width:900px){.uq-plan-grid{grid-template-columns:1fr 1fr}.uq-row{grid-template-columns:1fr}.uq-row span{white-space:normal}}
|
| 667 |
+
|
| 668 |
+
|
| 669 |
+
.download-progress-overlay{position:fixed;inset:0;z-index:9999;display:none;align-items:flex-end;justify-content:flex-end;padding:22px;pointer-events:none}
|
| 670 |
+
.download-progress-overlay.show{display:flex}
|
| 671 |
+
.download-progress-card{width:min(430px,calc(100vw - 28px));pointer-events:auto;border:1px solid rgba(103,180,238,.25);border-radius:16px;padding:14px 15px;background:rgba(3,22,40,.96);box-shadow:0 18px 50px rgba(0,0,0,.38);backdrop-filter:blur(16px)}
|
| 672 |
+
.download-progress-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}
|
| 673 |
+
.download-progress-head b{display:block;font-size:13px}
|
| 674 |
+
.download-progress-head span{display:block;color:var(--muted);font-size:11px;margin-top:4px}
|
| 675 |
+
.download-progress-close{border:0;background:transparent;color:#8eacc2;cursor:pointer;font-size:20px;line-height:1}
|
| 676 |
+
.download-progress-track{height:9px;margin:13px 0 9px;border-radius:999px;overflow:hidden;background:rgba(255,255,255,.08)}
|
| 677 |
+
.download-progress-bar{height:100%;width:0;border-radius:999px;background:linear-gradient(90deg,#398cff,#62d9ff);transition:width .18s linear}
|
| 678 |
+
.download-progress-bar.indeterminate{width:36%;animation:download-indeterminate 1.2s ease-in-out infinite}
|
| 679 |
+
.download-progress-bar.done{background:linear-gradient(90deg,#25b978,#6ce0aa)}
|
| 680 |
+
.download-progress-bar.error{width:100%;background:linear-gradient(90deg,#c7465d,#ff8c9d)}
|
| 681 |
+
.download-progress-meta{display:grid;grid-template-columns:1fr auto auto;gap:12px;font-size:10.5px;color:#9dbbd0}
|
| 682 |
+
.download-progress-hint{margin-top:9px;color:#7898af;font-size:10.5px;line-height:1.5}
|
| 683 |
+
.download-progress-actions{display:flex;justify-content:flex-end;margin-top:10px}
|
| 684 |
+
@keyframes download-indeterminate{0%{transform:translateX(-120%)}50%{transform:translateX(120%)}100%{transform:translateX(280%)}}
|
| 685 |
+
|
| 686 |
+
.download-progress-source{font-size:10px;color:#6f8ca1;margin-top:5px}
|
static/js/app.js
CHANGED
|
@@ -126,8 +126,7 @@ async function pushWorkspaceToServer(){if(!AUTH_ENABLED||!AUTH_TOKEN)return;WORK
|
|
| 126 |
async function syncWorkspaceFromServer(){if(!AUTH_ENABLED||!AUTH_TOKEN){WORKSPACE_SYNC={enabled:false,storage_mode:"local",status:"local"};return}try{const localSessions=loadSessions(),localSettings=loadSettings();const data=await j("/api/sidebar/workspace?user_id="+encodeURIComponent(USER_ID));S=mergeSessions(data.sessions||[],localSessions);SETTINGS={...SETTINGS,...localSettings,...(data.settings||{})};localStorage.setItem(K,JSON.stringify(S.slice(0,50)));localStorage.setItem(scopedKey(SETTINGS_BASE_K),JSON.stringify(SETTINGS));WORKSPACE_SYNC={enabled:!!data.sync_enabled,storage_mode:data.storage_mode||"server_session",status:"synced"};if(localSessions.length||Object.keys(localSettings||{}).length)await pushWorkspaceToServer()}catch(err){WORKSPACE_SYNC={enabled:false,storage_mode:"local",status:"error"};console.warn("workspace sync failed",err)}}
|
| 127 |
function exportWorkspaceBackup(){
|
| 128 |
const payload={schema:"squid-workspace-backup",schema_version:1,app_version:APP_VERSION,exported_at:new Date().toISOString(),sessions:S.slice(0,50),favorites:FAVORITES.slice(0,100),settings:SETTINGS};
|
| 129 |
-
const blob=new Blob([JSON.stringify(payload,null,2)],{type:"application/json;charset=utf-8"}),
|
| 130 |
-
a.href=url;a.download="squid_workspace_backup_"+new Date().toISOString().slice(0,10)+".json";a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);toast("本地工作区备份已导出")
|
| 131 |
}
|
| 132 |
async function importWorkspaceBackup(file){
|
| 133 |
if(!file)return;if(file.size>5*1024*1024)throw Error("备份文件不能超过 5 MB");
|
|
@@ -822,22 +821,22 @@ async function openDatasetComparison(items){
|
|
| 822 |
if(items.length<2){toast("请先选择至少 2 个数据集");return}
|
| 823 |
document.querySelector(".drawer-backdrop")?.remove();const wrap=document.createElement("div");wrap.className="drawer-backdrop";
|
| 824 |
wrap.innerHTML='<aside class="detail-drawer" role="dialog" aria-modal="true" aria-label="数据集对比"><div class="drawer-head"><div><h2>数据集对比</h2><p>正在读取 '+items.length+' 个数据源的最新详情</p></div><div class="view-actions"><button class="view-btn compare-export" disabled>↓ 导出 CSV</button><button class="drawer-close" aria-label="关闭">×</button></div></div><div class="comparison-live"><div class="loading">正在生成对比…</div></div></aside>';document.body.appendChild(wrap);const close=()=>wrap.remove();wrap.querySelector(".drawer-close").onclick=close;wrap.onclick=e=>{if(e.target===wrap)close()};
|
| 825 |
-
try{const details=await Promise.all(items.map(x=>fetchDatasetDetail(x).catch(()=>x)));if(!wrap.isConnected)return;const rows=[{label:"接入状态",value:x=>datasetStatusLabel(x.status)},{label:"元数据完整度",value:x=>(x.metadata_completeness?.score??0)+"%("+(x.metadata_completeness?.present_count??0)+" / "+(x.metadata_completeness?.expected_count??7)+")"},{label:"数据平面",value:x=>x.metadata?.data_plane||x.group},{label:"变量",value:x=>(x.variables||[]).map(v=>v+((x.variable_labels||{})[v]?" / "+x.variable_labels[v]:"")).join("、")||"读取具体文件确认"},{label:"匹配文件",value:x=>x.metadata?.file_count||"不适用 / 未返回"},{label:"数据量",value:x=>x.metadata?.total_size||"未返回"},{label:"时间范围",value:x=>x.metadata?.time_range||"实时接口未提供"},{label:"时间分辨率",value:x=>x.metadata?.temporal_resolution||"实时接口未提供"},{label:"空间分辨率",value:x=>x.metadata?.spatial_resolution||"实时接口未提供"},{label:"数据形态",value:x=>x.reference?.data_shape||"读取具体文件确认"},{label:"详情来源",value:x=>(x.provenance||[]).join(";")||"当前卡片摘要"}];wrap.querySelector(".comparison-live").innerHTML='<div class="compare-wrap"><table class="compare-table"><thead><tr><th>对比项</th>'+details.map(x=>'<th>'+esc(x.name)+'<br><span class="dataset-zh">'+esc(x.name_zh||"")+'</span></th>').join("")+'</tr></thead><tbody>'+rows.map(row=>'<tr><td>'+esc(row.label)+'</td>'+details.map(x=>'<td>'+esc(datasetMetaValue(row.value(x)))+'</td>').join("")+'</tr>').join("")+'</tbody></table></div>';const exportBtn=wrap.querySelector(".compare-export");exportBtn.disabled=false;exportBtn.onclick=()=>{const csvRows=[["对比项",...details.map(x=>x.name+(x.name_zh?" · "+x.name_zh:""))],...rows.map(row=>[row.label,...details.map(x=>datasetMetaValue(row.value(x)))])],csv=csvRows.map(row=>row.map(v=>'"'+String(v??"").replace(/"/g,'""')+'"').join(",")).join("\r\n"),blob=new Blob(["\ufeff"+csv],{type:"text/csv;charset=utf-8"})
|
| 826 |
}
|
| 827 |
function exportDatasetInventory(items,updatedAt){
|
| 828 |
-
const rows=[["数据平面","分类","英文名称","中文名称","接入状态","匹配文件数","匹配数据量(B)","变量","匹配依据","路径示例","盘点时间"],...items.map(x=>[x.group,x.category_zh||"海洋环境",x.name,x.name_zh||"",datasetStatusLabel(x.status),x.file_count??"",x.size_bytes??"",(x.variables||[]).join(";"),x.metadata?.matching_rule||"",(x.examples||[]).join(";"),updatedAt||""])],csv=rows.map(row=>row.map(v=>'"'+String(v??"").replace(/"/g,'""')+'"').join(",")).join("\r\n"),blob=new Blob(["\ufeff"+csv],{type:"text/csv;charset=utf-8"})
|
| 829 |
}
|
| 830 |
function openDatasetQuality(){
|
| 831 |
document.querySelector(".drawer-backdrop")?.remove();const wrap=document.createElement("div");wrap.className="drawer-backdrop";
|
| 832 |
wrap.innerHTML='<aside class="detail-drawer" role="dialog" aria-modal="true" aria-label="仓库体检"><div class="drawer-head"><div><h2>Hugging Face 仓库体检</h2><p>基于 main 分支实时文件树的轻量检查</p></div><button class="drawer-close" aria-label="关闭">×</button></div><div class="quality-live"><div class="loading">正在检查仓库清单…</div></div><div class="card-actions"><button class="view-btn quality-refresh">↻ 重新体检</button><button class="view-btn quality-export" disabled>↓ 导出体检 JSON</button></div></aside>';document.body.appendChild(wrap);const close=()=>wrap.remove();wrap.querySelector(".drawer-close").onclick=close;wrap.onclick=e=>{if(e.target===wrap)close()};
|
| 833 |
-
const load=async(refresh=false)=>{const box=wrap.querySelector(".quality-live"),exportBtn=wrap.querySelector(".quality-export");box.innerHTML='<div class="loading">正在检查仓库清单…</div>';exportBtn.disabled=true;try{const d=await j("/api/sidebar/datasets/quality"+(refresh?"?refresh=true":""));if(!wrap.isConnected)return;const stats=[["实际文件",d.file_count],["数据总量",formatBytes(d.total_size_bytes)],["零字节文件",d.zero_byte_count],["同名文件组",d.duplicate_basename_group_count],["未归类文件",d.unmapped_file_count],["≥1 GB 文件",d.large_file_count]],extensions=Object.entries(d.extension_counts||{}).map(([k,v])=>'<span class="tag">'+esc(k)+' · '+esc(v)+'</span>').join(""),findings=(d.findings||[]).map(x=>'<div class="finding '+esc(x.severity)+'"><b>'+esc(x.title)+'</b><p>'+esc(x.detail)+'</p></div>').join("")||'<div class="finding info"><b>未发现清单级异常</b><p>仍建议继续进行文件内部字段和缺失值质检。</p></div>',dups=(d.duplicate_groups||[]).map(x=>'<li><b>'+esc(x.basename)+'</b> · '+esc(x.count)+' 个路径<br>'+esc((x.paths||[]).join(";"))+'</li>').join(""),unmapped=(d.unmapped_files||[]).map(x=>'<li>'+esc(x)+'</li>').join(""),large=(d.large_files||[]).map(x=>'<li>'+esc(x.path)+' · '+esc(x.size)+'</li>').join(""),notes=(d.notes||[]).map(x=>'<li>'+esc(x)+'</li>').join("");box.innerHTML='<div class="row-meta">仓库:'+esc(d.repository)+'@'+esc(d.branch)+' · 检查时间:'+esc(formatTime(d.checked_at))+' · 文件树对象:'+esc(d.tree_object_count)+'</div><div class="meta-grid">'+stats.map(x=>'<div class="meta-item"><span>'+esc(x[0])+'</span><b>'+esc(x[1])+'</b></div>').join("")+'</div><div class="detail-section"><h3>检查结论</h3>'+findings+'</div><div class="detail-section"><h3>文件类型分布</h3><div class="tags">'+extensions+'</div></div>'+(dups?'<div class="detail-section"><h3>同名文件组(不等同于内容重复)</h3><ul class="quality-list">'+dups+'</ul></div>':'')+(unmapped?'<div class="detail-section"><h3>未归类文件预览</h3><ul class="quality-list">'+unmapped+'</ul></div>':'')+(large?'<div class="detail-section"><h3>超大文件预览</h3><ul class="quality-list">'+large+'</ul></div>':'')+'<div class="detail-section"><h3>解释说明</h3><ul class="path-list">'+notes+'</ul></div>';exportBtn.disabled=false;exportBtn.onclick=()=>{const blob=new Blob([JSON.stringify(d,null,2)],{type:"application/json;charset=utf-8"})
|
| 834 |
wrap.querySelector(".quality-refresh").onclick=()=>load(true);load()
|
| 835 |
}
|
| 836 |
function openMetadataAudit(){
|
| 837 |
document.querySelector(".drawer-backdrop")?.remove();const wrap=document.createElement("div");wrap.className="drawer-backdrop";
|
| 838 |
wrap.innerHTML='<aside class="detail-drawer" role="dialog" aria-modal="true" aria-label="元数据审计"><div class="drawer-head"><div><h2>数据集元数据审计</h2><p>核对核心盘点字段是否由实时接口或文件树提供</p></div><button class="drawer-close" aria-label="关闭">×</button></div><div class="metadata-audit-live"><div class="loading">正在审计元数据…</div></div><div class="card-actions"><button class="view-btn audit-refresh">↻ 重新审计</button><button class="view-btn audit-export" disabled>↓ 导出审计 CSV</button></div></aside>';
|
| 839 |
document.body.appendChild(wrap);const close=()=>wrap.remove();wrap.querySelector(".drawer-close").onclick=close;wrap.onclick=e=>{if(e.target===wrap)close()};
|
| 840 |
-
const load=async(refresh=false)=>{const box=wrap.querySelector(".metadata-audit-live"),exportBtn=wrap.querySelector(".audit-export");box.innerHTML='<div class="loading">正在审计元数据…</div>';exportBtn.disabled=true;try{const d=await j("/api/sidebar/datasets/metadata-audit"+(refresh?"?refresh=true":"")),summary=d.summary||{},records=d.records||[],labels=DATASET_META_LABELS,rows=records.map(x=>{const c=x.completeness||{},missing=(c.missing_fields||[]).map(v=>labels[v]||v).join("、")||"无",pending=(c.pending_fields||[]).map(v=>labels[v]||v).join("、")||"无";return '<tr><td>'+esc(x.group)+'</td><td><b>'+esc(x.name)+'</b><br><span class="dataset-zh">'+esc(x.name_zh||"")+'</span></td><td>'+esc(datasetStatusLabel(x.status))+'</td><td>'+esc(c.score??0)+'%<br><span class="row-meta">'+esc(c.present_count||0)+' / '+esc(c.expected_count||0)+'</span></td><td>'+esc(missing)+'</td><td>'+esc(pending)+'</td><td>'+esc(x.action||"")+'</td></tr>'}).join("");box.innerHTML='<div class="row-meta">审计时间:'+esc(formatTime(d.checked_at))+'</div><div class="summary-grid"><div class="summary-card"><strong>'+esc(summary.dataset_count||0)+'</strong><span>审计数据源</span></div><div class="summary-card"><strong>'+esc(summary.average_score||0)+'%</strong><span>可审计元数据平均完整度</span></div><div class="summary-card"><strong>'+esc(summary.incomplete_count||0)+'</strong><span>仍有可审计字段缺失</span></div></div><div class="compare-wrap"><table class="compare-table"><thead><tr><th>数据平面</th><th>数据源</th><th>状态</th><th>完整度</th><th>缺失字段</th><th>待文件级核验</th><th>建议动作</th></tr></thead><tbody>'+rows+'</tbody></table></div><div class="detail-note">'+esc((d.notes||[]).join(";"))+'</div>';exportBtn.disabled=false;exportBtn.onclick=()=>{const csvRows=[["数据平面","英文名称","中文名称","接入状态","可审计元数据完整度(%)","已返回字段","缺失字段","待文件级核验","建议动作","审计时间"],...records.map(x=>[x.group,x.name,x.name_zh||"",datasetStatusLabel(x.status),x.completeness?.score??0,(x.completeness?.present_fields||[]).map(v=>labels[v]||v).join(";"),(x.completeness?.missing_fields||[]).map(v=>labels[v]||v).join(";"),(x.completeness?.pending_fields||[]).map(v=>labels[v]||v).join(";"),x.action||"",d.checked_at||""])],csv=csvRows.map(row=>row.map(v=>'"'+String(v??"").replace(/"/g,'""')+'"').join(",")).join("\r\n"),blob=new Blob(["\ufeff"+csv],{type:"text/csv;charset=utf-8"})
|
| 841 |
wrap.querySelector(".audit-refresh").onclick=()=>load(true);load()
|
| 842 |
}
|
| 843 |
async function renderDatasets(force=false){
|
|
@@ -906,7 +905,7 @@ function openServiceDiagnostics(data,appState){
|
|
| 906 |
document.body.appendChild(wrap);const close=()=>wrap.remove();wrap.querySelector(".drawer-close").onclick=close;wrap.onclick=e=>{if(e.target===wrap)close()};
|
| 907 |
const payload={detected_at:data.updated_at,app_version:appState.app_version||null,diagnostic:d,marine:marine,huggingface:hf};
|
| 908 |
wrap.querySelector(".service-copy-report").onclick=async()=>{const text='服务诊断 '+(d.status||'-')+';健康 '+(d.healthy_count??'-')+'/'+(d.check_count??'-')+';平均响应 '+(d.average_response_ms??'-')+' ms;失败:'+((d.failed||[]).join('、')||'无')+';慢响应:'+((d.slow||[]).join('、')||'无');try{await navigator.clipboard.writeText(text);toast("诊断摘要已复制")}catch{toast("浏览器不允许自动复制")}};
|
| 909 |
-
wrap.querySelector(".service-download-report").onclick=()=>{const blob=new Blob([JSON.stringify(payload,null,2)],{type:"application/json;charset=utf-8"})
|
| 910 |
}
|
| 911 |
|
| 912 |
|
|
@@ -943,7 +942,7 @@ async function runUnifiedQueryPreview(){
|
|
| 943 |
async function renderServices(){
|
| 944 |
const root=$("workspaceInner");
|
| 945 |
root.innerHTML='<div class="view-head"><div><h1>数据服务</h1><p>统一查询 Ocean / Tuna / Squid,并实时检查底层数据服务状态。</p></div><button id="refreshServices" class="view-btn">↻ 立即检测</button></div>'+
|
| 946 |
-
'<div class="unified-query-card"><div><b>统一智能查询
|
| 947 |
'<div class="loading">正在检测服务…</div>';
|
| 948 |
$("refreshServices").onclick=renderServices;
|
| 949 |
if($("unifiedQueryRun"))$("unifiedQueryRun").onclick=runUnifiedQueryPreview;
|
|
@@ -960,7 +959,7 @@ async function renderServices(){
|
|
| 960 |
];
|
| 961 |
const diag='<div class="service-diagnostic"><div><span>综合状态</span><b>'+esc(d.status||"-")+'</b></div><div><span>健康检查</span><b>'+esc((d.healthy_count??"-")+" / "+(d.check_count??"-"))+'</b></div><div><span>平均响应</span><b>'+esc(d.average_response_ms==null?"-":d.average_response_ms+" ms")+'</b></div><div><span>慢响应</span><b>'+esc((d.slow||[]).length+" 项")+'</b></div></div><div class="service-actions"><button id="serviceDiagnostics" class="view-btn primary">查看诊断详情</button></div>';
|
| 962 |
root.innerHTML='<div class="view-head"><div><h1>数据服务 <span style="font-size:11px;opacity:.6">v'+esc(APP_VERSION)+'</span></h1><p>最后检测:'+esc(formatTime(data.updated_at))+' · '+esc(d.hint||"")+'</p></div><div style="display:flex;gap:8px;flex-wrap:wrap"><button id="serviceDiagnosticsTop" class="view-btn primary">查看诊断详情</button><button id="refreshServices" class="view-btn">↻ 立即检测</button></div></div>'+
|
| 963 |
-
'<div class="unified-query-card"><div><b>统一智能查询
|
| 964 |
$("refreshServices").onclick=renderServices;$("serviceDiagnostics").onclick=()=>openServiceDiagnostics(data,appState);$("serviceDiagnosticsTop").onclick=()=>openServiceDiagnostics(data,appState);if($("unifiedQueryRun"))$("unifiedQueryRun").onclick=runUnifiedQueryPreview;
|
| 965 |
}catch(err){
|
| 966 |
root.innerHTML='<div class="view-head"><div><h1>数据服务</h1><p>状态检测失败。</p></div><button id="refreshServices" class="view-btn">重试</button></div><div class="empty-view"><b>无法完成检测</b>'+esc(err.message||String(err))+'</div>';
|
|
@@ -979,8 +978,8 @@ function taskProgressHtml(task){
|
|
| 979 |
return '<div class="task-progress"><span style="width:100%"></span></div>'
|
| 980 |
}
|
| 981 |
function downloadTaskResult(task){
|
| 982 |
-
const blob=new Blob([JSON.stringify(task,null,2)],{type:"application/json;charset=utf-8"})
|
| 983 |
-
|
| 984 |
}
|
| 985 |
function taskResultHtml(result){
|
| 986 |
result=result||{};const blocks=[];
|
|
@@ -1173,20 +1172,193 @@ $("new").onclick=newConversation;
|
|
| 1173 |
$("clear").onclick=()=>{if(S.length&&confirm("清空全部最近对话?")){S=[];save();newConversation();hist();toast("最近对话已清空")}};
|
| 1174 |
$("refresh").onclick=()=>{status();toast("正在刷新服务状态")};
|
| 1175 |
if($("accountBtn"))$("accountBtn").onclick=e=>{e.stopPropagation();accountMenu()};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1176 |
document.addEventListener("click",e=>{
|
| 1177 |
-
const a=e.target.closest("a
|
| 1178 |
-
if(!a || !USER_ID)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1179 |
|
| 1180 |
if(SETTINGS.confirmDownload && !confirm("确认下载这个数据文件?")){
|
| 1181 |
e.preventDefault();
|
| 1182 |
return;
|
| 1183 |
}
|
|
|
|
| 1184 |
|
| 1185 |
-
let filename="";
|
| 1186 |
-
|
| 1187 |
-
const u=new URL(a.href);
|
| 1188 |
-
filename=(u.pathname.split("/").pop()||"").slice(0,180);
|
| 1189 |
-
}catch(_){}
|
| 1190 |
|
| 1191 |
fetch("/api/usage",{
|
| 1192 |
method:"POST",
|
|
@@ -1195,12 +1367,11 @@ document.addEventListener("click",e=>{
|
|
| 1195 |
body:JSON.stringify({
|
| 1196 |
user_id:USER_ID,
|
| 1197 |
event_type:"download_clicked",
|
| 1198 |
-
detail:{
|
| 1199 |
-
filename:filename,
|
| 1200 |
-
href:String(a.href||"").slice(0,1000)
|
| 1201 |
-
}
|
| 1202 |
})
|
| 1203 |
}).catch(()=>{});
|
|
|
|
|
|
|
| 1204 |
});
|
| 1205 |
|
| 1206 |
document.querySelectorAll(".domain-card").forEach(x=>{if(!x.getAttribute("onclick"))x.addEventListener("click",e=>{if(e.target.closest("button"))return;openDomainPanel(x.dataset.domain)})});
|
|
|
|
| 126 |
async function syncWorkspaceFromServer(){if(!AUTH_ENABLED||!AUTH_TOKEN){WORKSPACE_SYNC={enabled:false,storage_mode:"local",status:"local"};return}try{const localSessions=loadSessions(),localSettings=loadSettings();const data=await j("/api/sidebar/workspace?user_id="+encodeURIComponent(USER_ID));S=mergeSessions(data.sessions||[],localSessions);SETTINGS={...SETTINGS,...localSettings,...(data.settings||{})};localStorage.setItem(K,JSON.stringify(S.slice(0,50)));localStorage.setItem(scopedKey(SETTINGS_BASE_K),JSON.stringify(SETTINGS));WORKSPACE_SYNC={enabled:!!data.sync_enabled,storage_mode:data.storage_mode||"server_session",status:"synced"};if(localSessions.length||Object.keys(localSettings||{}).length)await pushWorkspaceToServer()}catch(err){WORKSPACE_SYNC={enabled:false,storage_mode:"local",status:"error"};console.warn("workspace sync failed",err)}}
|
| 127 |
function exportWorkspaceBackup(){
|
| 128 |
const payload={schema:"squid-workspace-backup",schema_version:1,app_version:APP_VERSION,exported_at:new Date().toISOString(),sessions:S.slice(0,50),favorites:FAVORITES.slice(0,100),settings:SETTINGS};
|
| 129 |
+
const blob=new Blob([JSON.stringify(payload,null,2)],{type:"application/json;charset=utf-8"});saveBlobWithProgress(blob,"squid_workspace_backup_"+new Date().toISOString().slice(0,10)+".json",{title:"正在导出工作区备份"});toast("本地工作区备份已导出")
|
|
|
|
| 130 |
}
|
| 131 |
async function importWorkspaceBackup(file){
|
| 132 |
if(!file)return;if(file.size>5*1024*1024)throw Error("备份文件不能超过 5 MB");
|
|
|
|
| 821 |
if(items.length<2){toast("请先选择至少 2 个数据集");return}
|
| 822 |
document.querySelector(".drawer-backdrop")?.remove();const wrap=document.createElement("div");wrap.className="drawer-backdrop";
|
| 823 |
wrap.innerHTML='<aside class="detail-drawer" role="dialog" aria-modal="true" aria-label="数据集对比"><div class="drawer-head"><div><h2>数据集对比</h2><p>正在读取 '+items.length+' 个数据源的最新详情</p></div><div class="view-actions"><button class="view-btn compare-export" disabled>↓ 导出 CSV</button><button class="drawer-close" aria-label="关闭">×</button></div></div><div class="comparison-live"><div class="loading">正在生成对比…</div></div></aside>';document.body.appendChild(wrap);const close=()=>wrap.remove();wrap.querySelector(".drawer-close").onclick=close;wrap.onclick=e=>{if(e.target===wrap)close()};
|
| 824 |
+
try{const details=await Promise.all(items.map(x=>fetchDatasetDetail(x).catch(()=>x)));if(!wrap.isConnected)return;const rows=[{label:"接入状态",value:x=>datasetStatusLabel(x.status)},{label:"元数据完整度",value:x=>(x.metadata_completeness?.score??0)+"%("+(x.metadata_completeness?.present_count??0)+" / "+(x.metadata_completeness?.expected_count??7)+")"},{label:"数据平面",value:x=>x.metadata?.data_plane||x.group},{label:"变量",value:x=>(x.variables||[]).map(v=>v+((x.variable_labels||{})[v]?" / "+x.variable_labels[v]:"")).join("、")||"读取具体文件确认"},{label:"匹配文件",value:x=>x.metadata?.file_count||"不适用 / 未返回"},{label:"数据量",value:x=>x.metadata?.total_size||"未返回"},{label:"时间范围",value:x=>x.metadata?.time_range||"实时接口未提供"},{label:"时间分辨率",value:x=>x.metadata?.temporal_resolution||"实时接口未提供"},{label:"空间分辨率",value:x=>x.metadata?.spatial_resolution||"实时接口未提供"},{label:"数据形态",value:x=>x.reference?.data_shape||"读取具体文件确认"},{label:"详情来源",value:x=>(x.provenance||[]).join(";")||"当前卡片摘要"}];wrap.querySelector(".comparison-live").innerHTML='<div class="compare-wrap"><table class="compare-table"><thead><tr><th>对比项</th>'+details.map(x=>'<th>'+esc(x.name)+'<br><span class="dataset-zh">'+esc(x.name_zh||"")+'</span></th>').join("")+'</tr></thead><tbody>'+rows.map(row=>'<tr><td>'+esc(row.label)+'</td>'+details.map(x=>'<td>'+esc(datasetMetaValue(row.value(x)))+'</td>').join("")+'</tr>').join("")+'</tbody></table></div>';const exportBtn=wrap.querySelector(".compare-export");exportBtn.disabled=false;exportBtn.onclick=()=>{const csvRows=[["对比项",...details.map(x=>x.name+(x.name_zh?" · "+x.name_zh:""))],...rows.map(row=>[row.label,...details.map(x=>datasetMetaValue(row.value(x)))])],csv=csvRows.map(row=>row.map(v=>'"'+String(v??"").replace(/"/g,'""')+'"').join(",")).join("\r\n"),blob=new Blob(["\ufeff"+csv],{type:"text/csv;charset=utf-8"});saveBlobWithProgress(blob,"marine_dataset_comparison_"+new Date().toISOString().slice(0,10)+".csv",{title:"正在导出数据集对比表"});toast("对比表已导出")}}catch(err){wrap.querySelector(".comparison-live").innerHTML='<div class="detail-note">对比读取失败:'+esc(err.message||String(err))+'</div>'}
|
| 825 |
}
|
| 826 |
function exportDatasetInventory(items,updatedAt){
|
| 827 |
+
const rows=[["数据平面","分类","英文名称","中文名称","接入状态","匹配文件数","匹配数据量(B)","变量","匹配依据","路径示例","盘点时间"],...items.map(x=>[x.group,x.category_zh||"海洋环境",x.name,x.name_zh||"",datasetStatusLabel(x.status),x.file_count??"",x.size_bytes??"",(x.variables||[]).join(";"),x.metadata?.matching_rule||"",(x.examples||[]).join(";"),updatedAt||""])],csv=rows.map(row=>row.map(v=>'"'+String(v??"").replace(/"/g,'""')+'"').join(",")).join("\r\n"),blob=new Blob(["\ufeff"+csv],{type:"text/csv;charset=utf-8"});saveBlobWithProgress(blob,"marine_dataset_live_inventory_"+new Date().toISOString().slice(0,10)+".csv",{title:"正在导出实时盘点表"});toast("实时盘点表已导出")
|
| 828 |
}
|
| 829 |
function openDatasetQuality(){
|
| 830 |
document.querySelector(".drawer-backdrop")?.remove();const wrap=document.createElement("div");wrap.className="drawer-backdrop";
|
| 831 |
wrap.innerHTML='<aside class="detail-drawer" role="dialog" aria-modal="true" aria-label="仓库体检"><div class="drawer-head"><div><h2>Hugging Face 仓库体检</h2><p>基于 main 分支实时文件树的轻量检查</p></div><button class="drawer-close" aria-label="关闭">×</button></div><div class="quality-live"><div class="loading">正在检查仓库清单…</div></div><div class="card-actions"><button class="view-btn quality-refresh">↻ 重新体检</button><button class="view-btn quality-export" disabled>↓ 导出体检 JSON</button></div></aside>';document.body.appendChild(wrap);const close=()=>wrap.remove();wrap.querySelector(".drawer-close").onclick=close;wrap.onclick=e=>{if(e.target===wrap)close()};
|
| 832 |
+
const load=async(refresh=false)=>{const box=wrap.querySelector(".quality-live"),exportBtn=wrap.querySelector(".quality-export");box.innerHTML='<div class="loading">正在检查仓库清单…</div>';exportBtn.disabled=true;try{const d=await j("/api/sidebar/datasets/quality"+(refresh?"?refresh=true":""));if(!wrap.isConnected)return;const stats=[["实际文件",d.file_count],["数据总量",formatBytes(d.total_size_bytes)],["零字节文件",d.zero_byte_count],["同名文件组",d.duplicate_basename_group_count],["未归类文件",d.unmapped_file_count],["≥1 GB 文件",d.large_file_count]],extensions=Object.entries(d.extension_counts||{}).map(([k,v])=>'<span class="tag">'+esc(k)+' · '+esc(v)+'</span>').join(""),findings=(d.findings||[]).map(x=>'<div class="finding '+esc(x.severity)+'"><b>'+esc(x.title)+'</b><p>'+esc(x.detail)+'</p></div>').join("")||'<div class="finding info"><b>未发现清单级异常</b><p>仍建议继续进行文件内部字段和缺失值质检。</p></div>',dups=(d.duplicate_groups||[]).map(x=>'<li><b>'+esc(x.basename)+'</b> · '+esc(x.count)+' 个路径<br>'+esc((x.paths||[]).join(";"))+'</li>').join(""),unmapped=(d.unmapped_files||[]).map(x=>'<li>'+esc(x)+'</li>').join(""),large=(d.large_files||[]).map(x=>'<li>'+esc(x.path)+' · '+esc(x.size)+'</li>').join(""),notes=(d.notes||[]).map(x=>'<li>'+esc(x)+'</li>').join("");box.innerHTML='<div class="row-meta">仓库:'+esc(d.repository)+'@'+esc(d.branch)+' · 检查时间:'+esc(formatTime(d.checked_at))+' · 文件树对象:'+esc(d.tree_object_count)+'</div><div class="meta-grid">'+stats.map(x=>'<div class="meta-item"><span>'+esc(x[0])+'</span><b>'+esc(x[1])+'</b></div>').join("")+'</div><div class="detail-section"><h3>检查结论</h3>'+findings+'</div><div class="detail-section"><h3>文件类型分布</h3><div class="tags">'+extensions+'</div></div>'+(dups?'<div class="detail-section"><h3>同名文件组(不等同于内容重复)</h3><ul class="quality-list">'+dups+'</ul></div>':'')+(unmapped?'<div class="detail-section"><h3>未归类文件预览</h3><ul class="quality-list">'+unmapped+'</ul></div>':'')+(large?'<div class="detail-section"><h3>超大文件预览</h3><ul class="quality-list">'+large+'</ul></div>':'')+'<div class="detail-section"><h3>解释说明</h3><ul class="path-list">'+notes+'</ul></div>';exportBtn.disabled=false;exportBtn.onclick=()=>{const blob=new Blob([JSON.stringify(d,null,2)],{type:"application/json;charset=utf-8"});saveBlobWithProgress(blob,"hf_fisheries_repository_quality_"+new Date().toISOString().slice(0,10)+".json",{title:"正在导出仓库体检报告"});toast("体检报告已导出")}}catch(err){box.innerHTML='<div class="detail-note">仓库体检失败:'+esc(err.message||String(err))+'</div>'}};
|
| 833 |
wrap.querySelector(".quality-refresh").onclick=()=>load(true);load()
|
| 834 |
}
|
| 835 |
function openMetadataAudit(){
|
| 836 |
document.querySelector(".drawer-backdrop")?.remove();const wrap=document.createElement("div");wrap.className="drawer-backdrop";
|
| 837 |
wrap.innerHTML='<aside class="detail-drawer" role="dialog" aria-modal="true" aria-label="元数据审计"><div class="drawer-head"><div><h2>数据集元数据审计</h2><p>核对核心盘点字段是否由实时接口或文件树提供</p></div><button class="drawer-close" aria-label="关闭">×</button></div><div class="metadata-audit-live"><div class="loading">正在审计元数据…</div></div><div class="card-actions"><button class="view-btn audit-refresh">↻ 重新审计</button><button class="view-btn audit-export" disabled>↓ 导出审计 CSV</button></div></aside>';
|
| 838 |
document.body.appendChild(wrap);const close=()=>wrap.remove();wrap.querySelector(".drawer-close").onclick=close;wrap.onclick=e=>{if(e.target===wrap)close()};
|
| 839 |
+
const load=async(refresh=false)=>{const box=wrap.querySelector(".metadata-audit-live"),exportBtn=wrap.querySelector(".audit-export");box.innerHTML='<div class="loading">正在审计元数据…</div>';exportBtn.disabled=true;try{const d=await j("/api/sidebar/datasets/metadata-audit"+(refresh?"?refresh=true":"")),summary=d.summary||{},records=d.records||[],labels=DATASET_META_LABELS,rows=records.map(x=>{const c=x.completeness||{},missing=(c.missing_fields||[]).map(v=>labels[v]||v).join("、")||"无",pending=(c.pending_fields||[]).map(v=>labels[v]||v).join("、")||"无";return '<tr><td>'+esc(x.group)+'</td><td><b>'+esc(x.name)+'</b><br><span class="dataset-zh">'+esc(x.name_zh||"")+'</span></td><td>'+esc(datasetStatusLabel(x.status))+'</td><td>'+esc(c.score??0)+'%<br><span class="row-meta">'+esc(c.present_count||0)+' / '+esc(c.expected_count||0)+'</span></td><td>'+esc(missing)+'</td><td>'+esc(pending)+'</td><td>'+esc(x.action||"")+'</td></tr>'}).join("");box.innerHTML='<div class="row-meta">审计时间:'+esc(formatTime(d.checked_at))+'</div><div class="summary-grid"><div class="summary-card"><strong>'+esc(summary.dataset_count||0)+'</strong><span>审计数据源</span></div><div class="summary-card"><strong>'+esc(summary.average_score||0)+'%</strong><span>可审计元数据平均完整度</span></div><div class="summary-card"><strong>'+esc(summary.incomplete_count||0)+'</strong><span>仍有可审计字段缺失</span></div></div><div class="compare-wrap"><table class="compare-table"><thead><tr><th>数据平面</th><th>数据源</th><th>状态</th><th>完整度</th><th>缺失字段</th><th>待文件级核验</th><th>建议动作</th></tr></thead><tbody>'+rows+'</tbody></table></div><div class="detail-note">'+esc((d.notes||[]).join(";"))+'</div>';exportBtn.disabled=false;exportBtn.onclick=()=>{const csvRows=[["数据平面","英文名称","中文名称","接入状态","可审计元数据完整度(%)","已返回字段","缺失字段","待文件级核验","建议动作","审计时间"],...records.map(x=>[x.group,x.name,x.name_zh||"",datasetStatusLabel(x.status),x.completeness?.score??0,(x.completeness?.present_fields||[]).map(v=>labels[v]||v).join(";"),(x.completeness?.missing_fields||[]).map(v=>labels[v]||v).join(";"),(x.completeness?.pending_fields||[]).map(v=>labels[v]||v).join(";"),x.action||"",d.checked_at||""])],csv=csvRows.map(row=>row.map(v=>'"'+String(v??"").replace(/"/g,'""')+'"').join(",")).join("\r\n"),blob=new Blob(["\ufeff"+csv],{type:"text/csv;charset=utf-8"});saveBlobWithProgress(blob,"marine_metadata_audit_"+new Date().toISOString().slice(0,10)+".csv",{title:"正在导出元数据审计表"});toast("元数据审计表已导出")}}catch(err){box.innerHTML='<div class="detail-note">元数据审计失败:'+esc(err.message||String(err))+'</div>'}};
|
| 840 |
wrap.querySelector(".audit-refresh").onclick=()=>load(true);load()
|
| 841 |
}
|
| 842 |
async function renderDatasets(force=false){
|
|
|
|
| 905 |
document.body.appendChild(wrap);const close=()=>wrap.remove();wrap.querySelector(".drawer-close").onclick=close;wrap.onclick=e=>{if(e.target===wrap)close()};
|
| 906 |
const payload={detected_at:data.updated_at,app_version:appState.app_version||null,diagnostic:d,marine:marine,huggingface:hf};
|
| 907 |
wrap.querySelector(".service-copy-report").onclick=async()=>{const text='服务诊断 '+(d.status||'-')+';健康 '+(d.healthy_count??'-')+'/'+(d.check_count??'-')+';平均响应 '+(d.average_response_ms??'-')+' ms;失败:'+((d.failed||[]).join('、')||'无')+';慢响应:'+((d.slow||[]).join('、')||'无');try{await navigator.clipboard.writeText(text);toast("诊断摘要已复制")}catch{toast("浏览器不允许自动复制")}};
|
| 908 |
+
wrap.querySelector(".service-download-report").onclick=()=>{const blob=new Blob([JSON.stringify(payload,null,2)],{type:"application/json;charset=utf-8"});saveBlobWithProgress(blob,"marine-service-diagnostic-"+new Date().toISOString().replace(/[:.]/g,"-")+".json",{title:"正在导出服务诊断报告"})};
|
| 909 |
}
|
| 910 |
|
| 911 |
|
|
|
|
| 942 |
async function renderServices(){
|
| 943 |
const root=$("workspaceInner");
|
| 944 |
root.innerHTML='<div class="view-head"><div><h1>数据服务</h1><p>统一查询 Ocean / Tuna / Squid,并实时检查底层数据服务状态。</p></div><button id="refreshServices" class="view-btn">↻ 立即检测</button></div>'+
|
| 945 |
+
'<div class="unified-query-card"><div><b>统一智能查询</b><p>用一句话描述时间、区域、物种和变量;系统先识别应该查询 Ocean、Tuna、Squid 中的哪些数据。</p></div><textarea id="unifiedQueryText" rows="3" placeholder="例如:查询 2002 年 8 月西北太平洋柔鱼记录,并匹配 SST、SSH、CHL"></textarea><div class="uq-actions"><button id="unifiedQueryRun" class="view-btn primary">分析并预览</button></div><div id="unifiedQueryResult"></div></div>'+
|
| 946 |
'<div class="loading">正在检测服务…</div>';
|
| 947 |
$("refreshServices").onclick=renderServices;
|
| 948 |
if($("unifiedQueryRun"))$("unifiedQueryRun").onclick=runUnifiedQueryPreview;
|
|
|
|
| 959 |
];
|
| 960 |
const diag='<div class="service-diagnostic"><div><span>综合状态</span><b>'+esc(d.status||"-")+'</b></div><div><span>健康检查</span><b>'+esc((d.healthy_count??"-")+" / "+(d.check_count??"-"))+'</b></div><div><span>平均响应</span><b>'+esc(d.average_response_ms==null?"-":d.average_response_ms+" ms")+'</b></div><div><span>慢响应</span><b>'+esc((d.slow||[]).length+" 项")+'</b></div></div><div class="service-actions"><button id="serviceDiagnostics" class="view-btn primary">查看诊断详情</button></div>';
|
| 961 |
root.innerHTML='<div class="view-head"><div><h1>数据服务 <span style="font-size:11px;opacity:.6">v'+esc(APP_VERSION)+'</span></h1><p>最后检测:'+esc(formatTime(data.updated_at))+' · '+esc(d.hint||"")+'</p></div><div style="display:flex;gap:8px;flex-wrap:wrap"><button id="serviceDiagnosticsTop" class="view-btn primary">查看诊断详情</button><button id="refreshServices" class="view-btn">↻ 立即检测</button></div></div>'+
|
| 962 |
+
'<div class="unified-query-card"><div><b>统一智能查询</b><p>自然语言 → 数据域识别 → 参数解析 → Ocean / Tuna / Squid 实时目录预览。当前阶段不自动下载大文件。</p></div><textarea id="unifiedQueryText" rows="3" placeholder="例如:查询 2002 年 8 月西北太平洋柔鱼记录,并匹配 SST、SSH、CHL"></textarea><div class="uq-actions"><button id="unifiedQueryRun" class="view-btn primary">分析并预览</button></div><div id="unifiedQueryResult"></div></div>'+diag+rows.map(x=>'<div class="service-row"><div class="service-head"><b>'+esc(x.name)+(x.ms!=null?'<span class="service-latency">'+esc(x.ms)+' ms</span>':'')+'</b>'+statusBadge(x.ok)+'</div><div class="row-detail">'+esc(x.detail)+'</div></div>').join("");
|
| 963 |
$("refreshServices").onclick=renderServices;$("serviceDiagnostics").onclick=()=>openServiceDiagnostics(data,appState);$("serviceDiagnosticsTop").onclick=()=>openServiceDiagnostics(data,appState);if($("unifiedQueryRun"))$("unifiedQueryRun").onclick=runUnifiedQueryPreview;
|
| 964 |
}catch(err){
|
| 965 |
root.innerHTML='<div class="view-head"><div><h1>数据服务</h1><p>状态检测失败。</p></div><button id="refreshServices" class="view-btn">重试</button></div><div class="empty-view"><b>无法完成检测</b>'+esc(err.message||String(err))+'</div>';
|
|
|
|
| 978 |
return '<div class="task-progress"><span style="width:100%"></span></div>'
|
| 979 |
}
|
| 980 |
function downloadTaskResult(task){
|
| 981 |
+
const blob=new Blob([JSON.stringify(task,null,2)],{type:"application/json;charset=utf-8"});
|
| 982 |
+
saveBlobWithProgress(blob,"task_"+(task.task_id||"result")+".json",{title:"正在导出任务结果"});toast("任务结果已导出")
|
| 983 |
}
|
| 984 |
function taskResultHtml(result){
|
| 985 |
result=result||{};const blocks=[];
|
|
|
|
| 1172 |
$("clear").onclick=()=>{if(S.length&&confirm("清空全部最近对话?")){S=[];save();newConversation();hist();toast("最近对话已清空")}};
|
| 1173 |
$("refresh").onclick=()=>{status();toast("正在刷新服务状态")};
|
| 1174 |
if($("accountBtn"))$("accountBtn").onclick=e=>{e.stopPropagation();accountMenu()};
|
| 1175 |
+
|
| 1176 |
+
function ensureDownloadProgressUi(){
|
| 1177 |
+
let wrap=$("downloadProgressOverlay");
|
| 1178 |
+
if(wrap)return wrap;
|
| 1179 |
+
wrap=document.createElement("div");
|
| 1180 |
+
wrap.id="downloadProgressOverlay";
|
| 1181 |
+
wrap.className="download-progress-overlay";
|
| 1182 |
+
wrap.innerHTML='<div class="download-progress-card" role="dialog" aria-modal="true">'+
|
| 1183 |
+
'<div class="download-progress-head"><div><b id="downloadProgressTitle">正在下载</b><span id="downloadProgressState">正在建立连接…</span></div><button id="downloadProgressClose" class="download-progress-close" type="button" title="隐藏">×</button></div>'+
|
| 1184 |
+
'<div class="download-progress-track"><div id="downloadProgressBar" class="download-progress-bar"></div></div>'+
|
| 1185 |
+
'<div class="download-progress-meta"><span id="downloadProgressAmount">0 B</span><span id="downloadProgressSpeed">—</span><span id="downloadProgressElapsed">0s</span></div>'+
|
| 1186 |
+
'<div id="downloadProgressHint" class="download-progress-hint">下载期间可以继续查看当前页面。</div>'+
|
| 1187 |
+
'<div class="download-progress-actions"><button id="downloadProgressCancel" class="view-btn" type="button">取消下载</button></div>'+
|
| 1188 |
+
'</div>';
|
| 1189 |
+
document.body.appendChild(wrap);
|
| 1190 |
+
$("downloadProgressClose").onclick=()=>wrap.classList.remove("show");
|
| 1191 |
+
return wrap;
|
| 1192 |
+
}
|
| 1193 |
+
let ACTIVE_DOWNLOAD_CONTROLLER=null;
|
| 1194 |
+
|
| 1195 |
+
function triggerBrowserSave(url,filename){
|
| 1196 |
+
const a=document.createElement("a");
|
| 1197 |
+
a.href=url;
|
| 1198 |
+
a.download=filename||"download";
|
| 1199 |
+
a.dataset.downloadBypass="1";
|
| 1200 |
+
a.style.display="none";
|
| 1201 |
+
document.body.appendChild(a);
|
| 1202 |
+
a.click();
|
| 1203 |
+
setTimeout(()=>a.remove(),0);
|
| 1204 |
+
}
|
| 1205 |
+
|
| 1206 |
+
function downloadFetchUrl(sourceUrl){
|
| 1207 |
+
const u=new URL(sourceUrl,location.href);
|
| 1208 |
+
if(u.protocol==="blob:"||u.protocol==="data:"||u.origin===location.origin)return u.href;
|
| 1209 |
+
return "/api/download/proxy?url="+encodeURIComponent(u.href);
|
| 1210 |
+
}
|
| 1211 |
+
|
| 1212 |
+
function saveBlobWithProgress(blob,filename="download",options={}){
|
| 1213 |
+
const overlay=ensureDownloadProgressUi(),bar=$("downloadProgressBar");
|
| 1214 |
+
overlay.classList.add("show");
|
| 1215 |
+
bar.classList.remove("indeterminate","error");bar.classList.add("done");bar.style.width="100%";
|
| 1216 |
+
$("downloadProgressTitle").textContent=options.title||("正在保存 "+filename);
|
| 1217 |
+
$("downloadProgressState").textContent="本地文件已生成";
|
| 1218 |
+
$("downloadProgressAmount").textContent=formatBytes(blob.size||0);
|
| 1219 |
+
$("downloadProgressSpeed").textContent="本地生成";
|
| 1220 |
+
$("downloadProgressElapsed").textContent="<1s";
|
| 1221 |
+
$("downloadProgressHint").textContent="文件已在浏览器中生成,无需等待网络传输。";
|
| 1222 |
+
$("downloadProgressCancel").disabled=true;
|
| 1223 |
+
$("downloadProgressCancel").textContent="已完成";
|
| 1224 |
+
const obj=URL.createObjectURL(blob);
|
| 1225 |
+
triggerBrowserSave(obj,filename);
|
| 1226 |
+
setTimeout(()=>URL.revokeObjectURL(obj),3000);
|
| 1227 |
+
return true;
|
| 1228 |
+
}
|
| 1229 |
+
|
| 1230 |
+
async function downloadWithProgress(url,filename="download",options={}){
|
| 1231 |
+
const overlay=ensureDownloadProgressUi();
|
| 1232 |
+
const title=$("downloadProgressTitle"),state=$("downloadProgressState"),bar=$("downloadProgressBar");
|
| 1233 |
+
const amount=$("downloadProgressAmount"),speed=$("downloadProgressSpeed"),elapsed=$("downloadProgressElapsed"),hint=$("downloadProgressHint"),cancel=$("downloadProgressCancel");
|
| 1234 |
+
const controller=new AbortController();
|
| 1235 |
+
ACTIVE_DOWNLOAD_CONTROLLER=controller;
|
| 1236 |
+
const started=performance.now();
|
| 1237 |
+
let received=0,lastBytes=0,lastTick=started,lastChunkAt=started,total=0,finished=false;
|
| 1238 |
+
overlay.classList.add("show");
|
| 1239 |
+
bar.classList.remove("indeterminate","done","error");
|
| 1240 |
+
bar.style.width="0%";
|
| 1241 |
+
title.textContent=options.title||("正在下载 "+filename);
|
| 1242 |
+
state.textContent="正在建立连接…";
|
| 1243 |
+
amount.textContent="0 B";speed.textContent="—";elapsed.textContent="0s";
|
| 1244 |
+
hint.textContent="正在连接数据服务;收到数据后会显示实时进度。";
|
| 1245 |
+
cancel.disabled=false;cancel.textContent="取消下载";
|
| 1246 |
+
cancel.onclick=()=>controller.abort();
|
| 1247 |
+
|
| 1248 |
+
const timer=setInterval(()=>{
|
| 1249 |
+
const now=performance.now(),secs=Math.max(1,(now-started)/1000);
|
| 1250 |
+
elapsed.textContent=Math.floor(secs)+"s";
|
| 1251 |
+
const dt=Math.max(.25,(now-lastTick)/1000),delta=received-lastBytes;
|
| 1252 |
+
if(delta>=0)speed.textContent=delta?formatBytes(delta/dt)+"/s":"0 B/s";
|
| 1253 |
+
lastBytes=received;lastTick=now;
|
| 1254 |
+
if(!finished && now-lastChunkAt>15000){
|
| 1255 |
+
state.textContent="15 秒未收到新数据";
|
| 1256 |
+
hint.textContent="连接仍未结束,可能是服务器正在准备文件或网络较慢;可以继续等待或取消。";
|
| 1257 |
+
}
|
| 1258 |
+
},1000);
|
| 1259 |
+
|
| 1260 |
+
try{
|
| 1261 |
+
const sourceUrl=new URL(url,location.href).href;
|
| 1262 |
+
const fetchUrl=downloadFetchUrl(sourceUrl);
|
| 1263 |
+
const resp=await fetch(fetchUrl,{headers:authHeaders(options.headers||{}),signal:controller.signal});
|
| 1264 |
+
if(!resp.ok)throw Error(await resp.text());
|
| 1265 |
+
total=Number(resp.headers.get("content-length")||0);
|
| 1266 |
+
state.textContent=total?"正在传输":"正在传输(服务器未提供总大小)";
|
| 1267 |
+
if(!total)bar.classList.add("indeterminate");
|
| 1268 |
+
hint.textContent=total?"正在接收数据,可以根据百分比判断进度。":"正在接收数据;虽然无法计算百分比,但已下载字节和速度会持续更新。";
|
| 1269 |
+
|
| 1270 |
+
if(!resp.body || !resp.body.getReader){
|
| 1271 |
+
const blob=await resp.blob();
|
| 1272 |
+
received=blob.size;
|
| 1273 |
+
amount.textContent=formatBytes(received);
|
| 1274 |
+
bar.classList.remove("indeterminate");bar.style.width="100%";
|
| 1275 |
+
const obj=URL.createObjectURL(blob);
|
| 1276 |
+
triggerBrowserSave(obj,filename);
|
| 1277 |
+
setTimeout(()=>URL.revokeObjectURL(obj),3000);
|
| 1278 |
+
}else{
|
| 1279 |
+
const reader=resp.body.getReader(),chunks=[];
|
| 1280 |
+
while(true){
|
| 1281 |
+
const {done,value}=await reader.read();
|
| 1282 |
+
if(done)break;
|
| 1283 |
+
if(value){
|
| 1284 |
+
chunks.push(value);received+=value.byteLength;lastChunkAt=performance.now();
|
| 1285 |
+
amount.textContent=total?(formatBytes(received)+" / "+formatBytes(total)):formatBytes(received);
|
| 1286 |
+
if(total){
|
| 1287 |
+
const pct=Math.min(100,received/total*100);
|
| 1288 |
+
bar.style.width=pct.toFixed(1)+"%";
|
| 1289 |
+
state.textContent="正在传输 "+pct.toFixed(0)+"%";
|
| 1290 |
+
}else{
|
| 1291 |
+
state.textContent="正在传输 · 已接收 "+formatBytes(received);
|
| 1292 |
+
}
|
| 1293 |
+
}
|
| 1294 |
+
}
|
| 1295 |
+
const blob=new Blob(chunks,{type:resp.headers.get("content-type")||"application/octet-stream"});
|
| 1296 |
+
const obj=URL.createObjectURL(blob);
|
| 1297 |
+
triggerBrowserSave(obj,filename);
|
| 1298 |
+
setTimeout(()=>URL.revokeObjectURL(obj),3000);
|
| 1299 |
+
}
|
| 1300 |
+
finished=true;
|
| 1301 |
+
bar.classList.remove("indeterminate","error");bar.classList.add("done");bar.style.width="100%";
|
| 1302 |
+
state.textContent="下载完成";
|
| 1303 |
+
hint.textContent="文件已传输完成并交给浏览器保存。";
|
| 1304 |
+
cancel.disabled=true;cancel.textContent="已完成";
|
| 1305 |
+
toast("下载完成");
|
| 1306 |
+
return true;
|
| 1307 |
+
}catch(err){
|
| 1308 |
+
finished=true;
|
| 1309 |
+
bar.classList.remove("indeterminate","done");bar.classList.add("error");
|
| 1310 |
+
if(err?.name==="AbortError"){
|
| 1311 |
+
state.textContent="下载已取消";hint.textContent="已停止当前传输。";
|
| 1312 |
+
}else{
|
| 1313 |
+
const sourceUrl=String(url||"");
|
| 1314 |
+
const isExternal=(()=>{try{return new URL(sourceUrl,location.href).origin!==location.origin}catch(_){return false}})();
|
| 1315 |
+
if(isExternal && options.nativeFallback!==false){
|
| 1316 |
+
state.textContent="已交给浏览器下载";
|
| 1317 |
+
hint.textContent="该外部站点不允许平台读取传输字节,因此无法显示精确百分比;已改用浏览器原生下载。";
|
| 1318 |
+
bar.classList.remove("error","indeterminate");bar.classList.add("done");bar.style.width="100%";
|
| 1319 |
+
triggerBrowserSave(sourceUrl,filename);
|
| 1320 |
+
toast("外部下载已交给浏览器");
|
| 1321 |
+
cancel.disabled=true;cancel.textContent="浏览器接管";
|
| 1322 |
+
return true;
|
| 1323 |
+
}
|
| 1324 |
+
state.textContent="下载失败";hint.textContent=String(err.message||err).slice(0,300);
|
| 1325 |
+
toast("下载失败:"+(err.message||String(err)));
|
| 1326 |
+
}
|
| 1327 |
+
cancel.disabled=true;cancel.textContent="已停止";
|
| 1328 |
+
return false;
|
| 1329 |
+
}finally{
|
| 1330 |
+
clearInterval(timer);
|
| 1331 |
+
ACTIVE_DOWNLOAD_CONTROLLER=null;
|
| 1332 |
+
}
|
| 1333 |
+
}
|
| 1334 |
+
|
| 1335 |
document.addEventListener("click",e=>{
|
| 1336 |
+
const a=e.target.closest("a");
|
| 1337 |
+
if(!a || a.dataset.downloadBypass==="1" || !USER_ID)return;
|
| 1338 |
+
|
| 1339 |
+
const href=String(a.getAttribute("href")||"").trim();
|
| 1340 |
+
if(!href)return;
|
| 1341 |
+
let u;
|
| 1342 |
+
try{u=new URL(href,location.href)}catch(_){return}
|
| 1343 |
+
|
| 1344 |
+
const path=u.pathname.toLowerCase();
|
| 1345 |
+
const looksDownload=(
|
| 1346 |
+
a.classList.contains("download") ||
|
| 1347 |
+
a.hasAttribute("download") ||
|
| 1348 |
+
a.dataset.download==="1" ||
|
| 1349 |
+
/\/download(?:\/|$)/.test(path) ||
|
| 1350 |
+
/\.(?:csv|tsv|json|zip|xlsx?|nc|netcdf|parquet|geojson|tif|tiff)(?:$|\?)/i.test(u.href)
|
| 1351 |
+
);
|
| 1352 |
+
if(!looksDownload)return;
|
| 1353 |
|
| 1354 |
if(SETTINGS.confirmDownload && !confirm("确认下载这个数据文件?")){
|
| 1355 |
e.preventDefault();
|
| 1356 |
return;
|
| 1357 |
}
|
| 1358 |
+
e.preventDefault();
|
| 1359 |
|
| 1360 |
+
let filename=(a.getAttribute("download")||u.pathname.split("/").pop()||"download").slice(0,180);
|
| 1361 |
+
filename=filename||"download";
|
|
|
|
|
|
|
|
|
|
| 1362 |
|
| 1363 |
fetch("/api/usage",{
|
| 1364 |
method:"POST",
|
|
|
|
| 1367 |
body:JSON.stringify({
|
| 1368 |
user_id:USER_ID,
|
| 1369 |
event_type:"download_clicked",
|
| 1370 |
+
detail:{filename,href:u.href.slice(0,1000)}
|
|
|
|
|
|
|
|
|
|
| 1371 |
})
|
| 1372 |
}).catch(()=>{});
|
| 1373 |
+
|
| 1374 |
+
downloadWithProgress(u.href,filename,{title:"正在下载 "+filename});
|
| 1375 |
});
|
| 1376 |
|
| 1377 |
document.querySelectorAll(".domain-card").forEach(x=>{if(!x.getAttribute("onclick"))x.addEventListener("click",e=>{if(e.target.closest("button"))return;openDomainPanel(x.dataset.domain)})});
|
static/js/project-package.js
CHANGED
|
@@ -176,7 +176,14 @@ async function renderProjectPackage(){
|
|
| 176 |
const skip=skipped.length?'<div class="detail-note" style="margin-top:8px">另有 '+esc(skipped.length)+' 项未加入,可在 ZIP 的 manifest.json 查看原因。</div>':'';
|
| 177 |
box.innerHTML='<div class="package-manifest">'+(rows||'<div class="detail-note">没有已加入文件。</div>')+'</div>'+skip;
|
| 178 |
};
|
| 179 |
-
$("packageDownload").onclick=async()=>{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
}catch(err){const status=$("packageBuildStatus");if(status)status.outerHTML='<div class="empty-view"><b>打包失败</b>'+esc(err.message||String(err))+'</div>'}
|
| 181 |
finally{b.disabled=false;b.textContent="生成所选 ZIP"}
|
| 182 |
};
|
|
|
|
| 176 |
const skip=skipped.length?'<div class="detail-note" style="margin-top:8px">另有 '+esc(skipped.length)+' 项未加入,可在 ZIP 的 manifest.json 查看原因。</div>':'';
|
| 177 |
box.innerHTML='<div class="package-manifest">'+(rows||'<div class="detail-note">没有已加入文件。</div>')+'</div>'+skip;
|
| 178 |
};
|
| 179 |
+
$("packageDownload").onclick=async()=>{
|
| 180 |
+
const btn=$("packageDownload");btn.disabled=true;btn.textContent="下载中…";
|
| 181 |
+
try{
|
| 182 |
+
await downloadWithProgress(r.download_url,"project_data_package.zip",{title:"正在下载项目数据包"});
|
| 183 |
+
}finally{
|
| 184 |
+
btn.disabled=false;btn.textContent="↓ 下载 ZIP";
|
| 185 |
+
}
|
| 186 |
+
};
|
| 187 |
}catch(err){const status=$("packageBuildStatus");if(status)status.outerHTML='<div class="empty-view"><b>打包失败</b>'+esc(err.message||String(err))+'</div>'}
|
| 188 |
finally{b.disabled=false;b.textContent="生成所选 ZIP"}
|
| 189 |
};
|
tests/test_download_progress_v391.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import pathlib
|
| 3 |
+
ROOT=pathlib.Path(__file__).resolve().parents[1]
|
| 4 |
+
|
| 5 |
+
def test_version_391():
|
| 6 |
+
assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="3.9.2"
|
| 7 |
+
|
| 8 |
+
def test_p1_label_removed():
|
| 9 |
+
js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
|
| 10 |
+
assert "统一智能查询 · P1" not in js
|
| 11 |
+
assert "统一智能查询" in js
|
| 12 |
+
|
| 13 |
+
def test_download_progress_manager_present():
|
| 14 |
+
js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
|
| 15 |
+
assert "function downloadWithProgress" in js
|
| 16 |
+
assert "content-length" in js
|
| 17 |
+
assert "15 秒未收到新数据" in js
|
| 18 |
+
assert "ACTIVE_DOWNLOAD_CONTROLLER" in js
|
| 19 |
+
|
| 20 |
+
def test_project_package_uses_progress_manager():
|
| 21 |
+
js=(ROOT/"static"/"js"/"project-package.js").read_text(encoding="utf-8")
|
| 22 |
+
assert 'downloadWithProgress(r.download_url,"project_data_package.zip"' in js
|
| 23 |
+
|
| 24 |
+
def test_progress_css_present():
|
| 25 |
+
css=(ROOT/"static"/"css"/"app.css").read_text(encoding="utf-8")
|
| 26 |
+
assert ".download-progress-overlay" in css
|
| 27 |
+
assert ".download-progress-bar.indeterminate" in css
|
tests/test_history_sidebar_v373.py
CHANGED
|
@@ -3,7 +3,7 @@ import pathlib
|
|
| 3 |
ROOT=pathlib.Path(__file__).resolve().parents[1]
|
| 4 |
|
| 5 |
def test_version_373():
|
| 6 |
-
assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="3.9.
|
| 7 |
|
| 8 |
def test_project_history_backend():
|
| 9 |
route=(ROOT/"routes"/"project_package.py").read_text(encoding="utf-8")
|
|
|
|
| 3 |
ROOT=pathlib.Path(__file__).resolve().parents[1]
|
| 4 |
|
| 5 |
def test_version_373():
|
| 6 |
+
assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="3.9.2"
|
| 7 |
|
| 8 |
def test_project_history_backend():
|
| 9 |
route=(ROOT/"routes"/"project_package.py").read_text(encoding="utf-8")
|
tests/test_p0_finish_v380.py
CHANGED
|
@@ -3,7 +3,7 @@ import pathlib, re, subprocess, shutil
|
|
| 3 |
ROOT=pathlib.Path(__file__).resolve().parents[1]
|
| 4 |
|
| 5 |
def test_version_380():
|
| 6 |
-
assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="3.9.
|
| 7 |
|
| 8 |
def test_chat_runtime_extracted():
|
| 9 |
ui=(ROOT/"ui_server.py").read_text(encoding="utf-8")
|
|
|
|
| 3 |
ROOT=pathlib.Path(__file__).resolve().parents[1]
|
| 4 |
|
| 5 |
def test_version_380():
|
| 6 |
+
assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="3.9.2"
|
| 7 |
|
| 8 |
def test_chat_runtime_extracted():
|
| 9 |
ui=(ROOT/"ui_server.py").read_text(encoding="utf-8")
|
tests/test_p0_phase2_v371.py
CHANGED
|
@@ -3,7 +3,7 @@ import pathlib
|
|
| 3 |
ROOT=pathlib.Path(__file__).resolve().parents[1]
|
| 4 |
|
| 5 |
def test_version():
|
| 6 |
-
assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="3.9.
|
| 7 |
|
| 8 |
def test_frontend_feature_modules_exist():
|
| 9 |
html=(ROOT/"app.html").read_text(encoding="utf-8")
|
|
|
|
| 3 |
ROOT=pathlib.Path(__file__).resolve().parents[1]
|
| 4 |
|
| 5 |
def test_version():
|
| 6 |
+
assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="3.9.2"
|
| 7 |
|
| 8 |
def test_frontend_feature_modules_exist():
|
| 9 |
html=(ROOT/"app.html").read_text(encoding="utf-8")
|
tests/test_p0_phase3_v372.py
CHANGED
|
@@ -3,7 +3,7 @@ import pathlib
|
|
| 3 |
ROOT=pathlib.Path(__file__).resolve().parents[1]
|
| 4 |
|
| 5 |
def test_version_372():
|
| 6 |
-
assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="3.9.
|
| 7 |
|
| 8 |
def test_route_modules_exist():
|
| 9 |
for name in ("datasets.py","user_state.py","project_package.py","data_health.py"):
|
|
|
|
| 3 |
ROOT=pathlib.Path(__file__).resolve().parents[1]
|
| 4 |
|
| 5 |
def test_version_372():
|
| 6 |
+
assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="3.9.2"
|
| 7 |
|
| 8 |
def test_route_modules_exist():
|
| 9 |
for name in ("datasets.py","user_state.py","project_package.py","data_health.py"):
|
tests/test_quality_center_v362.py
CHANGED
|
@@ -3,7 +3,7 @@ import pathlib
|
|
| 3 |
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
| 4 |
|
| 5 |
def test_version_371():
|
| 6 |
-
assert (ROOT / "VERSION").read_text(encoding="utf-8").strip() == "3.9.
|
| 7 |
|
| 8 |
def test_native_health_backend():
|
| 9 |
text = (ROOT / "routes" / "data_health.py").read_text(encoding="utf-8")
|
|
|
|
| 3 |
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
| 4 |
|
| 5 |
def test_version_371():
|
| 6 |
+
assert (ROOT / "VERSION").read_text(encoding="utf-8").strip() == "3.9.2"
|
| 7 |
|
| 8 |
def test_native_health_backend():
|
| 9 |
text = (ROOT / "routes" / "data_health.py").read_text(encoding="utf-8")
|
tests/test_release_stability.py
CHANGED
|
@@ -7,7 +7,7 @@ def test_version_is_single_source():
|
|
| 7 |
version = (ROOT / "VERSION").read_text(encoding="utf-8").strip()
|
| 8 |
ui = (ROOT / "ui_server.py").read_text(encoding="utf-8")
|
| 9 |
html = (ROOT / "app.html").read_text(encoding="utf-8")
|
| 10 |
-
assert version == "3.9.
|
| 11 |
assert 'with_name("VERSION")' in ui
|
| 12 |
assert "__APP_VERSION__" in html
|
| 13 |
|
|
|
|
| 7 |
version = (ROOT / "VERSION").read_text(encoding="utf-8").strip()
|
| 8 |
ui = (ROOT / "ui_server.py").read_text(encoding="utf-8")
|
| 9 |
html = (ROOT / "app.html").read_text(encoding="utf-8")
|
| 10 |
+
assert version == "3.9.2"
|
| 11 |
assert 'with_name("VERSION")' in ui
|
| 12 |
assert "__APP_VERSION__" in html
|
| 13 |
|
tests/test_runtime_hotfix_v376.py
CHANGED
|
@@ -3,7 +3,7 @@ import pathlib
|
|
| 3 |
ROOT=pathlib.Path(__file__).resolve().parents[1]
|
| 4 |
|
| 5 |
def test_version_376():
|
| 6 |
-
assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="3.9.
|
| 7 |
|
| 8 |
def test_no_stray_async_before_sidebar_toggle():
|
| 9 |
js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
|
|
|
|
| 3 |
ROOT=pathlib.Path(__file__).resolve().parents[1]
|
| 4 |
|
| 5 |
def test_version_376():
|
| 6 |
+
assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="3.9.2"
|
| 7 |
|
| 8 |
def test_no_stray_async_before_sidebar_toggle():
|
| 9 |
js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
|
tests/test_sidebar_collapse_v374.py
CHANGED
|
@@ -3,7 +3,7 @@ import pathlib
|
|
| 3 |
ROOT=pathlib.Path(__file__).resolve().parents[1]
|
| 4 |
|
| 5 |
def test_version_374():
|
| 6 |
-
assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="3.9.
|
| 7 |
|
| 8 |
def test_sidebar_toggle_markup():
|
| 9 |
html=(ROOT/"app.html").read_text(encoding="utf-8")
|
|
|
|
| 3 |
ROOT=pathlib.Path(__file__).resolve().parents[1]
|
| 4 |
|
| 5 |
def test_version_374():
|
| 6 |
+
assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="3.9.2"
|
| 7 |
|
| 8 |
def test_sidebar_toggle_markup():
|
| 9 |
html=(ROOT/"app.html").read_text(encoding="utf-8")
|
tests/test_sidebar_partial_collapse_v375.py
CHANGED
|
@@ -3,7 +3,7 @@ import pathlib
|
|
| 3 |
ROOT=pathlib.Path(__file__).resolve().parents[1]
|
| 4 |
|
| 5 |
def test_version_375():
|
| 6 |
-
assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="3.9.
|
| 7 |
|
| 8 |
def test_sidebar_order_and_groups():
|
| 9 |
html=(ROOT/"app.html").read_text(encoding="utf-8")
|
|
|
|
| 3 |
ROOT=pathlib.Path(__file__).resolve().parents[1]
|
| 4 |
|
| 5 |
def test_version_375():
|
| 6 |
+
assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="3.9.2"
|
| 7 |
|
| 8 |
def test_sidebar_order_and_groups():
|
| 9 |
html=(ROOT/"app.html").read_text(encoding="utf-8")
|
tests/test_unified_download_manager_v392.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import pathlib
|
| 3 |
+
ROOT=pathlib.Path(__file__).resolve().parents[1]
|
| 4 |
+
|
| 5 |
+
def test_version_392():
|
| 6 |
+
assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="3.9.2"
|
| 7 |
+
|
| 8 |
+
def test_download_proxy_route_registered():
|
| 9 |
+
route=(ROOT/"routes"/"downloads.py").read_text(encoding="utf-8")
|
| 10 |
+
ui=(ROOT/"ui_server.py").read_text(encoding="utf-8")
|
| 11 |
+
assert '"/api/download/proxy"' in route
|
| 12 |
+
assert "register_download_routes(app, globals())" in ui
|
| 13 |
+
assert "external download host is not allowlisted" in route
|
| 14 |
+
|
| 15 |
+
def test_frontend_all_downloads_use_manager():
|
| 16 |
+
js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
|
| 17 |
+
assert "function downloadFetchUrl" in js
|
| 18 |
+
assert '"/api/download/proxy?url="' in js
|
| 19 |
+
assert "function saveBlobWithProgress" in js
|
| 20 |
+
assert "looksDownload" in js
|
| 21 |
+
assert "downloadWithProgress(u.href,filename" in js
|
| 22 |
+
|
| 23 |
+
def test_local_exports_use_manager():
|
| 24 |
+
js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
|
| 25 |
+
for name in (
|
| 26 |
+
"squid_workspace_backup_",
|
| 27 |
+
"marine_dataset_comparison_",
|
| 28 |
+
"marine_dataset_live_inventory_",
|
| 29 |
+
"hf_fisheries_repository_quality_",
|
| 30 |
+
"marine_metadata_audit_",
|
| 31 |
+
"marine-service-diagnostic-",
|
| 32 |
+
):
|
| 33 |
+
assert name in js
|
| 34 |
+
assert js.count("saveBlobWithProgress(")>=7
|
| 35 |
+
|
| 36 |
+
def test_project_package_still_uses_manager():
|
| 37 |
+
pp=(ROOT/"static"/"js"/"project-package.js").read_text(encoding="utf-8")
|
| 38 |
+
assert 'downloadWithProgress(r.download_url,"project_data_package.zip"' in pp
|
tests/test_unified_query_v390.py
CHANGED
|
@@ -4,7 +4,7 @@ from services.unified_query import plan_unified_query
|
|
| 4 |
ROOT=pathlib.Path(__file__).resolve().parents[1]
|
| 5 |
|
| 6 |
def test_version_390():
|
| 7 |
-
assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="3.9.
|
| 8 |
|
| 9 |
def test_joint_squid_environment_plan():
|
| 10 |
p=plan_unified_query("查询 2002 年 8 月西北太平洋柔鱼记录,并匹配 SST、SSH、CHL")
|
|
@@ -32,6 +32,7 @@ def test_endpoint_registered_in_source():
|
|
| 32 |
|
| 33 |
def test_frontend_has_unified_query():
|
| 34 |
js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
|
| 35 |
-
assert "统一智能查询
|
|
|
|
| 36 |
assert "runUnifiedQueryPreview" in js
|
| 37 |
assert "/api/query/preview" in js
|
|
|
|
| 4 |
ROOT=pathlib.Path(__file__).resolve().parents[1]
|
| 5 |
|
| 6 |
def test_version_390():
|
| 7 |
+
assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="3.9.2"
|
| 8 |
|
| 9 |
def test_joint_squid_environment_plan():
|
| 10 |
p=plan_unified_query("查询 2002 年 8 月西北太平洋柔鱼记录,并匹配 SST、SSH、CHL")
|
|
|
|
| 32 |
|
| 33 |
def test_frontend_has_unified_query():
|
| 34 |
js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
|
| 35 |
+
assert "统一智能查询" in js
|
| 36 |
+
assert "统一智能查询 · P1" not in js
|
| 37 |
assert "runUnifiedQueryPreview" in js
|
| 38 |
assert "/api/query/preview" in js
|
tests/test_v381_navigation_ocean_intent.py
CHANGED
|
@@ -3,7 +3,7 @@ import pathlib, ast
|
|
| 3 |
ROOT=pathlib.Path(__file__).resolve().parents[1]
|
| 4 |
|
| 5 |
def test_version_381():
|
| 6 |
-
assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="3.9.
|
| 7 |
|
| 8 |
def test_recent_conversation_switches_to_chat():
|
| 9 |
js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
|
|
|
|
| 3 |
ROOT=pathlib.Path(__file__).resolve().parents[1]
|
| 4 |
|
| 5 |
def test_version_381():
|
| 6 |
+
assert (ROOT/"VERSION").read_text(encoding="utf-8").strip()=="3.9.2"
|
| 7 |
|
| 8 |
def test_recent_conversation_switches_to_chat():
|
| 9 |
js=(ROOT/"static"/"js"/"app.js").read_text(encoding="utf-8")
|
ui_server.py
CHANGED
|
@@ -27,6 +27,7 @@ from routes.datasets import register_datasets_routes
|
|
| 27 |
from routes.project_package import register_project_package_routes
|
| 28 |
from routes.chat import register_chat_routes
|
| 29 |
from routes.unified_query import register_unified_query_routes
|
|
|
|
| 30 |
from services.chat_runtime import init_chat_runtime
|
| 31 |
|
| 32 |
from fisheries_hf import (
|
|
@@ -3182,5 +3183,6 @@ async def sidebar_tasks(request: Request):
|
|
| 3182 |
stream_chat, harness_stream_chat, dispatch_chat_stream = init_chat_runtime(globals())
|
| 3183 |
register_chat_routes(app, globals())
|
| 3184 |
register_unified_query_routes(app, globals())
|
|
|
|
| 3185 |
|
| 3186 |
HTML = Path(__file__).with_name("app.html").read_text(encoding="utf-8")
|
|
|
|
| 27 |
from routes.project_package import register_project_package_routes
|
| 28 |
from routes.chat import register_chat_routes
|
| 29 |
from routes.unified_query import register_unified_query_routes
|
| 30 |
+
from routes.downloads import register_download_routes
|
| 31 |
from services.chat_runtime import init_chat_runtime
|
| 32 |
|
| 33 |
from fisheries_hf import (
|
|
|
|
| 3183 |
stream_chat, harness_stream_chat, dispatch_chat_stream = init_chat_runtime(globals())
|
| 3184 |
register_chat_routes(app, globals())
|
| 3185 |
register_unified_query_routes(app, globals())
|
| 3186 |
+
register_download_routes(app, globals())
|
| 3187 |
|
| 3188 |
HTML = Path(__file__).with_name("app.html").read_text(encoding="utf-8")
|