add login
This commit is contained in:
parent
26d73600a5
commit
95047b353b
Binary file not shown.
Binary file not shown.
211
backend/app/api/auth.py
Normal file
211
backend/app/api/auth.py
Normal file
@ -0,0 +1,211 @@
|
||||
"""认证 API
|
||||
|
||||
登录、密码修改、用户管理(管理员)
|
||||
"""
|
||||
|
||||
import secrets
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from app.core.auth import hash_password, verify_password, create_access_token
|
||||
from app.core.deps import get_current_user, get_current_admin
|
||||
from app.db.database import get_db
|
||||
from app.db.tables import users_table
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
# ---------- Request/Response Models ----------
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
old_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class CreateUserRequest(BaseModel):
|
||||
username: str
|
||||
role: str = "user"
|
||||
|
||||
|
||||
# ---------- Public Endpoints ----------
|
||||
|
||||
@router.post("/login")
|
||||
async def login(req: LoginRequest):
|
||||
"""用户登录,返回 JWT token"""
|
||||
async with get_db() as db:
|
||||
result = await db.execute(
|
||||
select(users_table).where(users_table.c.username == req.username)
|
||||
)
|
||||
user = result.mappings().first()
|
||||
|
||||
if user is None or not user["is_active"]:
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
|
||||
if not verify_password(req.password, user["password_hash"]):
|
||||
raise HTTPException(status_code=401, detail="用户名或密码错误")
|
||||
|
||||
token = create_access_token({"sub": str(user["id"]), "role": user["role"]})
|
||||
|
||||
return {
|
||||
"token": token,
|
||||
"user": {
|
||||
"id": user["id"],
|
||||
"username": user["username"],
|
||||
"role": user["role"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ---------- Authenticated Endpoints ----------
|
||||
|
||||
@router.get("/me")
|
||||
async def get_me(current_user: dict = Depends(get_current_user)):
|
||||
"""获取当前用户信息"""
|
||||
return {
|
||||
"id": current_user["id"],
|
||||
"username": current_user["username"],
|
||||
"role": current_user["role"],
|
||||
"is_active": current_user["is_active"],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
async def change_password(
|
||||
req: ChangePasswordRequest,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""修改自己的密码"""
|
||||
if not verify_password(req.old_password, current_user["password_hash"]):
|
||||
raise HTTPException(status_code=400, detail="旧密码错误")
|
||||
|
||||
new_hash = hash_password(req.new_password)
|
||||
async with get_db() as db:
|
||||
await db.execute(
|
||||
update(users_table)
|
||||
.where(users_table.c.id == current_user["id"])
|
||||
.values(password_hash=new_hash)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return {"message": "密码修改成功"}
|
||||
|
||||
|
||||
# ---------- Admin Endpoints ----------
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users(admin: dict = Depends(get_current_admin)):
|
||||
"""列出所有用户(管理员)"""
|
||||
async with get_db() as db:
|
||||
result = await db.execute(
|
||||
select(users_table).order_by(users_table.c.id)
|
||||
)
|
||||
rows = result.mappings().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": r["id"],
|
||||
"username": r["username"],
|
||||
"role": r["role"],
|
||||
"is_active": r["is_active"],
|
||||
"created_at": r["created_at"].isoformat() if r["created_at"] else None,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post("/users")
|
||||
async def create_user(req: CreateUserRequest, admin: dict = Depends(get_current_admin)):
|
||||
"""创建新用户(管理员),自动生成随机密码"""
|
||||
# 检查用户名是否已存在
|
||||
async with get_db() as db:
|
||||
result = await db.execute(
|
||||
select(users_table).where(users_table.c.username == req.username)
|
||||
)
|
||||
if result.first():
|
||||
raise HTTPException(status_code=400, detail="用户名已存在")
|
||||
|
||||
if req.role not in ("admin", "user"):
|
||||
raise HTTPException(status_code=400, detail="角色必须是 admin 或 user")
|
||||
|
||||
# 生成 12 位随机密码
|
||||
raw_password = secrets.token_urlsafe(9)
|
||||
password_hash = hash_password(raw_password)
|
||||
|
||||
await db.execute(
|
||||
users_table.insert().values(
|
||||
username=req.username,
|
||||
password_hash=password_hash,
|
||||
role=req.role,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"管理员 {admin['username']} 创建了用户 {req.username} ({req.role})")
|
||||
|
||||
return {
|
||||
"username": req.username,
|
||||
"password": raw_password,
|
||||
"role": req.role,
|
||||
"message": "请妥善保管密码,此密码仅显示一次",
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}")
|
||||
async def disable_user(user_id: int, admin: dict = Depends(get_current_admin)):
|
||||
"""禁用用户(软删除)"""
|
||||
if user_id == admin["id"]:
|
||||
raise HTTPException(status_code=400, detail="不能禁用自己")
|
||||
|
||||
async with get_db() as db:
|
||||
result = await db.execute(
|
||||
select(users_table).where(users_table.c.id == user_id)
|
||||
)
|
||||
user = result.mappings().first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
await db.execute(
|
||||
update(users_table)
|
||||
.where(users_table.c.id == user_id)
|
||||
.values(is_active=False)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return {"message": f"用户 {user['username']} 已禁用"}
|
||||
|
||||
|
||||
@router.post("/users/{user_id}/reset-password")
|
||||
async def reset_password(user_id: int, admin: dict = Depends(get_current_admin)):
|
||||
"""重置用户密码(管理员),生成新的随机密码"""
|
||||
async with get_db() as db:
|
||||
result = await db.execute(
|
||||
select(users_table).where(users_table.c.id == user_id)
|
||||
)
|
||||
user = result.mappings().first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
raw_password = secrets.token_urlsafe(9)
|
||||
password_hash = hash_password(raw_password)
|
||||
|
||||
await db.execute(
|
||||
update(users_table)
|
||||
.where(users_table.c.id == user_id)
|
||||
.values(password_hash=password_hash)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return {
|
||||
"username": user["username"],
|
||||
"password": raw_password,
|
||||
"message": "请妥善保管新密码,此密码仅显示一次",
|
||||
}
|
||||
@ -53,6 +53,15 @@ class Settings(BaseSettings):
|
||||
# 前端
|
||||
frontend_url: str = "http://localhost:3000"
|
||||
|
||||
# JWT 认证
|
||||
jwt_secret: str = "change-me-in-production"
|
||||
jwt_expiry_hours: int = 24
|
||||
jwt_algorithm: str = "HS256"
|
||||
|
||||
# 默认管理员(首次启动自动创建)
|
||||
admin_username: str = "admin"
|
||||
admin_password: str = "admin123"
|
||||
|
||||
model_config = {"env_file": ".env", "env_prefix": "ASTOCK_"}
|
||||
|
||||
|
||||
|
||||
0
backend/app/core/__init__.py
Normal file
0
backend/app/core/__init__.py
Normal file
33
backend/app/core/auth.py
Normal file
33
backend/app/core/auth.py
Normal file
@ -0,0 +1,33 @@
|
||||
"""JWT 和密码工具函数"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from app.config import settings
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def create_access_token(data: dict) -> str:
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + timedelta(hours=settings.jwt_expiry_hours)
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict | None:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
|
||||
return payload
|
||||
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
|
||||
return None
|
||||
57
backend/app/core/deps.py
Normal file
57
backend/app/core/deps.py
Normal file
@ -0,0 +1,57 @@
|
||||
"""FastAPI 认证依赖注入"""
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.auth import decode_access_token
|
||||
from app.db.database import get_db
|
||||
from app.db.tables import users_table
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
) -> dict:
|
||||
"""从 Authorization Bearer token 提取并验证用户"""
|
||||
payload = decode_access_token(credentials.credentials)
|
||||
if payload is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token 无效或已过期",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token 格式错误",
|
||||
)
|
||||
|
||||
async with get_db() as db:
|
||||
result = await db.execute(
|
||||
select(users_table).where(users_table.c.id == int(user_id))
|
||||
)
|
||||
user = result.mappings().first()
|
||||
|
||||
if user is None or not user["is_active"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="用户不存在或已禁用",
|
||||
)
|
||||
|
||||
return dict(user)
|
||||
|
||||
|
||||
async def get_current_admin(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""要求管理员角色"""
|
||||
if current_user["role"] != "admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="需要管理员权限",
|
||||
)
|
||||
return current_user
|
||||
Binary file not shown.
@ -69,3 +69,14 @@ recommendation_tracking_table = Table(
|
||||
Column("status", Text, default="active"),
|
||||
Column("created_at", DateTime, server_default=func.now()),
|
||||
)
|
||||
|
||||
users_table = Table(
|
||||
"users", metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("username", Text, nullable=False, unique=True),
|
||||
Column("password_hash", Text, nullable=False),
|
||||
Column("role", Text, default="user"),
|
||||
Column("is_active", Boolean, default=True),
|
||||
Column("created_at", DateTime, server_default=func.now()),
|
||||
Column("updated_at", DateTime, server_default=func.now()),
|
||||
)
|
||||
|
||||
@ -8,7 +8,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.config import settings
|
||||
from app.db.database import init_db
|
||||
from app.engine.scheduler import start_scheduler, stop_scheduler
|
||||
from app.api import market, sectors, recommendations, stocks, websocket, chat
|
||||
from app.api import market, sectors, recommendations, stocks, websocket, chat, auth
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if settings.debug else logging.INFO,
|
||||
@ -18,12 +18,37 @@ logging.basicConfig(
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def ensure_admin_exists():
|
||||
"""如果没有管理员用户,自动创建默认管理员"""
|
||||
from sqlalchemy import select, insert
|
||||
from app.db.database import get_db
|
||||
from app.db.tables import users_table
|
||||
from app.core.auth import hash_password
|
||||
|
||||
async with get_db() as db:
|
||||
result = await db.execute(
|
||||
select(users_table).where(users_table.c.role == "admin")
|
||||
)
|
||||
if result.first() is None:
|
||||
await db.execute(
|
||||
insert(users_table).values(
|
||||
username=settings.admin_username,
|
||||
password_hash=hash_password(settings.admin_password),
|
||||
role="admin",
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
logger.info(f"默认管理员用户 '{settings.admin_username}' 已创建")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# 启动
|
||||
logger.info("A 股分析推荐 Agent 启动中...")
|
||||
await init_db()
|
||||
logger.info("数据库初始化完成")
|
||||
await ensure_admin_exists()
|
||||
start_scheduler()
|
||||
logger.info("调度器已启动")
|
||||
yield
|
||||
@ -54,6 +79,7 @@ app.include_router(sectors.router)
|
||||
app.include_router(recommendations.router)
|
||||
app.include_router(stocks.router)
|
||||
app.include_router(chat.router)
|
||||
app.include_router(auth.router)
|
||||
|
||||
# WebSocket
|
||||
app.websocket("/ws")(websocket.ws_endpoint)
|
||||
|
||||
Binary file not shown.
@ -13,3 +13,7 @@ httpx==0.28.1
|
||||
websockets==14.1
|
||||
python-dotenv==1.0.1
|
||||
openai>=1.0.0
|
||||
PyJWT==2.8.0
|
||||
bcrypt==4.2.1
|
||||
passlib==1.7.4
|
||||
python-multipart==0.0.9
|
||||
|
||||
@ -1,30 +1,69 @@
|
||||
{
|
||||
"pages": {
|
||||
"/layout": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/css/app/layout.css",
|
||||
"static/chunks/app/layout.js"
|
||||
"/_not-found/page": [
|
||||
"static/chunks/webpack-76aa9cbbdedb6a49.js",
|
||||
"static/chunks/fd9d1056-f8a2d551cbb94c85.js",
|
||||
"static/chunks/117-d0aa9486d6cf1a7a.js",
|
||||
"static/chunks/main-app-7d7e5d1021afd90c.js",
|
||||
"static/chunks/app/_not-found/page-9a1795a099256da4.js"
|
||||
],
|
||||
"/recommendations/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/recommendations/page.js"
|
||||
"/layout": [
|
||||
"static/chunks/webpack-76aa9cbbdedb6a49.js",
|
||||
"static/chunks/fd9d1056-f8a2d551cbb94c85.js",
|
||||
"static/chunks/117-d0aa9486d6cf1a7a.js",
|
||||
"static/chunks/main-app-7d7e5d1021afd90c.js",
|
||||
"static/css/fa5094a6607a8c23.css",
|
||||
"static/chunks/448-92a7b932cf4502ac.js",
|
||||
"static/chunks/app/layout-97bdd231e78ddf3f.js"
|
||||
],
|
||||
"/login/page": [
|
||||
"static/chunks/webpack-76aa9cbbdedb6a49.js",
|
||||
"static/chunks/fd9d1056-f8a2d551cbb94c85.js",
|
||||
"static/chunks/117-d0aa9486d6cf1a7a.js",
|
||||
"static/chunks/main-app-7d7e5d1021afd90c.js",
|
||||
"static/chunks/app/login/page-778febf452923618.js"
|
||||
],
|
||||
"/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/page.js"
|
||||
"static/chunks/webpack-76aa9cbbdedb6a49.js",
|
||||
"static/chunks/fd9d1056-f8a2d551cbb94c85.js",
|
||||
"static/chunks/117-d0aa9486d6cf1a7a.js",
|
||||
"static/chunks/main-app-7d7e5d1021afd90c.js",
|
||||
"static/chunks/app/page-5a303311159f23ad.js"
|
||||
],
|
||||
"/recommendations/page": [
|
||||
"static/chunks/webpack-76aa9cbbdedb6a49.js",
|
||||
"static/chunks/fd9d1056-f8a2d551cbb94c85.js",
|
||||
"static/chunks/117-d0aa9486d6cf1a7a.js",
|
||||
"static/chunks/main-app-7d7e5d1021afd90c.js",
|
||||
"static/chunks/app/recommendations/page-ef6715bbb27168f0.js"
|
||||
],
|
||||
"/sectors/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/sectors/page.js"
|
||||
"static/chunks/webpack-76aa9cbbdedb6a49.js",
|
||||
"static/chunks/fd9d1056-f8a2d551cbb94c85.js",
|
||||
"static/chunks/117-d0aa9486d6cf1a7a.js",
|
||||
"static/chunks/main-app-7d7e5d1021afd90c.js",
|
||||
"static/chunks/app/sectors/page-2f0e16a2b83354cf.js"
|
||||
],
|
||||
"/chat/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/chat/page.js"
|
||||
"static/chunks/webpack-76aa9cbbdedb6a49.js",
|
||||
"static/chunks/fd9d1056-f8a2d551cbb94c85.js",
|
||||
"static/chunks/117-d0aa9486d6cf1a7a.js",
|
||||
"static/chunks/main-app-7d7e5d1021afd90c.js",
|
||||
"static/chunks/app/chat/page-2dd3304322bd4036.js"
|
||||
],
|
||||
"/stock/[code]/page": [
|
||||
"static/chunks/webpack-76aa9cbbdedb6a49.js",
|
||||
"static/chunks/fd9d1056-f8a2d551cbb94c85.js",
|
||||
"static/chunks/117-d0aa9486d6cf1a7a.js",
|
||||
"static/chunks/main-app-7d7e5d1021afd90c.js",
|
||||
"static/chunks/app/stock/[code]/page-aa4270127391b661.js"
|
||||
],
|
||||
"/users/page": [
|
||||
"static/chunks/webpack-76aa9cbbdedb6a49.js",
|
||||
"static/chunks/fd9d1056-f8a2d551cbb94c85.js",
|
||||
"static/chunks/117-d0aa9486d6cf1a7a.js",
|
||||
"static/chunks/main-app-7d7e5d1021afd90c.js",
|
||||
"static/chunks/app/users/page-e66e56f48050576b.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -1,19 +1,32 @@
|
||||
{
|
||||
"polyfillFiles": [
|
||||
"static/chunks/polyfills.js"
|
||||
"static/chunks/polyfills-42372ed130431b0a.js"
|
||||
],
|
||||
"devFiles": [],
|
||||
"ampDevFiles": [],
|
||||
"lowPriorityFiles": [
|
||||
"static/development/_buildManifest.js",
|
||||
"static/development/_ssgManifest.js"
|
||||
"static/77YFCNa-r_QnYgqwH2vOe/_buildManifest.js",
|
||||
"static/77YFCNa-r_QnYgqwH2vOe/_ssgManifest.js"
|
||||
],
|
||||
"rootMainFiles": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js"
|
||||
"static/chunks/webpack-76aa9cbbdedb6a49.js",
|
||||
"static/chunks/fd9d1056-f8a2d551cbb94c85.js",
|
||||
"static/chunks/117-d0aa9486d6cf1a7a.js",
|
||||
"static/chunks/main-app-7d7e5d1021afd90c.js"
|
||||
],
|
||||
"pages": {
|
||||
"/_app": []
|
||||
"/_app": [
|
||||
"static/chunks/webpack-76aa9cbbdedb6a49.js",
|
||||
"static/chunks/framework-f66176bb897dc684.js",
|
||||
"static/chunks/main-9c5f6b283127d940.js",
|
||||
"static/chunks/pages/_app-72b849fbd24ac258.js"
|
||||
],
|
||||
"/_error": [
|
||||
"static/chunks/webpack-76aa9cbbdedb6a49.js",
|
||||
"static/chunks/framework-f66176bb897dc684.js",
|
||||
"static/chunks/main-9c5f6b283127d940.js",
|
||||
"static/chunks/pages/_error-7ba65e1336b92748.js"
|
||||
]
|
||||
},
|
||||
"ampFirstPages": []
|
||||
}
|
||||
2
frontend/.next/cache/.tsbuildinfo
vendored
2
frontend/.next/cache/.tsbuildinfo
vendored
File diff suppressed because one or more lines are too long
Binary file not shown.
@ -1 +1,20 @@
|
||||
{}
|
||||
{
|
||||
"components/capital-flow.tsx -> echarts": {
|
||||
"id": 9614,
|
||||
"files": [
|
||||
"static/chunks/614.2cf8795c6fba79f8.js"
|
||||
]
|
||||
},
|
||||
"components/kline-chart.tsx -> echarts": {
|
||||
"id": 9614,
|
||||
"files": [
|
||||
"static/chunks/614.2cf8795c6fba79f8.js"
|
||||
]
|
||||
},
|
||||
"components/score-radar.tsx -> echarts": {
|
||||
"id": 9614,
|
||||
"files": [
|
||||
"static/chunks/614.2cf8795c6fba79f8.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,11 @@
|
||||
{
|
||||
"/_not-found/page": "app/_not-found/page.js",
|
||||
"/login/page": "app/login/page.js",
|
||||
"/api/chat/stream/route": "app/api/chat/stream/route.js",
|
||||
"/page": "app/page.js",
|
||||
"/sectors/page": "app/sectors/page.js",
|
||||
"/recommendations/page": "app/recommendations/page.js",
|
||||
"/chat/page": "app/chat/page.js"
|
||||
"/sectors/page": "app/sectors/page.js",
|
||||
"/chat/page": "app/chat/page.js",
|
||||
"/stock/[code]/page": "app/stock/[code]/page.js",
|
||||
"/users/page": "app/users/page.js"
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1 +1 @@
|
||||
self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]"
|
||||
self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]";
|
||||
@ -1,21 +1 @@
|
||||
self.__BUILD_MANIFEST = {
|
||||
"polyfillFiles": [
|
||||
"static/chunks/polyfills.js"
|
||||
],
|
||||
"devFiles": [],
|
||||
"ampDevFiles": [],
|
||||
"lowPriorityFiles": [],
|
||||
"rootMainFiles": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js"
|
||||
],
|
||||
"pages": {
|
||||
"/_app": []
|
||||
},
|
||||
"ampFirstPages": []
|
||||
};
|
||||
self.__BUILD_MANIFEST.lowPriorityFiles = [
|
||||
"/static/" + process.env.__NEXT_BUILD_ID + "/_buildManifest.js",
|
||||
,"/static/" + process.env.__NEXT_BUILD_ID + "/_ssgManifest.js",
|
||||
|
||||
];
|
||||
self.__BUILD_MANIFEST={polyfillFiles:["static/chunks/polyfills-42372ed130431b0a.js"],devFiles:[],ampDevFiles:[],lowPriorityFiles:[],rootMainFiles:["static/chunks/webpack-76aa9cbbdedb6a49.js","static/chunks/fd9d1056-f8a2d551cbb94c85.js","static/chunks/117-d0aa9486d6cf1a7a.js","static/chunks/main-app-7d7e5d1021afd90c.js"],pages:{"/_app":["static/chunks/webpack-76aa9cbbdedb6a49.js","static/chunks/framework-f66176bb897dc684.js","static/chunks/main-9c5f6b283127d940.js","static/chunks/pages/_app-72b849fbd24ac258.js"],"/_error":["static/chunks/webpack-76aa9cbbdedb6a49.js","static/chunks/framework-f66176bb897dc684.js","static/chunks/main-9c5f6b283127d940.js","static/chunks/pages/_error-7ba65e1336b92748.js"]},ampFirstPages:[]},self.__BUILD_MANIFEST.lowPriorityFiles=["/static/"+process.env.__NEXT_BUILD_ID+"/_buildManifest.js",,"/static/"+process.env.__NEXT_BUILD_ID+"/_ssgManifest.js"];
|
||||
@ -1 +1 @@
|
||||
self.__REACT_LOADABLE_MANIFEST="{}"
|
||||
self.__REACT_LOADABLE_MANIFEST='{"components/capital-flow.tsx -> echarts":{"id":9614,"files":["static/chunks/614.2cf8795c6fba79f8.js"]},"components/kline-chart.tsx -> echarts":{"id":9614,"files":["static/chunks/614.2cf8795c6fba79f8.js"]},"components/score-radar.tsx -> echarts":{"id":9614,"files":["static/chunks/614.2cf8795c6fba79f8.js"]}}';
|
||||
@ -1 +1 @@
|
||||
self.__NEXT_FONT_MANIFEST="{\"pages\":{},\"app\":{},\"appUsingSizeAdjust\":false,\"pagesUsingSizeAdjust\":false}"
|
||||
self.__NEXT_FONT_MANIFEST='{"pages":{},"app":{},"appUsingSizeAdjust":false,"pagesUsingSizeAdjust":false}';
|
||||
@ -1 +1 @@
|
||||
{}
|
||||
{"/_app":"pages/_app.js","/_error":"pages/_error.js","/_document":"pages/_document.js","/404":"pages/404.html"}
|
||||
@ -1 +1 @@
|
||||
self.__RSC_SERVER_MANIFEST="{\n \"node\": {},\n \"edge\": {},\n \"encryptionKey\": \"process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY\"\n}"
|
||||
self.__RSC_SERVER_MANIFEST="{\"node\":{},\"edge\":{},\"encryptionKey\":\"process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY\"}"
|
||||
@ -1,5 +1 @@
|
||||
{
|
||||
"node": {},
|
||||
"edge": {},
|
||||
"encryptionKey": "2xwJVyON4j1nUVj9bt2cAd3iPAWZ77oTyEHOq3x+eQU="
|
||||
}
|
||||
{"node":{},"edge":{},"encryptionKey":"mEKzCdLXC9Tw5xpwGdIONxBrCnit3XJOsa5Cm7fALlw="}
|
||||
@ -1,75 +0,0 @@
|
||||
"use strict";
|
||||
/*
|
||||
* ATTENTION: An "eval-source-map" devtool has been used.
|
||||
* This devtool is neither made for production nor for readable output files.
|
||||
* It uses "eval()" calls to create a separate source file with attached SourceMaps in the browser devtools.
|
||||
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
|
||||
* or disable the default devtool with "devtool: false".
|
||||
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
|
||||
*/
|
||||
exports.id = "vendor-chunks/@swc";
|
||||
exports.ids = ["vendor-chunks/@swc"];
|
||||
exports.modules = {
|
||||
|
||||
/***/ "(ssr)/./node_modules/@swc/helpers/esm/_class_private_field_loose_base.js":
|
||||
/*!**************************************************************************!*\
|
||||
!*** ./node_modules/@swc/helpers/esm/_class_private_field_loose_base.js ***!
|
||||
\**************************************************************************/
|
||||
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
||||
|
||||
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ _: () => (/* binding */ _class_private_field_loose_base),\n/* harmony export */ _class_private_field_loose_base: () => (/* binding */ _class_private_field_loose_base)\n/* harmony export */ });\nfunction _class_private_field_loose_base(receiver, privateKey) {\n if (!Object.prototype.hasOwnProperty.call(receiver, privateKey)) {\n throw new TypeError(\"attempted to use private field on non-instance\");\n }\n\n return receiver;\n}\n\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiKHNzcikvLi9ub2RlX21vZHVsZXMvQHN3Yy9oZWxwZXJzL2VzbS9fY2xhc3NfcHJpdmF0ZV9maWVsZF9sb29zZV9iYXNlLmpzIiwibWFwcGluZ3MiOiI7Ozs7O0FBQU87QUFDUDtBQUNBO0FBQ0E7O0FBRUE7QUFDQTtBQUNnRCIsInNvdXJjZXMiOlsid2VicGFjazovL2FzdG9jay1hZ2VudC1mcm9udGVuZC8uL25vZGVfbW9kdWxlcy9Ac3djL2hlbHBlcnMvZXNtL19jbGFzc19wcml2YXRlX2ZpZWxkX2xvb3NlX2Jhc2UuanM/MmI3MSJdLCJzb3VyY2VzQ29udGVudCI6WyJleHBvcnQgZnVuY3Rpb24gX2NsYXNzX3ByaXZhdGVfZmllbGRfbG9vc2VfYmFzZShyZWNlaXZlciwgcHJpdmF0ZUtleSkge1xuICAgIGlmICghT2JqZWN0LnByb3RvdHlwZS5oYXNPd25Qcm9wZXJ0eS5jYWxsKHJlY2VpdmVyLCBwcml2YXRlS2V5KSkge1xuICAgICAgICB0aHJvdyBuZXcgVHlwZUVycm9yKFwiYXR0ZW1wdGVkIHRvIHVzZSBwcml2YXRlIGZpZWxkIG9uIG5vbi1pbnN0YW5jZVwiKTtcbiAgICB9XG5cbiAgICByZXR1cm4gcmVjZWl2ZXI7XG59XG5leHBvcnQgeyBfY2xhc3NfcHJpdmF0ZV9maWVsZF9sb29zZV9iYXNlIGFzIF8gfTtcbiJdLCJuYW1lcyI6W10sInNvdXJjZVJvb3QiOiIifQ==\n//# sourceURL=webpack-internal:///(ssr)/./node_modules/@swc/helpers/esm/_class_private_field_loose_base.js\n");
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ "(ssr)/./node_modules/@swc/helpers/esm/_class_private_field_loose_key.js":
|
||||
/*!*************************************************************************!*\
|
||||
!*** ./node_modules/@swc/helpers/esm/_class_private_field_loose_key.js ***!
|
||||
\*************************************************************************/
|
||||
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
||||
|
||||
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ _: () => (/* binding */ _class_private_field_loose_key),\n/* harmony export */ _class_private_field_loose_key: () => (/* binding */ _class_private_field_loose_key)\n/* harmony export */ });\nvar id = 0;\n\nfunction _class_private_field_loose_key(name) {\n return \"__private_\" + id++ + \"_\" + name;\n}\n\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiKHNzcikvLi9ub2RlX21vZHVsZXMvQHN3Yy9oZWxwZXJzL2VzbS9fY2xhc3NfcHJpdmF0ZV9maWVsZF9sb29zZV9rZXkuanMiLCJtYXBwaW5ncyI6Ijs7Ozs7QUFBQTs7QUFFTztBQUNQO0FBQ0E7QUFDK0MiLCJzb3VyY2VzIjpbIndlYnBhY2s6Ly9hc3RvY2stYWdlbnQtZnJvbnRlbmQvLi9ub2RlX21vZHVsZXMvQHN3Yy9oZWxwZXJzL2VzbS9fY2xhc3NfcHJpdmF0ZV9maWVsZF9sb29zZV9rZXkuanM/ODY1ZiJdLCJzb3VyY2VzQ29udGVudCI6WyJ2YXIgaWQgPSAwO1xuXG5leHBvcnQgZnVuY3Rpb24gX2NsYXNzX3ByaXZhdGVfZmllbGRfbG9vc2Vfa2V5KG5hbWUpIHtcbiAgICByZXR1cm4gXCJfX3ByaXZhdGVfXCIgKyBpZCsrICsgXCJfXCIgKyBuYW1lO1xufVxuZXhwb3J0IHsgX2NsYXNzX3ByaXZhdGVfZmllbGRfbG9vc2Vfa2V5IGFzIF8gfTtcbiJdLCJuYW1lcyI6W10sInNvdXJjZVJvb3QiOiIifQ==\n//# sourceURL=webpack-internal:///(ssr)/./node_modules/@swc/helpers/esm/_class_private_field_loose_key.js\n");
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ "(ssr)/./node_modules/@swc/helpers/esm/_interop_require_default.js":
|
||||
/*!*******************************************************************!*\
|
||||
!*** ./node_modules/@swc/helpers/esm/_interop_require_default.js ***!
|
||||
\*******************************************************************/
|
||||
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
||||
|
||||
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ _: () => (/* binding */ _interop_require_default),\n/* harmony export */ _interop_require_default: () => (/* binding */ _interop_require_default)\n/* harmony export */ });\nfunction _interop_require_default(obj) {\n return obj && obj.__esModule ? obj : { default: obj };\n}\n\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiKHNzcikvLi9ub2RlX21vZHVsZXMvQHN3Yy9oZWxwZXJzL2VzbS9faW50ZXJvcF9yZXF1aXJlX2RlZmF1bHQuanMiLCJtYXBwaW5ncyI6Ijs7Ozs7QUFBTztBQUNQLDJDQUEyQztBQUMzQztBQUN5QyIsInNvdXJjZXMiOlsid2VicGFjazovL2FzdG9jay1hZ2VudC1mcm9udGVuZC8uL25vZGVfbW9kdWxlcy9Ac3djL2hlbHBlcnMvZXNtL19pbnRlcm9wX3JlcXVpcmVfZGVmYXVsdC5qcz9hNjA2Il0sInNvdXJjZXNDb250ZW50IjpbImV4cG9ydCBmdW5jdGlvbiBfaW50ZXJvcF9yZXF1aXJlX2RlZmF1bHQob2JqKSB7XG4gICAgcmV0dXJuIG9iaiAmJiBvYmouX19lc01vZHVsZSA/IG9iaiA6IHsgZGVmYXVsdDogb2JqIH07XG59XG5leHBvcnQgeyBfaW50ZXJvcF9yZXF1aXJlX2RlZmF1bHQgYXMgXyB9O1xuIl0sIm5hbWVzIjpbXSwic291cmNlUm9vdCI6IiJ9\n//# sourceURL=webpack-internal:///(ssr)/./node_modules/@swc/helpers/esm/_interop_require_default.js\n");
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ "(ssr)/./node_modules/@swc/helpers/esm/_interop_require_wildcard.js":
|
||||
/*!********************************************************************!*\
|
||||
!*** ./node_modules/@swc/helpers/esm/_interop_require_wildcard.js ***!
|
||||
\********************************************************************/
|
||||
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
||||
|
||||
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ _: () => (/* binding */ _interop_require_wildcard),\n/* harmony export */ _interop_require_wildcard: () => (/* binding */ _interop_require_wildcard)\n/* harmony export */ });\nfunction _getRequireWildcardCache(nodeInterop) {\n if (typeof WeakMap !== \"function\") return null;\n\n var cacheBabelInterop = new WeakMap();\n var cacheNodeInterop = new WeakMap();\n\n return (_getRequireWildcardCache = function(nodeInterop) {\n return nodeInterop ? cacheNodeInterop : cacheBabelInterop;\n })(nodeInterop);\n}\nfunction _interop_require_wildcard(obj, nodeInterop) {\n if (!nodeInterop && obj && obj.__esModule) return obj;\n if (obj === null || typeof obj !== \"object\" && typeof obj !== \"function\") return { default: obj };\n\n var cache = _getRequireWildcardCache(nodeInterop);\n\n if (cache && cache.has(obj)) return cache.get(obj);\n\n var newObj = { __proto__: null };\n var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor;\n\n for (var key in obj) {\n if (key !== \"default\" && Object.prototype.hasOwnProperty.call(obj, key)) {\n var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null;\n if (desc && (desc.get || desc.set)) Object.defineProperty(newObj, key, desc);\n else newObj[key] = obj[key];\n }\n }\n\n newObj.default = obj;\n\n if (cache) cache.set(obj, newObj);\n\n return newObj;\n}\n\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiKHNzcikvLi9ub2RlX21vZHVsZXMvQHN3Yy9oZWxwZXJzL2VzbS9faW50ZXJvcF9yZXF1aXJlX3dpbGRjYXJkLmpzIiwibWFwcGluZ3MiOiI7Ozs7O0FBQUE7QUFDQTs7QUFFQTtBQUNBOztBQUVBO0FBQ0E7QUFDQSxLQUFLO0FBQ0w7QUFDTztBQUNQO0FBQ0EsdUZBQXVGOztBQUV2Rjs7QUFFQTs7QUFFQSxtQkFBbUI7QUFDbkI7O0FBRUE7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7O0FBRUE7O0FBRUE7O0FBRUE7QUFDQTtBQUMwQyIsInNvdXJjZXMiOlsid2VicGFjazovL2FzdG9jay1hZ2VudC1mcm9udGVuZC8uL25vZGVfbW9kdWxlcy9Ac3djL2hlbHBlcnMvZXNtL19pbnRlcm9wX3JlcXVpcmVfd2lsZGNhcmQuanM/ZTY3YyJdLCJzb3VyY2VzQ29udGVudCI6WyJmdW5jdGlvbiBfZ2V0UmVxdWlyZVdpbGRjYXJkQ2FjaGUobm9kZUludGVyb3ApIHtcbiAgICBpZiAodHlwZW9mIFdlYWtNYXAgIT09IFwiZnVuY3Rpb25cIikgcmV0dXJuIG51bGw7XG5cbiAgICB2YXIgY2FjaGVCYWJlbEludGVyb3AgPSBuZXcgV2Vha01hcCgpO1xuICAgIHZhciBjYWNoZU5vZGVJbnRlcm9wID0gbmV3IFdlYWtNYXAoKTtcblxuICAgIHJldHVybiAoX2dldFJlcXVpcmVXaWxkY2FyZENhY2hlID0gZnVuY3Rpb24obm9kZUludGVyb3ApIHtcbiAgICAgICAgcmV0dXJuIG5vZGVJbnRlcm9wID8gY2FjaGVOb2RlSW50ZXJvcCA6IGNhY2hlQmFiZWxJbnRlcm9wO1xuICAgIH0pKG5vZGVJbnRlcm9wKTtcbn1cbmV4cG9ydCBmdW5jdGlvbiBfaW50ZXJvcF9yZXF1aXJlX3dpbGRjYXJkKG9iaiwgbm9kZUludGVyb3ApIHtcbiAgICBpZiAoIW5vZGVJbnRlcm9wICYmIG9iaiAmJiBvYmouX19lc01vZHVsZSkgcmV0dXJuIG9iajtcbiAgICBpZiAob2JqID09PSBudWxsIHx8IHR5cGVvZiBvYmogIT09IFwib2JqZWN0XCIgJiYgdHlwZW9mIG9iaiAhPT0gXCJmdW5jdGlvblwiKSByZXR1cm4geyBkZWZhdWx0OiBvYmogfTtcblxuICAgIHZhciBjYWNoZSA9IF9nZXRSZXF1aXJlV2lsZGNhcmRDYWNoZShub2RlSW50ZXJvcCk7XG5cbiAgICBpZiAoY2FjaGUgJiYgY2FjaGUuaGFzKG9iaikpIHJldHVybiBjYWNoZS5nZXQob2JqKTtcblxuICAgIHZhciBuZXdPYmogPSB7IF9fcHJvdG9fXzogbnVsbCB9O1xuICAgIHZhciBoYXNQcm9wZXJ0eURlc2NyaXB0b3IgPSBPYmplY3QuZGVmaW5lUHJvcGVydHkgJiYgT2JqZWN0LmdldE93blByb3BlcnR5RGVzY3JpcHRvcjtcblxuICAgIGZvciAodmFyIGtleSBpbiBvYmopIHtcbiAgICAgICAgaWYgKGtleSAhPT0gXCJkZWZhdWx0XCIgJiYgT2JqZWN0LnByb3RvdHlwZS5oYXNPd25Qcm9wZXJ0eS5jYWxsKG9iaiwga2V5KSkge1xuICAgICAgICAgICAgdmFyIGRlc2MgPSBoYXNQcm9wZXJ0eURlc2NyaXB0b3IgPyBPYmplY3QuZ2V0T3duUHJvcGVydHlEZXNjcmlwdG9yKG9iaiwga2V5KSA6IG51bGw7XG4gICAgICAgICAgICBpZiAoZGVzYyAmJiAoZGVzYy5nZXQgfHwgZGVzYy5zZXQpKSBPYmplY3QuZGVmaW5lUHJvcGVydHkobmV3T2JqLCBrZXksIGRlc2MpO1xuICAgICAgICAgICAgZWxzZSBuZXdPYmpba2V5XSA9IG9ialtrZXldO1xuICAgICAgICB9XG4gICAgfVxuXG4gICAgbmV3T2JqLmRlZmF1bHQgPSBvYmo7XG5cbiAgICBpZiAoY2FjaGUpIGNhY2hlLnNldChvYmosIG5ld09iaik7XG5cbiAgICByZXR1cm4gbmV3T2JqO1xufVxuZXhwb3J0IHsgX2ludGVyb3BfcmVxdWlyZV93aWxkY2FyZCBhcyBfIH07XG4iXSwibmFtZXMiOltdLCJzb3VyY2VSb290IjoiIn0=\n//# sourceURL=webpack-internal:///(ssr)/./node_modules/@swc/helpers/esm/_interop_require_wildcard.js\n");
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ "(ssr)/./node_modules/@swc/helpers/esm/_tagged_template_literal_loose.js":
|
||||
/*!*************************************************************************!*\
|
||||
!*** ./node_modules/@swc/helpers/esm/_tagged_template_literal_loose.js ***!
|
||||
\*************************************************************************/
|
||||
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
||||
|
||||
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ _: () => (/* binding */ _tagged_template_literal_loose),\n/* harmony export */ _tagged_template_literal_loose: () => (/* binding */ _tagged_template_literal_loose)\n/* harmony export */ });\nfunction _tagged_template_literal_loose(strings, raw) {\n if (!raw) raw = strings.slice(0);\n\n strings.raw = raw;\n\n return strings;\n}\n\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiKHNzcikvLi9ub2RlX21vZHVsZXMvQHN3Yy9oZWxwZXJzL2VzbS9fdGFnZ2VkX3RlbXBsYXRlX2xpdGVyYWxfbG9vc2UuanMiLCJtYXBwaW5ncyI6Ijs7Ozs7QUFBTztBQUNQOztBQUVBOztBQUVBO0FBQ0E7QUFDK0MiLCJzb3VyY2VzIjpbIndlYnBhY2s6Ly9hc3RvY2stYWdlbnQtZnJvbnRlbmQvLi9ub2RlX21vZHVsZXMvQHN3Yy9oZWxwZXJzL2VzbS9fdGFnZ2VkX3RlbXBsYXRlX2xpdGVyYWxfbG9vc2UuanM/OGJiNyJdLCJzb3VyY2VzQ29udGVudCI6WyJleHBvcnQgZnVuY3Rpb24gX3RhZ2dlZF90ZW1wbGF0ZV9saXRlcmFsX2xvb3NlKHN0cmluZ3MsIHJhdykge1xuICAgIGlmICghcmF3KSByYXcgPSBzdHJpbmdzLnNsaWNlKDApO1xuXG4gICAgc3RyaW5ncy5yYXcgPSByYXc7XG5cbiAgICByZXR1cm4gc3RyaW5ncztcbn1cbmV4cG9ydCB7IF90YWdnZWRfdGVtcGxhdGVfbGl0ZXJhbF9sb29zZSBhcyBfIH07XG4iXSwibmFtZXMiOltdLCJzb3VyY2VSb290IjoiIn0=\n//# sourceURL=webpack-internal:///(ssr)/./node_modules/@swc/helpers/esm/_tagged_template_literal_loose.js\n");
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ "(rsc)/./node_modules/@swc/helpers/esm/_interop_require_default.js":
|
||||
/*!*******************************************************************!*\
|
||||
!*** ./node_modules/@swc/helpers/esm/_interop_require_default.js ***!
|
||||
\*******************************************************************/
|
||||
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
||||
|
||||
eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */ _: () => (/* binding */ _interop_require_default),\n/* harmony export */ _interop_require_default: () => (/* binding */ _interop_require_default)\n/* harmony export */ });\nfunction _interop_require_default(obj) {\n return obj && obj.__esModule ? obj : { default: obj };\n}\n\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiKHJzYykvLi9ub2RlX21vZHVsZXMvQHN3Yy9oZWxwZXJzL2VzbS9faW50ZXJvcF9yZXF1aXJlX2RlZmF1bHQuanMiLCJtYXBwaW5ncyI6Ijs7Ozs7QUFBTztBQUNQLDJDQUEyQztBQUMzQztBQUN5QyIsInNvdXJjZXMiOlsid2VicGFjazovL2FzdG9jay1hZ2VudC1mcm9udGVuZC8uL25vZGVfbW9kdWxlcy9Ac3djL2hlbHBlcnMvZXNtL19pbnRlcm9wX3JlcXVpcmVfZGVmYXVsdC5qcz9kZjgyIl0sInNvdXJjZXNDb250ZW50IjpbImV4cG9ydCBmdW5jdGlvbiBfaW50ZXJvcF9yZXF1aXJlX2RlZmF1bHQob2JqKSB7XG4gICAgcmV0dXJuIG9iaiAmJiBvYmouX19lc01vZHVsZSA/IG9iaiA6IHsgZGVmYXVsdDogb2JqIH07XG59XG5leHBvcnQgeyBfaW50ZXJvcF9yZXF1aXJlX2RlZmF1bHQgYXMgXyB9O1xuIl0sIm5hbWVzIjpbXSwic291cmNlUm9vdCI6IiJ9\n//# sourceURL=webpack-internal:///(rsc)/./node_modules/@swc/helpers/esm/_interop_require_default.js\n");
|
||||
|
||||
/***/ })
|
||||
|
||||
};
|
||||
;
|
||||
File diff suppressed because one or more lines are too long
@ -1,215 +1 @@
|
||||
/*
|
||||
* ATTENTION: An "eval-source-map" devtool has been used.
|
||||
* This devtool is neither made for production nor for readable output files.
|
||||
* It uses "eval()" calls to create a separate source file with attached SourceMaps in the browser devtools.
|
||||
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
|
||||
* or disable the default devtool with "devtool: false".
|
||||
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
|
||||
*/
|
||||
/******/ (() => { // webpackBootstrap
|
||||
/******/ "use strict";
|
||||
/******/ var __webpack_modules__ = ({});
|
||||
/************************************************************************/
|
||||
/******/ // The module cache
|
||||
/******/ var __webpack_module_cache__ = {};
|
||||
/******/
|
||||
/******/ // The require function
|
||||
/******/ function __webpack_require__(moduleId) {
|
||||
/******/ // Check if module is in cache
|
||||
/******/ var cachedModule = __webpack_module_cache__[moduleId];
|
||||
/******/ if (cachedModule !== undefined) {
|
||||
/******/ return cachedModule.exports;
|
||||
/******/ }
|
||||
/******/ // Create a new module (and put it into the cache)
|
||||
/******/ var module = __webpack_module_cache__[moduleId] = {
|
||||
/******/ id: moduleId,
|
||||
/******/ loaded: false,
|
||||
/******/ exports: {}
|
||||
/******/ };
|
||||
/******/
|
||||
/******/ // Execute the module function
|
||||
/******/ var threw = true;
|
||||
/******/ try {
|
||||
/******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
|
||||
/******/ threw = false;
|
||||
/******/ } finally {
|
||||
/******/ if(threw) delete __webpack_module_cache__[moduleId];
|
||||
/******/ }
|
||||
/******/
|
||||
/******/ // Flag the module as loaded
|
||||
/******/ module.loaded = true;
|
||||
/******/
|
||||
/******/ // Return the exports of the module
|
||||
/******/ return module.exports;
|
||||
/******/ }
|
||||
/******/
|
||||
/******/ // expose the modules object (__webpack_modules__)
|
||||
/******/ __webpack_require__.m = __webpack_modules__;
|
||||
/******/
|
||||
/************************************************************************/
|
||||
/******/ /* webpack/runtime/compat get default export */
|
||||
/******/ (() => {
|
||||
/******/ // getDefaultExport function for compatibility with non-harmony modules
|
||||
/******/ __webpack_require__.n = (module) => {
|
||||
/******/ var getter = module && module.__esModule ?
|
||||
/******/ () => (module['default']) :
|
||||
/******/ () => (module);
|
||||
/******/ __webpack_require__.d(getter, { a: getter });
|
||||
/******/ return getter;
|
||||
/******/ };
|
||||
/******/ })();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/create fake namespace object */
|
||||
/******/ (() => {
|
||||
/******/ var getProto = Object.getPrototypeOf ? (obj) => (Object.getPrototypeOf(obj)) : (obj) => (obj.__proto__);
|
||||
/******/ var leafPrototypes;
|
||||
/******/ // create a fake namespace object
|
||||
/******/ // mode & 1: value is a module id, require it
|
||||
/******/ // mode & 2: merge all properties of value into the ns
|
||||
/******/ // mode & 4: return value when already ns object
|
||||
/******/ // mode & 16: return value when it's Promise-like
|
||||
/******/ // mode & 8|1: behave like require
|
||||
/******/ __webpack_require__.t = function(value, mode) {
|
||||
/******/ if(mode & 1) value = this(value);
|
||||
/******/ if(mode & 8) return value;
|
||||
/******/ if(typeof value === 'object' && value) {
|
||||
/******/ if((mode & 4) && value.__esModule) return value;
|
||||
/******/ if((mode & 16) && typeof value.then === 'function') return value;
|
||||
/******/ }
|
||||
/******/ var ns = Object.create(null);
|
||||
/******/ __webpack_require__.r(ns);
|
||||
/******/ var def = {};
|
||||
/******/ leafPrototypes = leafPrototypes || [null, getProto({}), getProto([]), getProto(getProto)];
|
||||
/******/ for(var current = mode & 2 && value; typeof current == 'object' && !~leafPrototypes.indexOf(current); current = getProto(current)) {
|
||||
/******/ Object.getOwnPropertyNames(current).forEach((key) => (def[key] = () => (value[key])));
|
||||
/******/ }
|
||||
/******/ def['default'] = () => (value);
|
||||
/******/ __webpack_require__.d(ns, def);
|
||||
/******/ return ns;
|
||||
/******/ };
|
||||
/******/ })();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/define property getters */
|
||||
/******/ (() => {
|
||||
/******/ // define getter functions for harmony exports
|
||||
/******/ __webpack_require__.d = (exports, definition) => {
|
||||
/******/ for(var key in definition) {
|
||||
/******/ if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
|
||||
/******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
|
||||
/******/ }
|
||||
/******/ }
|
||||
/******/ };
|
||||
/******/ })();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/ensure chunk */
|
||||
/******/ (() => {
|
||||
/******/ __webpack_require__.f = {};
|
||||
/******/ // This file contains only the entry chunk.
|
||||
/******/ // The chunk loading function for additional chunks
|
||||
/******/ __webpack_require__.e = (chunkId) => {
|
||||
/******/ return Promise.all(Object.keys(__webpack_require__.f).reduce((promises, key) => {
|
||||
/******/ __webpack_require__.f[key](chunkId, promises);
|
||||
/******/ return promises;
|
||||
/******/ }, []));
|
||||
/******/ };
|
||||
/******/ })();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/get javascript chunk filename */
|
||||
/******/ (() => {
|
||||
/******/ // This function allow to reference async chunks and sibling chunks for the entrypoint
|
||||
/******/ __webpack_require__.u = (chunkId) => {
|
||||
/******/ // return url for filenames based on template
|
||||
/******/ return "" + chunkId + ".js";
|
||||
/******/ };
|
||||
/******/ })();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/getFullHash */
|
||||
/******/ (() => {
|
||||
/******/ __webpack_require__.h = () => ("915ef4ca94558be7")
|
||||
/******/ })();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/hasOwnProperty shorthand */
|
||||
/******/ (() => {
|
||||
/******/ __webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))
|
||||
/******/ })();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/make namespace object */
|
||||
/******/ (() => {
|
||||
/******/ // define __esModule on exports
|
||||
/******/ __webpack_require__.r = (exports) => {
|
||||
/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
|
||||
/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
||||
/******/ }
|
||||
/******/ Object.defineProperty(exports, '__esModule', { value: true });
|
||||
/******/ };
|
||||
/******/ })();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/node module decorator */
|
||||
/******/ (() => {
|
||||
/******/ __webpack_require__.nmd = (module) => {
|
||||
/******/ module.paths = [];
|
||||
/******/ if (!module.children) module.children = [];
|
||||
/******/ return module;
|
||||
/******/ };
|
||||
/******/ })();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/startup entrypoint */
|
||||
/******/ (() => {
|
||||
/******/ __webpack_require__.X = (result, chunkIds, fn) => {
|
||||
/******/ // arguments: chunkIds, moduleId are deprecated
|
||||
/******/ var moduleId = chunkIds;
|
||||
/******/ if(!fn) chunkIds = result, fn = () => (__webpack_require__(__webpack_require__.s = moduleId));
|
||||
/******/ chunkIds.map(__webpack_require__.e, __webpack_require__)
|
||||
/******/ var r = fn();
|
||||
/******/ return r === undefined ? result : r;
|
||||
/******/ }
|
||||
/******/ })();
|
||||
/******/
|
||||
/******/ /* webpack/runtime/require chunk loading */
|
||||
/******/ (() => {
|
||||
/******/ // no baseURI
|
||||
/******/
|
||||
/******/ // object to store loaded chunks
|
||||
/******/ // "1" means "loaded", otherwise not loaded yet
|
||||
/******/ var installedChunks = {
|
||||
/******/ "webpack-runtime": 1
|
||||
/******/ };
|
||||
/******/
|
||||
/******/ // no on chunks loaded
|
||||
/******/
|
||||
/******/ var installChunk = (chunk) => {
|
||||
/******/ var moreModules = chunk.modules, chunkIds = chunk.ids, runtime = chunk.runtime;
|
||||
/******/ for(var moduleId in moreModules) {
|
||||
/******/ if(__webpack_require__.o(moreModules, moduleId)) {
|
||||
/******/ __webpack_require__.m[moduleId] = moreModules[moduleId];
|
||||
/******/ }
|
||||
/******/ }
|
||||
/******/ if(runtime) runtime(__webpack_require__);
|
||||
/******/ for(var i = 0; i < chunkIds.length; i++)
|
||||
/******/ installedChunks[chunkIds[i]] = 1;
|
||||
/******/
|
||||
/******/ };
|
||||
/******/
|
||||
/******/ // require() chunk loading for javascript
|
||||
/******/ __webpack_require__.f.require = (chunkId, promises) => {
|
||||
/******/ // "1" is the signal for "already loaded"
|
||||
/******/ if(!installedChunks[chunkId]) {
|
||||
/******/ if("webpack-runtime" != chunkId) {
|
||||
/******/ installChunk(require("./" + __webpack_require__.u(chunkId)));
|
||||
/******/ } else installedChunks[chunkId] = 1;
|
||||
/******/ }
|
||||
/******/ };
|
||||
/******/
|
||||
/******/ module.exports = __webpack_require__;
|
||||
/******/ __webpack_require__.C = installChunk;
|
||||
/******/
|
||||
/******/ // no HMR
|
||||
/******/
|
||||
/******/ // no HMR manifest
|
||||
/******/ })();
|
||||
/******/
|
||||
/************************************************************************/
|
||||
/******/
|
||||
/******/
|
||||
/******/ })()
|
||||
;
|
||||
(()=>{"use strict";var e={},r={};function t(o){var n=r[o];if(void 0!==n)return n.exports;var a=r[o]={exports:{}},u=!0;try{e[o](a,a.exports,t),u=!1}finally{u&&delete r[o]}return a.exports}t.m=e,t.n=e=>{var r=e&&e.__esModule?()=>e.default:()=>e;return t.d(r,{a:r}),r},(()=>{var e,r=Object.getPrototypeOf?e=>Object.getPrototypeOf(e):e=>e.__proto__;t.t=function(o,n){if(1&n&&(o=this(o)),8&n||"object"==typeof o&&o&&(4&n&&o.__esModule||16&n&&"function"==typeof o.then))return o;var a=Object.create(null);t.r(a);var u={};e=e||[null,r({}),r([]),r(r)];for(var f=2&n&&o;"object"==typeof f&&!~e.indexOf(f);f=r(f))Object.getOwnPropertyNames(f).forEach(e=>u[e]=()=>o[e]);return u.default=()=>o,t.d(a,u),a}})(),t.d=(e,r)=>{for(var o in r)t.o(r,o)&&!t.o(e,o)&&Object.defineProperty(e,o,{enumerable:!0,get:r[o]})},t.f={},t.e=e=>Promise.all(Object.keys(t.f).reduce((r,o)=>(t.f[o](e,r),r),[])),t.u=e=>""+e+".js",t.o=(e,r)=>Object.prototype.hasOwnProperty.call(e,r),t.r=e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},t.X=(e,r,o)=>{var n=r;o||(r=e,o=()=>t(t.s=n)),r.map(t.e,t);var a=o();return void 0===a?e:a},(()=>{var e={658:1},r=r=>{var o=r.modules,n=r.ids,a=r.runtime;for(var u in o)t.o(o,u)&&(t.m[u]=o[u]);a&&a(t);for(var f=0;f<n.length;f++)e[n[f]]=1};t.f.require=(o,n)=>{e[o]||(658!=o?r(require("./chunks/"+t.u(o))):e[o]=1)},module.exports=t,t.C=r})()})();
|
||||
File diff suppressed because one or more lines are too long
@ -1,39 +0,0 @@
|
||||
/*
|
||||
* ATTENTION: An "eval-source-map" devtool has been used.
|
||||
* This devtool is neither made for production nor for readable output files.
|
||||
* It uses "eval()" calls to create a separate source file with attached SourceMaps in the browser devtools.
|
||||
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
|
||||
* or disable the default devtool with "devtool: false".
|
||||
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
|
||||
*/
|
||||
(self["webpackChunk_N_E"] = self["webpackChunk_N_E"] || []).push([["app/layout"],{
|
||||
|
||||
/***/ "(app-pages-browser)/./node_modules/next/dist/build/webpack/loaders/next-flight-client-entry-loader.js?modules=%7B%22request%22%3A%22%2FUsers%2Faaron%2Fsource_code%2Fastock-agent%2Ffrontend%2Fsrc%2Fapp%2Fglobals.css%22%2C%22ids%22%3A%5B%5D%7D&server=false!":
|
||||
/*!***************************************************************************************************************************************************************************************************************************************************!*\
|
||||
!*** ./node_modules/next/dist/build/webpack/loaders/next-flight-client-entry-loader.js?modules=%7B%22request%22%3A%22%2FUsers%2Faaron%2Fsource_code%2Fastock-agent%2Ffrontend%2Fsrc%2Fapp%2Fglobals.css%22%2C%22ids%22%3A%5B%5D%7D&server=false! ***!
|
||||
\***************************************************************************************************************************************************************************************************************************************************/
|
||||
/***/ (function(__unused_webpack_module, __unused_webpack_exports, __webpack_require__) {
|
||||
|
||||
eval(__webpack_require__.ts("Promise.resolve(/*! import() eager */).then(__webpack_require__.bind(__webpack_require__, /*! ./src/app/globals.css */ \"(app-pages-browser)/./src/app/globals.css\"));\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiKGFwcC1wYWdlcy1icm93c2VyKS8uL25vZGVfbW9kdWxlcy9uZXh0L2Rpc3QvYnVpbGQvd2VicGFjay9sb2FkZXJzL25leHQtZmxpZ2h0LWNsaWVudC1lbnRyeS1sb2FkZXIuanM/bW9kdWxlcz0lN0IlMjJyZXF1ZXN0JTIyJTNBJTIyJTJGVXNlcnMlMkZhYXJvbiUyRnNvdXJjZV9jb2RlJTJGYXN0b2NrLWFnZW50JTJGZnJvbnRlbmQlMkZzcmMlMkZhcHAlMkZnbG9iYWxzLmNzcyUyMiUyQyUyMmlkcyUyMiUzQSU1QiU1RCU3RCZzZXJ2ZXI9ZmFsc2UhIiwibWFwcGluZ3MiOiJBQUFBLG9LQUF1RyIsInNvdXJjZXMiOlsid2VicGFjazovL19OX0UvPzBmMGIiXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0KC8qIHdlYnBhY2tNb2RlOiBcImVhZ2VyXCIgKi8gXCIvVXNlcnMvYWFyb24vc291cmNlX2NvZGUvYXN0b2NrLWFnZW50L2Zyb250ZW5kL3NyYy9hcHAvZ2xvYmFscy5jc3NcIik7XG4iXSwibmFtZXMiOltdLCJzb3VyY2VSb290IjoiIn0=\n//# sourceURL=webpack-internal:///(app-pages-browser)/./node_modules/next/dist/build/webpack/loaders/next-flight-client-entry-loader.js?modules=%7B%22request%22%3A%22%2FUsers%2Faaron%2Fsource_code%2Fastock-agent%2Ffrontend%2Fsrc%2Fapp%2Fglobals.css%22%2C%22ids%22%3A%5B%5D%7D&server=false!\n"));
|
||||
|
||||
/***/ }),
|
||||
|
||||
/***/ "(app-pages-browser)/./src/app/globals.css":
|
||||
/*!*****************************!*\
|
||||
!*** ./src/app/globals.css ***!
|
||||
\*****************************/
|
||||
/***/ (function(module, __webpack_exports__, __webpack_require__) {
|
||||
|
||||
"use strict";
|
||||
eval(__webpack_require__.ts("__webpack_require__.r(__webpack_exports__);\n/* harmony default export */ __webpack_exports__[\"default\"] = (\"d5e60da35bc9\");\nif (true) { module.hot.accept() }\n//# sourceURL=[module]\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiKGFwcC1wYWdlcy1icm93c2VyKS8uL3NyYy9hcHAvZ2xvYmFscy5jc3MiLCJtYXBwaW5ncyI6IjtBQUFBLCtEQUFlLGNBQWM7QUFDN0IsSUFBSSxJQUFVLElBQUksaUJBQWlCIiwic291cmNlcyI6WyJ3ZWJwYWNrOi8vX05fRS8uL3NyYy9hcHAvZ2xvYmFscy5jc3M/OTc1OSJdLCJzb3VyY2VzQ29udGVudCI6WyJleHBvcnQgZGVmYXVsdCBcImQ1ZTYwZGEzNWJjOVwiXG5pZiAobW9kdWxlLmhvdCkgeyBtb2R1bGUuaG90LmFjY2VwdCgpIH1cbiJdLCJuYW1lcyI6W10sInNvdXJjZVJvb3QiOiIifQ==\n//# sourceURL=webpack-internal:///(app-pages-browser)/./src/app/globals.css\n"));
|
||||
|
||||
/***/ })
|
||||
|
||||
},
|
||||
/******/ function(__webpack_require__) { // webpackRuntimeModules
|
||||
/******/ var __webpack_exec__ = function(moduleId) { return __webpack_require__(__webpack_require__.s = moduleId); }
|
||||
/******/ __webpack_require__.O(0, ["main-app"], function() { return __webpack_exec__("(app-pages-browser)/./node_modules/next/dist/build/webpack/loaders/next-flight-client-entry-loader.js?modules=%7B%22request%22%3A%22%2FUsers%2Faaron%2Fsource_code%2Fastock-agent%2Ffrontend%2Fsrc%2Fapp%2Fglobals.css%22%2C%22ids%22%3A%5B%5D%7D&server=false!"); });
|
||||
/******/ var __webpack_exports__ = __webpack_require__.O();
|
||||
/******/ _N_E = __webpack_exports__;
|
||||
/******/ }
|
||||
]);
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1 +0,0 @@
|
||||
self.__BUILD_MANIFEST = (function(a){return {__rewrites:{afterFiles:[{has:a,source:"\u002Fapi\u002F:path*",destination:a},{has:a,source:"\u002Fws",destination:a}],beforeFiles:[],fallback:[]},sortedPages:["\u002F_app"]}}(void 0));self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB()
|
||||
@ -1 +0,0 @@
|
||||
self.__SSG_MANIFEST=new Set;self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()
|
||||
@ -1 +0,0 @@
|
||||
{"c":[],"r":[],"m":[]}
|
||||
File diff suppressed because one or more lines are too long
@ -1,79 +0,0 @@
|
||||
// File: /Users/aaron/source_code/astock-agent/frontend/src/app/layout.tsx
|
||||
import * as entry from '../../../src/app/layout.js'
|
||||
import type { ResolvingMetadata, ResolvingViewport } from 'next/dist/lib/metadata/types/metadata-interface.js'
|
||||
|
||||
type TEntry = typeof import('../../../src/app/layout.js')
|
||||
|
||||
// Check that the entry is a valid entry
|
||||
checkFields<Diff<{
|
||||
default: Function
|
||||
config?: {}
|
||||
generateStaticParams?: Function
|
||||
revalidate?: RevalidateRange<TEntry> | false
|
||||
dynamic?: 'auto' | 'force-dynamic' | 'error' | 'force-static'
|
||||
dynamicParams?: boolean
|
||||
fetchCache?: 'auto' | 'force-no-store' | 'only-no-store' | 'default-no-store' | 'default-cache' | 'only-cache' | 'force-cache'
|
||||
preferredRegion?: 'auto' | 'global' | 'home' | string | string[]
|
||||
runtime?: 'nodejs' | 'experimental-edge' | 'edge'
|
||||
maxDuration?: number
|
||||
|
||||
metadata?: any
|
||||
generateMetadata?: Function
|
||||
viewport?: any
|
||||
generateViewport?: Function
|
||||
|
||||
}, TEntry, ''>>()
|
||||
|
||||
// Check the prop type of the entry function
|
||||
checkFields<Diff<LayoutProps, FirstArg<TEntry['default']>, 'default'>>()
|
||||
|
||||
// Check the arguments and return type of the generateMetadata function
|
||||
if ('generateMetadata' in entry) {
|
||||
checkFields<Diff<LayoutProps, FirstArg<MaybeField<TEntry, 'generateMetadata'>>, 'generateMetadata'>>()
|
||||
checkFields<Diff<ResolvingMetadata, SecondArg<MaybeField<TEntry, 'generateMetadata'>>, 'generateMetadata'>>()
|
||||
}
|
||||
|
||||
// Check the arguments and return type of the generateViewport function
|
||||
if ('generateViewport' in entry) {
|
||||
checkFields<Diff<LayoutProps, FirstArg<MaybeField<TEntry, 'generateViewport'>>, 'generateViewport'>>()
|
||||
checkFields<Diff<ResolvingViewport, SecondArg<MaybeField<TEntry, 'generateViewport'>>, 'generateViewport'>>()
|
||||
}
|
||||
|
||||
// Check the arguments and return type of the generateStaticParams function
|
||||
if ('generateStaticParams' in entry) {
|
||||
checkFields<Diff<{ params: PageParams }, FirstArg<MaybeField<TEntry, 'generateStaticParams'>>, 'generateStaticParams'>>()
|
||||
checkFields<Diff<{ __tag__: 'generateStaticParams', __return_type__: any[] | Promise<any[]> }, { __tag__: 'generateStaticParams', __return_type__: ReturnType<MaybeField<TEntry, 'generateStaticParams'>> }>>()
|
||||
}
|
||||
|
||||
type PageParams = any
|
||||
export interface PageProps {
|
||||
params?: any
|
||||
searchParams?: any
|
||||
}
|
||||
export interface LayoutProps {
|
||||
children?: React.ReactNode
|
||||
|
||||
params?: any
|
||||
}
|
||||
|
||||
// =============
|
||||
// Utility types
|
||||
type RevalidateRange<T> = T extends { revalidate: any } ? NonNegative<T['revalidate']> : never
|
||||
|
||||
// If T is unknown or any, it will be an empty {} type. Otherwise, it will be the same as Omit<T, keyof Base>.
|
||||
type OmitWithTag<T, K extends keyof any, _M> = Omit<T, K>
|
||||
type Diff<Base, T extends Base, Message extends string = ''> = 0 extends (1 & T) ? {} : OmitWithTag<T, keyof Base, Message>
|
||||
|
||||
type FirstArg<T extends Function> = T extends (...args: [infer T, any]) => any ? unknown extends T ? any : T : never
|
||||
type SecondArg<T extends Function> = T extends (...args: [any, infer T]) => any ? unknown extends T ? any : T : never
|
||||
type MaybeField<T, K extends string> = T extends { [k in K]: infer G } ? G extends Function ? G : never : never
|
||||
|
||||
|
||||
|
||||
function checkFields<_ extends { [k in keyof any]: never }>() {}
|
||||
|
||||
// https://github.com/sindresorhus/type-fest
|
||||
type Numeric = number | bigint
|
||||
type Zero = 0 | 0n
|
||||
type Negative<T extends Numeric> = T extends Zero ? never : `${T}` extends `-${string}` ? T : never
|
||||
type NonNegative<T extends Numeric> = T extends Zero ? T : Negative<T> extends never ? T : '__invalid_negative_number__'
|
||||
@ -5,9 +5,15 @@ const BACKEND_URL = process.env.BACKEND_URL || "http://localhost:8000";
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.json();
|
||||
|
||||
const authHeader = req.headers.get("Authorization");
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (authHeader) {
|
||||
headers["Authorization"] = authHeader;
|
||||
}
|
||||
|
||||
const backendRes = await fetch(`${BACKEND_URL}/api/chat/stream`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import "./globals.css";
|
||||
import { AuthProvider } from "@/hooks/use-auth";
|
||||
import { AuthGuard } from "@/components/auth-guard";
|
||||
import { UserMenu } from "@/components/user-menu";
|
||||
import { SidebarNav, MobileBottomNav } from "@/components/nav";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Dragon AI Agent",
|
||||
@ -21,6 +25,8 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body className="min-h-screen bg-bg-primary text-text-primary font-display">
|
||||
<AuthProvider>
|
||||
<AuthGuard>
|
||||
{/* Desktop: sidebar + main */}
|
||||
<div className="flex min-h-screen">
|
||||
{/* Desktop sidebar */}
|
||||
@ -42,25 +48,11 @@ export default function RootLayout({
|
||||
<div className="mx-5 h-px bg-gradient-to-r from-transparent via-white/[0.06] to-transparent" />
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 py-5 px-3 space-y-1">
|
||||
<SideNavItem href="/" icon={<DashboardIcon />} label="总览" />
|
||||
<SideNavItem href="/recommendations" icon={<TargetIcon />} label="推荐列表" />
|
||||
<SideNavItem href="/sectors" icon={<FireIcon />} label="板块分析" />
|
||||
<SideNavItem href="/chat" icon={<ChatIcon />} label="AI 对话" />
|
||||
</nav>
|
||||
<SidebarNav />
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-5 border-t border-white/[0.04]">
|
||||
<div className="text-xs text-text-muted leading-relaxed">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<span className="w-1 h-1 rounded-full bg-emerald-500" />
|
||||
<span>Tushare Pro + 腾讯行情</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-1 h-1 rounded-full bg-accent-cyan" />
|
||||
<span>AI 引擎: DeepSeek</span>
|
||||
</div>
|
||||
</div>
|
||||
<UserMenu />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@ -71,91 +63,10 @@ export default function RootLayout({
|
||||
</div>
|
||||
|
||||
{/* Mobile bottom nav */}
|
||||
<MobileNav />
|
||||
<MobileBottomNav />
|
||||
</AuthGuard>
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileNav() {
|
||||
return (
|
||||
<nav className="fixed bottom-0 left-0 right-0 md:hidden z-50 bg-bg-secondary/95 backdrop-blur-xl border-t border-white/[0.04]">
|
||||
<div className="flex justify-around py-2 pb-[max(0.5rem,env(safe-area-inset-bottom))]">
|
||||
<MobileNavItem href="/" label="总览">
|
||||
<DashboardIcon />
|
||||
</MobileNavItem>
|
||||
<MobileNavItem href="/recommendations" label="推荐">
|
||||
<TargetIcon />
|
||||
</MobileNavItem>
|
||||
<MobileNavItem href="/sectors" label="板块">
|
||||
<FireIcon />
|
||||
</MobileNavItem>
|
||||
<MobileNavItem href="/chat" label="对话">
|
||||
<ChatIcon />
|
||||
</MobileNavItem>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileNavItem({ href, label, children }: { href: string; label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="flex flex-col items-center gap-1 text-text-muted hover:text-text-primary transition-colors active:scale-95"
|
||||
>
|
||||
<span className="text-lg">{children}</span>
|
||||
<span className="text-xs font-medium">{label}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function SideNavItem({ href, icon, label }: { href: string; icon: React.ReactNode; label: string }) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm text-text-secondary hover:text-text-primary hover:bg-white/[0.04] transition-all duration-200"
|
||||
>
|
||||
<span className="text-base opacity-70">{icon}</span>
|
||||
<span className="font-medium">{label}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/* SVG Icons - clean, minimal */
|
||||
function DashboardIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1.5" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1.5" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1.5" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function TargetIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<circle cx="12" cy="12" r="6" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FireIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 2c.5 2.5-.5 5-2 7 1 0 2.5.5 3 2.5.5-2 2-3 3-4-1 3-1 6-4 8.5-1.5 1-3.5 1.5-5 1-1.5-.5-2.5-2-2.5-3.5 0-3 3-5 5-7.5C10 5 11 3.5 12 2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
85
frontend/src/app/login/page.tsx
Normal file
85
frontend/src/app/login/page.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const { login } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (!username.trim() || !password.trim()) {
|
||||
setError("请输入用户名和密码");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await login(username, password);
|
||||
router.push("/");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "登录失败,请重试");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-bg-primary">
|
||||
<div className="max-w-sm w-full mx-4 p-8 rounded-2xl bg-white/[0.02] border border-white/[0.06] backdrop-blur-sm">
|
||||
{/* Brand */}
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-amber-500 to-amber-600 flex items-center justify-center text-base font-bold text-white shadow-glow-sm mb-4">
|
||||
D
|
||||
</div>
|
||||
<h1 className="text-lg font-semibold tracking-tight text-text-primary">
|
||||
Dragon AI Agent
|
||||
</h1>
|
||||
<p className="text-sm text-text-muted mt-1">登录以继续</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="用户名"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full bg-white/[0.03] border border-white/[0.06] rounded-xl px-4 py-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 placeholder-text-muted/40"
|
||||
autoComplete="username"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full bg-white/[0.03] border border-white/[0.06] rounded-xl px-4 py-3 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 placeholder-text-muted/40"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="w-full bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 rounded-xl py-3 text-sm font-medium hover:from-amber-500/30 hover:to-amber-600/25 transition-all duration-200 border border-amber-500/10 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{submitting ? "登录中..." : "登录"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<p className="mt-4 text-center text-xs text-amber-400/80">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
322
frontend/src/app/users/page.tsx
Normal file
322
frontend/src/app/users/page.tsx
Normal file
@ -0,0 +1,322 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import {
|
||||
listUsersAPI,
|
||||
createUserAPI,
|
||||
disableUserAPI,
|
||||
resetPasswordAPI,
|
||||
type UserItem,
|
||||
} from "@/lib/api";
|
||||
|
||||
export default function UsersPage() {
|
||||
const { user: currentUser } = useAuth();
|
||||
const [users, setUsers] = useState<UserItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// Create user dialog state
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newUsername, setNewUsername] = useState("");
|
||||
const [newRole, setNewRole] = useState("user");
|
||||
const [createLoading, setCreateLoading] = useState(false);
|
||||
const [createError, setCreateError] = useState("");
|
||||
const [createdResult, setCreatedResult] = useState<{ username: string; password: string } | null>(null);
|
||||
|
||||
// Reset password result
|
||||
const [resetResult, setResetResult] = useState<{ username: string; password: string } | null>(null);
|
||||
|
||||
// Copy feedback
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
function copyCredential(username: string, password: string) {
|
||||
const text = `用户名:${username}\n密码:${password}`;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
try {
|
||||
const data = await listUsersAPI();
|
||||
setUsers(data);
|
||||
} catch {
|
||||
setError("加载用户列表失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser?.role === "admin") {
|
||||
fetchUsers();
|
||||
}
|
||||
}, [currentUser, fetchUsers]);
|
||||
|
||||
// Non-admin: show nothing (AuthGuard + route should prevent this)
|
||||
if (currentUser?.role !== "admin") {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 md:px-8 pt-6">
|
||||
<p className="text-text-muted text-sm">需要管理员权限</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setCreateError("");
|
||||
if (!newUsername.trim()) {
|
||||
setCreateError("请输入用户名");
|
||||
return;
|
||||
}
|
||||
setCreateLoading(true);
|
||||
try {
|
||||
const result = await createUserAPI(newUsername.trim(), newRole);
|
||||
setCreatedResult({ username: result.username, password: result.password });
|
||||
setNewUsername("");
|
||||
setNewRole("user");
|
||||
fetchUsers();
|
||||
} catch (err) {
|
||||
setCreateError(err instanceof Error ? err.message : "创建失败");
|
||||
} finally {
|
||||
setCreateLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisable(userId: number) {
|
||||
try {
|
||||
await disableUserAPI(userId);
|
||||
fetchUsers();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "操作失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetPassword(userId: number) {
|
||||
try {
|
||||
const result = await resetPasswordAPI(userId);
|
||||
setResetResult({ username: result.username, password: result.password });
|
||||
fetchUsers();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "操作失败");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between animate-fade-in-up">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">用户管理</h1>
|
||||
<p className="text-sm text-text-muted mt-1">创建和管理系统用户</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setShowCreate(true); setCreateError(""); setCreatedResult(null); }}
|
||||
className="px-4 py-2 rounded-xl text-sm font-medium bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/10 hover:from-amber-500/30 hover:to-amber-600/25 transition-all"
|
||||
>
|
||||
+ 创建用户
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<p className="text-sm text-amber-400/80 animate-fade-in-up">{error}</p>
|
||||
)}
|
||||
|
||||
{/* User list */}
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="glass-card-static p-4 animate-shimmer rounded-xl h-16" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 animate-fade-in-up delay-75">
|
||||
{users.map((u) => (
|
||||
<div
|
||||
key={u.id}
|
||||
className={`glass-card p-4 rounded-xl flex items-center justify-between gap-4 ${
|
||||
!u.is_active ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{/* Avatar */}
|
||||
<div className="w-9 h-9 rounded-full bg-white/[0.04] border border-white/[0.06] flex items-center justify-center text-sm font-medium text-text-secondary shrink-0">
|
||||
{u.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-text-primary truncate">
|
||||
{u.username}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[10px] px-1.5 py-0.5 rounded ${
|
||||
u.role === "admin"
|
||||
? "bg-amber-500/10 text-amber-400/80"
|
||||
: "bg-white/[0.04] text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{u.role}
|
||||
</span>
|
||||
{!u.is_active && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-red-500/10 text-red-400/80">
|
||||
已禁用
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{u.created_at && (
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
创建于 {new Date(u.created_at).toLocaleDateString("zh-CN")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{u.id !== currentUser!.id && (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => handleResetPassword(u.id)}
|
||||
className="px-3 py-1.5 rounded-lg text-xs text-text-secondary hover:text-text-primary bg-white/[0.03] hover:bg-white/[0.06] border border-white/[0.04] transition-all"
|
||||
>
|
||||
重置密码
|
||||
</button>
|
||||
{u.is_active ? (
|
||||
<button
|
||||
onClick={() => handleDisable(u.id)}
|
||||
className="px-3 py-1.5 rounded-lg text-xs text-red-400/60 hover:text-red-400 bg-red-500/[0.03] hover:bg-red-500/[0.08] border border-red-500/[0.06] transition-all"
|
||||
>
|
||||
禁用
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-text-muted">已禁用</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{users.length === 0 && (
|
||||
<div className="glass-card-static p-8 rounded-xl text-center">
|
||||
<p className="text-sm text-text-muted">暂无用户</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create User Dialog */}
|
||||
{showCreate && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setShowCreate(false)} />
|
||||
<div className="relative w-full max-w-sm mx-4 p-6 rounded-2xl bg-bg-card border border-white/[0.06] shadow-card">
|
||||
{createdResult ? (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-base font-semibold text-text-primary">用户创建成功</h3>
|
||||
<div className="p-4 rounded-xl bg-white/[0.02] border border-white/[0.04] space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-text-muted">用户名</span>
|
||||
<span className="text-text-primary font-medium">{createdResult.username}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm items-center">
|
||||
<span className="text-text-muted">密码</span>
|
||||
<span className="text-amber-400 font-mono text-xs">{createdResult.password}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-amber-400/60">请妥善保管密码,此密码仅显示一次</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => copyCredential(createdResult.username, createdResult.password)}
|
||||
className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/10 hover:from-amber-500/30 hover:to-amber-600/25 transition-all"
|
||||
>
|
||||
{copied ? "已复制" : "一键复制"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCreate(false)}
|
||||
className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-white/[0.04] border border-white/[0.06] text-text-secondary hover:text-text-primary hover:bg-white/[0.06] transition-all"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="text-base font-semibold text-text-primary mb-5">创建新用户</h3>
|
||||
<form onSubmit={handleCreate} className="space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="用户名"
|
||||
value={newUsername}
|
||||
onChange={(e) => setNewUsername(e.target.value)}
|
||||
className="w-full bg-white/[0.03] border border-white/[0.06] rounded-xl px-4 py-2.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 placeholder-text-muted/40"
|
||||
/>
|
||||
<select
|
||||
value={newRole}
|
||||
onChange={(e) => setNewRole(e.target.value)}
|
||||
className="w-full bg-white/[0.03] border border-white/[0.06] rounded-xl px-4 py-2.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 appearance-none"
|
||||
>
|
||||
<option value="user" className="bg-bg-card text-text-primary">普通用户</option>
|
||||
<option value="admin" className="bg-bg-card text-text-primary">管理员</option>
|
||||
</select>
|
||||
{createError && <p className="text-xs text-amber-400/80">{createError}</p>}
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreate(false)}
|
||||
className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-white/[0.04] border border-white/[0.06] text-text-secondary hover:text-text-primary hover:bg-white/[0.06] transition-all"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createLoading}
|
||||
className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/10 hover:from-amber-500/30 hover:to-amber-600/25 transition-all disabled:opacity-50"
|
||||
>
|
||||
{createLoading ? "创建中..." : "创建"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reset Password Result Dialog */}
|
||||
{resetResult && (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setResetResult(null)} />
|
||||
<div className="relative w-full max-w-sm mx-4 p-6 rounded-2xl bg-bg-card border border-white/[0.06] shadow-card space-y-4">
|
||||
<h3 className="text-base font-semibold text-text-primary">密码已重置</h3>
|
||||
<div className="p-4 rounded-xl bg-white/[0.02] border border-white/[0.04] space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-text-muted">用户名</span>
|
||||
<span className="text-text-primary font-medium">{resetResult.username}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm items-center">
|
||||
<span className="text-text-muted">新密码</span>
|
||||
<span className="text-amber-400 font-mono text-xs">{resetResult.password}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-amber-400/60">请妥善保管新密码,此密码仅显示一次</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => copyCredential(resetResult.username, resetResult.password)}
|
||||
className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/10 hover:from-amber-500/30 hover:to-amber-600/25 transition-all"
|
||||
>
|
||||
{copied ? "已复制" : "一键复制"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setResetResult(null)}
|
||||
className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-white/[0.04] border border-white/[0.06] text-text-secondary hover:text-text-primary hover:bg-white/[0.06] transition-all"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
frontend/src/components/auth-guard.tsx
Normal file
45
frontend/src/components/auth-guard.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
|
||||
const PUBLIC_PATHS = ["/login"];
|
||||
|
||||
export function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading } = useAuth();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
const isPublicPath = PUBLIC_PATHS.includes(pathname);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user && !isPublicPath) {
|
||||
router.replace("/login");
|
||||
}
|
||||
}, [loading, user, isPublicPath, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && user && pathname === "/login") {
|
||||
router.replace("/");
|
||||
}
|
||||
}, [loading, user, pathname, router]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-bg-primary">
|
||||
<div className="w-6 h-6 border border-amber-500/30 border-t-amber-500 rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user && !isPublicPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (user && pathname === "/login") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
134
frontend/src/components/change-password-dialog.tsx
Normal file
134
frontend/src/components/change-password-dialog.tsx
Normal file
@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { changePasswordAPI } from "@/lib/api";
|
||||
|
||||
interface ChangePasswordDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ChangePasswordDialog({ open, onClose }: ChangePasswordDialogProps) {
|
||||
const [oldPassword, setOldPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
function resetForm() {
|
||||
setOldPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
setError("");
|
||||
setSuccess(false);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
resetForm();
|
||||
onClose();
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
|
||||
if (!oldPassword || !newPassword || !confirmPassword) {
|
||||
setError("请填写所有字段");
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 6) {
|
||||
setError("新密码至少 6 位");
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError("两次输入的新密码不一致");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await changePasswordAPI(oldPassword, newPassword);
|
||||
setSuccess(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "修改失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={handleClose} />
|
||||
|
||||
{/* Dialog */}
|
||||
<div className="relative w-full max-w-sm mx-4 p-6 rounded-2xl bg-bg-card border border-white/[0.06] shadow-card">
|
||||
<h3 className="text-base font-semibold text-text-primary mb-5">修改密码</h3>
|
||||
|
||||
{success ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-emerald-400">密码修改成功,下次登录请使用新密码。</p>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="w-full py-2.5 rounded-xl text-sm font-medium bg-white/[0.04] border border-white/[0.06] text-text-secondary hover:text-text-primary hover:bg-white/[0.06] transition-all"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<input
|
||||
type="password"
|
||||
placeholder="旧密码"
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
className="w-full bg-white/[0.03] border border-white/[0.06] rounded-xl px-4 py-2.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 placeholder-text-muted/40"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="新密码(至少 6 位)"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="w-full bg-white/[0.03] border border-white/[0.06] rounded-xl px-4 py-2.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 placeholder-text-muted/40"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="确认新密码"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="w-full bg-white/[0.03] border border-white/[0.06] rounded-xl px-4 py-2.5 text-sm text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 placeholder-text-muted/40"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="text-xs text-amber-400/80">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-white/[0.04] border border-white/[0.06] text-text-secondary hover:text-text-primary hover:bg-white/[0.06] transition-all"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-1 py-2.5 rounded-xl text-sm font-medium bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 border border-amber-500/10 hover:from-amber-500/30 hover:to-amber-600/25 transition-all disabled:opacity-50"
|
||||
>
|
||||
{loading ? "提交中..." : "确认修改"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
126
frontend/src/components/nav.tsx
Normal file
126
frontend/src/components/nav.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
function DashboardIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1.5" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1.5" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1.5" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function TargetIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<circle cx="12" cy="12" r="6" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function FireIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 2c.5 2.5-.5 5-2 7 1 0 2.5.5 3 2.5.5-2 2-3 3-4-1 3-1 6-4 8.5-1.5 1-3.5 1.5-5 1-1.5-.5-2.5-2-2.5-3.5 0-3 3-5 5-7.5C10 5 11 3.5 12 2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function UsersIcon() {
|
||||
return (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SideNavItem({ href, icon, label }: { href: string; icon: React.ReactNode; label: string }) {
|
||||
const pathname = usePathname();
|
||||
const isActive = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm transition-all duration-200 ${
|
||||
isActive
|
||||
? "text-text-primary bg-white/[0.06]"
|
||||
: "text-text-secondary hover:text-text-primary hover:bg-white/[0.04]"
|
||||
}`}
|
||||
>
|
||||
<span className="text-base opacity-70">{icon}</span>
|
||||
<span className="font-medium">{label}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function SidebarNav() {
|
||||
const { user } = useAuth();
|
||||
|
||||
return (
|
||||
<nav className="flex-1 py-5 px-3 space-y-1">
|
||||
<SideNavItem href="/" icon={<DashboardIcon />} label="总览" />
|
||||
<SideNavItem href="/recommendations" icon={<TargetIcon />} label="推荐列表" />
|
||||
<SideNavItem href="/sectors" icon={<FireIcon />} label="板块分析" />
|
||||
<SideNavItem href="/chat" icon={<ChatIcon />} label="AI 对话" />
|
||||
{user?.role === "admin" && (
|
||||
<SideNavItem href="/users" icon={<UsersIcon />} label="用户管理" />
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileNavItem({ href, label, children }: { href: string; label: string; children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const isActive = href === "/" ? pathname === "/" : pathname.startsWith(href);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={`flex flex-col items-center gap-1 transition-colors active:scale-95 ${
|
||||
isActive ? "text-amber-400" : "text-text-muted hover:text-text-primary"
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg">{children}</span>
|
||||
<span className="text-xs font-medium">{label}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileBottomNav() {
|
||||
return (
|
||||
<nav className="fixed bottom-0 left-0 right-0 md:hidden z-50 bg-bg-secondary/95 backdrop-blur-xl border-t border-white/[0.04]">
|
||||
<div className="flex justify-around py-2 pb-[max(0.5rem,env(safe-area-inset-bottom))]">
|
||||
<MobileNavItem href="/" label="总览">
|
||||
<DashboardIcon />
|
||||
</MobileNavItem>
|
||||
<MobileNavItem href="/recommendations" label="推荐">
|
||||
<TargetIcon />
|
||||
</MobileNavItem>
|
||||
<MobileNavItem href="/sectors" label="板块">
|
||||
<FireIcon />
|
||||
</MobileNavItem>
|
||||
<MobileNavItem href="/chat" label="对话">
|
||||
<ChatIcon />
|
||||
</MobileNavItem>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
44
frontend/src/components/user-menu.tsx
Normal file
44
frontend/src/components/user-menu.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { ChangePasswordDialog } from "@/components/change-password-dialog";
|
||||
|
||||
export function UserMenu() {
|
||||
const { user, logout } = useAuth();
|
||||
const [showChangePassword, setShowChangePassword] = useState(false);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-text-muted">{user.username}</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-400/80">
|
||||
{user.role}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setShowChangePassword(true)}
|
||||
className="text-xs text-text-muted hover:text-text-secondary transition-colors"
|
||||
>
|
||||
修改密码
|
||||
</button>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-xs text-amber-400/60 hover:text-amber-400 transition-colors"
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChangePasswordDialog
|
||||
open={showChangePassword}
|
||||
onClose={() => setShowChangePassword(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
61
frontend/src/hooks/use-auth.tsx
Normal file
61
frontend/src/hooks/use-auth.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from "react";
|
||||
import { type AuthUser, loginAPI } from "@/lib/api";
|
||||
|
||||
interface AuthContextValue {
|
||||
user: AuthUser | null;
|
||||
loading: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue>({
|
||||
user: null,
|
||||
loading: true,
|
||||
login: async () => {},
|
||||
logout: () => {},
|
||||
});
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem("auth_token");
|
||||
const storedUser = localStorage.getItem("auth_user");
|
||||
if (token && storedUser) {
|
||||
try {
|
||||
setUser(JSON.parse(storedUser));
|
||||
} catch {
|
||||
localStorage.removeItem("auth_token");
|
||||
localStorage.removeItem("auth_user");
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
const res = await loginAPI(username, password);
|
||||
localStorage.setItem("auth_token", res.token);
|
||||
localStorage.setItem("auth_user", JSON.stringify(res.user));
|
||||
setUser(res.user);
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
localStorage.removeItem("auth_token");
|
||||
localStorage.removeItem("auth_user");
|
||||
setUser(null);
|
||||
window.location.href = "/login";
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
@ -1,21 +1,75 @@
|
||||
const API_BASE = "";
|
||||
|
||||
function getAuthToken(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
return localStorage.getItem("auth_token");
|
||||
}
|
||||
|
||||
function handleUnauthorized(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.removeItem("auth_token");
|
||||
localStorage.removeItem("auth_user");
|
||||
window.location.href = "/login";
|
||||
}
|
||||
|
||||
export async function fetchAPI<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`);
|
||||
const token = getAuthToken();
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}${path}`, { headers });
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized();
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function postAPI<T>(path: string, body?: unknown): Promise<T> {
|
||||
const token = getAuthToken();
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized();
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteAPI<T>(path: string): Promise<T> {
|
||||
const token = getAuthToken();
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
});
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized();
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.detail || `API error: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface MarketTemperatureData {
|
||||
trade_date: string;
|
||||
temperature: number;
|
||||
@ -86,12 +140,22 @@ export interface StreamEvent {
|
||||
export async function* streamChat(
|
||||
messages: ChatMessage[]
|
||||
): AsyncGenerator<StreamEvent, void, undefined> {
|
||||
const token = getAuthToken();
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/chat/stream`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers,
|
||||
body: JSON.stringify({ messages }),
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
handleUnauthorized();
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
if (!res.ok) throw new Error(`Chat API error: ${res.status}`);
|
||||
if (!res.body) throw new Error("No response body");
|
||||
|
||||
@ -121,3 +185,73 @@ export async function* streamChat(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
id: number;
|
||||
username: string;
|
||||
role: "admin" | "user";
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string;
|
||||
user: AuthUser;
|
||||
}
|
||||
|
||||
export async function loginAPI(username: string, password: string): Promise<LoginResponse> {
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.detail || `Login failed: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ---------- User Management ----------
|
||||
|
||||
export interface UserItem {
|
||||
id: number;
|
||||
username: string;
|
||||
role: "admin" | "user";
|
||||
is_active: boolean;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface CreateUserResult {
|
||||
username: string;
|
||||
password: string;
|
||||
role: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ResetPasswordResult {
|
||||
username: string;
|
||||
password: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export async function listUsersAPI(): Promise<UserItem[]> {
|
||||
return fetchAPI<UserItem[]>("/api/auth/users");
|
||||
}
|
||||
|
||||
export async function createUserAPI(username: string, role: string): Promise<CreateUserResult> {
|
||||
return postAPI<CreateUserResult>("/api/auth/users", { username, role });
|
||||
}
|
||||
|
||||
export async function disableUserAPI(userId: number): Promise<{ message: string }> {
|
||||
return deleteAPI<{ message: string }>(`/api/auth/users/${userId}`);
|
||||
}
|
||||
|
||||
export async function resetPasswordAPI(userId: number): Promise<ResetPasswordResult> {
|
||||
return postAPI<ResetPasswordResult>(`/api/auth/users/${userId}/reset-password`);
|
||||
}
|
||||
|
||||
export async function changePasswordAPI(oldPassword: string, newPassword: string): Promise<{ message: string }> {
|
||||
return postAPI<{ message: string }>("/api/auth/change-password", {
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword,
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user