add login

This commit is contained in:
aaron 2026-04-08 00:28:01 +08:00
parent 26d73600a5
commit 95047b353b
52 changed files with 1502 additions and 8425 deletions

211
backend/app/api/auth.py Normal file
View 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": "请妥善保管新密码,此密码仅显示一次",
}

View File

@ -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_"}

View File

33
backend/app/core/auth.py Normal file
View 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
View 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

View File

@ -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()),
)

View File

@ -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.

View File

@ -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

View File

@ -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"
]
}
}

View File

@ -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": []
}

File diff suppressed because one or more lines are too long

View File

@ -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"
]
}
}

View File

@ -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

View File

@ -1 +1 @@
self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]"
self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]";

View File

@ -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"];

View File

@ -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"]}}';

View File

@ -1 +1 @@
self.__NEXT_FONT_MANIFEST="{\"pages\":{},\"app\":{},\"appUsingSizeAdjust\":false,\"pagesUsingSizeAdjust\":false}"
self.__NEXT_FONT_MANIFEST='{"pages":{},"app":{},"appUsingSizeAdjust":false,"pagesUsingSizeAdjust":false}';

View File

@ -1 +1 @@
{}
{"/_app":"pages/_app.js","/_error":"pages/_error.js","/_document":"pages/_document.js","/404":"pages/404.html"}

View File

@ -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\"}"

View File

@ -1,5 +1 @@
{
"node": {},
"edge": {},
"encryptionKey": "2xwJVyON4j1nUVj9bt2cAd3iPAWZ77oTyEHOq3x+eQU="
}
{"node":{},"edge":{},"encryptionKey":"mEKzCdLXC9Tw5xpwGdIONxBrCnit3XJOsa5Cm7fALlw="}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -1 +0,0 @@
self.__SSG_MANIFEST=new Set;self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()

View File

@ -1 +0,0 @@
{"c":[],"r":[],"m":[]}

File diff suppressed because one or more lines are too long

View File

@ -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__'

View File

@ -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),
});

View File

@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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}</>;
}

View 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>
);
}

View 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>
);
}

View 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)}
/>
</>
);
}

View 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);
}

View File

@ -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,
});
}