Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 561f3332b4 | |||
| c628e208ca | |||
| 273924a36a | |||
| ff53290a57 | |||
| 7589e942eb |
+33
-4
@@ -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")
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user