Compare commits

..

5 Commits

8 changed files with 457 additions and 108 deletions
+33 -4
View File
@@ -1,10 +1,13 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI, Request
from fastapi.responses import RedirectResponse
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from app.database import engine, Base from starlette.middleware.base import BaseHTTPMiddleware
from app.database import engine, Base, SessionLocal
from app.models import User
from app.seed import seed from app.seed import seed
from app.routers import contracts, analysis, data_input, admin from app.routers import contracts, analysis, data_input, admin, auth
TEMPLATES_DIR = Path(__file__).parent / "templates" TEMPLATES_DIR = Path(__file__).parent / "templates"
@@ -14,6 +17,30 @@ def setup_jinja(app: FastAPI):
app.state.templates = env app.state.templates = env
class AuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
# Public paths
if request.url.path in ("/auth/login", "/auth/logout") or request.url.path.startswith("/auth/"):
return await call_next(request)
# Check session
user_id = request.cookies.get("ft_session")
if user_id:
db = SessionLocal()
try:
user = db.query(User).filter(User.id == int(user_id)).first()
if user:
request.state.user = user
return await call_next(request)
except (ValueError, TypeError):
pass
finally:
db.close()
# Redirect to login
return RedirectResponse(f"/auth/login?next={request.url.path}", status_code=303)
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
setup_jinja(app) setup_jinja(app)
@@ -24,6 +51,9 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="期货量化系统", lifespan=lifespan) app = FastAPI(title="期货量化系统", lifespan=lifespan)
app.add_middleware(AuthMiddleware)
app.include_router(auth.router)
app.include_router(contracts.router) app.include_router(contracts.router)
app.include_router(analysis.router) app.include_router(analysis.router)
app.include_router(data_input.router) app.include_router(data_input.router)
@@ -32,5 +62,4 @@ app.include_router(admin.router)
@app.get("/") @app.get("/")
def root(): def root():
from fastapi.responses import RedirectResponse
return RedirectResponse("/contracts") return RedirectResponse("/contracts")
+27
View File
@@ -1,3 +1,5 @@
import hashlib
import os
from datetime import date from datetime import date
from sqlalchemy import String, Integer, Float, Date, ForeignKey, Boolean, UniqueConstraint from sqlalchemy import String, Integer, Float, Date, ForeignKey, Boolean, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -60,3 +62,28 @@ class PositionSnapshot(Base):
position: Mapped[int] = mapped_column(Integer) position: Mapped[int] = mapped_column(Integer)
delta: Mapped[int] = mapped_column(Integer, default=0) delta: Mapped[int] = mapped_column(Integer, default=0)
avg_cost: Mapped[float] = mapped_column(Float) avg_cost: Mapped[float] = mapped_column(Float)
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(50), unique=True)
password_hash: Mapped[str] = mapped_column(String(128))
role: Mapped[str] = mapped_column(String(20), default="user")
@staticmethod
def hash_password(password: str) -> str:
salt = os.urandom(16)
dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 100000)
return salt.hex() + ":" + dk.hex()
def check_password(self, password: str) -> bool:
try:
salt_hex, dk_hex = self.password_hash.split(":")
salt = bytes.fromhex(salt_hex)
dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 100000)
return dk.hex() == dk_hex
except (ValueError, AttributeError):
return False
+35 -12
View File
@@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.models import Product, Contract from app.models import Product, Contract, DailyBar
from app.collector import sync_active_contracts, sync_one_contract from app.collector import sync_active_contracts, sync_one_contract
router = APIRouter(prefix="/admin", tags=["admin"]) router = APIRouter(prefix="/admin", tags=["admin"])
@@ -22,23 +22,35 @@ def admin_page(request: Request, db: Session = Depends(get_db)):
.order_by(Contract.code) .order_by(Contract.code)
.all() .all()
) )
contract_list = []
for c in contracts:
bar_count = (
db.query(DailyBar)
.filter(DailyBar.contract == c.code)
.count()
)
contract_list.append({
"id": c.id, "code": c.code, "name": c.name,
"is_active": c.is_active, "bar_count": bar_count,
})
product_data.append({ product_data.append({
"id": p.id, "id": p.id,
"code": p.code, "code": p.code,
"name": p.name, "name": p.name,
"exchange": p.exchange, "exchange": p.exchange,
"contracts": [ "contracts": contract_list,
{"id": c.id, "code": c.code, "name": c.name, "is_active": c.is_active}
for c in contracts
],
}) })
total_contracts = sum(len(pd["contracts"]) for pd in product_data)
template = request.app.state.templates.get_template("admin.html") template = request.app.state.templates.get_template("admin.html")
return HTMLResponse( return HTMLResponse(
template.render( template.render(
request=request, request=request,
active_nav="admin", active_nav="admin",
products=product_data, products=product_data,
total_contracts=total_contracts,
exchanges=EXCHANGES, exchanges=EXCHANGES,
) )
) )
@@ -57,7 +69,7 @@ def create_product(
p = Product(code=code.upper(), name=name, exchange=exchange) p = Product(code=code.upper(), name=name, exchange=exchange)
db.add(p) db.add(p)
db.commit() db.commit()
return RedirectResponse("/admin/", status_code=303) return RedirectResponse("/admin/?tab=product", status_code=303)
@router.post("/contract") @router.post("/contract")
@@ -78,7 +90,7 @@ def create_contract(
) )
db.add(c) db.add(c)
db.commit() db.commit()
return RedirectResponse("/admin/", status_code=303) return RedirectResponse("/admin/?tab=contract", status_code=303)
@router.post("/contract/{contract_id}/toggle") @router.post("/contract/{contract_id}/toggle")
@@ -87,7 +99,7 @@ def toggle_contract(contract_id: int, db: Session = Depends(get_db)):
if c: if c:
c.is_active = not c.is_active c.is_active = not c.is_active
db.commit() db.commit()
return RedirectResponse("/admin/", status_code=303) return RedirectResponse("/admin/?tab=contract", status_code=303)
@router.post("/contract/{contract_id}/delete") @router.post("/contract/{contract_id}/delete")
@@ -96,7 +108,7 @@ def delete_contract(contract_id: int, db: Session = Depends(get_db)):
if c: if c:
db.delete(c) db.delete(c)
db.commit() db.commit()
return RedirectResponse("/admin/", status_code=303) return RedirectResponse("/admin/?tab=contract", status_code=303)
@router.post("/product/{product_id}/delete") @router.post("/product/{product_id}/delete")
@@ -105,7 +117,7 @@ def delete_product(product_id: int, db: Session = Depends(get_db)):
if p: if p:
db.delete(p) db.delete(p)
db.commit() db.commit()
return RedirectResponse("/admin/", status_code=303) return RedirectResponse("/admin/?tab=product", status_code=303)
@router.post("/sync") @router.post("/sync")
@@ -113,10 +125,21 @@ def sync_all(request: Request):
results = sync_active_contracts() results = sync_active_contracts()
total = sum(results.values()) total = sum(results.values())
print(f"[sync] Synced {total} bars across {len(results)} contracts: {results}") print(f"[sync] Synced {total} bars across {len(results)} contracts: {results}")
return RedirectResponse("/admin/?synced=" + str(total), status_code=303) return RedirectResponse(f"/admin/?tab=sync&synced={total}", status_code=303)
@router.post("/sync/{contract_code}") @router.post("/sync/{contract_code}")
def sync_single(contract_code: str): def sync_single(contract_code: str):
count = sync_one_contract(contract_code.upper()) count = sync_one_contract(contract_code.upper())
return RedirectResponse(f"/admin/?synced={count}", status_code=303) return RedirectResponse(f"/admin/?tab=sync&synced={count}", status_code=303)
@router.post("/sync/product/{product_id}")
def sync_product(product_id: int, request: Request, db: Session = Depends(get_db)):
contracts = db.query(Contract).filter(
Contract.product_id == product_id, Contract.is_active == True
).all()
total = 0
for c in contracts:
total += sync_one_contract(c.code)
return RedirectResponse(f"/admin/?tab=sync&synced={total}", status_code=303)
+52
View File
@@ -0,0 +1,52 @@
from fastapi import APIRouter, Depends, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import User
router = APIRouter(prefix="/auth", tags=["auth"])
SESSION_COOKIE = "ft_session"
def get_current_user(request: Request, db: Session = Depends(get_db)) -> User | None:
user_id = request.cookies.get(SESSION_COOKIE)
if not user_id:
return None
try:
return db.query(User).filter(User.id == int(user_id)).first()
except (ValueError, TypeError):
return None
@router.get("/login", response_class=HTMLResponse)
def login_page(request: Request):
template = request.app.state.templates.get_template("login.html")
return HTMLResponse(template.render(request=request))
@router.post("/login")
def login(
request: Request,
username: str = Form(...),
password: str = Form(...),
db: Session = Depends(get_db),
):
user = db.query(User).filter(User.username == username).first()
if not user or not user.check_password(password):
template = request.app.state.templates.get_template("login.html")
return HTMLResponse(
template.render(request=request, error="用户名或密码错误"),
status_code=401,
)
resp = RedirectResponse("/contracts/", status_code=303)
resp.set_cookie(SESSION_COOKIE, str(user.id), httponly=True, max_age=86400 * 7)
return resp
@router.get("/logout")
def logout():
resp = RedirectResponse("/auth/login", status_code=303)
resp.delete_cookie(SESSION_COOKIE)
return resp
+11 -1
View File
@@ -1,7 +1,7 @@
"""Seed database from existing data files. Run once manually or on first start.""" """Seed database from existing data files. Run once manually or on first start."""
from datetime import date from datetime import date
from app.database import engine, Base, SessionLocal from app.database import engine, Base, SessionLocal
from app.models import DailyBar, PositionSnapshot, Product, Contract from app.models import DailyBar, PositionSnapshot, Product, Contract, User
from app.engine.lock_strategy import compute_amp_5d from app.engine.lock_strategy import compute_amp_5d
# --- Seed OHLCV data --- # --- Seed OHLCV data ---
@@ -202,6 +202,16 @@ def seed():
db = SessionLocal() db = SessionLocal()
try: try:
# --- Seed default user ---
if not db.query(User).first():
admin = User(
username="admin",
password_hash=User.hash_password("admin123"),
role="admin",
)
db.add(admin)
db.flush()
# --- Seed products and contracts --- # --- Seed products and contracts ---
if not db.query(Product).first(): if not db.query(Product).first():
fg = Product(code="FG", name="玻璃", exchange="CZCE") fg = Product(code="FG", name="玻璃", exchange="CZCE")
+192 -90
View File
@@ -5,105 +5,207 @@
{% block content %} {% block content %}
<style>
.tabs { display: flex; gap: 0; margin-bottom: 24px; border-bottom: 2px solid var(--border); }
.tab-btn {
padding: 10px 20px; border: none; background: none;
font-size: 0.88rem; font-weight: 500; color: var(--sub);
cursor: pointer; border-bottom: 2px solid transparent;
margin-bottom: -2px; transition: color .12s, border-color .12s;
}
.tab-btn:hover { color: var(--fg); }
.tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
</style>
{# ── Sync Bar ── #} {% set tab = request.query_params.get('tab', 'sync') %}
<div style="display:flex;align-items:center;gap:12px;margin-bottom:20px;padding:14px 18px;background:var(--surface);border:1px solid var(--border);border-radius:10px;">
<span style="font-size:0.85rem;color:var(--sub);">数据同步</span> <div class="tabs">
<form method="post" action="/admin/sync" style="display:inline;"> <button class="tab-btn{% if tab == 'sync' %} active{% endif %}" onclick="switchTab('sync')">📡 数据同步</button>
<button type="submit" class="btn btn-primary" style="font-size:0.82rem;padding:6px 16px;">同步全部合约</button> <button class="tab-btn{% if tab == 'product' %} active{% endif %}" onclick="switchTab('product')">🏷️ 品种管理</button>
</form> <button class="tab-btn{% if tab == 'contract' %} active{% endif %}" onclick="switchTab('contract')">📋 合约管理</button>
{% if request.query_params.get('synced') %} </div>
<span style="font-size:0.82rem;color:var(--success-fg);">✓ 已同步 {{ request.query_params.synced }} 条</span>
{# ═══════════════════ Tab: 数据同步 ═══════════════════ #}
<div class="tab-panel{% if tab == 'sync' %} active{% endif %}" id="tab-sync">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:20px;padding:14px 18px;background:var(--surface);border:1px solid var(--border);border-radius:10px;">
<span style="font-size:0.85rem;color:var(--sub);">全部合约</span>
<form method="post" action="/admin/sync" style="display:inline;">
<button type="submit" class="btn btn-primary" style="font-size:0.82rem;padding:6px 16px;">同步全部</button>
</form>
{% if request.query_params.get('synced') %}
<span style="font-size:0.82rem;color:var(--success-fg);">✓ 已同步 {{ request.query_params.synced }} 条</span>
{% endif %}
<span style="font-size:0.78rem;color:var(--sub);margin-left:auto;">{{ total_contracts }} 个合约</span>
</div>
{% if products %}
{% for p in products %}
<div style="margin-bottom:16px;border:1px solid var(--border);border-radius:10px;overflow:hidden;">
<div class="product-header"
style="display:flex;align-items:center;gap:12px;padding:12px 18px;background:var(--th-bg);cursor:pointer;user-select:none;"
onclick="toggleProduct(this)">
<span class="collapse-arrow" style="font-size:0.75rem;transition:transform .2s;display:inline-block;transform:rotate(-90deg);"></span>
<strong style="font-size:0.92rem;">{{ p.code }} · {{ p.name }}</strong>
<span style="font-size:0.78rem;color:var(--sub);">{{ p.exchange }}</span>
<span style="font-size:0.78rem;color:var(--sub);margin-left:8px;">{{ p.contracts|length }} 合约</span>
<form method="post" action="/admin/sync/product/{{ p.id }}" style="display:inline;margin-left:auto;" onclick="event.stopPropagation()">
<button type="submit" class="btn" style="font-size:0.78rem;padding:4px 14px;background:var(--accent);color:#fff;border-radius:5px;">同步该品种</button>
</form>
</div>
<div class="product-body" style="display:none;">
<table style="font-size:0.84rem;">
<tr><th style="text-align:left;padding:8px 14px;">合约</th><th style="text-align:center;padding:8px 14px;">数据条数</th><th style="text-align:center;padding:8px 14px;">状态</th><th style="text-align:center;padding:8px 14px;">操作</th></tr>
{% for c in p.contracts %}
<tr>
<td style="text-align:left;padding:6px 14px;"><strong>{{ c.code }}</strong></td>
<td style="text-align:center;padding:6px 14px;">{{ c.bar_count }}</td>
<td style="text-align:center;padding:6px 14px;">
{% if c.is_active %}<span class="badge badge-up">启用</span>{% else %}<span class="badge badge-down">停用</span>{% endif %}
</td>
<td style="text-align:center;padding:6px 14px;">
<a href="/contracts/{{ c.code }}" style="color:var(--accent);text-decoration:none;font-size:0.82rem;">查看</a>
<form method="post" action="/admin/sync/{{ c.code }}" style="display:inline;margin-left:8px;">
<button style="background:none;border:none;color:var(--accent);cursor:pointer;font-size:0.82rem;">↻ 同步</button>
</form>
</td>
</tr>
{% endfor %}
</table>
</div>
</div>
{% endfor %}
{% else %}
<div style="text-align:center;padding:40px;color:var(--sub);">暂无品种,请先在「品种管理」中新建。</div>
{% endif %} {% endif %}
</div> </div>
{# ── New Product ── #}
<div class="section-title">新建品种</div> {# ═══════════════════ Tab: 品种管理 ═══════════════════ #}
<div class="form-card" style="margin-bottom:24px;"> <div class="tab-panel{% if tab == 'product' %} active{% endif %}" id="tab-product">
<form method="post" action="/admin/product" style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;"> <div class="form-card" style="margin-bottom:24px;">
<div class="form-group" style="margin-bottom:0;flex:1;min-width:120px;"> <div class="section-title">新建品种</div>
<label>品种代码</label> <form method="post" action="/admin/product" style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;">
<input type="text" name="code" required placeholder="FG" maxlength="6" style="text-transform:uppercase;"> <div class="form-group" style="margin-bottom:0;flex:1;min-width:120px;">
</div> <label>品种代码</label>
<div class="form-group" style="margin-bottom:0;flex:1;min-width:120px;"> <input type="text" name="code" required placeholder="FG" maxlength="6" style="text-transform:uppercase;">
<label>品种名称</label> </div>
<input type="text" name="name" required placeholder="玻璃"> <div class="form-group" style="margin-bottom:0;flex:1;min-width:120px;">
</div> <label>品种名称</label>
<div class="form-group" style="margin-bottom:0;flex:1;min-width:120px;"> <input type="text" name="name" required placeholder="玻璃">
<label>交易所</label> </div>
<select name="exchange" required> <div class="form-group" style="margin-bottom:0;flex:1;min-width:120px;">
{% for ex in exchanges %} <label>交易所</label>
<option value="{{ ex }}">{{ ex }}</option> <select name="exchange" required>
{% endfor %} {% for ex in exchanges %}
</select> <option value="{{ ex }}">{{ ex }}</option>
</div> {% endfor %}
<button type="submit" class="btn btn-primary">新建</button> </select>
</form> </div>
<button type="submit" class="btn btn-primary">新建</button>
</form>
</div>
{% if products %}
<div class="table-wrap">
<table>
<tr><th>代码</th><th>名称</th><th>交易所</th><th>合约数</th><th>操作</th></tr>
{% for p in products %}
<tr>
<td><strong>{{ p.code }}</strong></td>
<td>{{ p.name }}</td>
<td>{{ p.exchange }}</td>
<td>{{ p.contracts|length }}</td>
<td>
<form method="post" action="/admin/product/{{ p.id }}/delete" onsubmit="return confirm('删除品种 {{ p.code }} 及其所有合约?')" style="display:inline;">
<button style="background:none;border:none;color:var(--danger);cursor:pointer;font-size:0.82rem;">删除</button>
</form>
</td>
</tr>
{% endfor %}
</table>
</div>
{% else %}
<div style="text-align:center;padding:40px;color:var(--sub);">暂无品种,请先新建。</div>
{% endif %}
</div> </div>
{# ── Product List ── #} {# ═══════════════════ Tab: 合约管理 ═══════════════════ #}
{% for p in products %} <div class="tab-panel{% if tab == 'contract' %} active{% endif %}" id="tab-contract">
<div class="section-title">{{ p.code }} · {{ p.name }} <span style="font-weight:400;color:var(--sub);font-size:0.78rem;">{{ p.exchange }}</span></div> {% if products %}
{% for p in products %}
<div class="section-title" style="margin-top:{% if not loop.first %}24px{% else %}0{% endif %};">{{ p.code }} · {{ p.name }}</div>
{# ── New Contract ── #} <div class="form-card" style="margin-bottom:12px;">
<div class="form-card" style="margin-bottom:12px;"> <form method="post" action="/admin/contract" style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;">
<form method="post" action="/admin/contract" style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap;"> <input type="hidden" name="product_id" value="{{ p.id }}">
<input type="hidden" name="product_id" value="{{ p.id }}"> <div class="form-group" style="margin-bottom:0;flex:1;min-width:140px;">
<div class="form-group" style="margin-bottom:0;flex:1;min-width:140px;"> <label>合约代码</label>
<label>合约代码</label> <input type="text" name="code" required placeholder="{{ p.code }}2609" maxlength="10" style="text-transform:uppercase;">
<input type="text" name="code" required placeholder="{{ p.code }}2609" maxlength="10" style="text-transform:uppercase;"> </div>
</div> <div class="form-group" style="margin-bottom:0;flex:2;min-width:160px;">
<div class="form-group" style="margin-bottom:0;flex:2;min-width:160px;"> <label>合约名称</label>
<label>合约名称</label> <input type="text" name="name" required placeholder="{{ p.name }}2609">
<input type="text" name="name" required placeholder="{{ p.name }}2609"> </div>
</div> <button type="submit" class="btn btn-primary">添加合约</button>
<button type="submit" class="btn btn-primary">添加合约</button> </form>
</form> </div>
</div>
{# ── Contract List ── #} {% if p.contracts %}
{% if p.contracts %} <div class="table-wrap" style="margin-bottom:20px;">
<div class="table-wrap" style="margin-bottom:20px;"> <table>
<table> <tr><th>合约代码</th><th>名称</th><th>状态</th><th>操作</th></tr>
<tr><th>合约代码</th><th>名称</th><th>状态</th><th>行情</th><th>操作</th></tr> {% for c in p.contracts %}
{% for c in p.contracts %} <tr>
<tr> <td><strong>{{ c.code }}</strong></td>
<td><strong>{{ c.code }}</strong></td> <td>{{ c.name }}</td>
<td>{{ c.name }}</td> <td>
<td> {% if c.is_active %}<span class="badge badge-up">启用</span>{% else %}<span class="badge badge-down">停用</span>{% endif %}
{% if c.is_active %} </td>
<span class="badge badge-up">启用</span> <td>
{% else %} <form method="post" action="/admin/contract/{{ c.id }}/toggle" style="display:inline;">
<span class="badge badge-down">停用</span> <button style="background:none;border:none;color:var(--sub);cursor:pointer;font-size:0.82rem;">
{% endif %} {% if c.is_active %}停用{% else %}启用{% endif %}
</td> </button>
<td> </form>
<a href="/contracts/{{ c.code }}" style="color:var(--accent);text-decoration:none;">查看</a> <form method="post" action="/admin/contract/{{ c.id }}/delete" style="display:inline;" onsubmit="return confirm('删除 {{ c.code }}')">
<form method="post" action="/admin/sync/{{ c.code }}" style="display:inline;margin-left:8px;"> <button style="background:none;border:none;color:var(--danger);cursor:pointer;font-size:0.82rem;">删除</button>
<button style="background:none;border:none;color:var(--accent);cursor:pointer;font-size:0.78rem;">↻ 同步</button> </form>
</form> </td>
</td> </tr>
<td> {% endfor %}
<form method="post" action="/admin/contract/{{ c.id }}/toggle" style="display:inline;"> </table>
<button style="background:none;border:none;color:var(--sub);cursor:pointer;font-size:0.82rem;"> </div>
{% if c.is_active %}停用{% else %}启用{% endif %} {% else %}
</button> <div style="color:var(--sub);font-size:0.85rem;margin-bottom:16px;">暂无合约</div>
</form> {% endif %}
<form method="post" action="/admin/contract/{{ c.id }}/delete" style="display:inline;" onsubmit="return confirm('删除 {{ c.code }}?此操作不可恢复。')">
<button style="background:none;border:none;color:var(--danger);cursor:pointer;font-size:0.82rem;">删除</button>
</form>
</td>
</tr>
{% endfor %} {% endfor %}
</table> {% else %}
<div style="text-align:center;padding:40px;color:var(--sub);">暂无品种,请先在「品种管理」中新建。</div>
{% endif %}
</div> </div>
{% endif %}
<form method="post" action="/admin/product/{{ p.id }}/delete" onsubmit="return confirm('删除品种 {{ p.code }} 及其所有合约?')" style="text-align:right;margin-bottom:28px;"> <script>
<button style="background:none;border:none;color:var(--danger);cursor:pointer;font-size:0.82rem;">删除品种 {{ p.code }}</button> function switchTab(name) {
</form> document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
document.querySelectorAll('.tab-panel').forEach(function(p) { p.classList.remove('active'); });
{% else %} document.querySelector('.tab-btn[onclick="switchTab(\'' + name + '\')"]').classList.add('active');
<div style="text-align:center;padding:40px;color:var(--sub);">暂无品种,请先新建。</div> document.getElementById('tab-' + name).classList.add('active');
{% endfor %} var url = new URL(window.location);
url.searchParams.set('tab', name);
history.replaceState(null, '', url);
}
function toggleProduct(header) {
var body = header.nextElementSibling;
var arrow = header.querySelector('.collapse-arrow');
if (body.style.display === 'none') {
body.style.display = 'block';
arrow.style.transform = 'rotate(0deg)';
} else {
body.style.display = 'none';
arrow.style.transform = 'rotate(-90deg)';
}
}
</script>
{% endblock %} {% endblock %}
+5 -1
View File
@@ -206,7 +206,11 @@
</a> </a>
</nav> </nav>
<div class="sidebar-footer"> <div class="sidebar-footer">
<button class="theme-btn" id="themeToggle">🌙 暗色模式</button> <div style="font-size:0.78rem;color:var(--sub);margin-bottom:8px;">
👤 {{ request.state.user.username if request.state.user else '—' }}
</div>
<a href="/auth/logout" style="font-size:0.78rem;color:var(--sub);text-decoration:none;">退出登录</a>
<button class="theme-btn" style="margin-top:8px;" id="themeToggle">🌙 暗色模式</button>
</div> </div>
</aside> </aside>
+102
View File
@@ -0,0 +1,102 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 · 期货量化</title>
<style>
:root {
--bg: #f8fafc; --surface: #fff; --fg: #1e293b; --sub: #64748b;
--border: #e2e8f0; --accent: #3b82f6; --danger: #ef4444;
}
[data-theme="dark"] {
--bg: #0f172a; --surface: #1e293b; --fg: #e2e8f0; --sub: #94a3b8;
--border: #334155; --accent: #60a5fa; --danger: #f87171;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, 'Segoe UI', system-ui, sans-serif;
background: var(--bg); color: var(--fg);
display: flex; align-items: center; justify-content: center;
min-height: 100vh;
}
.login-box {
background: var(--surface); border: 1px solid var(--border);
border-radius: 12px; padding: 40px; width: 360px; max-width: 90vw;
}
.login-box h1 { font-size: 1.3rem; text-align: center; margin-bottom: 8px; }
.login-box .sub { color: var(--sub); font-size: 0.85rem; text-align: center; margin-bottom: 28px; }
.form-group { margin-bottom: 16px; }
.form-group label { display: block; font-size: 0.8rem; font-weight: 600; color: var(--sub); margin-bottom: 5px; }
.form-group input {
width: 100%; padding: 10px 12px; border: 1px solid var(--border);
border-radius: 6px; font-size: 0.9rem; background: var(--bg); color: var(--fg);
}
.form-group input:focus { outline: none; border-color: var(--accent); }
.btn {
width: 100%; padding: 10px; border: none; border-radius: 6px;
font-size: 0.9rem; font-weight: 600; cursor: pointer;
background: var(--accent); color: #fff; margin-top: 8px;
}
.btn:hover { opacity: 0.9; }
.error {
background: #fef2f2; color: var(--danger); padding: 10px 14px;
border-radius: 6px; font-size: 0.85rem; margin-bottom: 16px; text-align: center;
}
.toggle-btn {
position: fixed; top: 16px; right: 24px;
background: var(--surface); border: 1px solid var(--border);
color: var(--fg); padding: 6px 14px; border-radius: 6px;
cursor: pointer; font-size: 0.82rem; z-index: 10;
}
.toggle-btn:hover { opacity: 0.8; }
</style>
</head>
<body>
<button class="toggle-btn" id="themeToggle">🌙 暗色模式</button>
<div class="login-box">
<h1>📊 期货量化系统</h1>
<p class="sub">请输入账号密码</p>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
<form method="post">
<div class="form-group">
<label>用户名</label>
<input type="text" name="username" required autofocus>
</div>
<div class="form-group">
<label>密码</label>
<input type="password" name="password" required>
</div>
<button type="submit" class="btn">登 录</button>
</form>
</div>
<script>
var themeBtn = document.getElementById('themeToggle');
themeBtn.addEventListener('click', toggleTheme);
function setTheme(dark) {
if (dark) {
document.documentElement.setAttribute('data-theme', 'dark');
themeBtn.textContent = '☀️ 亮色模式';
} else {
document.documentElement.removeAttribute('data-theme');
themeBtn.textContent = '🌙 暗色模式';
}
try { localStorage.setItem('ft-theme', dark ? 'dark' : 'light'); } catch(e) {}
}
function toggleTheme() {
setTheme(document.documentElement.getAttribute('data-theme') !== 'dark');
}
(function() {
try {
var s = localStorage.getItem('ft-theme');
if (s === 'dark' || (!s && matchMedia('(prefers-color-scheme: dark)').matches)) setTheme(true);
} catch(e) {}
})();
</script>
</body>
</html>