This commit is contained in:
2026-06-16 03:00:06 +08:00
parent 964c17c200
commit 5b4d7bf280
5 changed files with 612 additions and 50 deletions

View File

@@ -58,10 +58,11 @@ import position_cost as pc
import trade_calendar as cal import trade_calendar as cal
import data_manager as dm import data_manager as dm
import paper_trading as paper import paper_trading as paper
import task_manager
from db import init_db, get_session from db import init_db, get_session
from models import (DailyQuote, IndexDaily, SectorDaily, FundFlowDaily, from models import (DailyQuote, IndexDaily, SectorDaily, FundFlowDaily,
SentimentDaily, DragonTiger, Security, JobRun, StockMetric, Trade, SentimentDaily, DragonTiger, Security, JobRun, StockMetric, Trade,
AlertRule, AlertEvent, SelectorStrategy, SelectorAlert) AlertRule, AlertEvent, SelectorStrategy, SelectorAlert, ScheduledTask)
@asynccontextmanager @asynccontextmanager
@@ -71,8 +72,9 @@ async def lifespan(app: FastAPI):
init_auth.init_default_admin() init_auth.init_default_admin()
wl.init_default_groups() wl.init_default_groups()
paper.ensure_default_account() paper.ensure_default_account()
task_manager.init_tasks()
scheduler.start_scheduler() scheduler.start_scheduler()
print("[startup] db + scheduler + auth ready") print("[startup] db + scheduler + task_manager + auth ready")
except Exception as e: except Exception as e:
print("[startup] WARN:", repr(e)[:160]) print("[startup] WARN:", repr(e)[:160])
yield yield
@@ -1636,6 +1638,50 @@ def paper_place_order(account_id: int, req: PaperOrderIn):
return paper.place_order(account_id, req.code, req.side, req.qty, req.price, req.reason) return paper.place_order(account_id, req.code, req.side, req.qty, req.price, req.reason)
# ============ 定时任务管理 ============
@app.get("/api/tasks")
def list_tasks(current_user = Depends(require_admin)):
"""获取所有定时任务"""
return {"ok": True, "tasks": task_manager.get_all_tasks()}
class UpdateTaskRequest(BaseModel):
enabled: Optional[bool] = None
schedule_type: Optional[str] = None
cron_expression: Optional[str] = None
interval_seconds: Optional[int] = None
@app.put("/api/tasks/{task_id}")
def update_task(task_id: str, req: UpdateTaskRequest, current_user = Depends(require_admin)):
"""更新任务配置"""
return task_manager.update_task(
task_id,
enabled=req.enabled,
schedule_type=req.schedule_type,
cron_expression=req.cron_expression,
interval_seconds=req.interval_seconds
)
@app.post("/api/tasks/{task_id}/toggle")
def toggle_task(task_id: str, current_user = Depends(require_admin)):
"""切换任务开关"""
return task_manager.toggle_task(task_id)
@app.get("/api/tasks/{task_id}/logs")
def task_logs(task_id: str, limit: int = Query(50, le=200), current_user = Depends(require_admin)):
"""获取任务执行日志"""
return {"ok": True, "logs": task_manager.get_task_logs(task_id, limit)}
@app.post("/api/tasks/reload")
def reload_tasks(current_user = Depends(require_admin)):
"""重新加载调度器"""
return scheduler.reload_scheduler()
@app.get("/api/paper/accounts/{account_id}/portfolio") @app.get("/api/paper/accounts/{account_id}/portfolio")
def paper_get_portfolio(account_id: int): def paper_get_portfolio(account_id: int):
return paper.get_portfolio(account_id) return paper.get_portfolio(account_id)

View File

@@ -429,3 +429,22 @@ class WatchlistItem(Base):
sort_order: Mapped[int] = mapped_column(Integer, default=0) sort_order: Mapped[int] = mapped_column(Integer, default=0)
note: Mapped[str] = mapped_column(String(200), default="") # 个股备注 note: Mapped[str] = mapped_column(String(200), default="") # 个股备注
added_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now()) added_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now())
class ScheduledTask(Base):
"""定时任务配置。"""
__tablename__ = "scheduled_tasks"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
task_id: Mapped[str] = mapped_column(String(40), unique=True, index=True) # 任务标识
name: Mapped[str] = mapped_column(String(80)) # 任务名称
description: Mapped[str] = mapped_column(String(200), default="") # 描述
enabled: Mapped[bool] = mapped_column(default=True) # 是否启用
schedule_type: Mapped[str] = mapped_column(String(20), default="cron") # cron/interval
cron_expression: Mapped[str] = mapped_column(String(50), default="") # cron表达式
interval_seconds: Mapped[int] = mapped_column(Integer, default=0) # 间隔秒数
category: Mapped[str] = mapped_column(String(20), default="其他") # 分类
last_run: Mapped[dt.datetime | None] = mapped_column(DateTime, nullable=True) # 上次运行
run_count: Mapped[int] = mapped_column(Integer, default=0) # 运行次数
last_status: Mapped[str] = mapped_column(String(20), default="") # 上次状态
last_message: Mapped[str] = mapped_column(String(500), default="") # 上次消息
created_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now())

View File

