feat(HEL-566): 去掉主站行情管理入口并修好模型列表收起
主站行情管理并入数据中枢
- 数据中枢数据源配置页新增「主站行情任务」卡:交易时段后台刷新开关、
手动后台刷新、历史区间回补(任务提交 + 轮询主站 job 记录亮灯),
数据与调度仍归主站,控制台只经桥接代操作
- 新增桥接端点 /api/hub-admin/market/{refresh,backfill};回补改为
market.backfill 后台任务(jobs.config.json 注册,锁独立,超时 30 分钟),
桥接调用秒回,不再阻塞
- 主站桌面端删除顶栏「行情管理」按钮与整个行情管理对话框,清理随之
失效的 CSS;移动端删除 system/admin 页与入口,管理员专区只留数据中枢
- Tushare Token / iFinD 凭证编辑沿用数据源页既有凭证区,无功能缺失
模型池收起修复
- 拉出模型清单后按钮切换为「收起列表」,收起只留一行摘要;再次点击
重新拉取并展开;勾选添加完成后清单自动收起(原逻辑保留)
- 交互全部沿用 HEL-558 已确认样式的既有按钮与提示组件,未新增视觉
自测
- verify_baseline 通过;pytest 492 项通过;数据中枢 240 项通过
- verify_datahub_console 新增 [11b] 行情任务桥接端到端段;UI 自测新增
行情任务卡开关往返、模型清单展开/收起/再展开/添加自动收起,日夜主题
与 1030 窄屏复验通过
- Playwright e2e 102/103:唯一失败项在基线提交上同样失败(本机字体度量
导致的头部溢出,与本卡无关)
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -117,7 +117,7 @@ compose.yaml
|
|||||||
.env.example 环境变量模板(复制为 .env 后填写)
|
.env.example 环境变量模板(复制为 .env 后填写)
|
||||||
```
|
```
|
||||||
|
|
||||||
管理员的配置入口在数据中枢(页面右上角「数据中枢」按钮,固定指向 `http://192.168.200.11:8766/admin/`):数据源凭证、模型池、会员与邀请码都在那里维护,数据仍存在主站同一份 SQLite 里。主站自身只保留「行情管理」面板(Tushare Token、后台刷新开关、手动刷新与补数)。所有用户读取同一份 SQLite 行情快照。`.env` 中的 Tushare 和平台 LLM 配置只用于初始化系统配置。
|
管理员的配置入口在数据中枢(页面右上角「数据中枢」按钮,固定指向 `http://192.168.200.11:8766/admin/`):数据源凭证、模型池、会员与邀请码都在那里维护,主站行情任务(后台刷新开关、手动刷新与历史回补)在数据中枢「数据源配置」页的行情任务卡维护,数据仍存在主站同一份 SQLite 里,主站不再保留单独的「行情管理」入口。所有用户读取同一份 SQLite 行情快照。`.env` 中的 Tushare 和平台 LLM 配置只用于初始化系统配置。
|
||||||
|
|
||||||
数据中枢用主站的管理员账号进入,没有独立账号;桥接令牌 `HUB_ADMIN_TOKEN` 需在主站与中枢两侧 `.env` 填成同一个值。详见 [xiaobai-datahub/README.md](xiaobai-datahub/README.md)。
|
数据中枢用主站的管理员账号进入,没有独立账号;桥接令牌 `HUB_ADMIN_TOKEN` 需在主站与中枢两侧 `.env` 填成同一个值。详见 [xiaobai-datahub/README.md](xiaobai-datahub/README.md)。
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ HUB_SERVICE_HANDLERS = {
|
|||||||
"/api/hub-admin/settings/save": "hub_save_settings",
|
"/api/hub-admin/settings/save": "hub_save_settings",
|
||||||
"/api/hub-admin/settings/test": "hub_test_model",
|
"/api/hub-admin/settings/test": "hub_test_model",
|
||||||
"/api/hub-admin/models/fetch": "hub_fetch_models",
|
"/api/hub-admin/models/fetch": "hub_fetch_models",
|
||||||
|
"/api/hub-admin/market/refresh": "hub_market_refresh",
|
||||||
|
"/api/hub-admin/market/backfill": "hub_market_backfill",
|
||||||
"/api/hub-admin/members": "hub_members",
|
"/api/hub-admin/members": "hub_members",
|
||||||
"/api/hub-admin/membership/save": "hub_save_membership",
|
"/api/hub-admin/membership/save": "hub_save_membership",
|
||||||
"/api/hub-admin/invites": "hub_invites",
|
"/api/hub-admin/invites": "hub_invites",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from datetime import date
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
|
|
||||||
from backend.features.accounts.security import token_hash, verify_password
|
from backend.features.accounts.security import token_hash, verify_password
|
||||||
@@ -111,6 +112,44 @@ class HubAdminHttpMixin:
|
|||||||
except (ValueError, json.JSONDecodeError) as exc:
|
except (ValueError, json.JSONDecodeError) as exc:
|
||||||
self._hub_failure(exc)
|
self._hub_failure(exc)
|
||||||
|
|
||||||
|
def hub_market_refresh(self) -> None:
|
||||||
|
"""HEL-566: manual snapshot refresh, submitted as a tracked job."""
|
||||||
|
try:
|
||||||
|
body = self._hub_body()
|
||||||
|
trade_date = str(body.get("trade_date") or "") or date.today().isoformat()
|
||||||
|
refresh = self.application_service.request_background_sync(trade_date)
|
||||||
|
started = bool(refresh.get("started"))
|
||||||
|
self.send_json(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"started": started,
|
||||||
|
"job_key": str(refresh.get("job_key") or ""),
|
||||||
|
"message": "后台刷新已开始" if started else "已有后台刷新任务正在运行",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except (ValueError, json.JSONDecodeError) as exc:
|
||||||
|
self._hub_failure(exc)
|
||||||
|
|
||||||
|
def hub_market_backfill(self) -> None:
|
||||||
|
"""HEL-566: historical snapshot backfill, submitted as a tracked job."""
|
||||||
|
try:
|
||||||
|
body = self._hub_body()
|
||||||
|
result = self.application_service.request_market_backfill(
|
||||||
|
str(body.get("start_date") or ""),
|
||||||
|
str(body.get("end_date") or ""),
|
||||||
|
)
|
||||||
|
started = bool(result.get("started"))
|
||||||
|
self.send_json(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"started": started,
|
||||||
|
"job_key": str(result.get("job_key") or ""),
|
||||||
|
"message": "历史回补任务已开始" if started else "已有回补任务正在运行",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except (ValueError, json.JSONDecodeError) as exc:
|
||||||
|
self._hub_failure(exc)
|
||||||
|
|
||||||
def hub_members(self) -> None:
|
def hub_members(self) -> None:
|
||||||
service = self.application_service
|
service = self.application_service
|
||||||
self.send_json(
|
self.send_json(
|
||||||
|
|||||||
@@ -32,6 +32,23 @@ class JobServiceMixin:
|
|||||||
)
|
)
|
||||||
return {"started": started, "job_key": key if started else ""}
|
return {"started": started, "job_key": key if started else ""}
|
||||||
|
|
||||||
|
def request_market_backfill(self, start_date: str, end_date: str) -> dict[str, object]:
|
||||||
|
"""HEL-566: submit the admin snapshot backfill as a tracked job.
|
||||||
|
|
||||||
|
The data hub console triggers this over the service bridge; the work
|
||||||
|
itself can run for minutes, so it must not block the bridge call.
|
||||||
|
"""
|
||||||
|
normalized_start = normalize_date(start_date)
|
||||||
|
normalized_end = normalize_date(end_date)
|
||||||
|
key = f"backfill:{normalized_start}:{normalized_end}:{time.time_ns()}"
|
||||||
|
started = self.jobs.submit(
|
||||||
|
"market.backfill",
|
||||||
|
key,
|
||||||
|
lambda: self.backfill(normalized_start, normalized_end),
|
||||||
|
{"start_date": normalized_start, "end_date": normalized_end, "trigger": "administrator"},
|
||||||
|
)
|
||||||
|
return {"started": started, "job_key": key if started else ""}
|
||||||
|
|
||||||
def _background_refresh_tick(self) -> None:
|
def _background_refresh_tick(self) -> None:
|
||||||
if not (
|
if not (
|
||||||
self.configured
|
self.configured
|
||||||
|
|||||||
@@ -479,8 +479,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "frontend/index.html",
|
"path": "frontend/index.html",
|
||||||
"bytes": 46900,
|
"bytes": 44025,
|
||||||
"lines": 638
|
"lines": 608
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/data/providers/tushare_industries.py",
|
"path": "backend/data/providers/tushare_industries.py",
|
||||||
@@ -547,11 +547,6 @@
|
|||||||
"bytes": 15743,
|
"bytes": 15743,
|
||||||
"lines": 401
|
"lines": 401
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "frontend/shared/dashboard.js",
|
|
||||||
"bytes": 15063,
|
|
||||||
"lines": 321
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "frontend/pages/pools/page.html",
|
"path": "frontend/pages/pools/page.html",
|
||||||
"bytes": 14942,
|
"bytes": 14942,
|
||||||
@@ -567,10 +562,15 @@
|
|||||||
"bytes": 14467,
|
"bytes": 14467,
|
||||||
"lines": 356
|
"lines": 356
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "frontend/shared/dashboard.js",
|
||||||
|
"bytes": 13708,
|
||||||
|
"lines": 304
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "frontend/shared/session.js",
|
"path": "frontend/shared/session.js",
|
||||||
"bytes": 13633,
|
"bytes": 13567,
|
||||||
"lines": 296
|
"lines": 295
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/features/market/insights_auction_data.py",
|
"path": "backend/features/market/insights_auction_data.py",
|
||||||
@@ -642,16 +642,16 @@
|
|||||||
"bytes": 6739,
|
"bytes": 6739,
|
||||||
"lines": 156
|
"lines": 156
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "frontend/shared/context.js",
|
|
||||||
"bytes": 6547,
|
|
||||||
"lines": 220
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "backend/application.py",
|
"path": "backend/application.py",
|
||||||
"bytes": 6500,
|
"bytes": 6500,
|
||||||
"lines": 164
|
"lines": 164
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "frontend/shared/context.js",
|
||||||
|
"bytes": 6492,
|
||||||
|
"lines": 219
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "frontend/pages/sentiment/page.html",
|
"path": "frontend/pages/sentiment/page.html",
|
||||||
"bytes": 6488,
|
"bytes": 6488,
|
||||||
@@ -682,6 +682,11 @@
|
|||||||
"bytes": 5592,
|
"bytes": 5592,
|
||||||
"lines": 123
|
"lines": 123
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/http/dispatch.py",
|
||||||
|
"bytes": 5401,
|
||||||
|
"lines": 141
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "frontend/pages.config.js",
|
"path": "frontend/pages.config.js",
|
||||||
"bytes": 5385,
|
"bytes": 5385,
|
||||||
@@ -697,11 +702,6 @@
|
|||||||
"bytes": 5350,
|
"bytes": 5350,
|
||||||
"lines": 74
|
"lines": 74
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "backend/http/dispatch.py",
|
|
||||||
"bytes": 5281,
|
|
||||||
"lines": 139
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "frontend/shared/feedback.js",
|
"path": "frontend/shared/feedback.js",
|
||||||
"bytes": 5157,
|
"bytes": 5157,
|
||||||
@@ -717,11 +717,6 @@
|
|||||||
"bytes": 4712,
|
"bytes": 4712,
|
||||||
"lines": 106
|
"lines": 106
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "frontend/shared/admin.js",
|
|
||||||
"bytes": 4500,
|
|
||||||
"lines": 109
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "backend/data/providers/tushare_helpers.py",
|
"path": "backend/data/providers/tushare_helpers.py",
|
||||||
"bytes": 4406,
|
"bytes": 4406,
|
||||||
@@ -757,11 +752,6 @@
|
|||||||
"bytes": 3369,
|
"bytes": 3369,
|
||||||
"lines": 81
|
"lines": 81
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "frontend/app.js",
|
|
||||||
"bytes": 3337,
|
|
||||||
"lines": 95
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "frontend/pages/themes/page.html",
|
"path": "frontend/pages/themes/page.html",
|
||||||
"bytes": 3316,
|
"bytes": 3316,
|
||||||
@@ -772,11 +762,21 @@
|
|||||||
"bytes": 3299,
|
"bytes": 3299,
|
||||||
"lines": 57
|
"lines": 57
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "backend/jobs/service.py",
|
||||||
|
"bytes": 3186,
|
||||||
|
"lines": 76
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/features/market/insights_context.py",
|
"path": "backend/features/market/insights_context.py",
|
||||||
"bytes": 3175,
|
"bytes": 3175,
|
||||||
"lines": 84
|
"lines": 84
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "frontend/app.js",
|
||||||
|
"bytes": 3081,
|
||||||
|
"lines": 91
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "frontend/pages/market/bindings.js",
|
"path": "frontend/pages/market/bindings.js",
|
||||||
"bytes": 2663,
|
"bytes": 2663,
|
||||||
@@ -787,11 +787,6 @@
|
|||||||
"bytes": 2597,
|
"bytes": 2597,
|
||||||
"lines": 69
|
"lines": 69
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"path": "backend/jobs/service.py",
|
|
||||||
"bytes": 2337,
|
|
||||||
"lines": 59
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"path": "backend/features/mentor/routes.py",
|
"path": "backend/features/mentor/routes.py",
|
||||||
"bytes": 2299,
|
"bytes": 2299,
|
||||||
@@ -887,6 +882,11 @@
|
|||||||
"bytes": 817,
|
"bytes": 817,
|
||||||
"lines": 23
|
"lines": 23
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "frontend/shared/admin.js",
|
||||||
|
"bytes": 800,
|
||||||
|
"lines": 18
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "backend/features/sentiment/routes.py",
|
"path": "backend/features/sentiment/routes.py",
|
||||||
"bytes": 724,
|
"bytes": 724,
|
||||||
|
|||||||
@@ -21,6 +21,16 @@
|
|||||||
"max_attempts": 1,
|
"max_attempts": 1,
|
||||||
"output_version": "screener-library-v8"
|
"output_version": "screener-library-v8"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "market.backfill",
|
||||||
|
"schedule": "administrator request from the data hub console",
|
||||||
|
"input_date_policy": "requested start and end trade dates",
|
||||||
|
"dependencies": ["market provider", "trade calendar", "database"],
|
||||||
|
"lock_key": "market-backfill",
|
||||||
|
"timeout_seconds": 1800,
|
||||||
|
"max_attempts": 1,
|
||||||
|
"output_version": "snapshot-backfill-v1"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "market.ifind-event-enrichment",
|
"id": "market.ifind-event-enrichment",
|
||||||
"schedule": "on demand after market close",
|
"schedule": "on demand after market close",
|
||||||
|
|||||||
@@ -13,10 +13,6 @@ async function initialize() {
|
|||||||
document.querySelector("#journalDate").max = todayString();
|
document.querySelector("#journalDate").max = todayString();
|
||||||
document.querySelector("#tradeLogDate").value = elements.tradeDate.value;
|
document.querySelector("#tradeLogDate").value = elements.tradeDate.value;
|
||||||
document.querySelector("#tradeLogDate").max = todayString();
|
document.querySelector("#tradeLogDate").max = todayString();
|
||||||
document.querySelector("#backfillStart").value = todayString();
|
|
||||||
document.querySelector("#backfillEnd").value = todayString();
|
|
||||||
document.querySelector("#backfillStart").max = todayString();
|
|
||||||
document.querySelector("#backfillEnd").max = todayString();
|
|
||||||
document.querySelector("#qiObservationDate").value = elements.tradeDate.value;
|
document.querySelector("#qiObservationDate").value = elements.tradeDate.value;
|
||||||
document.querySelector("#qiObservationDate").max = todayString();
|
document.querySelector("#qiObservationDate").max = todayString();
|
||||||
document.querySelector("#accountBirthDate").max = todayString();
|
document.querySelector("#accountBirthDate").max = todayString();
|
||||||
|
|||||||
@@ -157,7 +157,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<button id="refreshButton" class="button command-button" type="button" title="刷新"><i data-lucide="refresh-cw"></i><span>刷新</span></button>
|
<button id="refreshButton" class="button command-button" type="button" title="刷新"><i data-lucide="refresh-cw"></i><span>刷新</span></button>
|
||||||
<button id="syncButton" class="button primary command-button" type="button" title="后台刷新" hidden><i data-lucide="cloud-download"></i><span>后台刷新</span></button>
|
<button id="syncButton" class="button primary command-button" type="button" title="后台刷新" hidden><i data-lucide="cloud-download"></i><span>后台刷新</span></button>
|
||||||
<button id="marketAdminButton" class="button command-button" type="button" title="行情管理" hidden><i data-lucide="settings-2"></i><span>行情管理</span></button>
|
|
||||||
<button id="settingsButton" class="button command-button" type="button" title="数据中枢" hidden><i data-lucide="database"></i><span>数据中枢</span></button>
|
<button id="settingsButton" class="button command-button" type="button" title="数据中枢" hidden><i data-lucide="database"></i><span>数据中枢</span></button>
|
||||||
<div class="account-menu-shell">
|
<div class="account-menu-shell">
|
||||||
<div id="accountRoleBadges" class="account-role-badges" aria-label="账号身份">
|
<div id="accountRoleBadges" class="account-role-badges" aria-label="账号身份">
|
||||||
@@ -592,35 +591,6 @@
|
|||||||
</section>
|
</section>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<dialog id="adminDialog" class="settings-dialog admin-dialog" aria-labelledby="adminDialogTitle">
|
|
||||||
<div class="dialog-header">
|
|
||||||
<div><span class="dialog-eyebrow">管理员</span><h2 id="adminDialogTitle">行情管理</h2></div>
|
|
||||||
<button id="closeAdminDialog" class="icon-button" type="button" aria-label="关闭" title="关闭"><i data-lucide="x"></i></button>
|
|
||||||
</div>
|
|
||||||
<div id="adminConnectionStatus" class="connection-status" aria-live="polite">正在读取系统状态</div>
|
|
||||||
<p class="form-hint">模型池、会员与邀请码已统一在数据中枢管理,点击顶栏「数据中枢」进入。</p>
|
|
||||||
<div class="admin-panel" data-admin-panel="market">
|
|
||||||
<form id="systemMarketForm" class="settings-section">
|
|
||||||
<div class="settings-section-heading"><h3>公共行情</h3><span id="systemDataStatus">待检查</span></div>
|
|
||||||
<label class="form-field"><span>行情来源凭据</span><input id="systemTokenInput" type="text" value="请在数据中枢后台统一管理" disabled></label>
|
|
||||||
<label class="form-field"><span>实时来源凭据</span><input id="systemIfindTokenInput" type="text" value="请在数据中枢后台统一管理" disabled></label>
|
|
||||||
<label class="switch-control"><input id="systemBackgroundRefresh" type="checkbox"><span>启用交易时段后台刷新</span></label>
|
|
||||||
<p class="form-hint">所有用户读取同一份后台快照,页面不会随后台任务自动重绘。</p>
|
|
||||||
<div id="datahubRouteStatus" class="admin-refresh-status" data-tone="idle" role="status" aria-live="polite"><i data-lucide="database"></i><span>数据中枢线路待检查</span></div>
|
|
||||||
<div id="adminRefreshStatus" class="admin-refresh-status" data-tone="idle" role="status" aria-live="polite"><i data-lucide="circle-dot"></i><span>尚未手动刷新</span></div>
|
|
||||||
<div class="dialog-actions admin-inline-actions"><button id="adminRefreshButton" class="button" type="button"><i data-lucide="refresh-cw"></i>立即后台刷新</button><button class="button primary" type="submit">保存刷新设置</button></div>
|
|
||||||
</form>
|
|
||||||
<section class="settings-section">
|
|
||||||
<div class="settings-section-heading"><h3>历史数据回补</h3><span>管理员任务</span></div>
|
|
||||||
<div class="backfill-controls">
|
|
||||||
<label class="form-field"><span>开始日期</span><input id="backfillStart" type="date"></label>
|
|
||||||
<label class="form-field"><span>结束日期</span><input id="backfillEnd" type="date"></label>
|
|
||||||
</div>
|
|
||||||
<div class="dialog-actions"><button id="backfillButton" class="button" type="button">开始回补</button></div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</dialog>
|
|
||||||
|
|
||||||
<div id="loadingOverlay" class="loading-overlay" hidden>
|
<div id="loadingOverlay" class="loading-overlay" hidden>
|
||||||
<div class="loading-box">
|
<div class="loading-box">
|
||||||
<span class="spinner" aria-hidden="true"></span>
|
<span class="spinner" aria-hidden="true"></span>
|
||||||
|
|||||||
@@ -51,8 +51,7 @@
|
|||||||
items: [
|
items: [
|
||||||
{ key: "system/profile", label: "账号资料", icon: "user" },
|
{ key: "system/profile", label: "账号资料", icon: "user" },
|
||||||
{ key: "system/password", label: "修改密码", icon: "lock" },
|
{ key: "system/password", label: "修改密码", icon: "lock" },
|
||||||
{ key: "system/membership", label: "会员状态", icon: "gem" },
|
{ key: "system/membership", label: "会员状态", icon: "gem" }
|
||||||
{ key: "system/admin", label: "行情管理", icon: "sliders-horizontal", adminOnly: true }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3407,44 +3407,6 @@
|
|||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-sys-status-list {
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-sys-status-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: var(--font-size-body);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-sys-dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-sys-dot--ok {
|
|
||||||
background: var(--market-down);
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-sys-switch-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-sys-switch-row strong {
|
|
||||||
display: block;
|
|
||||||
font-size: var(--font-size-body);
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.m-btn-outline,
|
.m-btn-outline,
|
||||||
.m-btn-outline-danger {
|
.m-btn-outline-danger {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
+6
-142
@@ -3132,7 +3132,6 @@
|
|||||||
"system/profile": setupSystemPage,
|
"system/profile": setupSystemPage,
|
||||||
"system/password": setupSystemPage,
|
"system/password": setupSystemPage,
|
||||||
"system/membership": setupSystemPage,
|
"system/membership": setupSystemPage,
|
||||||
"system/admin": setupSystemPage,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const COMPLEX_LOADERS = {
|
const COMPLEX_LOADERS = {
|
||||||
@@ -3154,7 +3153,6 @@
|
|||||||
"system/profile": loadSystem,
|
"system/profile": loadSystem,
|
||||||
"system/password": loadSystem,
|
"system/password": loadSystem,
|
||||||
"system/membership": loadSystem,
|
"system/membership": loadSystem,
|
||||||
"system/admin": loadSystem,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function isComplexPage(key) {
|
function isComplexPage(key) {
|
||||||
@@ -4928,14 +4926,6 @@
|
|||||||
return String(key || state.key || "").indexOf("system/") === 0;
|
return String(key || state.key || "").indexOf("system/") === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function systemForbiddenHtml() {
|
|
||||||
return '<div class="m-state m-motion-rise-in" data-system-page="forbidden">' +
|
|
||||||
'<span class="m-state-icon">' + icon("lock", 26) + "</span>" +
|
|
||||||
"<p>仅管理员可访问</p>" +
|
|
||||||
"<small>行情管理与数据中枢需要管理员权限。</small>" +
|
|
||||||
"</div>";
|
|
||||||
}
|
|
||||||
|
|
||||||
function systemFill(html) {
|
function systemFill(html) {
|
||||||
const scroll = document.getElementById("m-scroll");
|
const scroll = document.getElementById("m-scroll");
|
||||||
if (scroll) scroll.innerHTML = html;
|
if (scroll) scroll.innerHTML = html;
|
||||||
@@ -4975,26 +4965,17 @@
|
|||||||
|
|
||||||
function loadSystem() {
|
function loadSystem() {
|
||||||
const key = state.key;
|
const key = state.key;
|
||||||
if (key === "system/admin" && !global.MobileSession.isAdmin()) {
|
|
||||||
systemFill(systemForbiddenHtml());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (key === "system/password") {
|
if (key === "system/password") {
|
||||||
renderSystemPassword();
|
renderSystemPassword();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const seq = nextSeq();
|
const seq = nextSeq();
|
||||||
const url = key === "system/admin" ? "/api/admin/settings" : "/api/account/status";
|
const url = "/api/account/status";
|
||||||
return global.MobileAPI.request(url).then(function (payload) {
|
return global.MobileAPI.request(url).then(function (payload) {
|
||||||
if (seq !== state.seq || state.key !== key) return;
|
if (seq !== state.seq || state.key !== key) return;
|
||||||
if (key === "system/admin") {
|
state.system.account = payload || {};
|
||||||
state.system.admin = payload || {};
|
if (key === "system/profile") renderSystemProfile();
|
||||||
renderSystemAdmin();
|
else renderSystemMembership();
|
||||||
} else {
|
|
||||||
state.system.account = payload || {};
|
|
||||||
if (key === "system/profile") renderSystemProfile();
|
|
||||||
else renderSystemMembership();
|
|
||||||
}
|
|
||||||
}).catch(function (error) {
|
}).catch(function (error) {
|
||||||
if (seq !== state.seq || state.key !== key) return;
|
if (seq !== state.seq || state.key !== key) return;
|
||||||
systemFill(errorHtml(error && error.message ? error.message : "加载失败"));
|
systemFill(errorHtml(error && error.message ? error.message : "加载失败"));
|
||||||
@@ -5078,9 +5059,6 @@
|
|||||||
{ key: "system/password", icon: "lock", label: "修改密码", hint: "建议定期更换" },
|
{ key: "system/password", icon: "lock", label: "修改密码", hint: "建议定期更换" },
|
||||||
{ key: "system/membership", icon: "gem", label: "会员状态", hint: "有效期与智能分析额度" }
|
{ key: "system/membership", icon: "gem", label: "会员状态", hint: "有效期与智能分析额度" }
|
||||||
];
|
];
|
||||||
const adminRows = [
|
|
||||||
{ key: "system/admin", icon: "sliders-horizontal", label: "行情管理", hint: "数据源状态 · 后台刷新 · 历史回补" }
|
|
||||||
];
|
|
||||||
const html =
|
const html =
|
||||||
'<div class="m-sys-home" data-system-page="home">' +
|
'<div class="m-sys-home" data-system-page="home">' +
|
||||||
'<div class="m-card m-sys-profile-card"><span class="m-sys-avatar">' + escapeHtml(avatar) + "</span><div>" +
|
'<div class="m-card m-sys-profile-card"><span class="m-sys-avatar">' + escapeHtml(avatar) + "</span><div>" +
|
||||||
@@ -5093,7 +5071,7 @@
|
|||||||
'<h3 class="m-sys-group-title">偏好</h3>' +
|
'<h3 class="m-sys-group-title">偏好</h3>' +
|
||||||
'<div class="m-card m-sys-list">' + systemThemeRowHtml() + "</div>" +
|
'<div class="m-card m-sys-list">' + systemThemeRowHtml() + "</div>" +
|
||||||
(global.MobileSession.isAdmin()
|
(global.MobileSession.isAdmin()
|
||||||
? '<h3 class="m-sys-group-title">管理员专区</h3><div class="m-card m-sys-list">' + adminRows.map(systemRowHtml).join("") + dataHubRowHtml() + "</div>"
|
? '<h3 class="m-sys-group-title">管理员专区</h3><div class="m-card m-sys-list">' + dataHubRowHtml() + "</div>"
|
||||||
: "") +
|
: "") +
|
||||||
'<h3 class="m-sys-group-title">其他</h3>' +
|
'<h3 class="m-sys-group-title">其他</h3>' +
|
||||||
'<div class="m-card m-sys-list">' +
|
'<div class="m-card m-sys-list">' +
|
||||||
@@ -5106,7 +5084,7 @@
|
|||||||
'<span class="m-sys-row-body"><strong>退出登录</strong><small>退出后需要重新登录</small></span>' +
|
'<span class="m-sys-row-body"><strong>退出登录</strong><small>退出后需要重新登录</small></span>' +
|
||||||
'<span class="m-sys-row-chevron">' + icon("chevron-right", 16) + "</span></button>" +
|
'<span class="m-sys-row-chevron">' + icon("chevron-right", 16) + "</span></button>" +
|
||||||
"</div>" +
|
"</div>" +
|
||||||
'<p class="m-sys-foot">' + (global.MobileSession.isAdmin() ? "小白复盘 · 内网个人版" : "行情管理与数据中枢仅管理员可见") + "</p>" +
|
'<p class="m-sys-foot">' + (global.MobileSession.isAdmin() ? "小白复盘 · 内网个人版" : "数据中枢仅管理员可见") + "</p>" +
|
||||||
"</div>";
|
"</div>";
|
||||||
document.getElementById("m-view").innerHTML = html;
|
document.getElementById("m-view").innerHTML = html;
|
||||||
}
|
}
|
||||||
@@ -5196,55 +5174,6 @@
|
|||||||
systemFill(html);
|
systemFill(html);
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusDot(ok) {
|
|
||||||
return '<span class="m-sys-dot' + (ok ? " m-sys-dot--ok" : "") + '"></span>';
|
|
||||||
}
|
|
||||||
|
|
||||||
function datahubStatusText(hub) {
|
|
||||||
const enabled = number(hub.enabled_reads);
|
|
||||||
const total = number(hub.total_reads) || enabled;
|
|
||||||
const fallbacks = hub.fallback_labels || [];
|
|
||||||
if (fallbacks.length) return " 备用 " + fallbacks.join("、");
|
|
||||||
if (hub.configured) return " 主线路 " + enabled + "/" + total;
|
|
||||||
return " 未配置";
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSystemAdmin() {
|
|
||||||
const payload = state.system.admin || {};
|
|
||||||
const data = payload.data || {};
|
|
||||||
const ifind = data.ifind || {};
|
|
||||||
const marketHtml =
|
|
||||||
'<div class="m-sys-body" data-system-admin-panel="market">' +
|
|
||||||
'<div class="m-card m-sys-section"><strong>数据源状态</strong>' +
|
|
||||||
'<div class="m-sys-status-list">' +
|
|
||||||
'<div class="m-sys-status-item"><span>数据中枢</span><span>' + statusDot(data.configured) + (data.configured ? " 已连接" : " 未连接") + "</span></div>" +
|
|
||||||
'<div class="m-sys-status-item"><span>iFinD</span><span>' + statusDot(ifind.configured) + (ifind.configured ? " 已配置" : " 未配置") + "</span></div>" +
|
|
||||||
'<div class="m-sys-status-item"><span>行情快照</span><strong>' + number(data.snapshot_dates) + " 个交易日</strong></div>" +
|
|
||||||
'<div class="m-sys-status-item"><span>后台刷新</span><span>' + statusDot(data.background_refresh_enabled) + (data.background_refresh_enabled ? " 已启用" : " 已暂停") + "</span></div>" +
|
|
||||||
'<div class="m-sys-status-item"><span>数据中枢</span><span>' + statusDot(Boolean((data.datahub || {}).configured) && !((data.datahub || {}).fallback_count)) + datahubStatusText(data.datahub || {}) + "</span></div>" +
|
|
||||||
"</div></div>" +
|
|
||||||
'<div class="m-card m-sys-section"><strong>数据源密钥</strong>' +
|
|
||||||
formFieldHtml("行情来源凭据", '<input id="m-sys-token" type="text" value="请在数据中枢后台统一管理" disabled>', false) +
|
|
||||||
formFieldHtml("实时来源凭据", '<input id="m-sys-ifind" type="text" value="请在数据中枢后台统一管理" disabled>', false) +
|
|
||||||
'<button class="m-btn-primary" type="button" data-system-save-market>刷新状态</button></div>' +
|
|
||||||
'<div class="m-card m-sys-section"><strong>后台刷新</strong>' +
|
|
||||||
'<div class="m-sys-switch-row"><div><strong>交易时段自动刷新</strong><p class="m-sys-hint">开启后后台定时更新快照</p></div>' +
|
|
||||||
'<button class="m-theme-switch" type="button" data-system-toggle-refresh role="switch" aria-checked="' + (data.background_refresh_enabled ? "true" : "false") + '" aria-label="交易时段自动刷新"><span class="m-theme-switch-thumb"></span></button></div>' +
|
|
||||||
'<input id="m-sys-bg-refresh" type="checkbox"' + (data.background_refresh_enabled ? " checked" : "") + ' hidden>' +
|
|
||||||
'<button class="m-btn-outline" type="button" data-system-refresh>立即刷新一次</button>' +
|
|
||||||
'<p class="m-sys-hint">所有用户读取同一份快照,刷新不影响当前页面内容。</p></div>' +
|
|
||||||
'<div class="m-card m-sys-section"><strong>历史数据回补</strong>' +
|
|
||||||
formFieldHtml("开始日期", dateInputHtml("m-sys-backfill-start", ""), false) +
|
|
||||||
formFieldHtml("结束日期", dateInputHtml("m-sys-backfill-end", ""), false) +
|
|
||||||
'<button class="m-btn-outline" type="button" data-system-backfill>开始回补</button>' +
|
|
||||||
'<p class="m-sys-hint">回补用于补齐缺失的历史行情,开始前会再次确认;回补期间页面可正常使用。</p></div>' +
|
|
||||||
'<div class="m-card m-sys-section"><strong>模型池 · 会员 · 邀请码</strong>' +
|
|
||||||
'<p class="m-sys-hint">这些配置已统一在数据中枢管理。</p>' +
|
|
||||||
'<button class="m-btn-outline" type="button" data-system-datahub>打开数据中枢</button></div>' +
|
|
||||||
"</div>";
|
|
||||||
systemFill(marketHtml);
|
|
||||||
}
|
|
||||||
|
|
||||||
function dataHubConsoleUrl() {
|
function dataHubConsoleUrl() {
|
||||||
if (global.XIAOBAI_DATAHUB_URL) return String(global.XIAOBAI_DATAHUB_URL);
|
if (global.XIAOBAI_DATAHUB_URL) return String(global.XIAOBAI_DATAHUB_URL);
|
||||||
// HEL-564: 固定内网地址,见 frontend/shared/admin.js 同名函数。
|
// HEL-564: 固定内网地址,见 frontend/shared/admin.js 同名函数。
|
||||||
@@ -5373,67 +5302,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveSystemMarket() {
|
|
||||||
const button = document.querySelector("[data-system-save-market]");
|
|
||||||
if (button) button.disabled = true;
|
|
||||||
loadSystem().then(function () {
|
|
||||||
showToast("数据中枢状态已刷新");
|
|
||||||
}).catch(function (error) {
|
|
||||||
showToast(error && error.message ? error.message : "数据中枢状态读取失败");
|
|
||||||
}).then(function () {
|
|
||||||
if (button) button.disabled = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSystemRefresh() {
|
|
||||||
const enabled = !Boolean((state.system.admin && state.system.admin.data && state.system.admin.data.background_refresh_enabled));
|
|
||||||
global.MobileAPI.request("/api/admin/settings", "POST", {
|
|
||||||
background_refresh_enabled: enabled,
|
|
||||||
}).then(function () {
|
|
||||||
showToast(enabled ? "后台刷新已启用" : "后台刷新已暂停");
|
|
||||||
loadSystem();
|
|
||||||
}).catch(function (error) {
|
|
||||||
showToast(error && error.message ? error.message : "后台刷新设置失败");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function startSystemRefresh() {
|
|
||||||
const button = document.querySelector("[data-system-refresh]");
|
|
||||||
if (button) button.disabled = true;
|
|
||||||
global.MobileAPI.request("/api/admin/refresh", "POST", { trade_date: todayString() }).then(function (payload) {
|
|
||||||
showToast((payload && payload.message) || "后台刷新已提交");
|
|
||||||
}).catch(function (error) {
|
|
||||||
showToast(error && error.message ? error.message : "后台刷新启动失败");
|
|
||||||
}).then(function () {
|
|
||||||
if (button) button.disabled = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function startSystemBackfill() {
|
|
||||||
const startDate = (document.getElementById("m-sys-backfill-start") || {}).value;
|
|
||||||
const endDate = (document.getElementById("m-sys-backfill-end") || {}).value;
|
|
||||||
openConfirmSheet("开始历史回补?", "将按选定日期补齐缺失行情,回补期间页面仍可使用。", {
|
|
||||||
confirmLabel: "开始回补",
|
|
||||||
centered: true,
|
|
||||||
onConfirm: function () {
|
|
||||||
const button = document.querySelector("[data-system-backfill]");
|
|
||||||
if (button) button.disabled = true;
|
|
||||||
global.MobileAPI.request("/api/backfill", "POST", {
|
|
||||||
start_date: startDate,
|
|
||||||
end_date: endDate,
|
|
||||||
}).then(function (payload) {
|
|
||||||
const count = payload && payload.results ? payload.results.length : 0;
|
|
||||||
showToast("历史回补完成,共处理 " + count + " 个工作日");
|
|
||||||
loadSystem();
|
|
||||||
}).catch(function (error) {
|
|
||||||
showToast(error && error.message ? error.message : "回补失败");
|
|
||||||
}).then(function () {
|
|
||||||
if (button) button.disabled = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindEvents() {
|
function bindEvents() {
|
||||||
window.addEventListener("hashchange", closeSheet);
|
window.addEventListener("hashchange", closeSheet);
|
||||||
|
|
||||||
@@ -5566,10 +5434,6 @@
|
|||||||
if (event.target.closest("[data-system-save-password]")) { saveSystemPassword(); return; }
|
if (event.target.closest("[data-system-save-password]")) { saveSystemPassword(); return; }
|
||||||
if (event.target.closest("[data-system-switch]")) { switchSystemAccount(); return; }
|
if (event.target.closest("[data-system-switch]")) { switchSystemAccount(); return; }
|
||||||
if (event.target.closest("[data-system-logout]")) { logoutSystemAccount(); return; }
|
if (event.target.closest("[data-system-logout]")) { logoutSystemAccount(); return; }
|
||||||
if (event.target.closest("[data-system-save-market]")) { saveSystemMarket(); return; }
|
|
||||||
if (event.target.closest("[data-system-toggle-refresh]")) { toggleSystemRefresh(); return; }
|
|
||||||
if (event.target.closest("[data-system-refresh]")) { startSystemRefresh(); return; }
|
|
||||||
if (event.target.closest("[data-system-backfill]")) { startSystemBackfill(); return; }
|
|
||||||
if (event.target.closest("[data-system-datahub]")) { openDataHubConsole(); return; }
|
if (event.target.closest("[data-system-datahub]")) { openDataHubConsole(); return; }
|
||||||
|
|
||||||
// 我的复盘:新增/编辑/删除/筛选/提交等操作
|
// 我的复盘:新增/编辑/删除/筛选/提交等操作
|
||||||
|
|||||||
@@ -1,54 +1,3 @@
|
|||||||
async function backfillData() {
|
|
||||||
const button = document.querySelector("#backfillButton");
|
|
||||||
button.disabled = true;
|
|
||||||
setLoading(true, "正在回补历史交易日");
|
|
||||||
try {
|
|
||||||
const payload = await apiRequest("/api/backfill", "POST", {
|
|
||||||
start_date: document.querySelector("#backfillStart").value,
|
|
||||||
end_date: document.querySelector("#backfillEnd").value,
|
|
||||||
});
|
|
||||||
const failed = (payload.failed_count || 0);
|
|
||||||
const skipped = (payload.skipped_non_trading_days || []).length;
|
|
||||||
const suffix = failed
|
|
||||||
? `,失败 ${failed} 个`
|
|
||||||
: skipped
|
|
||||||
? `,跳过 ${skipped} 个非交易日`
|
|
||||||
: "";
|
|
||||||
showToast(`历史回补完成,共处理 ${payload.results.length} 个交易日${suffix}`);
|
|
||||||
state.sentimentHistory = null;
|
|
||||||
state.sentimentHistoryKey = "";
|
|
||||||
if (state.activeView === "sentimentCycleView") {
|
|
||||||
await loadSentimentHistory(true);
|
|
||||||
}
|
|
||||||
await openAdminSettings(true);
|
|
||||||
} catch (error) {
|
|
||||||
showToast(error.message);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
button.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async function openAdminSettings(refreshOnly = false) {
|
|
||||||
if (state.user?.role !== "admin") return;
|
|
||||||
if (!refreshOnly) openModalDialog(elements.adminDialog);
|
|
||||||
const status = document.querySelector("#adminConnectionStatus");
|
|
||||||
status.textContent = "正在读取系统状态";
|
|
||||||
try {
|
|
||||||
const payload = await apiRequest("/api/admin/settings");
|
|
||||||
const data = payload.data || {};
|
|
||||||
const ifind = data.ifind || {};
|
|
||||||
status.textContent = `数据中枢 ${data.configured ? "已连接" : "未连接"} · iFinD ${ifind.configured ? "已配置" : "未配置"} · ${number(data.snapshot_dates)} 个交易日`;
|
|
||||||
status.classList.toggle("connected", Boolean(data.configured));
|
|
||||||
setText("systemDataStatus", data.background_refresh_enabled ? "后台刷新已启用" : "后台刷新已暂停");
|
|
||||||
renderDatahubRouteStatus(data.datahub || {});
|
|
||||||
document.querySelector("#systemBackgroundRefresh").checked = Boolean(data.background_refresh_enabled);
|
|
||||||
} catch (error) {
|
|
||||||
status.textContent = error.message || "系统配置读取失败";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function dataHubConsoleUrl() {
|
function dataHubConsoleUrl() {
|
||||||
if (window.XIAOBAI_DATAHUB_URL) return String(window.XIAOBAI_DATAHUB_URL);
|
if (window.XIAOBAI_DATAHUB_URL) return String(window.XIAOBAI_DATAHUB_URL);
|
||||||
// HEL-564: 固定内网地址。域名只反代 8765,按主机名推导会拼出打不开的
|
// HEL-564: 固定内网地址。域名只反代 8765,按主机名推导会拼出打不开的
|
||||||
@@ -61,49 +10,9 @@ function openDataHubConsole() {
|
|||||||
window.open(dataHubConsoleUrl(), "_blank", "noopener");
|
window.open(dataHubConsoleUrl(), "_blank", "noopener");
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDatahubRouteStatus(hub) {
|
/* HEL-566: 主站「行情管理」已整体并入数据中枢(数据源配置页的
|
||||||
const box = document.querySelector("#datahubRouteStatus");
|
主站行情任务卡:后台刷新开关 / 手动刷新 / 历史回补),主站只保留
|
||||||
if (!box) return;
|
顶栏「数据中枢」入口。 */
|
||||||
const label = box.querySelector("span");
|
|
||||||
const enabled = number(hub.enabled_reads);
|
|
||||||
const total = number(hub.total_reads) || enabled;
|
|
||||||
const fallbacks = hub.fallback_labels || [];
|
|
||||||
if (fallbacks.length) {
|
|
||||||
box.dataset.tone = "warning";
|
|
||||||
if (label) label.textContent = `数据中枢主线路 ${enabled}/${total} · 备用 ${fallbacks.length} 类:${fallbacks.join("、")}`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
box.dataset.tone = hub.configured ? "success" : "idle";
|
|
||||||
if (label) {
|
|
||||||
label.textContent = hub.configured
|
|
||||||
? `数据中枢主线路 ${enabled}/${total},当前无备用`
|
|
||||||
: "数据中枢未配置,网站只保留已有真实快照";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveMarketSettings(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
const button = event.currentTarget.querySelector("button[type='submit']");
|
|
||||||
button.disabled = true;
|
|
||||||
try {
|
|
||||||
await apiRequest("/api/admin/settings", "POST", {
|
|
||||||
background_refresh_enabled: document.querySelector("#systemBackgroundRefresh").checked,
|
|
||||||
});
|
|
||||||
showToast("行情刷新设置已保存");
|
|
||||||
await openAdminSettings(true);
|
|
||||||
} catch (error) {
|
|
||||||
showToast(error.message || "系统配置保存失败");
|
|
||||||
} finally {
|
|
||||||
button.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function bindAdminEvents() {
|
function bindAdminEvents() {
|
||||||
document.querySelector("#settingsButton").addEventListener("click", openDataHubConsole);
|
document.querySelector("#settingsButton").addEventListener("click", openDataHubConsole);
|
||||||
document.querySelector("#marketAdminButton").addEventListener("click", () => openAdminSettings());
|
|
||||||
document.querySelector("#closeAdminDialog").addEventListener("click", () => elements.adminDialog.close());
|
|
||||||
document.querySelector("#backfillButton").addEventListener("click", backfillData);
|
|
||||||
document.querySelector("#systemMarketForm").addEventListener("submit", saveMarketSettings);
|
|
||||||
document.querySelector("#adminRefreshButton").addEventListener("click", startAdminRefresh);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-109
@@ -121,23 +121,6 @@
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-dialog {
|
|
||||||
width: min(1060px, -24px + 100vw);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-panel[hidden] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-inline-actions {
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
justify-content: space-between;
|
|
||||||
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-dialog > form,
|
|
||||||
.settings-section > form {
|
.settings-section > form {
|
||||||
padding: 0px;
|
padding: 0px;
|
||||||
}
|
}
|
||||||
@@ -160,22 +143,6 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.switch-control {
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
gap: 9px;
|
|
||||||
|
|
||||||
margin-top: 13px;
|
|
||||||
|
|
||||||
color: var(--text-secondary);
|
|
||||||
|
|
||||||
font-size: 13px;
|
|
||||||
|
|
||||||
font-weight: 650;
|
|
||||||
}
|
|
||||||
|
|
||||||
.connection-status {
|
.connection-status {
|
||||||
margin: 18px 18px 0px;
|
margin: 18px 18px 0px;
|
||||||
|
|
||||||
@@ -200,8 +167,7 @@
|
|||||||
padding: 18px;
|
padding: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-dialog .settings-section > form,
|
.settings-dialog .settings-section > form {
|
||||||
.settings-dialog.admin-dialog > form {
|
|
||||||
padding: 0px;
|
padding: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,22 +211,6 @@
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.backfill-controls {
|
|
||||||
display: grid;
|
|
||||||
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.admin-inline-actions {
|
|
||||||
align-items: stretch;
|
|
||||||
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-hint {
|
.form-hint {
|
||||||
margin: 10px 0px 0px;
|
margin: 10px 0px 0px;
|
||||||
|
|
||||||
@@ -271,32 +221,6 @@
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-refresh-status {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 12px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--surface-muted);
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-refresh-status svg {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
width: 15px;
|
|
||||||
height: 15px;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-refresh-status[data-tone="running"] { color: var(--primary); }
|
|
||||||
.admin-refresh-status[data-tone="success"] { color: var(--down); }
|
|
||||||
.admin-refresh-status[data-tone="warning"] { color: var(--warning); }
|
|
||||||
.admin-refresh-status[data-tone="failure"] { color: var(--up); }
|
|
||||||
|
|
||||||
.account-button > span {
|
.account-button > span {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
|
||||||
@@ -359,8 +283,7 @@
|
|||||||
padding-left: 12px;
|
padding-left: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-dialog .settings-section > form,
|
.settings-dialog .settings-section > form {
|
||||||
.settings-dialog.admin-dialog > form {
|
|
||||||
padding: 0px;
|
padding: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,10 +291,6 @@
|
|||||||
.settings-dialog .form-hint {
|
.settings-dialog .form-hint {
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.backfill-controls {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-menu-chevron {
|
.account-menu-chevron {
|
||||||
@@ -1297,32 +1216,6 @@ button.account-role-badge:focus-visible {
|
|||||||
max-width: 460px;
|
max-width: 460px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-dialog.admin-dialog {
|
|
||||||
width: min(900px, -28px + 100vw);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-dialog .connection-status {
|
|
||||||
margin: 12px 20px 0px;
|
|
||||||
|
|
||||||
padding: 8px 10px;
|
|
||||||
|
|
||||||
border-radius: 7px;
|
|
||||||
|
|
||||||
color: var(--text-secondary);
|
|
||||||
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-dialog .admin-panel {
|
|
||||||
max-width: 860px;
|
|
||||||
|
|
||||||
margin-inline: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-dialog .settings-section + .settings-section {
|
|
||||||
border-top: 1px solid var(--dialog-line);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.account-settings-dialog .membership-status-grid {
|
.account-settings-dialog .membership-status-grid {
|
||||||
grid-template-columns: repeat(2, minmax(0px, 1fr));
|
grid-template-columns: repeat(2, minmax(0px, 1fr));
|
||||||
|
|||||||
@@ -212,7 +212,6 @@ const elements = {
|
|||||||
entityDetailDialog: document.querySelector("#entityDetailDialog"),
|
entityDetailDialog: document.querySelector("#entityDetailDialog"),
|
||||||
entityDetailChart: document.querySelector("#entityDetailChart"),
|
entityDetailChart: document.querySelector("#entityDetailChart"),
|
||||||
settingsDialog: document.querySelector("#settingsDialog"),
|
settingsDialog: document.querySelector("#settingsDialog"),
|
||||||
adminDialog: document.querySelector("#adminDialog"),
|
|
||||||
priceChart: document.querySelector("#priceChart"),
|
priceChart: document.querySelector("#priceChart"),
|
||||||
stockPreview: document.querySelector("#stockPreview"),
|
stockPreview: document.querySelector("#stockPreview"),
|
||||||
stockPreviewBackdrop: document.querySelector("#stockPreviewBackdrop"),
|
stockPreviewBackdrop: document.querySelector("#stockPreviewBackdrop"),
|
||||||
|
|||||||
@@ -38,22 +38,18 @@ async function loadDashboard(force = false, background = false, showOverlay = tr
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startAdminRefresh() {
|
async function startAdminRefresh() {
|
||||||
const buttons = [document.querySelector("#syncButton"), document.querySelector("#adminRefreshButton")].filter(Boolean);
|
const buttons = [document.querySelector("#syncButton")].filter(Boolean);
|
||||||
buttons.forEach((button) => { button.disabled = true; });
|
buttons.forEach((button) => { button.disabled = true; });
|
||||||
const requestedDate = elements.tradeDate.value;
|
const requestedDate = elements.tradeDate.value;
|
||||||
setAdminRefreshStatus("running", `正在刷新 ${requestedDate} 的行情,请稍候…`, "loader-circle");
|
|
||||||
try {
|
try {
|
||||||
const payload = await apiRequest("/api/admin/refresh", "POST", { trade_date: requestedDate });
|
const payload = await apiRequest("/api/admin/refresh", "POST", { trade_date: requestedDate });
|
||||||
if (!payload.started || !payload.job_key) {
|
if (!payload.started || !payload.job_key) {
|
||||||
setAdminRefreshStatus("warning", "已有刷新任务正在运行,请稍后再试。", "clock-3");
|
|
||||||
showToast(payload.message || "已有后台刷新任务正在运行");
|
showToast(payload.message || "已有后台刷新任务正在运行");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setStatus(`正在刷新 ${requestedDate} 的行情`);
|
setStatus(`正在刷新 ${requestedDate} 的行情`);
|
||||||
const job = await waitForAdminRefresh(payload.job_key);
|
const job = await waitForAdminRefresh(payload.job_key);
|
||||||
if (job.status === "failed") {
|
if (job.status === "failed") {
|
||||||
const reason = job.message || job.error_code || "数据源未返回结果";
|
|
||||||
setAdminRefreshStatus("failure", `刷新失败:${reason}`, "circle-x");
|
|
||||||
setStatus("后台刷新失败");
|
setStatus("后台刷新失败");
|
||||||
showToast("后台刷新失败");
|
showToast("后台刷新失败");
|
||||||
return;
|
return;
|
||||||
@@ -68,25 +64,20 @@ async function startAdminRefresh() {
|
|||||||
const updated = formatTimestamp(meta.updated_at);
|
const updated = formatTimestamp(meta.updated_at);
|
||||||
const freshness = dashboardFreshnessMessage(meta);
|
const freshness = dashboardFreshnessMessage(meta);
|
||||||
if (meta.realtime && actualCompact === requestedCompact && !meta.carried_forward) {
|
if (meta.realtime && actualCompact === requestedCompact && !meta.carried_forward) {
|
||||||
setAdminRefreshStatus("success", `刷新成功:已获取 ${actualDate} 的盘中行情,更新时间 ${updated}`, "circle-check");
|
|
||||||
showToast(`刷新成功:已获取 ${actualDate} 的盘中行情`);
|
showToast(`刷新成功:已获取 ${actualDate} 的盘中行情`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (freshness || actualCompact !== requestedCompact || meta.carried_forward || meta.limit_data_source === "derived") {
|
if (freshness || actualCompact !== requestedCompact || meta.carried_forward || meta.limit_data_source === "derived") {
|
||||||
setAdminRefreshStatus("warning", freshness || `部分正式数据尚未到齐,当前展示 ${actualDate || "最近可用数据"}`, "triangle-alert");
|
|
||||||
setStatus(freshness || "部分正式数据尚未到齐,当前展示最近可用数据");
|
setStatus(freshness || "部分正式数据尚未到齐,当前展示最近可用数据");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (meta.notice) {
|
if (meta.notice) {
|
||||||
setAdminRefreshStatus("warning", `已刷新到 ${actualDate}(${updated}),但数据源提示:${meta.notice}`, "triangle-alert");
|
|
||||||
showToast(`已刷新到 ${actualDate},请留意数据源提示`);
|
showToast(`已刷新到 ${actualDate},请留意数据源提示`);
|
||||||
} else {
|
} else {
|
||||||
setAdminRefreshStatus("success", `刷新成功:已获取 ${actualDate} 的最新行情,更新时间 ${updated}`, "circle-check");
|
showToast(`刷新成功:已获取 ${actualDate} 的最新行情,更新时间 ${updated}`);
|
||||||
showToast(`刷新成功:已获取 ${actualDate} 的最新行情`);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error.message || "后台刷新失败";
|
const message = error.message || "后台刷新失败";
|
||||||
setAdminRefreshStatus("failure", `刷新失败:${message}`, "circle-x");
|
|
||||||
setStatus("后台刷新失败");
|
setStatus("后台刷新失败");
|
||||||
showToast(message);
|
showToast(message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -94,14 +85,6 @@ async function startAdminRefresh() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setAdminRefreshStatus(tone, message, icon = "circle-dot") {
|
|
||||||
const status = document.querySelector("#adminRefreshStatus");
|
|
||||||
if (!status) return;
|
|
||||||
status.dataset.tone = tone;
|
|
||||||
status.innerHTML = `<i data-lucide="${icon}"></i><span>${escapeHtml(message)}</span>`;
|
|
||||||
refreshIcons();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForAdminRefresh(jobKey) {
|
async function waitForAdminRefresh(jobKey) {
|
||||||
for (let attempt = 0; attempt < 120; attempt += 1) {
|
for (let attempt = 0; attempt < 120; attempt += 1) {
|
||||||
const payload = await apiRequest("/api/admin/settings");
|
const payload = await apiRequest("/api/admin/settings");
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ async function applyAuthenticatedSession(session) {
|
|||||||
const isAdmin = session.user?.role === "admin";
|
const isAdmin = session.user?.role === "admin";
|
||||||
updateAccountIdentityBadges(session.user?.membership || {});
|
updateAccountIdentityBadges(session.user?.membership || {});
|
||||||
document.querySelector("#settingsButton").hidden = !isAdmin;
|
document.querySelector("#settingsButton").hidden = !isAdmin;
|
||||||
document.querySelector("#marketAdminButton").hidden = !isAdmin;
|
|
||||||
document.querySelector("#syncButton").hidden = !isAdmin;
|
document.querySelector("#syncButton").hidden = !isAdmin;
|
||||||
document.querySelector("#reasonForm").hidden = !isAdmin;
|
document.querySelector("#reasonForm").hidden = !isAdmin;
|
||||||
document.querySelector("#sectorPhaseManager").hidden = !isAdmin;
|
document.querySelector("#sectorPhaseManager").hidden = !isAdmin;
|
||||||
|
|||||||
@@ -632,7 +632,6 @@ test("admin shell opens every primary workspace and global search", async ({ pag
|
|||||||
await expect(page.locator("#authGate")).toBeHidden();
|
await expect(page.locator("#authGate")).toBeHidden();
|
||||||
await openHeaderCommandMenu(page);
|
await openHeaderCommandMenu(page);
|
||||||
await expect(page.locator("#settingsButton")).toBeVisible();
|
await expect(page.locator("#settingsButton")).toBeVisible();
|
||||||
await expect(page.locator("#marketAdminButton")).toBeVisible();
|
|
||||||
await expect(page.locator("#syncButton")).toBeVisible();
|
await expect(page.locator("#syncButton")).toBeVisible();
|
||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
await page.locator("#alertButton").click();
|
await page.locator("#alertButton").click();
|
||||||
@@ -1754,7 +1753,6 @@ test("regular account cannot see admin controls and member features are gated",
|
|||||||
await mockApplication(page, session("user", false));
|
await mockApplication(page, session("user", false));
|
||||||
await page.goto("/index.html");
|
await page.goto("/index.html");
|
||||||
await expect(page.locator("#settingsButton")).toBeHidden();
|
await expect(page.locator("#settingsButton")).toBeHidden();
|
||||||
await expect(page.locator("#marketAdminButton")).toBeHidden();
|
|
||||||
await expect(page.locator("#syncButton")).toBeHidden();
|
await expect(page.locator("#syncButton")).toBeHidden();
|
||||||
await expect(page.locator("#accountVipLabel")).toHaveText("非会员");
|
await expect(page.locator("#accountVipLabel")).toHaveText("非会员");
|
||||||
await page.locator('[data-view="screenerView"]').first().click();
|
await page.locator('[data-view="screenerView"]').first().click();
|
||||||
@@ -2222,7 +2220,6 @@ test("native dialogs share one lifecycle and success feedback stays content-size
|
|||||||
"alertsDialog",
|
"alertsDialog",
|
||||||
"assistantDialog",
|
"assistantDialog",
|
||||||
"settingsDialog",
|
"settingsDialog",
|
||||||
"adminDialog",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
@@ -3333,16 +3330,6 @@ test("global dialogs share the stage 18 geometry without changing account or adm
|
|||||||
expect(Math.abs(accountBox.y + accountBox.height / 2 - 450)).toBeLessThanOrEqual(2);
|
expect(Math.abs(accountBox.y + accountBox.height / 2 - 450)).toBeLessThanOrEqual(2);
|
||||||
await page.locator("#closeSettingsDialog").click();
|
await page.locator("#closeSettingsDialog").click();
|
||||||
|
|
||||||
await openHeaderCommandMenu(page);
|
|
||||||
await page.locator("#marketAdminButton").click();
|
|
||||||
await expect(page.locator("#adminDialog")).toHaveAttribute("aria-labelledby", "adminDialogTitle");
|
|
||||||
await expect(page.locator("#systemMarketForm")).toBeVisible();
|
|
||||||
const adminBox = await page.locator("#adminDialog").boundingBox();
|
|
||||||
expect(adminBox.width).toBeLessThanOrEqual(902);
|
|
||||||
expect(Math.abs(adminBox.x + adminBox.width / 2 - 720)).toBeLessThanOrEqual(2);
|
|
||||||
expect(Math.abs(adminBox.y + adminBox.height / 2 - 450)).toBeLessThanOrEqual(2);
|
|
||||||
await page.locator("#closeAdminDialog").click();
|
|
||||||
|
|
||||||
await page.setViewportSize({ width: 375, height: 812 });
|
await page.setViewportSize({ width: 375, height: 812 });
|
||||||
await page.locator("#headerMenuButton").click();
|
await page.locator("#headerMenuButton").click();
|
||||||
await page.locator('[data-mobile-command-target="alertButton"]').click();
|
await page.locator('[data-mobile-command-target="alertButton"]').click();
|
||||||
@@ -3511,12 +3498,6 @@ test("B-199 screener review and account surfaces fit day night viewports", async
|
|||||||
await shot("account-1600-day");
|
await shot("account-1600-day");
|
||||||
await page.locator("#closeSettingsDialog").click();
|
await page.locator("#closeSettingsDialog").click();
|
||||||
|
|
||||||
await openHeaderCommandMenu(page);
|
|
||||||
await page.locator("#marketAdminButton").click();
|
|
||||||
await expect(page.locator("#adminDialog")).toBeVisible();
|
|
||||||
await shot("admin-1600-day");
|
|
||||||
await page.locator("#closeAdminDialog").click();
|
|
||||||
|
|
||||||
await page.locator("#themeToggle").click();
|
await page.locator("#themeToggle").click();
|
||||||
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
|
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
|
||||||
await page.locator('[data-view="screenerView"]').first().click();
|
await page.locator('[data-view="screenerView"]').first().click();
|
||||||
@@ -3611,7 +3592,6 @@ test("desktop header keeps refresh, admin commands and account identity visible"
|
|||||||
await expect(page.locator("#refreshButton")).toBeVisible();
|
await expect(page.locator("#refreshButton")).toBeVisible();
|
||||||
await expect(page.locator("#syncButton")).toBeVisible();
|
await expect(page.locator("#syncButton")).toBeVisible();
|
||||||
await expect(page.locator("#settingsButton")).toBeVisible();
|
await expect(page.locator("#settingsButton")).toBeVisible();
|
||||||
await expect(page.locator("#marketAdminButton")).toBeVisible();
|
|
||||||
await expect(page.locator("#accountAdminBadge")).toBeVisible();
|
await expect(page.locator("#accountAdminBadge")).toBeVisible();
|
||||||
await expect(page.locator("#accountVipBadge")).toBeVisible();
|
await expect(page.locator("#accountVipBadge")).toBeVisible();
|
||||||
await expect(page.locator("#accountButton")).toBeVisible();
|
await expect(page.locator("#accountButton")).toBeVisible();
|
||||||
@@ -3624,9 +3604,6 @@ test("desktop header keeps refresh, admin commands and account identity visible"
|
|||||||
expect(geometry.nameFits, `${viewport.width} account name truncated`).toBe(true);
|
expect(geometry.nameFits, `${viewport.width} account name truncated`).toBe(true);
|
||||||
await page.locator("#refreshButton").click();
|
await page.locator("#refreshButton").click();
|
||||||
await expect(page.locator("#loadingOverlay")).toBeHidden();
|
await expect(page.locator("#loadingOverlay")).toBeHidden();
|
||||||
await page.locator("#marketAdminButton").click();
|
|
||||||
await expect(page.locator("#adminDialog")).toBeVisible();
|
|
||||||
await page.locator("#closeAdminDialog").click();
|
|
||||||
await page.locator("#accountButton").click();
|
await page.locator("#accountButton").click();
|
||||||
await expect(page.locator("#accountDropdown")).toBeVisible();
|
await expect(page.locator("#accountDropdown")).toBeVisible();
|
||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
@@ -3671,7 +3648,6 @@ test("desktop header keeps refresh, admin commands and account identity visible"
|
|||||||
await expect(page.locator("#refreshButton")).toBeVisible();
|
await expect(page.locator("#refreshButton")).toBeVisible();
|
||||||
await expect(page.locator("#syncButton")).toBeHidden();
|
await expect(page.locator("#syncButton")).toBeHidden();
|
||||||
await expect(page.locator("#settingsButton")).toBeHidden();
|
await expect(page.locator("#settingsButton")).toBeHidden();
|
||||||
await expect(page.locator("#marketAdminButton")).toBeHidden();
|
|
||||||
await expect(page.locator("#accountAdminBadge")).toBeHidden();
|
await expect(page.locator("#accountAdminBadge")).toBeHidden();
|
||||||
await expect(page.locator("#accountVipBadge")).toBeVisible();
|
await expect(page.locator("#accountVipBadge")).toBeVisible();
|
||||||
await expect(page.locator("#accountName")).toHaveText("normal_user");
|
await expect(page.locator("#accountName")).toHaveText("normal_user");
|
||||||
|
|||||||
@@ -161,13 +161,6 @@ async function mockMobileApi(page, options = {}) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
} else if (path === "/api/admin/settings") {
|
|
||||||
payload = {
|
|
||||||
data: { configured: true, snapshot_dates: 12, background_refresh_enabled: true, ifind: { configured: false } },
|
|
||||||
llm: { models: [{ id: "model-1", name: "主模型", base_url: "https://api.openai.com/v1", model: "gpt-4.1", configured: true }], primary_model_id: "model-1", fallback_model_id: "" },
|
|
||||||
membership: { member_daily_limit: 50 },
|
|
||||||
users: [{ id: 2, username: "normal_user", role: "user", membership_subscribed: true, membership_status: "active", membership_expires_at: "2026-09-18", used_today: 3 }],
|
|
||||||
};
|
|
||||||
} else if (/^\/api\/stock\/\d+\/preview$/.test(path)) {
|
} else if (/^\/api\/stock\/\d+\/preview$/.test(path)) {
|
||||||
payload = {
|
payload = {
|
||||||
meta: { trade_date: "2026-07-22", intraday_status: "available" },
|
meta: { trade_date: "2026-07-22", intraday_status: "available" },
|
||||||
@@ -264,7 +257,6 @@ const SYSTEM_PAGES = [
|
|||||||
["system/profile", "账号资料"],
|
["system/profile", "账号资料"],
|
||||||
["system/password", "修改密码"],
|
["system/password", "修改密码"],
|
||||||
["system/membership", "会员状态"],
|
["system/membership", "会员状态"],
|
||||||
["system/admin", "行情管理"],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const PLACEHOLDER_COPY = "该功能页将在后续批次实现";
|
const PLACEHOLDER_COPY = "该功能页将在后续批次实现";
|
||||||
@@ -347,7 +339,7 @@ test("system management pages render real content instead of placeholders", asyn
|
|||||||
await expect(page.locator("#m-title")).toHaveText(label);
|
await expect(page.locator("#m-title")).toHaveText(label);
|
||||||
await expect(page.locator(".m-placeholder")).toHaveCount(0);
|
await expect(page.locator(".m-placeholder")).toHaveCount(0);
|
||||||
await expect(page.locator("#m-view")).not.toContainText(PLACEHOLDER_COPY);
|
await expect(page.locator("#m-view")).not.toContainText(PLACEHOLDER_COPY);
|
||||||
await expect(page.locator("[data-system-page], [data-system-admin-panel]").first()).toBeVisible();
|
await expect(page.locator("[data-system-page]").first()).toBeVisible();
|
||||||
expect(await measureOverflow(page)).toBeLessThanOrEqual(1);
|
expect(await measureOverflow(page)).toBeLessThanOrEqual(1);
|
||||||
}
|
}
|
||||||
await navigateToFeature(page, "system/profile");
|
await navigateToFeature(page, "system/profile");
|
||||||
@@ -357,9 +349,10 @@ test("system management pages render real content instead of placeholders", asyn
|
|||||||
await expect(page.locator("#m-sys-password-current")).toBeVisible();
|
await expect(page.locator("#m-sys-password-current")).toBeVisible();
|
||||||
await navigateToFeature(page, "system/membership");
|
await navigateToFeature(page, "system/membership");
|
||||||
await expect(page.locator(".m-sys-grid")).toBeVisible();
|
await expect(page.locator(".m-sys-grid")).toBeVisible();
|
||||||
await navigateToFeature(page, "system/admin");
|
// HEL-566: 行情管理并入数据中枢后,管理员专区只保留数据中枢入口
|
||||||
await expect(page.locator("#m-sys-token")).toBeVisible();
|
await page.evaluate(() => { window.MobileRouter.navigate("#/hub/system"); });
|
||||||
await expect(page.locator("[data-system-datahub]")).toBeVisible();
|
await expect(page.locator("[data-system-datahub]")).toBeVisible();
|
||||||
|
await expect(page.locator("#m-view")).not.toContainText("行情管理");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("system home groups entries and keeps admin-only items gated", async ({ page }) => {
|
test("system home groups entries and keeps admin-only items gated", async ({ page }) => {
|
||||||
@@ -375,20 +368,9 @@ test("system home groups entries and keeps admin-only items gated", async ({ pag
|
|||||||
expect(await measureOverflow(page)).toBeLessThanOrEqual(1);
|
expect(await measureOverflow(page)).toBeLessThanOrEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("system settings tabs, model editor, delete confirm and theme toggle work", async ({ page }) => {
|
test("profile delete confirm, theme toggle and data hub entry work", async ({ page }) => {
|
||||||
await mockMobileApi(page);
|
await mockMobileApi(page);
|
||||||
await openMobile(page);
|
await openMobile(page);
|
||||||
await navigateToFeature(page, "system/admin");
|
|
||||||
await expect(page.locator("[data-system-admin-panel='market']")).toBeVisible();
|
|
||||||
await page.locator("[data-system-admin-tab='models']").click();
|
|
||||||
await expect(page.locator("[data-system-admin-panel='models']")).toBeVisible();
|
|
||||||
await page.locator("[data-system-edit-model]").first().click();
|
|
||||||
await expect(page.locator(".m-sheet-root.is-open")).toBeVisible();
|
|
||||||
await expect(page.locator(".m-sheet-head h2")).toHaveText("编辑模型");
|
|
||||||
await page.locator("[data-sheet-close]").click();
|
|
||||||
await page.locator("[data-system-admin-tab='market']").click();
|
|
||||||
await expect(page.locator("#m-sys-token")).toBeVisible();
|
|
||||||
|
|
||||||
await navigateToFeature(page, "system/profile");
|
await navigateToFeature(page, "system/profile");
|
||||||
await page.locator("[data-system-delete-birth]").click();
|
await page.locator("[data-system-delete-birth]").click();
|
||||||
await expect(page.locator(".m-dialog")).toBeVisible();
|
await expect(page.locator(".m-dialog")).toBeVisible();
|
||||||
@@ -428,7 +410,7 @@ test("empty birth profile save shows a validation toast", async ({ page }) => {
|
|||||||
await expect(page.locator("#m-toast")).toContainText("请填写完整出生日期和时间");
|
await expect(page.locator("#m-toast")).toContainText("请填写完整出生日期和时间");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("non-admin cannot open system admin pages as placeholders", async ({ page }) => {
|
test("non-admin sees no admin-only entries and removed admin page falls back to placeholder", async ({ page }) => {
|
||||||
await mockMobileApi(page, { auth: authSession("user", true) });
|
await mockMobileApi(page, { auth: authSession("user", true) });
|
||||||
await openMobile(page);
|
await openMobile(page);
|
||||||
await page.evaluate(() => { window.MobileRouter.navigate("#/hub/system"); });
|
await page.evaluate(() => { window.MobileRouter.navigate("#/hub/system"); });
|
||||||
@@ -436,9 +418,10 @@ test("non-admin cannot open system admin pages as placeholders", async ({ page }
|
|||||||
await expect(page.locator('[data-route="#/feature/system/admin"]')).toHaveCount(0);
|
await expect(page.locator('[data-route="#/feature/system/admin"]')).toHaveCount(0);
|
||||||
await expect(page.locator("[data-system-datahub]")).toHaveCount(0);
|
await expect(page.locator("[data-system-datahub]")).toHaveCount(0);
|
||||||
await expect(page.locator("[data-system-switch]")).toBeVisible();
|
await expect(page.locator("[data-system-switch]")).toBeVisible();
|
||||||
await navigateToFeature(page, "system/admin");
|
// HEL-566: 行情管理页已删除,直达旧路由只会看到占位说明,不再有行情管理内容
|
||||||
await expect(page.locator("#m-view")).not.toContainText(PLACEHOLDER_COPY);
|
await page.evaluate(() => { window.MobileRouter.navigate("#/feature/system/admin"); });
|
||||||
await expect(page.locator("[data-system-page='forbidden']")).toBeVisible();
|
await expect(page.locator(".m-placeholder")).toBeVisible();
|
||||||
|
await expect(page.locator("#m-view")).not.toContainText("行情管理");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("assistant chat renders with presets and input", async ({ page }) => {
|
test("assistant chat renders with presets and input", async ({ page }) => {
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ class FrontendBoundaryTests(unittest.TestCase):
|
|||||||
"XiaobaiState.create",
|
"XiaobaiState.create",
|
||||||
"XiaobaiAPI.configure",
|
"XiaobaiAPI.configure",
|
||||||
"function loadDashboard(",
|
"function loadDashboard(",
|
||||||
"function openAdminSettings(",
|
"function openDataHubConsole(",
|
||||||
"function syncThemeControl(",
|
"function syncThemeControl(",
|
||||||
"function initializeAutoTableSorting(",
|
"function initializeAutoTableSorting(",
|
||||||
):
|
):
|
||||||
@@ -244,7 +244,7 @@ class FrontendBoundaryTests(unittest.TestCase):
|
|||||||
"context.js": "XiaobaiState.create",
|
"context.js": "XiaobaiState.create",
|
||||||
"application.js": "XiaobaiAPI.configure",
|
"application.js": "XiaobaiAPI.configure",
|
||||||
"dashboard.js": "function loadDashboard(",
|
"dashboard.js": "function loadDashboard(",
|
||||||
"admin.js": "function openAdminSettings(",
|
"admin.js": "function openDataHubConsole(",
|
||||||
"theme.js": "function syncThemeControl(",
|
"theme.js": "function syncThemeControl(",
|
||||||
"table.js": "function initializeAutoTableSorting(",
|
"table.js": "function initializeAutoTableSorting(",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,14 +166,13 @@ class FrontendContractTests(unittest.TestCase):
|
|||||||
self.assertIn('id="accountDropdown"', self.html)
|
self.assertIn('id="accountDropdown"', self.html)
|
||||||
self.assertIn('id="settingsButton"', self.html)
|
self.assertIn('id="settingsButton"', self.html)
|
||||||
|
|
||||||
def test_backfill_clears_sentiment_cache_and_reloads_sentiment_view(self):
|
def test_market_admin_panel_is_served_by_the_data_hub(self):
|
||||||
start = self.script.index("async function backfillData")
|
# HEL-566: 行情管理整体并入数据中枢,主站前端不再有行情管理入口与回补表单
|
||||||
end = self.script.index("async function openAdminSettings", start)
|
self.assertNotIn("id=\"marketAdminButton\"", self.html)
|
||||||
backfill = self.script[start:end]
|
self.assertNotIn("id=\"adminDialog\"", self.html)
|
||||||
self.assertIn("state.sentimentHistory = null;", backfill)
|
self.assertNotIn("async function backfillData", self.script)
|
||||||
self.assertIn('state.sentimentHistoryKey = "";', backfill)
|
self.assertNotIn("function openAdminSettings", self.script)
|
||||||
self.assertIn('if (state.activeView === "sentimentCycleView")', backfill)
|
self.assertNotIn("/api/backfill", self.script)
|
||||||
self.assertIn("await loadSentimentHistory(true);", backfill)
|
|
||||||
|
|
||||||
def test_screener_uses_progressive_strategy_editor(self):
|
def test_screener_uses_progressive_strategy_editor(self):
|
||||||
for step in ("regime", "strategy", "run", "result"):
|
for step in ("regime", "strategy", "run", "result"):
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ class HttpDispatchContractTests(unittest.TestCase):
|
|||||||
self.assertTrue(claimed.isdisjoint(methods))
|
self.assertTrue(claimed.isdisjoint(methods))
|
||||||
claimed.update(methods)
|
claimed.update(methods)
|
||||||
self.assertLessEqual(len(path.read_text(encoding="utf-8").splitlines()), line_limit)
|
self.assertLessEqual(len(path.read_text(encoding="utf-8").splitlines()), line_limit)
|
||||||
self.assertEqual(len(claimed), 28)
|
self.assertEqual(len(claimed), 29)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -24,12 +24,14 @@ class MobileSystemPagesRegressionTests(unittest.TestCase):
|
|||||||
"system/profile",
|
"system/profile",
|
||||||
"system/password",
|
"system/password",
|
||||||
"system/membership",
|
"system/membership",
|
||||||
"system/admin",
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
for key in keys:
|
for key in keys:
|
||||||
self.assertIn(f'"{key}": setupSystemPage', pages)
|
self.assertIn(f'"{key}": setupSystemPage', pages)
|
||||||
self.assertIn(f'"{key}": loadSystem', pages)
|
self.assertIn(f'"{key}": loadSystem', pages)
|
||||||
|
# HEL-566: 行情管理并入数据中枢后,主站不再有 system/admin 页
|
||||||
|
self.assertNotIn('"system/admin"', nav)
|
||||||
|
self.assertNotIn('"system/admin"', pages)
|
||||||
|
|
||||||
def test_placeholder_copy_is_only_a_router_fallback(self) -> None:
|
def test_placeholder_copy_is_only_a_router_fallback(self) -> None:
|
||||||
router = ROUTER.read_text(encoding="utf-8")
|
router = ROUTER.read_text(encoding="utf-8")
|
||||||
@@ -46,16 +48,12 @@ class MobileSystemPagesRegressionTests(unittest.TestCase):
|
|||||||
'data-system-page="profile"',
|
'data-system-page="profile"',
|
||||||
'data-system-page="password"',
|
'data-system-page="password"',
|
||||||
'data-system-page="membership"',
|
'data-system-page="membership"',
|
||||||
'data-system-page="forbidden"',
|
|
||||||
'data-system-admin-panel="market"',
|
|
||||||
"m-sys-birth-date",
|
"m-sys-birth-date",
|
||||||
"m-sys-password-current",
|
"m-sys-password-current",
|
||||||
"m-sys-token",
|
|
||||||
"data-system-switch",
|
"data-system-switch",
|
||||||
"data-system-datahub",
|
"data-system-datahub",
|
||||||
"管理员专区",
|
"管理员专区",
|
||||||
"刷新状态",
|
"数据源 · 模型池 · 会员与邀请码",
|
||||||
"打开数据中枢",
|
|
||||||
'location.assign("/login/")',
|
'location.assign("/login/")',
|
||||||
):
|
):
|
||||||
self.assertIn(marker, pages)
|
self.assertIn(marker, pages)
|
||||||
@@ -71,10 +69,6 @@ class MobileSystemPagesRegressionTests(unittest.TestCase):
|
|||||||
for name in (
|
for name in (
|
||||||
"data-system-save-birth",
|
"data-system-save-birth",
|
||||||
"data-system-save-password",
|
"data-system-save-password",
|
||||||
"data-system-save-market",
|
|
||||||
"data-system-refresh",
|
|
||||||
"data-system-toggle-refresh",
|
|
||||||
"data-system-backfill",
|
|
||||||
"data-system-datahub",
|
"data-system-datahub",
|
||||||
):
|
):
|
||||||
self.assertIn(name, pages)
|
self.assertIn(name, pages)
|
||||||
|
|||||||
@@ -264,6 +264,40 @@ def main() -> int:
|
|||||||
row = next((u for u in body.get("users") or [] if u["id"] == member_id), {})
|
row = next((u for u in body.get("users") or [] if u["id"] == member_id), {})
|
||||||
check("开通 3 个月会员生效", status == 200 and row.get("membership_status") == "active" and row.get("membership_expires_at"), f"{status} {row.get('membership_status')} {row.get('membership_expires_at')}")
|
check("开通 3 个月会员生效", status == 200 and row.get("membership_status") == "active" and row.get("membership_expires_at"), f"{status} {row.get('membership_status')} {row.get('membership_expires_at')}")
|
||||||
|
|
||||||
|
print("\n[11b] 主站行情任务(后台刷新开关 / 手动刷新 / 历史回补)经桥接可用")
|
||||||
|
status, body, _ = request(f"{hub}/admin/api/market/status", cookie=admin_cookie)
|
||||||
|
check("读取主站行情状态", status == 200 and body.get("background_refresh_enabled") is True, f"{status} {body.get('error', '')}")
|
||||||
|
status, body, _ = request(
|
||||||
|
f"{hub}/admin/api/market/settings", {"background_refresh_enabled": False}, "POST", write_headers, admin_cookie
|
||||||
|
)
|
||||||
|
check("暂停后台刷新生效", status == 200 and body.get("background_refresh_enabled") is False, f"{status} {body.get('background_refresh_enabled')}")
|
||||||
|
status, mainsite_setting, _ = request(f"{review}/api/admin/settings", cookie=admin_cookie)
|
||||||
|
check("主站侧确认开关已落库", (mainsite_setting.get("data") or {}).get("background_refresh_enabled") is False, str((mainsite_setting.get("data") or {}).get("background_refresh_enabled")))
|
||||||
|
status, body, _ = request(
|
||||||
|
f"{hub}/admin/api/market/settings", {"background_refresh_enabled": True}, "POST", write_headers, admin_cookie
|
||||||
|
)
|
||||||
|
check("重新启用后台刷新", status == 200 and body.get("background_refresh_enabled") is True, f"{status}")
|
||||||
|
|
||||||
|
status, body, _ = request(f"{hub}/admin/api/market/refresh", {}, "POST", write_headers, admin_cookie)
|
||||||
|
refresh_key = str(body.get("job_key") or "")
|
||||||
|
check("提交手动后台刷新任务", status == 200 and body.get("started") and refresh_key.startswith("manual:"), f"{status} {body}")
|
||||||
|
status, body, _ = request(f"{hub}/admin/api/market/backfill", {"start_date": "", "end_date": ""}, "POST", write_headers, admin_cookie)
|
||||||
|
check("回补缺日期被拒绝", status == 400, f"{status} {body.get('error', '')}")
|
||||||
|
status, body, _ = request(
|
||||||
|
f"{hub}/admin/api/market/backfill", {"start_date": "2026-09-01", "end_date": "2026-09-08"}, "POST", write_headers, admin_cookie
|
||||||
|
)
|
||||||
|
backfill_key = str(body.get("job_key") or "")
|
||||||
|
check("提交历史回补任务", status == 200 and body.get("started") and backfill_key.startswith("backfill:"), f"{status} {body}")
|
||||||
|
terminal = []
|
||||||
|
for _ in range(60):
|
||||||
|
status, body, _ = request(f"{hub}/admin/api/market/status", cookie=admin_cookie)
|
||||||
|
jobs = {str(item.get("idempotency_key")): str(item.get("status")) for item in body.get("jobs") or []}
|
||||||
|
if jobs.get(refresh_key) in {"success", "failed"} and jobs.get(backfill_key) in {"success", "failed"}:
|
||||||
|
terminal = [jobs[refresh_key], jobs[backfill_key]]
|
||||||
|
break
|
||||||
|
time.sleep(0.5)
|
||||||
|
check("两个任务都进入终态(沙箱无行情源,失败也是正确终态)", len(terminal) == 2 and all(t in {"success", "failed"} for t in terminal), str(terminal))
|
||||||
|
|
||||||
print("\n[12] 桥接令牌是唯一信任边界")
|
print("\n[12] 桥接令牌是唯一信任边界")
|
||||||
status, body, _ = request(f"{review}/api/hub-admin/status", {}, "POST", {"X-Hub-Admin-Token": "wrong-token"})
|
status, body, _ = request(f"{review}/api/hub-admin/status", {}, "POST", {"X-Hub-Admin-Token": "wrong-token"})
|
||||||
check("桥接端点拒绝错误令牌", status == 401, f"{status} {body}")
|
check("桥接端点拒绝错误令牌", status == 401, f"{status} {body}")
|
||||||
|
|||||||
@@ -7,8 +7,11 @@
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import verify_datahub_console as backend
|
import verify_datahub_console as backend
|
||||||
@@ -61,6 +64,8 @@ def main() -> int:
|
|||||||
f"{review}/api/auth/register", {"username": "boss", "password": backend.PASSWORD}, "POST"
|
f"{review}/api/auth/register", {"username": "boss", "password": backend.PASSWORD}, "POST"
|
||||||
)
|
)
|
||||||
admin_cookie = backend.session_cookie(cookie_header).split("=", 1)[1]
|
admin_cookie = backend.session_cookie(cookie_header).split("=", 1)[1]
|
||||||
|
vendor_port = start_fake_vendor()
|
||||||
|
vendor_url = f"http://127.0.0.1:{vendor_port}/v1"
|
||||||
|
|
||||||
with sync_playwright() as play:
|
with sync_playwright() as play:
|
||||||
browser = play.chromium.launch(executable_path=chrome_path())
|
browser = play.chromium.launch(executable_path=chrome_path())
|
||||||
@@ -110,7 +115,28 @@ def main() -> int:
|
|||||||
check("凭证输入是密码框(不回显明文)",
|
check("凭证输入是密码框(不回显明文)",
|
||||||
page.get_attribute('[data-cred-input="tushare_token"]', "type") == "password")
|
page.get_attribute('[data-cred-input="tushare_token"]', "type") == "password")
|
||||||
check("原有接口清单没被删掉", page.locator("table.dtable").count() >= 1)
|
check("原有接口清单没被删掉", page.locator("table.dtable").count() >= 1)
|
||||||
|
|
||||||
|
print("\n[UI-3b] 主站行情任务卡(HEL-566 自主站行情管理迁入)")
|
||||||
|
page.wait_for_selector('[data-market-toggle]', timeout=10000)
|
||||||
|
check("行情任务卡渲染出开关按钮", page.is_visible('[data-market-toggle]'))
|
||||||
|
check("默认显示后台刷新已启用", "已启用" in page.inner_text("#marketCard"), page.inner_text("#marketCard")[:60])
|
||||||
|
check("手动刷新与回补控件齐全",
|
||||||
|
page.locator("[data-market-refresh]").count() == 1
|
||||||
|
and page.locator("[data-market-backfill]").count() == 1
|
||||||
|
and page.locator("[data-market-refresh-date]").count() == 1
|
||||||
|
and page.locator("[data-market-backfill-start]").count() == 1
|
||||||
|
and page.locator("[data-market-backfill-end]").count() == 1)
|
||||||
|
with page.expect_response(lambda res: "/admin/api/market/settings" in res.url, timeout=15000):
|
||||||
|
page.click("[data-market-toggle]")
|
||||||
|
page.wait_for_function("() => document.querySelector('#marketCard') && document.querySelector('#marketCard').innerText.includes('已暂停')", timeout=15000)
|
||||||
|
check("点击后后台刷新变为已暂停", "已暂停" in page.inner_text("#marketCard"))
|
||||||
|
check("按钮文案切换为启用", "启用后台刷新" in page.inner_text("[data-market-toggle]"))
|
||||||
|
with page.expect_response(lambda res: "/admin/api/market/settings" in res.url, timeout=15000):
|
||||||
|
page.click("[data-market-toggle]")
|
||||||
|
page.wait_for_function("() => document.querySelector('#marketCard') && document.querySelector('#marketCard').innerText.includes('已启用')", timeout=15000)
|
||||||
|
check("再次点击恢复已启用", "已启用" in page.inner_text("#marketCard"))
|
||||||
if shots:
|
if shots:
|
||||||
|
page.screenshot(path=str(shots / "ui-market-card.png"), full_page=True)
|
||||||
page.screenshot(path=str(shots / "ui-sources-night.png"), full_page=True)
|
page.screenshot(path=str(shots / "ui-sources-night.png"), full_page=True)
|
||||||
|
|
||||||
print("\n[UI-4] 模型池按供应商组织,可拉取/手填")
|
print("\n[UI-4] 模型池按供应商组织,可拉取/手填")
|
||||||
@@ -149,6 +175,44 @@ def main() -> int:
|
|||||||
if shots:
|
if shots:
|
||||||
page.screenshot(path=str(shots / "ui-models-night.png"), full_page=True)
|
page.screenshot(path=str(shots / "ui-models-night.png"), full_page=True)
|
||||||
|
|
||||||
|
print("\n[UI-4c] 模型清单可收起、可再次展开(HEL-566)")
|
||||||
|
page.fill("#newVendorUrl", vendor_url)
|
||||||
|
page.click("#addVendor")
|
||||||
|
page.wait_for_selector(f'[data-vend-fetch="{vendor_url}"]', timeout=10000)
|
||||||
|
page.fill(f'[data-vend-key="{vendor_url}"]', "sk-local-smoke")
|
||||||
|
with page.expect_response(lambda res: "/models/fetch" in res.url, timeout=20000):
|
||||||
|
page.click(f'[data-vend-fetch="{vendor_url}"]')
|
||||||
|
page.wait_for_selector(".vend-fetch-list", timeout=15000)
|
||||||
|
check("拉取成功展开模型清单", page.locator(".vend-fetch-list .vend-pick").count() == 2,
|
||||||
|
str(page.locator(".vend-fetch-list .vend-pick").count()))
|
||||||
|
check("清单展开时按钮变为收起列表", "收起列表" in page.inner_text(f'[data-vend-fetch="{vendor_url}"]'))
|
||||||
|
if shots:
|
||||||
|
page.screenshot(path=str(shots / "ui-models-list-open.png"), full_page=True)
|
||||||
|
page.click(f'[data-vend-fetch="{vendor_url}"]')
|
||||||
|
page.wait_for_function(
|
||||||
|
"() => document.querySelector('.vend-fetch-list') === null && [...document.querySelectorAll('.vend-note')].some((n) => n.innerText.includes('清单已收起'))",
|
||||||
|
timeout=10000,
|
||||||
|
)
|
||||||
|
check("点击收起后清单隐藏", page.locator(".vend-fetch-list").count() == 0)
|
||||||
|
check("收起后按钮回到拉取文案", "获取模型列表" in page.inner_text(f'[data-vend-fetch="{vendor_url}"]'))
|
||||||
|
if shots:
|
||||||
|
page.screenshot(path=str(shots / "ui-models-list-collapsed.png"), full_page=True)
|
||||||
|
with page.expect_response(lambda res: "/models/fetch" in res.url, timeout=20000):
|
||||||
|
page.click(f'[data-vend-fetch="{vendor_url}"]')
|
||||||
|
page.wait_for_selector(".vend-fetch-list", timeout=15000)
|
||||||
|
check("重新拉取后清单再次展开", page.locator(".vend-fetch-list .vend-pick").count() == 2)
|
||||||
|
rows_before = page.locator(".model-row").count()
|
||||||
|
page.locator(".vend-fetch-list .vend-pick input").first.check()
|
||||||
|
with page.expect_response(lambda res: "/models/save" in res.url, timeout=20000):
|
||||||
|
page.click(f'[data-vend-add="{vendor_url}"]')
|
||||||
|
page.wait_for_function(
|
||||||
|
"() => document.querySelector('.vend-fetch-list') === null",
|
||||||
|
timeout=15000,
|
||||||
|
)
|
||||||
|
check("勾选添加完成后清单自动收起", page.locator(".vend-fetch-list").count() == 0)
|
||||||
|
check("模型已入池", page.locator(".model-row").count() == rows_before + 1,
|
||||||
|
f"{page.locator('.model-row').count()}")
|
||||||
|
|
||||||
print("\n[UI-5] 会员管理 + 邀请码:生成、复制、作废")
|
print("\n[UI-5] 会员管理 + 邀请码:生成、复制、作废")
|
||||||
page.click('[data-nav="members"]')
|
page.click('[data-nav="members"]')
|
||||||
page.wait_for_selector("#createInvites", timeout=10000)
|
page.wait_for_selector("#createInvites", timeout=10000)
|
||||||
@@ -295,5 +359,30 @@ def _is_light(colour: str) -> bool:
|
|||||||
return sum(numbers) / 3 > 160
|
return sum(numbers) / 3 > 160
|
||||||
|
|
||||||
|
|
||||||
|
class FakeVendorHandler(BaseHTTPRequestHandler):
|
||||||
|
"""本地假供应商:提供 OpenAI 兼容的 GET /v1/models,供拉取清单的真链路自测。"""
|
||||||
|
|
||||||
|
def do_GET(self) -> None: # noqa: N802
|
||||||
|
if self.path.rstrip("/").endswith("/models"):
|
||||||
|
body = json.dumps({"data": [{"id": "smoke-model-a"}, {"id": "smoke-model-b"}]}).encode("utf-8")
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
return
|
||||||
|
self.send_error(404)
|
||||||
|
|
||||||
|
def log_message(self, *_args: object) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def start_fake_vendor() -> int:
|
||||||
|
port = backend.free_port()
|
||||||
|
server = ThreadingHTTPServer(("127.0.0.1", port), FakeVendorHandler)
|
||||||
|
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||||
|
return port
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
raise SystemExit(main())
|
raise SystemExit(main())
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ const state = {
|
|||||||
// 站点侧数据(模型池 / 会员 / 邀请码)由主站经桥接返回,只在本页缓存
|
// 站点侧数据(模型池 / 会员 / 邀请码)由主站经桥接返回,只在本页缓存
|
||||||
models: null, members: null, invites: null,
|
models: null, members: null, invites: null,
|
||||||
vendorDraft: {}, freshCodes: {}, modelTests: {},
|
vendorDraft: {}, freshCodes: {}, modelTests: {},
|
||||||
|
// HEL-566: 主站行情任务(后台刷新开关 / 手动刷新 / 历史回补)同样经桥接
|
||||||
|
market: { status: null, toggleBusy: false, jobs: { refresh: null, backfill: null } },
|
||||||
};
|
};
|
||||||
|
|
||||||
async function api(path, options = {}) {
|
async function api(path, options = {}) {
|
||||||
@@ -735,6 +737,7 @@ function sourcesHtml() {
|
|||||||
`<span class="flex1"></span>` +
|
`<span class="flex1"></span>` +
|
||||||
`<span style="font-size:10px;color:var(--dim);letter-spacing:.12em">凭证安全存储 · 探测只读 · 不影响盘中链路</span>` +
|
`<span style="font-size:10px;color:var(--dim);letter-spacing:.12em">凭证安全存储 · 探测只读 · 不影响盘中链路</span>` +
|
||||||
`</div>` +
|
`</div>` +
|
||||||
|
`<div id="marketCard" class="col">${marketCardHtml()}</div>` +
|
||||||
`<div id="srcCards" class="col">${vm.cards.map(sourceCardHtml).join('')}</div>` +
|
`<div id="srcCards" class="col">${vm.cards.map(sourceCardHtml).join('')}</div>` +
|
||||||
`<div class="rsv-grid">${vm.reserved.map((r) => `<div class="rsv">${ledHtml('off')}<span style="font-size:13px">${esc(r.label)}</span>${tagHtml('dashed', '预留位 · 未接入')}</div>`).join('')}</div>` +
|
`<div class="rsv-grid">${vm.reserved.map((r) => `<div class="rsv">${ledHtml('off')}<span style="font-size:13px">${esc(r.label)}</span>${tagHtml('dashed', '预留位 · 未接入')}</div>`).join('')}</div>` +
|
||||||
`</div>`;
|
`</div>`;
|
||||||
@@ -746,6 +749,148 @@ function renderSourceCards() {
|
|||||||
paintSparks();
|
paintSparks();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =====================================================================
|
||||||
|
主站行情任务(HEL-566:自主站「行情管理」迁入,数据与调度仍归主站)
|
||||||
|
===================================================================== */
|
||||||
|
function dashDate(compact) {
|
||||||
|
const s = String(compact || '');
|
||||||
|
return /^\d{8}$/.test(s) ? `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}` : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMarketStatus() {
|
||||||
|
try {
|
||||||
|
state.market.status = await api('/admin/api/market/status');
|
||||||
|
} catch (err) {
|
||||||
|
state.market.status = { error: err.message };
|
||||||
|
}
|
||||||
|
renderMarketCard();
|
||||||
|
}
|
||||||
|
|
||||||
|
function marketJobNote(job) {
|
||||||
|
if (!job) return '';
|
||||||
|
if (job.status === 'running') return `${ledHtml('rev', true)}任务运行中…`;
|
||||||
|
if (job.status === 'success') return `${ledHtml('ok')}任务完成${job.finished_at ? ' · ' + esc(hm(job.finished_at)) : ''}`;
|
||||||
|
return `${ledHtml('fail')}任务失败${job.message || job.error_code ? ' · ' + esc(plainError(String(job.message || job.error_code || ''), 24)) : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function marketCardHtml() {
|
||||||
|
const data = state.market.status;
|
||||||
|
let body;
|
||||||
|
if (!data) body = '<p class="form-hint">正在读取主站行情设置…</p>';
|
||||||
|
else if (data.error) {
|
||||||
|
body = `<div class="vend-note bad">${ledHtml('fail')}主站行情状态读取失败 · ${esc(data.error)}</div>`;
|
||||||
|
} else {
|
||||||
|
const enabled = Boolean(data.background_refresh_enabled);
|
||||||
|
body =
|
||||||
|
`<div class="route-line">` +
|
||||||
|
`<div class="field"><span class="lab">交易时段后台刷新</span>` +
|
||||||
|
`<span class="vend-note">${ledHtml(enabled ? 'ok' : 'off')}${enabled ? '已启用 · 后台定时更新公共快照' : '已暂停 · 交易时段不再自动刷新'}</span></div>` +
|
||||||
|
`<span class="form-actions">` +
|
||||||
|
`<button class="tbtn" data-market-toggle${state.market.toggleBusy ? ' disabled' : ''}>${state.market.toggleBusy ? '保存中…' : (enabled ? '暂停后台刷新' : '启用后台刷新')}</button>` +
|
||||||
|
`</span></div>` +
|
||||||
|
`<div class="route-line">` +
|
||||||
|
`<div class="field"><span class="lab">手动刷新快照日期</span><input type="date" data-market-refresh-date value="${esc(dashDate(today()))}"></div>` +
|
||||||
|
`<span class="form-actions"><button class="pbtn" data-market-refresh>立即后台刷新</button>` +
|
||||||
|
`<span class="vend-note">${marketJobNote(state.market.jobs.refresh)}</span></span></div>` +
|
||||||
|
`<div class="form-hint">所有用户共读同一份主站快照,任务在主站后台执行,完成后此处亮绿灯。</div>` +
|
||||||
|
`<div class="route-line">` +
|
||||||
|
`<div class="field"><span class="lab">历史回补开始日期</span><input type="date" data-market-backfill-start></div>` +
|
||||||
|
`<div class="field"><span class="lab">历史回补结束日期</span><input type="date" data-market-backfill-end></div>` +
|
||||||
|
`<span class="form-actions"><button class="tbtn" data-market-backfill>开始回补</button>` +
|
||||||
|
`<span class="vend-note">${marketJobNote(state.market.jobs.backfill)}</span></span></div>` +
|
||||||
|
`<div class="form-hint">补齐缺失的历史行情快照,区间最多 15 个交易日,周末与节假日自动跳过。</div>`;
|
||||||
|
}
|
||||||
|
return panelHtml('主站行情任务', tagHtml('dim', '自主站行情管理迁入'), body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarketCard() {
|
||||||
|
const el = $('marketCard');
|
||||||
|
if (!el) return;
|
||||||
|
el.innerHTML = marketCardHtml();
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackMarketJob(kind, jobKey) {
|
||||||
|
state.market.jobs[kind] = { status: 'running' };
|
||||||
|
renderMarketCard();
|
||||||
|
let attempts = 0;
|
||||||
|
// 刷新最快数十秒;回补按分钟计。超时只是停止等待,任务仍在主站继续。
|
||||||
|
const limit = kind === 'backfill' ? 450 : 120;
|
||||||
|
const timer = setInterval(async () => {
|
||||||
|
attempts += 1;
|
||||||
|
if (attempts > limit) {
|
||||||
|
clearInterval(timer);
|
||||||
|
state.market.jobs[kind] = { status: 'running', message: '等待超时,任务仍在主站执行,可稍后重进本页查看结果' };
|
||||||
|
renderMarketCard();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const status = await api('/admin/api/market/status');
|
||||||
|
state.market.status = status;
|
||||||
|
const job = (status.jobs || []).find((item) => item.idempotency_key === jobKey);
|
||||||
|
if (job && (job.status === 'success' || job.status === 'failed')) {
|
||||||
|
clearInterval(timer);
|
||||||
|
state.market.jobs[kind] = job;
|
||||||
|
renderMarketCard();
|
||||||
|
toast(
|
||||||
|
job.status === 'success' ? (kind === 'backfill' ? '历史回补完成' : '后台刷新完成') : `任务失败:${job.message || job.error_code || '未知错误'}`,
|
||||||
|
job.status === 'success' ? 'ok' : 'err'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (job) state.market.jobs[kind] = job;
|
||||||
|
} catch {}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleMarketRefresh() {
|
||||||
|
const data = state.market.status;
|
||||||
|
if (!data || data.error || state.market.toggleBusy) return;
|
||||||
|
state.market.toggleBusy = true;
|
||||||
|
renderMarketCard();
|
||||||
|
try {
|
||||||
|
state.market.status = await api('/admin/api/market/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ background_refresh_enabled: !data.background_refresh_enabled }),
|
||||||
|
});
|
||||||
|
toast(!data.background_refresh_enabled ? '后台刷新已启用' : '后台刷新已暂停', 'ok');
|
||||||
|
} catch (err) {
|
||||||
|
toast(err.message, 'err');
|
||||||
|
}
|
||||||
|
state.market.toggleBusy = false;
|
||||||
|
renderMarketCard();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startMarketRefresh() {
|
||||||
|
const dateEl = document.querySelector('[data-market-refresh-date]');
|
||||||
|
try {
|
||||||
|
const result = await api('/admin/api/market/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ trade_date: dateEl ? dateEl.value : '' }),
|
||||||
|
});
|
||||||
|
if (!result.started) { toast(result.message || '已有后台刷新任务正在运行', 'err'); return; }
|
||||||
|
toast(result.message || '后台刷新已开始', 'ok');
|
||||||
|
trackMarketJob('refresh', result.job_key);
|
||||||
|
} catch (err) { toast(err.message, 'err'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startMarketBackfill() {
|
||||||
|
const startEl = document.querySelector('[data-market-backfill-start]');
|
||||||
|
const endEl = document.querySelector('[data-market-backfill-end]');
|
||||||
|
const start = startEl ? startEl.value : '';
|
||||||
|
const end = endEl ? endEl.value : '';
|
||||||
|
if (!start || !end) { toast('请先选择回补的开始与结束日期', 'err'); return; }
|
||||||
|
if (!window.confirm(`确定回补 ${start} 至 ${end} 的历史行情?`)) return;
|
||||||
|
try {
|
||||||
|
const result = await api('/admin/api/market/backfill', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ start_date: start, end_date: end }),
|
||||||
|
});
|
||||||
|
if (!result.started) { toast(result.message || '已有回补任务正在运行', 'err'); return; }
|
||||||
|
toast(result.message || '回补任务已开始', 'ok');
|
||||||
|
trackMarketJob('backfill', result.job_key);
|
||||||
|
} catch (err) { toast(err.message, 'err'); }
|
||||||
|
}
|
||||||
|
|
||||||
/* =====================================================================
|
/* =====================================================================
|
||||||
数据血缘页(接口级状态 + 已配置/待观测 + 真实频率 + 事件脉冲)
|
数据血缘页(接口级状态 + 已配置/待观测 + 真实频率 + 事件脉冲)
|
||||||
===================================================================== */
|
===================================================================== */
|
||||||
@@ -1002,7 +1147,7 @@ function lineageHtml() {
|
|||||||
===================================================================== */
|
===================================================================== */
|
||||||
function vendorDraft(key) {
|
function vendorDraft(key) {
|
||||||
if (!state.vendorDraft[key]) {
|
if (!state.vendorDraft[key]) {
|
||||||
state.vendorDraft[key] = { fetching: false, fetched: null, fetchedAt: '', error: '', manual: false, url: '', key: '' };
|
state.vendorDraft[key] = { fetching: false, fetched: null, fetchedAt: '', error: '', manual: false, url: '', key: '', collapsed: false };
|
||||||
}
|
}
|
||||||
return state.vendorDraft[key];
|
return state.vendorDraft[key];
|
||||||
}
|
}
|
||||||
@@ -1045,6 +1190,12 @@ function vendorFetchHtml(group, draft) {
|
|||||||
manualEntryHtml(group);
|
manualEntryHtml(group);
|
||||||
}
|
}
|
||||||
if (!draft.fetched) return group.models.length ? '' : manualEntryHtml(group);
|
if (!draft.fetched) return group.models.length ? '' : manualEntryHtml(group);
|
||||||
|
// HEL-566: 拉取结果可收起。收起后只留一行摘要,重新拉取即可再次展开。
|
||||||
|
if (draft.collapsed) {
|
||||||
|
return `<div class="vend-note">${ledHtml('ok')}已拉取 ${draft.fetched.length} 个模型` +
|
||||||
|
`${draft.fetchedAt ? ' · ' + esc(draft.fetchedAt) : ''} · 清单已收起</div>` +
|
||||||
|
manualEntryHtml(group);
|
||||||
|
}
|
||||||
const existing = new Set(group.models.map((model) => String(model.model || '')));
|
const existing = new Set(group.models.map((model) => String(model.model || '')));
|
||||||
const pending = draft.fetched.filter((name) => !existing.has(name));
|
const pending = draft.fetched.filter((name) => !existing.has(name));
|
||||||
const picks = draft.fetched.map((name) => {
|
const picks = draft.fetched.map((name) => {
|
||||||
@@ -1086,7 +1237,7 @@ function vendorCardHtml(group) {
|
|||||||
`<span class="form-hint">${group.configured ? '掩码显示 · 留空保持不变' : '密钥仅服务端加密保存,任何界面不回显明文'}</span></div>` +
|
`<span class="form-hint">${group.configured ? '掩码显示 · 留空保持不变' : '密钥仅服务端加密保存,任何界面不回显明文'}</span></div>` +
|
||||||
`<span class="vend-acts">` +
|
`<span class="vend-acts">` +
|
||||||
`<button class="tbtn" data-vend-fetch="${esc(group.base_url)}"${draft.fetching ? ' disabled' : ''}>` +
|
`<button class="tbtn" data-vend-fetch="${esc(group.base_url)}"${draft.fetching ? ' disabled' : ''}>` +
|
||||||
`${group.models.length ? '重新拉取模型列表' : '获取模型列表'}</button>` +
|
`${vendFetchLabel(group, draft)}</button>` +
|
||||||
`<button class="pbtn" data-vend-save="${esc(group.base_url)}">保存供应商</button></span>` +
|
`<button class="pbtn" data-vend-save="${esc(group.base_url)}">保存供应商</button></span>` +
|
||||||
`</div>` +
|
`</div>` +
|
||||||
vendorFetchHtml(group, draft) +
|
vendorFetchHtml(group, draft) +
|
||||||
@@ -1098,6 +1249,13 @@ function vendorCardHtml(group) {
|
|||||||
`<div class="panel-b vend-body">${body}</div></section>`;
|
`<div class="panel-b vend-body">${body}</div></section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* HEL-566: 列表展开时按钮变「收起列表」,收起/未拉取时回到拉取文案,
|
||||||
|
点击重新拉取会再次展开,交互沿用既有按钮,不新增视觉元素。 */
|
||||||
|
function vendFetchLabel(group, draft) {
|
||||||
|
if (draft.fetched && !draft.collapsed && !draft.fetching) return '收起列表';
|
||||||
|
return group.models.length ? '重新拉取模型列表' : '获取模型列表';
|
||||||
|
}
|
||||||
|
|
||||||
function modelsHtml() {
|
function modelsHtml() {
|
||||||
const data = state.models;
|
const data = state.models;
|
||||||
if (!data) return panelHtml('模型池', '', '<p class="form-hint">正在从主站读取模型池…</p>');
|
if (!data) return panelHtml('模型池', '', '<p class="form-hint">正在从主站读取模型池…</p>');
|
||||||
@@ -1267,7 +1425,11 @@ function renderPage(p) {
|
|||||||
mainEl.dataset.page = p;
|
mainEl.dataset.page = p;
|
||||||
document.querySelectorAll('[data-nav]').forEach((b) => b.classList.toggle('act', b.dataset.nav === p));
|
document.querySelectorAll('[data-nav]').forEach((b) => b.classList.toggle('act', b.dataset.nav === p));
|
||||||
if (p === 'overview') { mainEl.innerHTML = overviewHtml(); paintSparks(); flashChangedLatencies(); }
|
if (p === 'overview') { mainEl.innerHTML = overviewHtml(); paintSparks(); flashChangedLatencies(); }
|
||||||
else if (p === 'sources') { mainEl.innerHTML = sourcesHtml(); paintSparks(); }
|
else if (p === 'sources') {
|
||||||
|
mainEl.innerHTML = sourcesHtml();
|
||||||
|
paintSparks();
|
||||||
|
if (!state.market.status) loadMarketStatus();
|
||||||
|
}
|
||||||
else if (p === 'models') {
|
else if (p === 'models') {
|
||||||
mainEl.innerHTML = modelsHtml();
|
mainEl.innerHTML = modelsHtml();
|
||||||
restoreVendorDrafts();
|
restoreVendorDrafts();
|
||||||
@@ -1325,7 +1487,7 @@ function updatePhaseTag() {
|
|||||||
/* ---------- 模型池 / 会员页的事件(沿用 mainEl 的委托,不另加监听) ---------- */
|
/* ---------- 模型池 / 会员页的事件(沿用 mainEl 的委托,不另加监听) ---------- */
|
||||||
function handleModelClick(e) {
|
function handleModelClick(e) {
|
||||||
const fetchBtn = e.target.closest('[data-vend-fetch]');
|
const fetchBtn = e.target.closest('[data-vend-fetch]');
|
||||||
if (fetchBtn) { fetchVendorModels(fetchBtn.dataset.vendFetch); return true; }
|
if (fetchBtn) { toggleVendorList(fetchBtn.dataset.vendFetch); return true; }
|
||||||
const saveVendor = e.target.closest('[data-vend-save]');
|
const saveVendor = e.target.closest('[data-vend-save]');
|
||||||
if (saveVendor) { saveVendorProfile(saveVendor.dataset.vendSave); return true; }
|
if (saveVendor) { saveVendorProfile(saveVendor.dataset.vendSave); return true; }
|
||||||
const manual = e.target.closest('[data-vend-manual]');
|
const manual = e.target.closest('[data-vend-manual]');
|
||||||
@@ -1373,6 +1535,7 @@ async function fetchVendorModels(baseUrl) {
|
|||||||
if (!key && !vendorGroup(baseUrl).configured) { toast('请先填写 API Key,再拉取模型列表', 'err'); return; }
|
if (!key && !vendorGroup(baseUrl).configured) { toast('请先填写 API Key,再拉取模型列表', 'err'); return; }
|
||||||
draft.fetching = true;
|
draft.fetching = true;
|
||||||
draft.error = '';
|
draft.error = '';
|
||||||
|
draft.collapsed = false;
|
||||||
renderPage('models');
|
renderPage('models');
|
||||||
try {
|
try {
|
||||||
const result = await api('/admin/api/models/fetch', { method: 'POST', body: JSON.stringify({ base_url: url, api_key: key }) });
|
const result = await api('/admin/api/models/fetch', { method: 'POST', body: JSON.stringify({ base_url: url, api_key: key }) });
|
||||||
@@ -1387,6 +1550,17 @@ async function fetchVendorModels(baseUrl) {
|
|||||||
renderPage('models');
|
renderPage('models');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* HEL-566: 拉出的清单可收起可展开——展开时收起,收起/未拉取时重新拉取。 */
|
||||||
|
function toggleVendorList(baseUrl) {
|
||||||
|
const draft = vendorDraft(baseUrl);
|
||||||
|
if (!draft.fetching && draft.fetched && !draft.collapsed) {
|
||||||
|
draft.collapsed = true;
|
||||||
|
renderPage('models');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetchVendorModels(baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
function nextModelId(model) {
|
function nextModelId(model) {
|
||||||
// 主站要求模型 ID 匹配 [A-Za-z0-9_-]{3,80},这里把模型名压成合法且唯一的 ID。
|
// 主站要求模型 ID 匹配 [A-Za-z0-9_-]{3,80},这里把模型名压成合法且唯一的 ID。
|
||||||
const used = new Set(state.models.models.map((m) => m.id));
|
const used = new Set(state.models.models.map((m) => m.id));
|
||||||
@@ -1444,6 +1618,7 @@ async function addModels(baseUrl, names) {
|
|||||||
if (!added.length) { toast('这些模型已在池中', 'err'); return; }
|
if (!added.length) { toast('这些模型已在池中', 'err'); return; }
|
||||||
const draft = vendorDraft(baseUrl);
|
const draft = vendorDraft(baseUrl);
|
||||||
draft.fetched = null;
|
draft.fetched = null;
|
||||||
|
draft.collapsed = false;
|
||||||
draft.manual = false;
|
draft.manual = false;
|
||||||
draft.key = '';
|
draft.key = '';
|
||||||
const extra = {};
|
const extra = {};
|
||||||
@@ -1592,6 +1767,12 @@ async function revokeInvite(codeId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mainEl.addEventListener('click', (e) => {
|
mainEl.addEventListener('click', (e) => {
|
||||||
|
const mt = e.target.closest('[data-market-toggle]');
|
||||||
|
if (mt) { toggleMarketRefresh(); return; }
|
||||||
|
const mr = e.target.closest('[data-market-refresh]');
|
||||||
|
if (mr) { startMarketRefresh(); return; }
|
||||||
|
const mb = e.target.closest('[data-market-backfill]');
|
||||||
|
if (mb) { startMarketBackfill(); return; }
|
||||||
const pb = e.target.closest('[data-probe]');
|
const pb = e.target.closest('[data-probe]');
|
||||||
if (pb) { doProbe(pb.dataset.probe); return; }
|
if (pb) { doProbe(pb.dataset.probe); return; }
|
||||||
const tg = e.target.closest('[data-toggle]');
|
const tg = e.target.closest('[data-toggle]');
|
||||||
|
|||||||
@@ -238,6 +238,18 @@ class HubRequestHandler(BaseHTTPRequestHandler):
|
|||||||
self._json(console.test_model(self._read_json()), HTTPStatus.OK)
|
self._json(console.test_model(self._read_json()), HTTPStatus.OK)
|
||||||
elif path == "/admin/api/models/fetch" and method == "POST":
|
elif path == "/admin/api/models/fetch" and method == "POST":
|
||||||
self._json(console.fetch_models(self._read_json()), HTTPStatus.OK)
|
self._json(console.fetch_models(self._read_json()), HTTPStatus.OK)
|
||||||
|
elif path == "/admin/api/market/status" and method == "GET":
|
||||||
|
self._json(console.market_status(), HTTPStatus.OK)
|
||||||
|
elif path == "/admin/api/market/settings" and method == "POST":
|
||||||
|
self._json(console.save_market_settings(self._read_json()), HTTPStatus.OK)
|
||||||
|
elif path == "/admin/api/market/refresh" and method == "POST":
|
||||||
|
payload = console.market_refresh(self._read_json(allow_empty=True))
|
||||||
|
self.hub.pipeline.audit(user["username"], "market_refresh", str(payload.get("job_key") or ""), "")
|
||||||
|
self._json(payload, HTTPStatus.OK)
|
||||||
|
elif path == "/admin/api/market/backfill" and method == "POST":
|
||||||
|
payload = console.market_backfill(self._read_json())
|
||||||
|
self.hub.pipeline.audit(user["username"], "market_backfill", str(payload.get("job_key") or ""), "")
|
||||||
|
self._json(payload, HTTPStatus.OK)
|
||||||
elif path == "/admin/api/members" and method == "GET":
|
elif path == "/admin/api/members" and method == "GET":
|
||||||
self._json(console.members(), HTTPStatus.OK)
|
self._json(console.members(), HTTPStatus.OK)
|
||||||
elif path == "/admin/api/members/save" and method == "POST":
|
elif path == "/admin/api/members/save" and method == "POST":
|
||||||
|
|||||||
@@ -79,6 +79,54 @@ class SiteConsole:
|
|||||||
)
|
)
|
||||||
return {"models": payload.get("models") or []}
|
return {"models": payload.get("models") or []}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- market
|
||||||
|
# HEL-566: 行情管理整体并入数据中枢。后台刷新开关、手动刷新与历史回补
|
||||||
|
# 仍由主站执行(数据与调度都归主站),控制台只是经桥接代为操作,
|
||||||
|
# 任务状态也从主站 /api/hub-admin/status 的 jobs 里读。
|
||||||
|
def market_status(self) -> dict[str, Any]:
|
||||||
|
payload = self._call("/api/hub-admin/status")
|
||||||
|
data = payload.get("data") or {}
|
||||||
|
return {
|
||||||
|
"background_refresh_enabled": bool(data.get("background_refresh_enabled")),
|
||||||
|
"configured": bool(data.get("configured")),
|
||||||
|
"snapshot_dates": data.get("snapshot_dates") or 0,
|
||||||
|
"jobs": data.get("jobs") or [],
|
||||||
|
}
|
||||||
|
|
||||||
|
def save_market_settings(self, body: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
if "background_refresh_enabled" not in body:
|
||||||
|
raise ApiError("INVALID_ARGUMENT", "没有需要保存的行情设置")
|
||||||
|
self._call(
|
||||||
|
"/api/hub-admin/settings/save",
|
||||||
|
{"background_refresh_enabled": bool(body.get("background_refresh_enabled"))},
|
||||||
|
)
|
||||||
|
return self.market_status()
|
||||||
|
|
||||||
|
def market_refresh(self, body: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = self._call(
|
||||||
|
"/api/hub-admin/market/refresh",
|
||||||
|
{"trade_date": str(body.get("trade_date") or "")},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"started": bool(payload.get("started")),
|
||||||
|
"job_key": str(payload.get("job_key") or ""),
|
||||||
|
"message": str(payload.get("message") or ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
def market_backfill(self, body: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
payload = self._call(
|
||||||
|
"/api/hub-admin/market/backfill",
|
||||||
|
{
|
||||||
|
"start_date": str(body.get("start_date") or ""),
|
||||||
|
"end_date": str(body.get("end_date") or ""),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"started": bool(payload.get("started")),
|
||||||
|
"job_key": str(payload.get("job_key") or ""),
|
||||||
|
"message": str(payload.get("message") or ""),
|
||||||
|
}
|
||||||
|
|
||||||
# --------------------------------------------------------------- members
|
# --------------------------------------------------------------- members
|
||||||
def members(self) -> dict[str, Any]:
|
def members(self) -> dict[str, Any]:
|
||||||
payload = self._call("/api/hub-admin/members")
|
payload = self._call("/api/hub-admin/members")
|
||||||
|
|||||||
@@ -122,6 +122,60 @@ class MemberAndQuotaTests(unittest.TestCase):
|
|||||||
self.assertEqual([], self.bridge.paths())
|
self.assertEqual([], self.bridge.paths())
|
||||||
|
|
||||||
|
|
||||||
|
class MarketTaskTests(unittest.TestCase):
|
||||||
|
"""HEL-566: 主站行情管理并入中枢后的桥接映射。"""
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.bridge = RecordingBridge(
|
||||||
|
{
|
||||||
|
"/api/hub-admin/status": {
|
||||||
|
"data": {
|
||||||
|
"background_refresh_enabled": True,
|
||||||
|
"configured": True,
|
||||||
|
"snapshot_dates": 12,
|
||||||
|
"jobs": [{"idempotency_key": "manual:20260916:1", "status": "success"}],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/hub-admin/settings/save": {},
|
||||||
|
"/api/hub-admin/market/refresh": {"started": True, "job_key": "manual:20260916:1", "message": "后台刷新已开始"},
|
||||||
|
"/api/hub-admin/market/backfill": {"started": True, "job_key": "backfill:20260901:20260908:1", "message": "历史回补任务已开始"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.console = SiteConsole(self.bridge)
|
||||||
|
|
||||||
|
def test_market_status_projects_only_what_the_card_needs(self) -> None:
|
||||||
|
payload = self.console.market_status()
|
||||||
|
self.assertTrue(payload["background_refresh_enabled"])
|
||||||
|
self.assertEqual(1, len(payload["jobs"]))
|
||||||
|
self.assertNotIn("llm", payload)
|
||||||
|
self.assertNotIn("users", payload)
|
||||||
|
|
||||||
|
def test_toggling_background_refresh_saves_then_reads_back(self) -> None:
|
||||||
|
payload = self.console.save_market_settings({"background_refresh_enabled": False})
|
||||||
|
self.assertEqual(
|
||||||
|
["/api/hub-admin/settings/save", "/api/hub-admin/status"],
|
||||||
|
self.bridge.paths(),
|
||||||
|
)
|
||||||
|
self.assertEqual({"background_refresh_enabled": False}, self.bridge.calls[0][1])
|
||||||
|
self.assertTrue(payload["background_refresh_enabled"]) # 读回的是主站真实状态
|
||||||
|
|
||||||
|
def test_saving_without_the_toggle_is_refused(self) -> None:
|
||||||
|
with self.assertRaises(ApiError):
|
||||||
|
self.console.save_market_settings({})
|
||||||
|
self.assertEqual([], self.bridge.paths())
|
||||||
|
|
||||||
|
def test_refresh_and_backfill_forward_their_payloads(self) -> None:
|
||||||
|
refresh = self.console.market_refresh({"trade_date": "2026-09-16"})
|
||||||
|
self.assertTrue(refresh["started"])
|
||||||
|
self.assertEqual({"trade_date": "2026-09-16"}, self.bridge.calls[0][1])
|
||||||
|
backfill = self.console.market_backfill({"start_date": "2026-09-01", "end_date": "2026-09-08"})
|
||||||
|
self.assertEqual("backfill:20260901:20260908:1", backfill["job_key"])
|
||||||
|
self.assertEqual(
|
||||||
|
{"start_date": "2026-09-01", "end_date": "2026-09-08"},
|
||||||
|
self.bridge.calls[1][1],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class InviteTests(unittest.TestCase):
|
class InviteTests(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
self.bridge = RecordingBridge(
|
self.bridge = RecordingBridge(
|
||||||
|
|||||||
Reference in New Issue
Block a user