Skip to content

Commit 3e1f989

Browse files
committed
feat: GUI 总览页添加检查更新按钮
查询 API 获取各产品最新日期,就地刷新表格显示准确的落后天数。 包含确认弹窗(提示 API 额度消耗)、并发查询、全局错误中止、 部分失败处理、source 字段区分数据来源。
2 parents 5f1dcd6 + 78f2ae7 commit 3e1f989

8 files changed

Lines changed: 408 additions & 12 deletions

File tree

quantclass_sync_internal/data_query.py

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ def get_products_overview(
6666
data_root: Path,
6767
catalog_products: Sequence[str],
6868
today: Optional[date] = None,
69+
api_latest_dates: Optional[Dict[str, str]] = None,
6970
) -> List[Dict[str, Any]]:
7071
"""返回所有产品的状态总览列表。
7172
@@ -76,6 +77,8 @@ def get_products_overview(
7677
- last_status: 上次同步状态 ("ok" / "error" / "skipped" / "")
7778
- last_error: 上次错误信息 (str)
7879
- status_color: 状态颜色 ("green" / "yellow" / "red" / "gray")
80+
81+
api_latest_dates: 传入时用 API 实时日期作为参考,跳过缓存和宽限期逻辑。
7982
"""
8083
log_dir = report_dir_path(data_root)
8184
last_results = read_or_backfill_product_last_status(log_dir)
@@ -85,16 +88,24 @@ def get_products_overview(
8588
local_date = read_local_timestamp_date(data_root, product)
8689
last = last_results.get(product, {})
8790
last_status = last.get("status", "")
88-
# 用缓存的 API 日期作为参考,避免周末/假日误报落后;
89-
# 缓存超过宽限期或无缓存时降级回 today,提示可能有新数据
90-
cached_api_date = _parse_date(last.get("date_time", ""))
91-
cache_fresh = (
92-
cached_api_date is not None
93-
and today is not None
94-
and (today - cached_api_date).days <= _STALE_GRACE_DAYS
95-
)
96-
ref_date = cached_api_date if cache_fresh else today
91+
92+
# 优先用传入的 API 实时日期(检查更新按钮场景)
93+
api_date = _parse_date((api_latest_dates or {}).get(product, ""))
94+
if api_date is not None:
95+
ref_date = api_date
96+
else:
97+
# 用缓存的 API 日期作为参考,避免周末/假日误报落后;
98+
# 缓存超过宽限期或无缓存时降级回 today,提示可能有新数据
99+
cached_api_date = _parse_date(last.get("date_time", ""))
100+
cache_fresh = (
101+
cached_api_date is not None
102+
and today is not None
103+
and (today - cached_api_date).days <= _STALE_GRACE_DAYS
104+
)
105+
ref_date = cached_api_date if cache_fresh else today
106+
97107
behind = _days_behind(local_date, ref_date)
108+
# last_status=error 时 _status_color 强制返回 red,不被 api_latest_dates 覆盖(符合预期)
98109
color = _status_color(behind, last_status)
99110
overview.append({
100111
"name": product,

quantclass_sync_internal/gui/api.py

Lines changed: 128 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,9 @@
3636
get_run_detail,
3737
get_run_history,
3838
)
39-
from ..models import CommandContext, UserConfig, log_error, log_info, new_run_id
40-
from ..orchestrator import load_catalog_or_raise, run_update_with_settings
39+
from ..http_client import get_latest_time
40+
from ..models import CommandContext, FatalRequestError, UserConfig, log_error, log_info, new_run_id
41+
from ..orchestrator import _build_headers, load_catalog_or_raise, run_update_with_settings
4142
from ..status_store import report_dir_path
4243

4344

@@ -464,6 +465,131 @@ def get_health_report(self) -> Dict[str, Any]:
464465

465466
return {"ok": True, "health": health}
466467

468+
def check_updates(self) -> Dict[str, Any]:
469+
"""查询 API 获取各产品最新日期,返回实时 overview。
470+
471+
并发查询,总超时 30 秒。区分全局错误(401/403 立即中止)
472+
和单产品错误(跳过计入失败列表)。
473+
"""
474+
from concurrent.futures import ThreadPoolExecutor, as_completed, TimeoutError as FuturesTimeoutError
475+
from datetime import date as _date
476+
477+
user_config, data_root, catalog, err = self._resolve_config()
478+
if err:
479+
return {"ok": False, "error": err}
480+
481+
if not catalog:
482+
return {
483+
"ok": True, "products": [],
484+
"summary": {"green": 0, "yellow": 0, "red": 0, "gray": 0},
485+
"checked": 0, "failed": 0, "failed_products": [],
486+
}
487+
488+
# 解析凭证
489+
secrets_file = DEFAULT_USER_SECRETS_FILE.resolve()
490+
try:
491+
api_key, hid, _ = resolve_credentials_for_update(
492+
cli_api_key="", cli_hid="", secrets_file=secrets_file,
493+
)
494+
except Exception as exc:
495+
return {"ok": False, "error": f"凭证解析失败:{exc}"}
496+
497+
if not api_key or not hid:
498+
return {"ok": False, "error": "未找到有效凭证(API Key / HID),请先完成配置。"}
499+
500+
headers = _build_headers(api_key)
501+
api_base = DEFAULT_API_BASE
502+
503+
# 并发查询各产品 API 最新日期
504+
api_latest_dates: Dict[str, str] = {}
505+
failed_products: list = []
506+
# 全局中止信号:401/403 时通知其他 worker 提前退出
507+
abort_event = threading.Event()
508+
global_error_holder: list = []
509+
510+
def _query_one(product: str) -> tuple:
511+
"""查询单个产品,返回 (product, date_str, error)。"""
512+
if abort_event.is_set():
513+
return (product, None, "已中止")
514+
try:
515+
latest = get_latest_time(api_base, product, hid, headers)
516+
return (product, latest, None)
517+
except FatalRequestError as exc:
518+
if exc.status_code in (401, 403):
519+
abort_event.set()
520+
global_error_holder.append(exc)
521+
return (product, None, str(exc))
522+
return (product, None, str(exc))
523+
except Exception as exc:
524+
return (product, None, str(exc))
525+
526+
executor = ThreadPoolExecutor(max_workers=max(1, min(8, len(catalog))))
527+
try:
528+
futures = {executor.submit(_query_one, p): p for p in catalog}
529+
for future in as_completed(futures, timeout=30):
530+
product, latest, error = future.result()
531+
if error:
532+
failed_products.append(product)
533+
else:
534+
api_latest_dates[product] = latest
535+
# 检测到全局错误后不再等待剩余 future
536+
if abort_event.is_set():
537+
break
538+
except FuturesTimeoutError:
539+
pass # 超时后下面统一处理未完成的产品
540+
except Exception as exc:
541+
return {"ok": False, "error": f"检查更新失败:{exc}"}
542+
finally:
543+
executor.shutdown(wait=False, cancel_futures=True)
544+
545+
# 全局错误(401/403):立即返回
546+
if global_error_holder:
547+
exc = global_error_holder[0]
548+
return {"ok": False, "error": f"API 凭证或额度异常(HTTP {exc.status_code}):{exc}"}
549+
550+
# 按产品名补漏:未进入 api_latest_dates 也未进入 failed_products 的归入失败
551+
for product in catalog:
552+
if product not in api_latest_dates and product not in failed_products:
553+
failed_products.append(product)
554+
555+
# 用 API 日期生成实时 overview
556+
try:
557+
raw_products = get_products_overview(
558+
data_root, catalog, today=_date.today(), api_latest_dates=api_latest_dates,
559+
)
560+
except Exception as exc:
561+
return {"ok": False, "error": f"状态计算失败:{exc}"}
562+
563+
# 转换为前端字段名,附加 source 标记
564+
products = []
565+
for p in raw_products:
566+
source = "api" if p["name"] in api_latest_dates else "cached"
567+
products.append({
568+
"name": p["name"],
569+
"color": p["status_color"],
570+
"local_date": p["local_date"],
571+
"behind_days": p["days_behind"],
572+
"last_result": p["last_status"],
573+
"last_error": p["last_error"],
574+
"source": source,
575+
})
576+
577+
# 统计卡片
578+
summary = {"green": 0, "yellow": 0, "red": 0, "gray": 0}
579+
for p in products:
580+
color = p.get("color", "gray")
581+
if color in summary:
582+
summary[color] += 1
583+
584+
return {
585+
"ok": True,
586+
"products": products,
587+
"summary": summary,
588+
"checked": len(api_latest_dates),
589+
"failed": len(failed_products),
590+
"failed_products": sorted(failed_products),
591+
}
592+
467593
# ------------------------------------------------------------------
468594
# 同步线程内部逻辑(不对外暴露)
469595
# ------------------------------------------------------------------

quantclass_sync_internal/gui/assets/app.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ document.addEventListener('alpine:init', () => {
3232
historyError: '', // 历史页错误信息
3333
historyLoaded: false, // 历史列表是否已加载过(避免重复请求)
3434

35+
// ===== 检查更新状态 =====
36+
checkUpdateLoading: false,
37+
checkUpdateConfirmVisible: false,
38+
checkUpdateResult: null, // {message, isError} 或 null
39+
3540
// ===== 健康检查状态 =====
3641
healthReport: null, // 健康报告结果对象(null 表示未检查过)
3742
healthLoading: false, // 是否正在检查中
@@ -298,6 +303,33 @@ document.addEventListener('alpine:init', () => {
298303
this.historyLoading = false;
299304
},
300305

306+
// ===== 检查更新 =====
307+
308+
// 确认后执行检查更新:查询 API 获取各产品最新日期,就地刷新表格
309+
async doCheckUpdates() {
310+
this.checkUpdateConfirmVisible = false;
311+
if (this.checkUpdateLoading) return;
312+
this.checkUpdateLoading = true;
313+
this.checkUpdateResult = null;
314+
try {
315+
const res = await window.pywebview.api.check_updates();
316+
if (res.ok === false) {
317+
this.checkUpdateResult = { message: res.error, isError: true };
318+
} else {
319+
// 就地刷新表格和统计卡片
320+
this.products = res.products;
321+
this.summary = res.summary;
322+
const msg = '成功查询 ' + res.checked + ' 个产品' +
323+
(res.failed > 0 ? ',' + res.failed + ' 个查询失败' : '');
324+
this.checkUpdateResult = { message: msg, isError: false };
325+
}
326+
} catch (e) {
327+
console.error('checkUpdates failed:', e);
328+
this.checkUpdateResult = { message: String(e), isError: true };
329+
}
330+
this.checkUpdateLoading = false;
331+
},
332+
301333
// ===== 健康检查 =====
302334

303335
// 调用 Python 端 get_health_report(),扫描数据目录检测三类问题

quantclass_sync_internal/gui/assets/index.html

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,14 +135,22 @@ <h2 class="setup-title">QuantClass Sync 初始设置</h2>
135135
</div>
136136
</div>
137137

138-
<!-- 搜索框 -->
138+
<!-- 搜索框 + 检查更新 -->
139139
<div class="filter-bar">
140140
<input
141141
type="text"
142142
class="search-input"
143143
placeholder="搜索产品..."
144144
x-model="searchText"
145145
/>
146+
<button
147+
class="btn btn-health"
148+
@click="checkUpdateConfirmVisible = true"
149+
:disabled="checkUpdateLoading || syncStatus === 'syncing'"
150+
>
151+
<span x-show="!checkUpdateLoading">检查更新</span>
152+
<span x-show="checkUpdateLoading">检查中...</span>
153+
</button>
146154
</div>
147155

148156
<!-- 产品状态表格 -->
@@ -172,6 +180,12 @@ <h2 class="setup-title">QuantClass Sync 初始设置</h2>
172180
无匹配产品
173181
</div>
174182

183+
<!-- 检查更新结果提示 -->
184+
<div x-show="checkUpdateResult" class="health-result" x-cloak style="margin-bottom: 12px;">
185+
<p :class="checkUpdateResult?.isError ? 'text-error' : 'text-success'"
186+
x-text="checkUpdateResult?.message"></p>
187+
</div>
188+
175189
<!-- 数据健康检查 -->
176190
<div class="health-section">
177191
<button class="btn btn-health" @click="checkHealth()" :disabled="healthLoading">
@@ -389,6 +403,17 @@ <h2 class="setup-title">QuantClass Sync 初始设置</h2>
389403
</div>
390404

391405
</main>
406+
407+
<!-- 检查更新确认弹窗(须在 x-data 作用域内) -->
408+
<div class="modal-overlay" x-show="checkUpdateConfirmVisible" x-cloak>
409+
<div class="modal-box">
410+
<p>此操作会消耗 API 访问次数,是否继续?</p>
411+
<div class="modal-actions">
412+
<button class="btn btn-health" @click="doCheckUpdates()">确认</button>
413+
<button class="btn" @click="checkUpdateConfirmVisible = false">取消</button>
414+
</div>
415+
</div>
416+
</div>
392417
</div>
393418
</body>
394419
</html>

quantclass_sync_internal/gui/assets/style.css

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
/* Alpine.js 初始化前隐藏带 x-cloak 的元素,防止闪烁 */
2+
[x-cloak] { display: none !important; }
3+
14
/* ========== 全局重置与基础 ========== */
25
*, *::before, *::after {
36
box-sizing: border-box;
@@ -706,3 +709,20 @@ body {
706709
cursor: not-allowed;
707710
pointer-events: none;
708711
}
712+
713+
/* 检查更新确认弹窗 */
714+
.modal-overlay {
715+
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
716+
background: rgba(0,0,0,0.3); display: flex;
717+
align-items: center; justify-content: center; z-index: 100;
718+
}
719+
.modal-box {
720+
background: #fff; border-radius: 12px; padding: 24px;
721+
max-width: 360px; text-align: center; box-shadow: 0 4px 20px rgba(0,0,0,0.15);
722+
}
723+
.modal-actions {
724+
margin-top: 16px; display: flex; gap: 12px; justify-content: center;
725+
}
726+
727+
/* 检查更新结果文字 */
728+
.text-success { color: #16a34a; }

0 commit comments

Comments
 (0)