@@ -106,11 +106,95 @@ def _job_verify():
print("[predict] verify error:", repr(e)[:160]) print("[predict] verify error:", repr(e)[:160])
def reload_scheduler():
"""重新加载调度器(应用新配置)"""
global _scheduler
if _scheduler:
_scheduler.shutdown(wait=False)
_scheduler = None
start_scheduler()
return {"ok": True, "msg": "调度器已重新加载"}
def start_scheduler(): def start_scheduler():
global _scheduler global _scheduler
if _scheduler is not None: if _scheduler is not None:
return _scheduler return _scheduler
# 先初始化任务配置
try:
import task_manager
task_manager.init_tasks()
except Exception as e:
print(f"[scheduler] init tasks error: {repr(e)[:120]}")
_scheduler = BackgroundScheduler(timezone="Asia/Shanghai") _scheduler = BackgroundScheduler(timezone="Asia/Shanghai")
# 从数据库加载任务配置
from db import get_session
from models import ScheduledTask
from sqlalchemy import select
try:
with get_session() as s:
tasks = s.execute(select(ScheduledTask).where(ScheduledTask.enabled == True)).scalars().all()
for task in tasks:
_add_job_from_config(task)
except Exception as e:
print(f"[scheduler] load tasks error: {repr(e)[:120]}")
# 降级:使用默认配置
_add_default_jobs()
_scheduler.start()
return _scheduler
def _add_job_from_config(task):
"""根据配置添加任务"""
job_func = _get_job_function(task.task_id)
if not job_func:
return
if task.schedule_type == "cron" and task.cron_expression:
# 解析 cron 表达式 (格式: "mon-fri 16:00")
parts = task.cron_expression.split()
if len(parts) == 2:
day_of_week, time_str = parts
hour, minute = map(int, time_str.split(':'))
_scheduler.add_job(
job_func,
CronTrigger(day_of_week=day_of_week, hour=hour, minute=minute),
id=task.task_id,
replace_existing=True,
misfire_grace_time=3600
)
elif task.schedule_type == "interval" and task.interval_seconds:
_scheduler.add_job(
job_func,
IntervalTrigger(seconds=task.interval_seconds),
id=task.task_id,
replace_existing=True,
max_instances=1
)
def _get_job_function(task_id):
"""获取任务函数"""
job_map = {
"daily_ingest": _job,
"alert_check": _safe_check_alerts,
"daily_report": _job_report,
"verify_pred": _job_verify,
"signal_stats": lambda: _job_signal_stats(),
"intraday_scan": _safe_scan_intraday,
"calendar_alerts": _job_calendar_alerts
}
return job_map.get(task_id)
def _add_default_jobs():
"""添加默认任务配置(降级方案)"""
_scheduler.add_job( _scheduler.add_job(
_job, CronTrigger(day_of_week="mon-fri", hour=config.INGEST_HOUR, minute=config.INGEST_MINUTE), _job, CronTrigger(day_of_week="mon-fri", hour=config.INGEST_HOUR, minute=config.INGEST_MINUTE),
id="daily_ingest", replace_existing=True, misfire_grace_time=3600, id="daily_ingest", replace_existing=True, misfire_grace_time=3600,
@@ -119,37 +203,33 @@ def start_scheduler():
_safe_check_alerts, IntervalTrigger(seconds=60), _safe_check_alerts, IntervalTrigger(seconds=60),
id="alert_check", replace_existing=True, max_instances=1, id="alert_check", replace_existing=True, max_instances=1,
) )
# 收盘入库之后 10 分钟生成 AI 复盘日报并推送
_rep_total = config.INGEST_HOUR * 60 + config.INGEST_MINUTE + 10 _rep_total = config.INGEST_HOUR * 60 + config.INGEST_MINUTE + 10
_scheduler.add_job( _scheduler.add_job(
_job_report, CronTrigger(day_of_week="mon-fri", hour=(_rep_total // 60) % 24, minute=_rep_total % 60), _job_report, CronTrigger(day_of_week="mon-fri", hour=(_rep_total // 60) % 24, minute=_rep_total % 60),
id="daily_report", replace_existing=True, misfire_grace_time=3600, id="daily_report", replace_existing=True, misfire_grace_time=3600,
) )
# 收盘后核验到期预测(实测准确率)
_scheduler.add_job( _scheduler.add_job(
_job_verify, CronTrigger(day_of_week="mon-fri", hour=(_rep_total // 60) % 24, minute=(_rep_total + 5) % 60), _job_verify, CronTrigger(day_of_week="mon-fri", hour=(_rep_total // 60) % 24, minute=(_rep_total + 5) % 60),
id="verify_pred", replace_existing=True, misfire_grace_time=3600, id="verify_pred", replace_existing=True, misfire_grace_time=3600,
) )
# 每周六重算信号历史胜率
_scheduler.add_job( _scheduler.add_job(
_job_signal_stats, CronTrigger(day_of_week="sat", hour=9, minute=0), _job_signal_stats, CronTrigger(day_of_week="sat", hour=9, minute=0),
id="signal_stats", replace_existing=True, misfire_grace_time=7200, id="signal_stats", replace_existing=True, misfire_grace_time=7200,
) )
# 盘中异动扫描(交易时间每分钟)
_scheduler.add_job( _scheduler.add_job(
_safe_scan_intraday, IntervalTrigger(seconds=60), _safe_scan_intraday, IntervalTrigger(seconds=60),
id="intraday_scan", replace_existing=True, max_instances=1, id="intraday_scan", replace_existing=True, max_instances=1,
) )
# 每日早盘前推送日历事件提醒(持仓股除权、解禁、财报等)
_scheduler.add_job( _scheduler.add_job(
_job_calendar_alerts, CronTrigger(day_of_week="mon-fri", hour=8, minute=30), _job_calendar_alerts, CronTrigger(day_of_week="mon-fri", hour=8, minute=30),
id="calendar_alerts", replace_existing=True, misfire_grace_time=3600, id="calendar_alerts", replace_existing=True, misfire_grace_time=3600,
) )
_scheduler.start()
return _scheduler
def _safe_check_alerts(): def _safe_check_alerts():
# 只在交易日的交易时间执行
if not intraday_radar._is_trading_time():
return
try: try:
alerts.check_alerts() alerts.check_alerts()
except Exception as e: except Exception as e:
@@ -157,6 +237,9 @@ def _safe_check_alerts():
def _safe_scan_intraday(): def _safe_scan_intraday():
# 只在交易时间执行
if not intraday_radar._is_trading_time():
return
try: try:
result = intraday_radar.scan_all() result = intraday_radar.scan_all()
if result.get("count", 0) > 0: if result.get("count", 0) > 0:

208
backend/task_manager.py Normal file
View File

@@ -0,0 +1,208 @@
"""定时任务管理系统"""
from typing import Dict, Any, List
from sqlalchemy import select
from db import get_session
from models import ScheduledTask
import scheduler
# 任务配置定义
TASK_CONFIGS = {
"daily_ingest": {
"name": "每日数据入库",
"description": "收盘后自动抓取并入库股票、板块、资金等数据",
"default_enabled": True,
"default_schedule": "cron",
"default_cron": "mon-fri 16:00",
"category": "数据入库"
},
"alert_check": {
"name": "预警检查",
"description": "每分钟检查价格预警规则(仅交易时间)",
"default_enabled": True,
"default_schedule": "interval",
"default_interval": 60,
"category": "实时监控"
},
"daily_report": {
"name": "AI复盘日报",
"description": "生成每日复盘报告并推送",
"default_enabled": True,
"default_schedule": "cron",
"default_cron": "mon-fri 16:10",
"category": "AI分析"
},
"verify_pred": {
"name": "预测准确率核验",
"description": "核验到期的AI预测结果",
"default_enabled": True,
"default_schedule": "cron",
"default_cron": "mon-fri 16:15",
"category": "AI分析"
},
"signal_stats": {
"name": "信号历史胜率",
"description": "重新计算技术信号历史统计",
"default_enabled": True,
"default_schedule": "cron",
"default_cron": "sat 09:00",
"category": "AI分析"
},
"intraday_scan": {
"name": "盘中异动扫描",
"description": "实时扫描急涨急跌、放量突破等异动",
"default_enabled": True,
"default_schedule": "interval",
"default_interval": 60,
"category": "实时监控"
},
"calendar_alerts": {
"name": "日历事件提醒",
"description": "推送除权、解禁、财报等重要事件",
"default_enabled": True,
"default_schedule": "cron",
"default_cron": "mon-fri 08:30",
"category": "事件提醒"
}
}
def init_tasks():
"""初始化任务配置到数据库"""
with get_session() as s:
for task_id, config in TASK_CONFIGS.items():
existing = s.execute(
select(ScheduledTask).where(ScheduledTask.task_id == task_id)
).scalar_one_or_none()
if not existing:
task = ScheduledTask(
task_id=task_id,
name=config["name"],
description=config["description"],
enabled=config["default_enabled"],
schedule_type=config["default_schedule"],
cron_expression=config.get("default_cron"),
interval_seconds=config.get("default_interval"),
category=config["category"]
)
s.add(task)
s.commit()
def get_all_tasks() -> List[Dict[str, Any]]:
"""获取所有任务配置"""
with get_session() as s:
tasks = s.execute(select(ScheduledTask).order_by(ScheduledTask.id)).scalars().all()
return [{
"id": t.id,
"task_id": t.task_id,
"name": t.name,
"description": t.description,
"enabled": t.enabled,
"schedule_type": t.schedule_type,
"cron_expression": t.cron_expression,
"interval_seconds": t.interval_seconds,
"category": t.category,
"last_run": t.last_run.strftime("%Y-%m-%d %H:%M:%S") if t.last_run else None,
"next_run": get_next_run_time(t.task_id),
"run_count": t.run_count,
"last_status": t.last_status
} for t in tasks]
def get_next_run_time(task_id: str) -> str:
"""获取任务下次运行时间"""
if scheduler._scheduler:
job = scheduler._scheduler.get_job(task_id)
if job and job.next_run_time:
return job.next_run_time.strftime("%Y-%m-%d %H:%M:%S")
return "未调度"
def update_task(task_id: str, enabled: bool = None, schedule_type: str = None,
cron_expression: str = None, interval_seconds: int = None) -> Dict[str, Any]:
"""更新任务配置"""
with get_session() as s:
task = s.execute(
select(ScheduledTask).where(ScheduledTask.task_id == task_id)
).scalar_one_or_none()
if not task:
return {"ok": False, "msg": "任务不存在"}
if enabled is not None:
task.enabled = enabled
if schedule_type is not None:
task.schedule_type = schedule_type
if cron_expression is not None:
task.cron_expression = cron_expression
if interval_seconds is not None:
task.interval_seconds = interval_seconds
s.commit()
# 重新调度
scheduler.reload_scheduler()
return {"ok": True, "msg": "任务配置已更新"}
def toggle_task(task_id: str) -> Dict[str, Any]:
"""切换任务开关"""
with get_session() as s:
task = s.execute(
select(ScheduledTask).where(ScheduledTask.task_id == task_id)
).scalar_one_or_none()
if not task:
return {"ok": False, "msg": "任务不存在"}
task.enabled = not task.enabled
s.commit()
# 重新调度
scheduler.reload_scheduler()
return {
"ok": True,
"enabled": task.enabled,
"msg": f"任务已{'启用' if task.enabled else '禁用'}"
}
def record_task_run(task_id: str, status: str, message: str = ""):
"""记录任务执行"""
import datetime as dt
with get_session() as s:
task = s.execute(
select(ScheduledTask).where(ScheduledTask.task_id == task_id)
).scalar_one_or_none()
if task:
task.last_run = dt.datetime.now()
task.run_count += 1
task.last_status = status
task.last_message = message[:500] if message else ""
s.commit()
def get_task_logs(task_id: str = None, limit: int = 50) -> List[Dict[str, Any]]:
"""获取任务执行日志"""
# 这里可以从 JobRun 表读取,或者创建专门的 TaskLog 表
from models import JobRun
with get_session() as s:
stmt = select(JobRun).order_by(JobRun.id.desc()).limit(limit)
if task_id:
stmt = stmt.where(JobRun.job == task_id)
logs = s.execute(stmt).scalars().all()
return [{
"id": log.id,
"task_id": log.job,
"status": log.status,
"started": log.started_at.strftime("%Y-%m-%d %H:%M:%S") if log.started_at else "",
"finished": log.finished_at.strftime("%Y-%m-%d %H:%M:%S") if log.finished_at else "",
"duration": (log.finished_at - log.started_at).total_seconds() if log.finished_at and log.started_at else 0,
"message": log.message
} for log in logs]

284
prototype/app.js vendored
View File

@@ -53,7 +53,7 @@ function showLoginModal() {
bg.id = '_login_modal'; bg.id = '_login_modal';
bg.style.cssText = 'position:fixed;inset:0;background:#00000099;z-index:20000;display:flex;align-items:center;justify-content:center'; bg.style.cssText = 'position:fixed;inset:0;background:#00000099;z-index:20000;display:flex;align-items:center;justify-content:center';
bg.innerHTML = `<div style="background:#0f1520;border:1px solid var(--border);border-radius:8px;padding:24px 28px;width:320px;box-shadow:0 16px 48px #000c"> bg.innerHTML = `<div style="background:#0f1520;border:1px solid var(--border);border-radius:8px;padding:24px 28px;width:320px;box-shadow:0 16px 48px #000c">
<h3 style="margin:0 0 16px;font-size:15px">?? 登录</h3> <h3 style="margin:0 0 16px;font-size:15px">🔐 登录</h3>
<div style="margin-bottom:10px"><input id="_li_user" placeholder="用户名" value="admin" style="width:100%;box-sizing:border-box;height:32px;background:#0a0e15;border:1px solid var(--border);color:var(--text);padding:0 10px;border-radius:4px"/></div> <div style="margin-bottom:10px"><input id="_li_user" placeholder="用户名" value="admin" style="width:100%;box-sizing:border-box;height:32px;background:#0a0e15;border:1px solid var(--border);color:var(--text);padding:0 10px;border-radius:4px"/></div>
<div style="margin-bottom:14px"><input id="_li_pass" type="password" placeholder="密码" value="admin123" style="width:100%;box-sizing:border-box;height:32px;background:#0a0e15;border:1px solid var(--border);color:var(--text);padding:0 10px;border-radius:4px"/></div> <div style="margin-bottom:14px"><input id="_li_pass" type="password" placeholder="密码" value="admin123" style="width:100%;box-sizing:border-box;height:32px;background:#0a0e15;border:1px solid var(--border);color:var(--text);padding:0 10px;border-radius:4px"/></div>
<div style="display:flex;gap:8px"> <div style="display:flex;gap:8px">
@@ -118,6 +118,7 @@ const MENU = [
{ icon: '⚙', name: '策略与中台', children: [ { icon: '⚙', name: '策略与中台', children: [
{ id: 'backtest', name: '策略回测' }, { id: 'backtest', name: '策略回测' },
{ id: 'admin', name: '数据中台' }, { id: 'admin', name: '数据中台' },
{ id: 'tasks', name: '定时任务' },
]}, ]},
{ icon: '◈', name: 'AI 分析', children: [ { icon: '◈', name: 'AI 分析', children: [
{ id: 'ai-today', name: '今日策略' }, { id: 'ai-today', name: '今日策略' },
@@ -140,7 +141,7 @@ const MENU = [
{ id: 'alert-list', name: '预警规则' }, { id: 'alert-list', name: '预警规则' },
{ id: 'alert-events', name: '触发记录' }, { id: 'alert-events', name: '触发记录' },
]}, ]},
{ icon: '??', name: '用户中心', children: [ { icon: '👤', name: '用户中心', children: [
{ id: 'user-profile', name: '我的账户' }, { id: 'user-profile', name: '我的账户' },
{ id: 'user-manage', name: '用户管理' }, { id: 'user-manage', name: '用户管理' },
]}, ]},
@@ -153,7 +154,7 @@ function renderMenu() {
const nav = document.getElementById('menu'); const nav = document.getElementById('menu');
nav.innerHTML = MENU.map((g, gi) => ` nav.innerHTML = MENU.map((g, gi) => `
<div class="menu-group ${gi === 0 ? 'open' : ''}" data-gi="${gi}"> <div class="menu-group ${gi === 0 ? 'open' : ''}" data-gi="${gi}">
<div class="g-head"><span class="ico">${g.icon}</span><span class="g-name">${g.name}</span><span class="arrow">?</span></div> <div class="g-head"><span class="ico">${g.icon}</span><span class="g-name">${g.name}</span><span class="arrow"></span></div>
<div class="submenu"> <div class="submenu">
${g.children.map(c => `<a href="#${c.id}" data-id="${c.id}">${c.name}${c.soon ? ' ·' : ''}</a>`).join('')} ${g.children.map(c => `<a href="#${c.id}" data-id="${c.id}">${c.name}${c.soon ? ' ·' : ''}</a>`).join('')}
</div> </div>
@@ -275,8 +276,7 @@ function colorByPct(p){ const a=Math.min(Math.abs(p)/10,1); return p>=0?`rgba(24
function treemapOption(items){ function treemapOption(items){
const data = items.map(it => { const data = items.map(it => {
const node = { name: it.name, value: it.value||1, pct: it.pct, const node = { name: it.name, value: it.value||1, pct: it.pct,
itemStyle: { color: colorByPct(it.pct) }, itemStyle: { color: colorByPct(it.pct) } };
label: { formatter: `{name|${it.name}}\n{pct|${sign(it.pct)}${fmt(it.pct)}%}` } };
if (it.children) node.children = it.children; if (it.children) node.children = it.children;
return node; return node;
}); });
@@ -286,7 +286,9 @@ function treemapOption(items){
series: [{ type: 'treemap', roam: false, nodeClick: 'zoomToNode', drillDownIcon: '', breadcrumb: { show: false }, series: [{ type: 'treemap', roam: false, nodeClick: 'zoomToNode', drillDownIcon: '', breadcrumb: { show: false },
width: '100%', height: '100%', top: 4, left: 0, right: 0, bottom: 4, width: '100%', height: '100%', top: 4, left: 0, right: 0, bottom: 4,
visibleMin: 100, visibleMin: 100,
label: { show: true, color: '#fff', overflow: 'truncate', rich: { name:{fontSize:12,fontWeight:600,color:'#fff'}, pct:{fontSize:11,color:'#fff',padding:[3,0,0,0]} } }, label: { show: true, color: '#fff', overflow: 'truncate',
formatter: params => `${params.name}\n${sign(params.data.pct)}${fmt(params.data.pct)}%`,
rich: { name:{fontSize:12,fontWeight:600,color:'#fff'}, pct:{fontSize:11,color:'#fff',padding:[3,0,0,0]} } },
upperLabel: { show: true, height: 24, color: '#fff', fontWeight: 700, fontSize: 13, backgroundColor: '#00000066' }, upperLabel: { show: true, height: 24, color: '#fff', fontWeight: 700, fontSize: 13, backgroundColor: '#00000066' },
itemStyle: { borderColor: '#0a0e15', borderWidth: 2, gapWidth: 2 }, itemStyle: { borderColor: '#0a0e15', borderWidth: 2, gapWidth: 2 },
levels: [ levels: [
@@ -371,35 +373,65 @@ let REVIEW_SYMBOL = '600519';
async function showSectorStocksModal(sectorName) { async function showSectorStocksModal(sectorName) {
const old = document.getElementById('_sector_modal'); const old = document.getElementById('_sector_modal');
if (old) old.remove(); if (old) old.remove();
const bg = document.createElement('div'); const bg = document.createElement('div');
bg.id = '_sector_modal'; bg.id = '_sector_modal';
bg.style.cssText = 'position:fixed;inset:0;background:#00000088;z-index:10000;display:flex;align-items:center;justify-content:center;'; bg.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:10000;display:flex;align-items:center;justify-content:center;';
bg.innerHTML = `<div style="background:#0f1520;border:1px solid var(--border);border-radius:8px;width:min(860px,95vw);max-height:85vh;display:flex;flex-direction:column;box-shadow:0 16px 48px #000c" onclick="event.stopPropagation()">
<div style="display:flex;align-items:center;padding:12px 16px;border-bottom:1px solid var(--border);flex-shrink:0"> const modal = document.createElement('div');
<h3 style="margin:0;font-size:15px;font-weight:700;flex:1">?? ${sectorName} · 板块成分股</h3> modal.style.cssText = 'background:#0f1520;border:1px solid var(--border);border-radius:8px;width:min(860px,95vw);max-height:85vh;display:flex;flex-direction:column;box-shadow:0 16px 48px rgba(0,0,0,0.7);';
<button onclick="document.getElementById('_sector_modal').remove()" style="background:none;border:none;color:var(--text-mute);font-size:20px;cursor:pointer;padding:0 4px">×</button>
</div> const header = document.createElement('div');
<div id="_sector_body" style="padding:12px;overflow-y:auto;flex:1"><div class="trend-loading">加载中…</div></div> header.style.cssText = 'display:flex;align-items:center;padding:12px 16px;border-bottom:1px solid var(--border);flex-shrink:0;';
</div>`; header.innerHTML = `<h3 style="margin:0;font-size:15px;font-weight:700;flex:1">📊 ${sectorName} · 板块成分股</h3>`;
bg.onclick = () => bg.remove();
const closeBtn = document.createElement('button');
closeBtn.textContent = '×';
closeBtn.style.cssText = 'background:none;border:none;color:var(--text-mute);font-size:20px;cursor:pointer;padding:0 4px;';
closeBtn.onclick = () => bg.remove();
header.appendChild(closeBtn);
const body = document.createElement('div');
body.style.cssText = 'padding:12px;overflow-y:auto;flex:1;';
body.innerHTML = '<div class="trend-loading">加载中…</div>';
modal.appendChild(header);
modal.appendChild(body);
bg.appendChild(modal);
document.body.appendChild(bg); document.body.appendChild(bg);
bg.onclick = (e) => { if (e.target === bg) bg.remove(); };
let r; let r;
try { r = await apiGet('/api/treemap/sector_stocks?name=' + encodeURIComponent(sectorName) + '&limit=50'); } try {
catch { document.getElementById('_sector_body').innerHTML = '<div class="trend-loading">后端未连接</div>'; return; } r = await apiGet('/api/treemap/sector_stocks?name=' + encodeURIComponent(sectorName) + '&limit=100');
} catch {
body.innerHTML = '<div class="trend-loading">后端未连接</div>';
return;
}
const stocks = r.stocks || []; const stocks = r.stocks || [];
if (!stocks.length) { document.getElementById('_sector_body').innerHTML = '<div class="trend-loading">暂无数据</div>'; return; } if (!stocks.length) {
body.innerHTML = '<div class="trend-loading">暂无数据</div>';
return;
}
const rows = stocks.map((s,i) => `<tr class="clickrow" data-code="${s.code}" style="cursor:pointer"> const rows = stocks.map((s,i) => `<tr class="clickrow" data-code="${s.code}" style="cursor:pointer">
<td>${i+1}</td> <td>${i+1}</td>
<td><b>${s.name}</b> <span style="color:var(--text-mute)">${s.code}</span></td> <td><b>${s.symbol || s.name}</b> <span style="color:var(--text-mute)">${s.code}</span></td>
<td class="num">${fmt(s.price)}</td> <td class="num">${fmt(s.price)}</td>
<td class="${cls(s.pct)} num">${sign(s.pct)}${fmt(s.pct)}%</td> <td class="${cls(s.pct)} num">${sign(s.pct)}${fmt(s.pct)}%</td>
<td class="num">${fmt(s.amount,2)}亿</td> <td class="num">${fmt(s.amount,2)}亿</td>
<td style="color:var(--accent);font-size:12px">查看详情 →</td></tr>`).join(''); <td style="color:var(--accent);font-size:12px">查看详情 →</td></tr>`).join('');
document.getElementById('_sector_body').innerHTML = `<div style="color:var(--text-mute);font-size:12px;margin-bottom:8px">点击股票行查看 K 线详情</div><table class="grid-tbl"><thead><tr><th>#</th><th>名称/代码</th><th>现价</th><th>涨跌幅</th><th>成交额</th><th></th></tr></thead><tbody>${rows}</tbody></table>`;
document.getElementById('_sector_body').querySelectorAll('.clickrow').forEach(tr => { body.innerHTML = `<div style="color:var(--text-mute);font-size:12px;margin-bottom:8px">共 ${stocks.length} 只 · 点击股票行查看 K 线详情</div>
<table class="grid-tbl"><thead><tr><th>#</th><th>名称/代码</th><th>现价</th><th>涨跌幅</th><th>成交额</th><th></th></tr></thead>
<tbody>${rows}</tbody></table>`;
body.querySelectorAll('.clickrow').forEach(tr => {
tr.addEventListener('click', () => { tr.addEventListener('click', () => {
REVIEW_SYMBOL = tr.dataset.code; REVIEW_SYMBOL = tr.dataset.code;
document.getElementById('_sector_modal').remove(); bg.remove();
navigate('review-stock'); navigate('review-stock');
}); });
}); });
@@ -413,19 +445,15 @@ async function loadTreemapWithLeaders() {
const leaders = leadersResp.sectors || {}; const leaders = leadersResp.sectors || {};
return boards.map(b => { return boards.map(b => {
const stocks = leaders[b.name] || []; const stocks = leaders[b.name] || [];
if (!stocks.length) return b; // 过滤掉占位符数据(名称包含板块名+数字的)
const children = stocks.map(s => ({ const validStocks = stocks.filter(s => s.name && s.code && !s.name.match(new RegExp(`^${b.name}\\d+$`)));
if (!validStocks.length) return b;
const children = validStocks.map(s => ({
name: s.name, value: Math.max(s.amount || 1, 1), pct: s.pct, name: s.name, value: Math.max(s.amount || 1, 1), pct: s.pct,
itemStyle: { color: colorByPct(s.pct) }, itemStyle: { color: colorByPct(s.pct) },
label: { show: true, formatter: `{name|${s.name}}\n{pct|${sign(s.pct)}${fmt(s.pct)}%}`, label: { show: true, formatter: `{name|${s.name}}\n{pct|${sign(s.pct)}${fmt(s.pct)}%}`,
rich: { name:{fontSize:11,fontWeight:600,color:'#fff'}, pct:{fontSize:10,color:'#fff'} } } rich: { name:{fontSize:11,fontWeight:600,color:'#fff'}, pct:{fontSize:10,color:'#fff'} } }
})); }));
const moreVal = Math.max((b.value || 10) * 0.08, 0.5);
children.push({
name: '更多...', value: moreVal, pct: 0, _sector: b.name,
itemStyle: { color: '#1a2236' },
label: { show: true, formatter: '{name|更多...}', rich: { name:{fontSize:11,color:'#7d8796'} } }
});
return { ...b, children }; return { ...b, children };
}); });
} }
@@ -491,7 +519,7 @@ const VIEWS = {
try { r = await apiGet('/api/treemap?mode=sector' + dateParam); } catch { r = null; } try { r = await apiGet('/api/treemap?mode=sector' + dateParam); } catch { r = null; }
const label = document.getElementById('cloud-date-label'); const label = document.getElementById('cloud-date-label');
if (!r || !r.items || !r.items.length) { if (!r || !r.items || !r.items.length) {
if (label) label.innerHTML = `<span style="color:var(--gold)">? ${date} 无历史数据,请先在「数据中台」执行入库</span>`; if (label) label.innerHTML = `<span style="color:var(--gold)"> ${date} 无历史数据,请先在「数据中台」执行入库</span>`;
t.setOption(treemapOption([]), true); t.setOption(treemapOption([]), true);
return; return;
} }
@@ -505,7 +533,7 @@ const VIEWS = {
t.setOption(treemapOption(data), true); t.setOption(treemapOption(data), true);
t.off('click'); t.on('click', p => { t.off('click'); t.on('click', p => {
if (!p.data) return; if (!p.data) return;
const sector = p.data.name === '更多...' ? p.data._sector : (p.treePathInfo && p.treePathInfo[0] ? p.treePathInfo[0].name : p.data.name); const sector = p.treePathInfo && p.treePathInfo[0] ? p.treePathInfo[0].name : p.data.name;
showSectorStocksModal(sector); showSectorStocksModal(sector);
}); });
} }
@@ -735,7 +763,7 @@ const VIEWS = {
bodyEl.innerHTML = `<div class="md-doc">${mdToHtml(r.content)}</div>`; bodyEl.innerHTML = `<div class="md-doc">${mdToHtml(r.content)}</div>`;
}; };
const loadHist = async () => { const loadHist = async () => {
try { const h = await apiGet('/api/report/history?limit=60'); histEl.innerHTML = (h.list||[]).map(x=>`<option value="${x.date}">${x.date}${x.pushed?' ?':''}</option>`).join('') || '<option>无历史</option>'; } catch {} try { const h = await apiGet('/api/report/history?limit=60'); histEl.innerHTML = (h.list||[]).map(x=>`<option value="${x.date}">${x.date}${x.pushed?' ':''}</option>`).join('') || '<option>无历史</option>'; } catch {}
}; };
const loadDate = async (date) => { bodyEl.innerHTML='<div class="loading">加载中…</div>'; try { show(await apiGet('/api/report/daily'+(date?`?date=${date}`:''))); } catch { show(null); } }; const loadDate = async (date) => { bodyEl.innerHTML='<div class="loading">加载中…</div>'; try { show(await apiGet('/api/report/daily'+(date?`?date=${date}`:''))); } catch { show(null); } };
histEl.onchange = () => loadDate(histEl.value); histEl.onchange = () => loadDate(histEl.value);
@@ -758,7 +786,7 @@ const VIEWS = {
快线<input id="rs-fast" value="5" style="width:42px;height:26px;background:#0a0e15;border:1px solid var(--border);color:var(--text);padding:0 6px"/> 快线<input id="rs-fast" value="5" style="width:42px;height:26px;background:#0a0e15;border:1px solid var(--border);color:var(--text);padding:0 6px"/>
慢线<input id="rs-slow" value="20" style="width:42px;height:26px;background:#0a0e15;border:1px solid var(--border);color:var(--text);padding:0 6px"/> 慢线<input id="rs-slow" value="20" style="width:42px;height:26px;background:#0a0e15;border:1px solid var(--border);color:var(--text);padding:0 6px"/>
<button id="rs-load" class="btn-run">加载</button> <button id="rs-load" class="btn-run">加载</button>
<button id="rs-play" class="btn-run" style="background:#2a3140;border-color:#2a3140">? 回放</button> <button id="rs-play" class="btn-run" style="background:#2a3140;border-color:#2a3140"> 回放</button>
</span></div> </span></div>
<div class="panel-body"><div id="rs-stats" class="row c4" style="margin-bottom:8px"></div><div id="rs-chart" style="height:480px"></div><div id="rs-msg" style="color:var(--text-dim);padding:6px"></div></div></div>`; <div class="panel-body"><div id="rs-stats" class="row c4" style="margin-bottom:8px"></div><div id="rs-chart" style="height:480px"></div><div id="rs-msg" style="color:var(--text-dim);padding:6px"></div></div></div>`;
let chart, playTimer = null; let chart, playTimer = null;
@@ -1237,6 +1265,103 @@ const VIEWS = {
series:[{type:'line',data:r.equity,symbol:'none',areaStyle:{color:'#e8a13a22'},lineStyle:{width:1.5,color:'#e8a13a'}}] }); series:[{type:'line',data:r.equity,symbol:'none',areaStyle:{color:'#e8a13a22'},lineStyle:{width:1.5,color:'#e8a13a'}}] });
}, },
async tasks(view) {
view.innerHTML = `<div class="panel" style="margin-bottom:10px"><div class="panel-head"><span class="bar"></span>定时任务管理
<button id="tasks-reload" class="btn-run" style="margin-left:auto;background:#2a3140;border-color:#2a3140">重新加载调度器</button></div>
<div class="panel-body"><div id="tasks-list"></div></div></div>
<div class="panel"><div class="panel-head"><span class="bar"></span>执行日志 <select id="task-log-filter" style="margin-left:auto;height:26px;background:#0a0e15;border:1px solid var(--border);color:var(--text);padding:0 6px"><option value="">全部任务</option></select></div>
<div class="panel-body" style="padding:0"><div id="task-logs"></div></div></div>`;
const loadTasks = async () => {
let r; try { r = await apiGet('/api/tasks'); } catch { document.getElementById('tasks-list').innerHTML='<div class="loading">后端未连接</div>'; return; }
const tasks = r.tasks || [];
// 按分类分组
const categories = {};
tasks.forEach(t => {
if (!categories[t.category]) categories[t.category] = [];
categories[t.category].push(t);
});
let html = '';
for (const [cat, items] of Object.entries(categories)) {
html += `<div style="margin-bottom:16px"><div style="font-weight:600;margin-bottom:8px;color:var(--accent)">${cat}</div>`;
items.forEach(t => {
const statusColor = t.last_status === 'success' ? 'var(--up)' : (t.last_status === 'error' ? 'var(--down)' : 'var(--text-mute)');
html += `<div class="task-item" style="display:flex;align-items:center;padding:10px 12px;background:#0a0e15;border-radius:4px;margin-bottom:6px">
<label style="display:flex;align-items:center;flex:1;cursor:pointer">
<input type="checkbox" class="task-toggle" data-id="${t.task_id}" ${t.enabled?'checked':''}>
<div style="margin-left:10px">
<div style="font-weight:600">${t.name}</div>
<div style="font-size:11px;color:var(--text-dim);margin-top:2px">${t.description}</div>
</div>
</label>
<div style="display:flex;align-items:center;gap:12px;font-size:12px;color:var(--text-mute)">
<div>
<div>调度:${t.schedule_type === 'cron' ? t.cron_expression : `${t.interval_seconds}`}</div>
<div style="margin-top:2px">下次:${t.next_run || '未调度'}</div>
</div>
<div>
<div>执行:${t.run_count}次</div>
<div style="margin-top:2px;color:${statusColor}">状态:${t.last_status || '—'}</div>
</div>
<div>
<div>上次:${t.last_run || '—'}</div>
</div>
<button class="task-edit" data-id="${t.task_id}" style="height:24px;padding:0 8px;background:#2a3140;border:1px solid var(--border);color:var(--text);border-radius:4px;cursor:pointer">配置</button>
</div>
</div>`;
});
html += '</div>';
}
document.getElementById('tasks-list').innerHTML = html;
// 绑定开关事件
document.querySelectorAll('.task-toggle').forEach(el => {
el.onchange = async () => {
try { await apiPost('/api/tasks/' + el.dataset.id + '/toggle'); loadTasks(); }
catch { alert('操作失败'); }
};
});
// 绑定编辑事件
document.querySelectorAll('.task-edit').forEach(el => {
el.onclick = () => showTaskEditModal(el.dataset.id, tasks.find(t => t.task_id === el.dataset.id));
});
// 填充日志筛选
const sel = document.getElementById('task-log-filter');
sel.innerHTML = '<option value="">全部任务</option>' + tasks.map(t => `<option value="${t.task_id}">${t.name}</option>`).join('');
};
const loadLogs = async (taskId = '') => {
let r; try { r = await apiGet('/api/tasks/' + (taskId || 'all') + '/logs?limit=50'); } catch { return; }
const logs = r.logs || [];
if (!logs.length) { document.getElementById('task-logs').innerHTML = '<div class="loading">暂无日志</div>'; return; }
const rows = logs.map(log => `<tr>
<td>${log.started}</td>
<td>${log.task_id}</td>
<td><span class="tag ${log.status === 'success' ? '' : (log.status === 'error' ? 'hot' : '')}">${log.status}</span></td>
<td>${log.duration ? log.duration.toFixed(1) + 's' : '—'}</td>
<td style="text-align:left;color:var(--text-dim)">${log.message ? log.message.substring(0, 80) : '—'}</td>
</tr>`).join('');
document.getElementById('task-logs').innerHTML = `<table class="grid-tbl"><thead><tr><th>时间</th><th>任务</th><th>状态</th><th>耗时</th><th>消息</th></tr></thead><tbody>${rows}</tbody></table>`;
};
document.getElementById('tasks-reload').onclick = async () => {
try { await apiPost('/api/tasks/reload'); alert('调度器已重新加载'); loadTasks(); }
catch { alert('重新加载失败'); }
};
document.getElementById('task-log-filter').onchange = (e) => loadLogs(e.target.value);
await loadTasks();
await loadLogs();
},
async 'paper-trading'(view) { async 'paper-trading'(view) {
view.innerHTML = ` view.innerHTML = `
<div class="panel" style="margin-bottom:10px"> <div class="panel" style="margin-bottom:10px">
@@ -1346,7 +1471,7 @@ const VIEWS = {
<div class="panel-head"><span class="bar"></span>我的账户</div> <div class="panel-head"><span class="bar"></span>我的账户</div>
<div class="panel-body"> <div class="panel-body">
<div style="margin-bottom:16px;padding:12px;background:#0a0e15;border-radius:4px"> <div style="margin-bottom:16px;padding:12px;background:#0a0e15;border-radius:4px">
<div style="font-size:18px;font-weight:700;margin-bottom:4px">${me.is_admin?'??':'??'} ${me.username}</div> <div style="font-size:18px;font-weight:700;margin-bottom:4px">${me.is_admin?'👑':'👤'} ${me.username}</div>
<div style="color:var(--text-mute);font-size:12px">${me.is_admin?'管理员':'普通用户'}</div> <div style="color:var(--text-mute);font-size:12px">${me.is_admin?'管理员':'普通用户'}</div>
</div> </div>
<div style="margin-bottom:8px;font-weight:600">修改密码</div> <div style="margin-bottom:8px;font-weight:600">修改密码</div>
@@ -1592,6 +1717,87 @@ function closeTrendModal() {
} }
// 显示走势分析弹窗 // 显示走势分析弹窗
function showTaskEditModal(taskId, task) {
const old = document.getElementById('_task_edit_modal');
if (old) old.remove();
const bg = document.createElement('div');
bg.id = '_task_edit_modal';
bg.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:10000;display:flex;align-items:center;justify-content:center;';
const modal = document.createElement('div');
modal.style.cssText = 'background:#0f1520;border:1px solid var(--border);border-radius:8px;width:min(500px,90vw);padding:20px;box-shadow:0 16px 48px rgba(0,0,0,0.7);';
modal.innerHTML = `
<h3 style="margin:0 0 16px;font-size:15px;font-weight:700">⚙ 配置任务:${task.name}</h3>
<div style="margin-bottom:12px">
<label style="display:block;margin-bottom:4px;font-size:12px;color:var(--text-dim)">调度类型</label>
<select id="te-type" style="width:100%;height:32px;background:#0a0e15;border:1px solid var(--border);color:var(--text);padding:0 8px;border-radius:4px">
<option value="cron" ${task.schedule_type === 'cron' ? 'selected' : ''}>定时(Cron)</option>
<option value="interval" ${task.schedule_type === 'interval' ? 'selected' : ''}>间隔(秒)</option>
</select>
</div>
<div id="te-cron" style="margin-bottom:12px;${task.schedule_type === 'cron' ? '' : 'display:none'}">
<label style="display:block;margin-bottom:4px;font-size:12px;color:var(--text-dim)">Cron表达式 (格式: mon-fri 16:00)</label>
<input id="te-cron-val" value="${task.cron_expression || ''}" style="width:100%;height:32px;background:#0a0e15;border:1px solid var(--border);color:var(--text);padding:0 10px;border-radius:4px"/>
</div>
<div id="te-interval" style="margin-bottom:16px;${task.schedule_type === 'interval' ? '' : 'display:none'}">
<label style="display:block;margin-bottom:4px;font-size:12px;color:var(--text-dim)">间隔秒数</label>
<input id="te-interval-val" type="number" value="${task.interval_seconds || 60}" style="width:100%;height:32px;background:#0a0e15;border:1px solid var(--border);color:var(--text);padding:0 10px;border-radius:4px"/>
</div>
<div style="display:flex;gap:8px">
<button id="te-save" class="btn-run" style="flex:1">保存</button>
<button id="te-cancel" class="btn-run" style="flex:1;background:#2a3140;border-color:#2a3140">取消</button>
</div>
`;
bg.appendChild(modal);
document.body.appendChild(bg);
const typeEl = document.getElementById('te-type');
const cronEl = document.getElementById('te-cron');
const intervalEl = document.getElementById('te-interval');
typeEl.onchange = () => {
if (typeEl.value === 'cron') {
cronEl.style.display = 'block';
intervalEl.style.display = 'none';
} else {
cronEl.style.display = 'none';
intervalEl.style.display = 'block';
}
};
document.getElementById('te-save').onclick = async () => {
const scheduleType = typeEl.value;
const payload = { schedule_type: scheduleType };
if (scheduleType === 'cron') {
payload.cron_expression = document.getElementById('te-cron-val').value.trim();
if (!payload.cron_expression) { alert('请填写Cron表达式'); return; }
} else {
payload.interval_seconds = parseInt(document.getElementById('te-interval-val').value);
if (!payload.interval_seconds || payload.interval_seconds < 1) { alert('请填写有效的间隔秒数'); return; }
}
try {
await fetch(API_BASE + '/api/tasks/' + taskId, {
method: 'PUT',
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
bg.remove();
alert('配置已保存,调度器将自动重新加载');
navigate('tasks');
} catch {
alert('保存失败');
}
};
document.getElementById('te-cancel').onclick = () => bg.remove();
bg.onclick = (e) => { if (e.target === bg) bg.remove(); };
}
function showTrendModal(symbol, name, date, initPeriod) { function showTrendModal(symbol, name, date, initPeriod) {
closeTrendModal(); closeTrendModal();
const bg = document.createElement('div'); const bg = document.createElement('div');
@@ -1599,7 +1805,7 @@ function showTrendModal(symbol, name, date, initPeriod) {
bg.innerHTML = ` bg.innerHTML = `
<div class="trend-modal" onclick="event.stopPropagation()"> <div class="trend-modal" onclick="event.stopPropagation()">
<div class="trend-modal-head"> <div class="trend-modal-head">
<h3>?? 走势分析 <span style="color:var(--text-dim);font-size:13px;font-weight:400">${name || symbol} (${symbol})</span></h3> <h3>📈 走势分析 <span style="color:var(--text-dim);font-size:13px;font-weight:400">${name || symbol} (${symbol})</span></h3>
<span class="seg period-seg"> <span class="seg period-seg">
<button data-p="daily" class="${initPeriod==='daily'?'active':''}">日K</button> <button data-p="daily" class="${initPeriod==='daily'?'active':''}">日K</button>
<button data-p="weekly" class="${initPeriod==='weekly'?'active':''}">周K</button> <button data-p="weekly" class="${initPeriod==='weekly'?'active':''}">周K</button>
@@ -1660,7 +1866,7 @@ function bindKlineContextMenu(chart, getSymbol, getName) {
const menu = document.createElement('div'); const menu = document.createElement('div');
menu.className = 'ctx-menu'; menu.id = '_ctx_menu'; menu.className = 'ctx-menu'; menu.id = '_ctx_menu';
menu.innerHTML = ` menu.innerHTML = `
<div class="ctx-menu-item" id="_ctx_trend">?? 走势分析</div> <div class="ctx-menu-item" id="_ctx_trend">📈 走势分析</div>
<div class="ctx-menu-sep"></div> <div class="ctx-menu-sep"></div>
<div class="ctx-menu-item" id="_ctx_close">关闭</div>`; <div class="ctx-menu-item" id="_ctx_close">关闭</div>`;
menu.style.left = e.event.clientX + 'px'; menu.style.left = e.event.clientX + 'px';
@@ -1688,7 +1894,7 @@ async function initUserState() {
const j = await r.json(); const j = await r.json();
const el = document.getElementById('user-info'); const el = document.getElementById('user-info');
const btn = document.getElementById('logout-btn'); const btn = document.getElementById('logout-btn');
if (el) { el.textContent = (j.is_admin ? '?? ' : '?? ') + j.username; el.style.color = j.is_admin ? 'var(--gold)' : 'var(--text)'; } if (el) { el.textContent = (j.is_admin ? '👑 ' : '👤 ') + j.username; el.style.color = j.is_admin ? 'var(--gold)' : 'var(--text)'; }
if (btn) btn.style.display = 'inline-block'; if (btn) btn.style.display = 'inline-block';
// 登录成功后导航 // 登录成功后导航
navigate(location.hash.slice(1) && VIEW_INDEX[location.hash.slice(1)] ? location.hash.slice(1) : 'overview'); navigate(location.hash.slice(1) && VIEW_INDEX[location.hash.slice(1)] ? location.hash.slice(1) : 'overview');
@@ -1804,7 +2010,7 @@ function showLoginRequired() {
bg.remove(); bg.remove();
const el = document.getElementById('user-info'); const el = document.getElementById('user-info');
const btn = document.getElementById('logout-btn'); const btn = document.getElementById('logout-btn');
if (el) { el.textContent = (j.is_admin?'?? ':'?? ')+j.username; el.style.color = j.is_admin?'var(--gold)':'var(--text)'; } if (el) { el.textContent = (j.is_admin ? '👑 ' : '👤 ') + j.username; el.style.color = j.is_admin ? 'var(--gold)' : 'var(--text)'; }
if (btn) btn.style.display = 'inline-block'; if (btn) btn.style.display = 'inline-block';
navigate(location.hash.slice(1) && VIEW_INDEX[location.hash.slice(1)] ? location.hash.slice(1) : 'overview'); navigate(location.hash.slice(1) && VIEW_INDEX[location.hash.slice(1)] ? location.hash.slice(1) : 'overview');
} else { } else {