Zin299 commited on
Commit
30dfd74
·
1 Parent(s): af45085

Deploy v3.9.2 unified download manager

Browse files
README.md CHANGED
@@ -9,7 +9,7 @@ app_port: 7860
9
 
10
  # Global Marine Foundation Data Agent
11
 
12
- Current UI release: **v3.9.0**.
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.0
 
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"}),url=URL.createObjectURL(blob),a=document.createElement("a");
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"}),url=URL.createObjectURL(blob),a=document.createElement("a");a.href=url;a.download="marine_dataset_comparison_"+new Date().toISOString().slice(0,10)+".csv";a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);toast("对比表已导出")}}catch(err){wrap.querySelector(".comparison-live").innerHTML='<div class="detail-note">对比读取失败:'+esc(err.message||String(err))+'</div>'}
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"}),url=URL.createObjectURL(blob),a=document.createElement("a");a.href=url;a.download="marine_dataset_live_inventory_"+new Date().toISOString().slice(0,10)+".csv";a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);toast("实时盘点表已导出")
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"}),url=URL.createObjectURL(blob),a=document.createElement("a");a.href=url;a.download="hf_fisheries_repository_quality_"+new Date().toISOString().slice(0,10)+".json";a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);toast("体检报告已导出")}}catch(err){box.innerHTML='<div class="detail-note">仓库体检失败:'+esc(err.message||String(err))+'</div>'}};
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"}),url=URL.createObjectURL(blob),a=document.createElement("a");a.href=url;a.download="marine_metadata_audit_"+new Date().toISOString().slice(0,10)+".csv";a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);toast("元数据审计表已导出")}}catch(err){box.innerHTML='<div class="detail-note">元数据审计失败:'+esc(err.message||String(err))+'</div>'}};
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"}),a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download="marine-service-diagnostic-"+new Date().toISOString().replace(/[:.]/g,"-")+".json";a.click();setTimeout(()=>URL.revokeObjectURL(a.href),1000)};
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>统一智能查询 · P1</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>'+
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>统一智能查询 · P1</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("");
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"}),url=URL.createObjectURL(blob),a=document.createElement("a");
983
- a.href=url;a.download="task_"+(task.task_id||"result")+".json";a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);toast("任务结果已导出")
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.download");
1178
- if(!a || !USER_ID) return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1179
 
1180
  if(SETTINGS.confirmDownload && !confirm("确认下载这个数据文件?")){
1181
  e.preventDefault();
1182
  return;
1183
  }
 
1184
 
1185
- let filename="";
1186
- try{
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()=>{const btn=$("packageDownload");btn.disabled=true;btn.textContent="下载中…";try{const resp=await fetch(r.download_url,{headers:authHeaders()});if(!resp.ok)throw Error(await resp.text());const blob=await resp.blob(),url=URL.createObjectURL(blob),a=document.createElement("a");a.href=url;a.download="project_data_package.zip";a.click();setTimeout(()=>URL.revokeObjectURL(url),2000);toast("项目数据包已下载")}catch(err){toast("下载失败:"+(err.message||String(err)))}finally{btn.disabled=false;btn.textContent="↓ 下载 ZIP"}};
 
 
 
 
 
 
 
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.0"
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.0"
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.0"
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.0"
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.0"
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.0"
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.0"
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.0"
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.0"
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.0"
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 "统一智能查询 · P1" in js
 
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.0"
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")