feat(api): add public RESTful API with API key auth and documentation page
This commit is contained in:
43
app/i18n.py
43
app/i18n.py
@@ -6,6 +6,7 @@ _translations: dict[str, dict[str, str]] = {
|
||||
"nav.settings": {"zh": "设置", "en": "Settings"},
|
||||
"nav.invites": {"zh": "邀请码管理", "en": "Invites"},
|
||||
"nav.logout": {"zh": "退出", "en": "Logout"},
|
||||
"nav.api_docs": {"zh": "API 文档", "en": "API Docs"},
|
||||
|
||||
# ── Auth ──
|
||||
"login.title": {"zh": "登录", "en": "Login"},
|
||||
@@ -59,6 +60,7 @@ _translations: dict[str, dict[str, str]] = {
|
||||
# ── Postcards list ──
|
||||
"pc_list.title": {"zh": " 的明信片", "en": "'s postcards"},
|
||||
"pc_list.new": {"zh": "+ 新明信片", "en": "+ New postcard"},
|
||||
"pc_list.duplicate": {"zh": "编号已存在", "en": "Card number already exists"},
|
||||
"pc_list.all_countries": {"zh": "全部国家", "en": "All countries"},
|
||||
"pc_list.filter": {"zh": "筛选", "en": "Filter"},
|
||||
"pc_list.tab_sent": {"zh": "寄出", "en": "Sent"},
|
||||
@@ -208,6 +210,47 @@ _translations: dict[str, dict[str, str]] = {
|
||||
"flash.password_short": {"zh": "新密码长度至少6位", "en": "Password must be at least 6 characters"},
|
||||
"flash.password_ok": {"zh": "密码修改成功", "en": "Password changed"},
|
||||
|
||||
# ── API Keys ──
|
||||
"settings.tab_apikeys": {"zh": "API Keys", "en": "API Keys"},
|
||||
"apikey.title": {"zh": "API Key 管理", "en": "API Key Management"},
|
||||
"apikey.hint": {"zh": "使用 API Key 通过 HTTP 请求访问星笺的公共 API。请妥善保管你的 Key,它仅在创建时显示一次。", "en": "Use API Keys to access Mailova's public API via HTTP requests. Keep your key safe — it is only shown once upon creation."},
|
||||
"apikey.created": {"zh": "✅ 新 Key 已创建:", "en": "✅ New key created:"},
|
||||
"apikey.copy_now": {"zh": "⚠️ 请立即复制保存,关闭后将无法再次查看完整 Key。", "en": "⚠️ Copy it now. You won't be able to see the full key again after leaving this page."},
|
||||
"apikey.name_ph": {"zh": "Key 名称(可选)", "en": "Key name (optional)"},
|
||||
"apikey.create": {"zh": "创建 Key", "en": "Create Key"},
|
||||
"apikey.name": {"zh": "名称", "en": "Name"},
|
||||
"apikey.key": {"zh": "Key", "en": "Key"},
|
||||
"apikey.created_at": {"zh": "创建时间", "en": "Created"},
|
||||
"apikey.last_used": {"zh": "最后使用", "en": "Last used"},
|
||||
"apikey.delete": {"zh": "删除", "en": "Delete"},
|
||||
"apikey.confirm_delete": {"zh": "确定删除此 API Key?", "en": "Delete this API key?"},
|
||||
"apikey.empty": {"zh": "暂无 API Key", "en": "No API keys yet"},
|
||||
|
||||
# ── API Docs ──
|
||||
"api_docs.title": {"zh": "API 文档", "en": "API Documentation"},
|
||||
"api_docs.intro": {"zh": "星笺提供 RESTful API,供第三方应用集成使用。所有需要鉴权的请求需在 Header 中携带 X-API-Key。", "en": "Mailova provides a RESTful API for third-party integrations. All authenticated requests must include the X-API-Key header."},
|
||||
"api_docs.base_url": {"zh": "基础 URL", "en": "Base URL"},
|
||||
"api_docs.auth": {"zh": "鉴权方式", "en": "Authentication"},
|
||||
"api_docs.auth_desc": {"zh": "在请求头中携带你的 API Key:", "en": "Include your API Key in the request header:"},
|
||||
"api_docs.profile_ops": {"zh": "Profile 操作", "en": "Profile Operations"},
|
||||
"api_docs.postcard_ops": {"zh": "明信片操作", "en": "Postcard Operations"},
|
||||
"api_docs.upload_op": {"zh": "图片上传", "en": "Image Upload"},
|
||||
"api_docs.key_ops": {"zh": "API Key 管理", "en": "API Key Management"},
|
||||
"api_docs.showcase_ops": {"zh": "公开展示(无需鉴权)", "en": "Public Showcase (no auth)"},
|
||||
"api_docs.method": {"zh": "方法", "en": "Method"},
|
||||
"api_docs.path": {"zh": "路径", "en": "Path"},
|
||||
"api_docs.desc": {"zh": "说明", "en": "Description"},
|
||||
"api_docs.params": {"zh": "参数", "en": "Parameters"},
|
||||
"api_docs.body": {"zh": "请求体 (JSON)", "en": "Request Body (JSON)"},
|
||||
"api_docs.response": {"zh": "响应", "en": "Response"},
|
||||
"api_docs.status": {"zh": "状态码", "en": "Status"},
|
||||
"api_docs.go_settings": {"zh": "去设置页创建 API Key →", "en": "Go to Settings to create an API Key →"},
|
||||
"api_docs.try_it": {"zh": "试试看", "en": "Try it"},
|
||||
"api_docs.api_key_ph": {"zh": "输入你的 API Key", "en": "Enter your API Key"},
|
||||
"api_docs.send": {"zh": "发送", "en": "Send"},
|
||||
"api_docs.response_label": {"zh": "响应结果", "en": "Response"},
|
||||
"api_docs.no_auth": {"zh": "此接口无需鉴权", "en": "No authentication required"},
|
||||
|
||||
# ── Misc ──
|
||||
"misc.required": {"zh": " *", "en": " *"},
|
||||
}
|
||||
|
||||
@@ -5,15 +5,16 @@ from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.config import UPLOAD_DIR
|
||||
from app.database import init_db
|
||||
from app.routers import web
|
||||
from app.routers import web, api
|
||||
|
||||
app = FastAPI(title="Postcard Manager")
|
||||
app = FastAPI(title="Mailova 星笺")
|
||||
|
||||
UPLOAD_DIR.mkdir(exist_ok=True)
|
||||
app.mount("/static", StaticFiles(directory=str(Path(__file__).resolve().parent / "static")), name="static")
|
||||
app.mount("/uploads", StaticFiles(directory=str(UPLOAD_DIR)), name="uploads")
|
||||
|
||||
app.include_router(web.router)
|
||||
app.include_router(api.router)
|
||||
|
||||
init_db()
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ class User(Base):
|
||||
sessions = relationship("UserSession", back_populates="user", cascade="all, delete-orphan")
|
||||
invite_codes = relationship("InviteCode", back_populates="creator", foreign_keys="InviteCode.created_by", cascade="all, delete-orphan")
|
||||
invited_by_code = relationship("InviteCode", foreign_keys=[invited_by_code_id])
|
||||
api_keys = relationship("ApiKey", back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class InviteCode(Base):
|
||||
@@ -74,7 +75,7 @@ class Postcard(Base):
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
profile_id = Column(Integer, ForeignKey("profiles.id", ondelete="CASCADE"), nullable=False)
|
||||
card_number = Column(String(64), nullable=False)
|
||||
card_number = Column(String(64), nullable=False, unique=True)
|
||||
status = Column(String(16), nullable=False)
|
||||
# sent & delivered
|
||||
recipient_name = Column(String(64))
|
||||
@@ -98,3 +99,17 @@ class Postcard(Base):
|
||||
@validates("country")
|
||||
def _upper_country(self, key, value):
|
||||
return value.strip().upper() if value else None
|
||||
|
||||
|
||||
class ApiKey(Base):
|
||||
__tablename__ = "api_keys"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
key = Column(String(64), unique=True, nullable=False, index=True)
|
||||
name = Column(String(64), nullable=False, default="")
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
created_at = Column(DateTime, default=_utcnow)
|
||||
last_used_at = Column(DateTime, nullable=True)
|
||||
|
||||
user = relationship("User", back_populates="api_keys")
|
||||
|
||||
407
app/routers/api.py
Normal file
407
app/routers/api.py
Normal file
@@ -0,0 +1,407 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from secrets import token_hex
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query, UploadFile, File
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session as DbSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import ApiKey, Profile, Postcard, User
|
||||
from app.config import UPLOAD_DIR
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["api"])
|
||||
|
||||
|
||||
# ─── Auth Dependency ──────────────────────────────────────────────
|
||||
|
||||
def get_api_user(
|
||||
x_api_key: str = Header(..., alias="X-API-Key"),
|
||||
db: DbSession = Depends(get_db),
|
||||
) -> User:
|
||||
"""Validate X-API-Key header and return the associated user."""
|
||||
api_key = db.query(ApiKey).filter(ApiKey.key == x_api_key).first()
|
||||
if not api_key or not api_key.is_active:
|
||||
raise HTTPException(status_code=401, detail="Invalid or inactive API key")
|
||||
# Update last_used_at
|
||||
api_key.last_used_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return api_key.user
|
||||
|
||||
|
||||
def get_api_user_optional(
|
||||
x_api_key: str | None = Header(None, alias="X-API-Key"),
|
||||
db: DbSession = Depends(get_db),
|
||||
) -> User | None:
|
||||
"""Like get_api_user but returns None if no key provided (for public endpoints)."""
|
||||
if not x_api_key:
|
||||
return None
|
||||
return get_api_user(x_api_key, db)
|
||||
|
||||
|
||||
# ─── Pydantic Schemas ────────────────────────────────────────────
|
||||
|
||||
class ProfileCreate(BaseModel):
|
||||
nickname: str
|
||||
|
||||
class ProfileUpdate(BaseModel):
|
||||
nickname: str | None = None
|
||||
|
||||
class ProfileOut(BaseModel):
|
||||
id: int
|
||||
nickname: str
|
||||
showcase_enabled: bool
|
||||
showcase_visible: str
|
||||
notes: str
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class PostcardCreate(BaseModel):
|
||||
card_number: str
|
||||
status: str
|
||||
recipient_name: str | None = None
|
||||
country: str | None = None
|
||||
send_time: str | None = None
|
||||
arrival_time: str | None = None
|
||||
sender_name: str | None = None
|
||||
receive_time: str | None = None
|
||||
notes: str | None = None
|
||||
|
||||
class PostcardUpdate(BaseModel):
|
||||
card_number: str | None = None
|
||||
status: str | None = None
|
||||
recipient_name: str | None = None
|
||||
country: str | None = None
|
||||
send_time: str | None = None
|
||||
arrival_time: str | None = None
|
||||
sender_name: str | None = None
|
||||
receive_time: str | None = None
|
||||
notes: str | None = None
|
||||
|
||||
class PostcardOut(BaseModel):
|
||||
id: int
|
||||
profile_id: int
|
||||
card_number: str
|
||||
status: str
|
||||
recipient_name: str | None
|
||||
country: str | None
|
||||
send_time: datetime | None
|
||||
arrival_time: datetime | None
|
||||
sender_name: str | None
|
||||
receive_time: datetime | None
|
||||
notes: str
|
||||
image_front: str | None
|
||||
image_back: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ApiKeyCreate(BaseModel):
|
||||
name: str = ""
|
||||
|
||||
class ApiKeyOut(BaseModel):
|
||||
id: int
|
||||
key: str
|
||||
name: str
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
last_used_at: datetime | None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ─── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
def _parse_date(s: str | None) -> datetime | None:
|
||||
"""Parse a date or datetime string to datetime."""
|
||||
if not s:
|
||||
return None
|
||||
s = s.strip()
|
||||
for fmt in ("%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M", "%Y-%m-%d"):
|
||||
try:
|
||||
return datetime.strptime(s, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
raise HTTPException(status_code=400, detail=f"Invalid date format: {s}")
|
||||
|
||||
|
||||
def _profile_or_404(user: User, profile_id: int, db: DbSession) -> Profile:
|
||||
p = db.query(Profile).filter(Profile.id == profile_id, Profile.user_id == user.id).first()
|
||||
if not p:
|
||||
raise HTTPException(status_code=404, detail="Profile not found")
|
||||
return p
|
||||
|
||||
|
||||
def _postcard_or_404(user: User, profile_id: int, postcard_id: int, db: DbSession) -> Postcard:
|
||||
_profile_or_404(user, profile_id, db)
|
||||
pc = db.query(Postcard).filter(
|
||||
Postcard.id == postcard_id, Postcard.profile_id == profile_id
|
||||
).first()
|
||||
if not pc:
|
||||
raise HTTPException(status_code=404, detail="Postcard not found")
|
||||
return pc
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Profiles
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
@router.get("/profiles", response_model=list[ProfileOut])
|
||||
def list_profiles(user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
|
||||
return db.query(Profile).filter(Profile.user_id == user.id).order_by(Profile.id).all()
|
||||
|
||||
|
||||
@router.post("/profiles", response_model=ProfileOut, status_code=201)
|
||||
def create_profile(body: ProfileCreate, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
|
||||
p = Profile(nickname=body.nickname.strip(), user_id=user.id)
|
||||
db.add(p)
|
||||
db.commit()
|
||||
db.refresh(p)
|
||||
return p
|
||||
|
||||
|
||||
@router.get("/profiles/{profile_id}", response_model=ProfileOut)
|
||||
def get_profile(profile_id: int, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
|
||||
return _profile_or_404(user, profile_id, db)
|
||||
|
||||
|
||||
@router.put("/profiles/{profile_id}", response_model=ProfileOut)
|
||||
def update_profile(profile_id: int, body: ProfileUpdate, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
|
||||
p = _profile_or_404(user, profile_id, db)
|
||||
if body.nickname is not None:
|
||||
p.nickname = body.nickname.strip()
|
||||
db.commit()
|
||||
db.refresh(p)
|
||||
return p
|
||||
|
||||
|
||||
@router.delete("/profiles/{profile_id}")
|
||||
def delete_profile(profile_id: int, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
|
||||
p = _profile_or_404(user, profile_id, db)
|
||||
db.delete(p)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Postcards
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
@router.get("/profiles/{profile_id}/postcards", response_model=list[PostcardOut])
|
||||
def list_postcards(
|
||||
profile_id: int,
|
||||
status: str | None = Query(None),
|
||||
user: User = Depends(get_api_user),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
_profile_or_404(user, profile_id, db)
|
||||
q = db.query(Postcard).filter(Postcard.profile_id == profile_id)
|
||||
if status:
|
||||
q = q.filter(Postcard.status == status)
|
||||
return q.order_by(Postcard.id.desc()).all()
|
||||
|
||||
|
||||
@router.post("/profiles/{profile_id}/postcards", response_model=PostcardOut, status_code=201)
|
||||
def create_postcard(
|
||||
profile_id: int,
|
||||
body: PostcardCreate,
|
||||
user: User = Depends(get_api_user),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
_profile_or_404(user, profile_id, db)
|
||||
cn = body.card_number.strip()
|
||||
if db.query(Postcard).filter(Postcard.card_number == cn).first():
|
||||
raise HTTPException(status_code=409, detail="Card number already exists")
|
||||
pc = Postcard(
|
||||
profile_id=profile_id,
|
||||
card_number=cn,
|
||||
status=body.status,
|
||||
recipient_name=body.recipient_name,
|
||||
country=body.country.strip().upper() if body.country else None,
|
||||
send_time=_parse_date(body.send_time),
|
||||
arrival_time=_parse_date(body.arrival_time),
|
||||
sender_name=body.sender_name,
|
||||
receive_time=_parse_date(body.receive_time),
|
||||
notes=body.notes or "",
|
||||
)
|
||||
db.add(pc)
|
||||
db.commit()
|
||||
db.refresh(pc)
|
||||
return pc
|
||||
|
||||
|
||||
@router.get("/profiles/{profile_id}/postcards/{postcard_id}", response_model=PostcardOut)
|
||||
def get_postcard(
|
||||
profile_id: int,
|
||||
postcard_id: int,
|
||||
user: User = Depends(get_api_user),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
return _postcard_or_404(user, profile_id, postcard_id, db)
|
||||
|
||||
|
||||
@router.put("/profiles/{profile_id}/postcards/{postcard_id}", response_model=PostcardOut)
|
||||
def update_postcard(
|
||||
profile_id: int,
|
||||
postcard_id: int,
|
||||
body: PostcardUpdate,
|
||||
user: User = Depends(get_api_user),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
pc = _postcard_or_404(user, profile_id, postcard_id, db)
|
||||
for field in ("status", "recipient_name", "sender_name", "notes"):
|
||||
val = getattr(body, field)
|
||||
if val is not None:
|
||||
setattr(pc, field, val.strip() if isinstance(val, str) else val)
|
||||
if body.card_number is not None:
|
||||
cn = body.card_number.strip()
|
||||
dup = db.query(Postcard).filter(Postcard.card_number == cn, Postcard.id != postcard_id).first()
|
||||
if dup:
|
||||
raise HTTPException(status_code=409, detail="Card number already exists")
|
||||
pc.card_number = cn
|
||||
if body.country is not None:
|
||||
pc.country = body.country.strip().upper() if body.country else None
|
||||
for field in ("send_time", "arrival_time", "receive_time"):
|
||||
val = getattr(body, field)
|
||||
if val is not None:
|
||||
setattr(pc, field, _parse_date(val))
|
||||
db.commit()
|
||||
db.refresh(pc)
|
||||
return pc
|
||||
|
||||
|
||||
@router.delete("/profiles/{profile_id}/postcards/{postcard_id}")
|
||||
def delete_postcard(
|
||||
profile_id: int,
|
||||
postcard_id: int,
|
||||
user: User = Depends(get_api_user),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
pc = _postcard_or_404(user, profile_id, postcard_id, db)
|
||||
db.delete(pc)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Image Upload
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
@router.post("/profiles/{profile_id}/postcards/{postcard_id}/upload", response_model=PostcardOut)
|
||||
async def upload_image(
|
||||
profile_id: int,
|
||||
postcard_id: int,
|
||||
side: str = Query("front", pattern="^(front|back)$"),
|
||||
file: UploadFile = File(...),
|
||||
user: User = Depends(get_api_user),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
pc = _postcard_or_404(user, profile_id, postcard_id, db)
|
||||
ext = Path(file.filename).suffix if file.filename else ".jpg"
|
||||
save_name = f"pc_{pc.id}_{side}{ext}"
|
||||
save_path = UPLOAD_DIR / save_name
|
||||
content = await file.read()
|
||||
save_path.write_bytes(content)
|
||||
if side == "front":
|
||||
pc.image_front = f"/uploads/{save_name}"
|
||||
else:
|
||||
pc.image_back = f"/uploads/{save_name}"
|
||||
db.commit()
|
||||
db.refresh(pc)
|
||||
return pc
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# API Key Management
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
@router.get("/keys", response_model=list[ApiKeyOut])
|
||||
def list_api_keys(user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
|
||||
return db.query(ApiKey).filter(ApiKey.user_id == user.id).order_by(ApiKey.created_at.desc()).all()
|
||||
|
||||
|
||||
@router.post("/keys", response_model=ApiKeyOut, status_code=201)
|
||||
def create_api_key(body: ApiKeyCreate, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
|
||||
key = f"mv_{token_hex(24)}"
|
||||
ak = ApiKey(key=key, name=body.name.strip(), user_id=user.id)
|
||||
db.add(ak)
|
||||
db.commit()
|
||||
db.refresh(ak)
|
||||
return ak
|
||||
|
||||
|
||||
@router.delete("/keys/{key_id}")
|
||||
def delete_api_key(key_id: int, user: User = Depends(get_api_user), db: DbSession = Depends(get_db)):
|
||||
ak = db.query(ApiKey).filter(ApiKey.id == key_id, ApiKey.user_id == user.id).first()
|
||||
if not ak:
|
||||
raise HTTPException(status_code=404, detail="API key not found")
|
||||
db.delete(ak)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Showcase (public, no auth required)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class ShowcaseProfileOut(BaseModel):
|
||||
id: int
|
||||
nickname: str
|
||||
showcase_visible: str
|
||||
sent: list[PostcardOut]
|
||||
received: list[PostcardOut]
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ShowcaseUserOut(BaseModel):
|
||||
username: str
|
||||
showcase_mode: str
|
||||
profiles: list[ShowcaseProfileOut]
|
||||
|
||||
|
||||
@router.get("/showcase/{username}", response_model=ShowcaseUserOut)
|
||||
def showcase_user(username: str, db: DbSession = Depends(get_db)):
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
profiles = db.query(Profile).filter(Profile.user_id == user.id, Profile.showcase_enabled == True).all()
|
||||
result_profiles = []
|
||||
for p in profiles:
|
||||
cards = db.query(Postcard).filter(Postcard.profile_id == p.id, Postcard.showcase_hidden == False).all()
|
||||
if p.showcase_visible in ("sent", "both"):
|
||||
sent = [c for c in cards if c.status in ("sent", "delivered")]
|
||||
else:
|
||||
sent = []
|
||||
if p.showcase_visible in ("received", "both"):
|
||||
received = [c for c in cards if c.status == "received"]
|
||||
else:
|
||||
received = []
|
||||
if sent or received:
|
||||
sent.sort(key=lambda c: c.send_time or c.created_at, reverse=True)
|
||||
received.sort(key=lambda c: c.receive_time or c.created_at, reverse=True)
|
||||
result_profiles.append(ShowcaseProfileOut(
|
||||
id=p.id, nickname=p.nickname, showcase_visible=p.showcase_visible,
|
||||
sent=sent, received=received,
|
||||
))
|
||||
return ShowcaseUserOut(username=user.username, showcase_mode=user.showcase_mode, profiles=result_profiles)
|
||||
|
||||
|
||||
@router.get("/showcase/{username}/profile/{profile_id}", response_model=ShowcaseProfileOut)
|
||||
def showcase_profile(username: str, profile_id: int, db: DbSession = Depends(get_db)):
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
p = db.query(Profile).filter(Profile.id == profile_id, Profile.user_id == user.id, Profile.showcase_enabled == True).first()
|
||||
if not p:
|
||||
raise HTTPException(status_code=404, detail="Profile not found or showcase disabled")
|
||||
cards = db.query(Postcard).filter(Postcard.profile_id == p.id, Postcard.showcase_hidden == False).all()
|
||||
sent = [c for c in cards if c.status in ("sent", "delivered")] if p.showcase_visible in ("sent", "both") else []
|
||||
received = [c for c in cards if c.status == "received"] if p.showcase_visible in ("received", "both") else []
|
||||
sent.sort(key=lambda c: c.send_time or c.created_at, reverse=True)
|
||||
received.sort(key=lambda c: c.receive_time or c.created_at, reverse=True)
|
||||
return ShowcaseProfileOut(id=p.id, nickname=p.nickname, showcase_visible=p.showcase_visible, sent=sent, received=received)
|
||||
@@ -305,13 +305,16 @@ def settings_page(
|
||||
user: User = Depends(get_current_user_web),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
from app.models import ApiKey
|
||||
profiles = db.query(Profile).filter(Profile.user_id == user.id).all()
|
||||
profile_cards = {}
|
||||
for p in profiles:
|
||||
profile_cards[p.id] = db.query(Postcard).filter(Postcard.profile_id == p.id).all()
|
||||
api_keys = db.query(ApiKey).filter(ApiKey.user_id == user.id).order_by(ApiKey.created_at.desc()).all()
|
||||
return templates.TemplateResponse("settings.html", {
|
||||
"request": request, "user": user, "profiles": profiles,
|
||||
"profile_cards": profile_cards, "active_tab": active_tab,
|
||||
"api_keys": api_keys,
|
||||
})
|
||||
|
||||
|
||||
@@ -558,6 +561,13 @@ def postcard_create(
|
||||
if not profile:
|
||||
return _redirect("/profiles")
|
||||
|
||||
cn = card_number.strip()
|
||||
if db.query(Postcard).filter(Postcard.card_number == cn).first():
|
||||
return templates.TemplateResponse("postcard_form.html", {
|
||||
"request": request, "user": user, "profile": profile, "postcard": None,
|
||||
"error": _t("pc_list.duplicate", user.language),
|
||||
})
|
||||
|
||||
def _parse_dt(s: str) -> datetime | None:
|
||||
s = s.strip()
|
||||
if not s:
|
||||
@@ -638,6 +648,14 @@ def postcard_update(
|
||||
if not pc:
|
||||
return _redirect("/profiles")
|
||||
|
||||
cn = card_number.strip()
|
||||
dup = db.query(Postcard).filter(Postcard.card_number == cn, Postcard.id != postcard_id).first()
|
||||
if dup:
|
||||
return templates.TemplateResponse("postcard_form.html", {
|
||||
"request": request, "user": user, "profile": pc.profile, "postcard": pc,
|
||||
"error": _t("pc_list.duplicate", user.language),
|
||||
})
|
||||
|
||||
def _parse_dt(s: str) -> datetime | None:
|
||||
s = s.strip()
|
||||
if not s:
|
||||
@@ -821,3 +839,54 @@ def public_showcase(request: Request, username: str, db: Session = Depends(get_d
|
||||
"profile_groups": profile_groups, "sent_all": sent_all, "received_all": received_all,
|
||||
"lang": user.language,
|
||||
})
|
||||
|
||||
|
||||
# ---------- API Keys ----------
|
||||
|
||||
@router.post("/settings/apikeys/create")
|
||||
def apikey_create(
|
||||
request: Request,
|
||||
name: str = Form(""),
|
||||
user: User = Depends(get_current_user_web),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
from app.models import ApiKey
|
||||
from secrets import token_hex
|
||||
key = f"mv_{token_hex(24)}"
|
||||
ak = ApiKey(key=key, name=name.strip(), user_id=user.id)
|
||||
db.add(ak)
|
||||
db.commit()
|
||||
db.refresh(ak)
|
||||
# Re-render settings with apikeys tab active and the new key shown
|
||||
api_keys = db.query(ApiKey).filter(ApiKey.user_id == user.id).order_by(ApiKey.created_at.desc()).all()
|
||||
return templates.TemplateResponse("settings.html", {
|
||||
"request": request, "user": user, "active_tab": "apikeys",
|
||||
"api_keys": api_keys, "new_key": ak.key, "success": "API Key created",
|
||||
})
|
||||
|
||||
|
||||
@router.post("/settings/apikeys/{key_id}/delete")
|
||||
def apikey_delete(
|
||||
key_id: int,
|
||||
user: User = Depends(get_current_user_web),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
from app.models import ApiKey
|
||||
ak = db.query(ApiKey).filter(ApiKey.id == key_id, ApiKey.user_id == user.id).first()
|
||||
if ak:
|
||||
db.delete(ak)
|
||||
db.commit()
|
||||
return _redirect("/settings?tab=apikeys")
|
||||
|
||||
|
||||
# ---------- API Docs ----------
|
||||
|
||||
@router.get("/api/docs", response_class=HTMLResponse)
|
||||
def api_docs_page(
|
||||
request: Request,
|
||||
user: User = Depends(get_current_user_web),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
return templates.TemplateResponse("api_docs.html", {
|
||||
"request": request, "user": user,
|
||||
})
|
||||
|
||||
306
app/templates/api_docs.html
Normal file
306
app/templates/api_docs.html
Normal file
@@ -0,0 +1,306 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ 'api_docs.title'|t(user.language) }} - {{ 'brand'|t(user.language) }}{% endblock %}
|
||||
{% block content %}
|
||||
<style>
|
||||
.doc-section { margin-bottom:2rem; }
|
||||
.doc-section h2 { font-size:1.15rem; margin-bottom:.75rem; padding-bottom:.5rem; border-bottom:1px solid var(--border); }
|
||||
.doc-section h3 { font-size:1rem; margin:1rem 0 .5rem; }
|
||||
.doc-endpoint { background:var(--card-bg); border:1px solid var(--border); border-radius:8px; padding:1rem 1.25rem; margin-bottom:1rem; }
|
||||
.doc-endpoint .ep-header { display:flex; align-items:center; gap:.6rem; margin-bottom:.5rem; flex-wrap:wrap; }
|
||||
.doc-method { display:inline-block; padding:2px 8px; border-radius:4px; font-size:.75rem; font-weight:700; color:#fff; white-space:nowrap; }
|
||||
.method-get { background:#22c55e; }
|
||||
.method-post { background:#3b82f6; }
|
||||
.method-put { background:#f59e0b; }
|
||||
.method-delete { background:#ef4444; }
|
||||
.doc-path { font-family:monospace; font-size:.9rem; color:var(--text); word-break:break-all; }
|
||||
.doc-desc { color:var(--text-muted); font-size:.85rem; margin-bottom:.5rem; }
|
||||
.doc-table { width:100%; border-collapse:collapse; font-size:.82rem; margin-top:.5rem; }
|
||||
.doc-table th, .doc-table td { text-align:left; padding:4px 8px; border-bottom:1px solid var(--border); }
|
||||
.doc-table th { color:var(--text-muted); font-weight:600; white-space:nowrap; }
|
||||
.doc-table td:first-child { font-family:monospace; white-space:nowrap; }
|
||||
.doc-note { color:var(--text-muted); font-size:.82rem; margin-top:.25rem; }
|
||||
.doc-json { background:var(--bg); border:1px solid var(--border); border-radius:6px; padding:.5rem .75rem; font-family:monospace; font-size:.8rem; margin-top:.5rem; overflow-x:auto; white-space:pre; }
|
||||
.doc-auth-box { background:var(--bg); border:1px solid var(--border); border-radius:6px; padding:1rem; margin:.75rem 0; }
|
||||
.doc-auth-box code { background:var(--card-bg); padding:2px 6px; border-radius:3px; font-size:.82rem; }
|
||||
.try-box { margin-top:.5rem; display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; }
|
||||
.try-box input { flex:1; min-width:200px; font-family:monospace; font-size:.82rem; }
|
||||
.try-box button { flex-shrink:0; }
|
||||
.try-result { margin-top:.5rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; padding:.5rem .75rem; font-family:monospace; font-size:.78rem; max-height:300px; overflow:auto; white-space:pre-wrap; word-break:break-all; display:none; }
|
||||
.try-result.show { display:block; }
|
||||
.tag-required { color:var(--danger); font-size:.75rem; }
|
||||
</style>
|
||||
|
||||
<div class="section-header">
|
||||
<h1>{{ 'api_docs.title'|t(user.language) }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="doc-section">
|
||||
<p style="color:var(--text-muted);font-size:.9rem">{{ 'api_docs.intro'|t(user.language) }}</p>
|
||||
<p style="margin-top:.5rem"><a href="/settings?tab=apikeys" class="btn btn-sm" style="display:inline-block">{{ 'api_docs.go_settings'|t(user.language) }}</a></p>
|
||||
</div>
|
||||
|
||||
<div class="doc-section">
|
||||
<h2>{{ 'api_docs.base_url'|t(user.language) }}</h2>
|
||||
<div class="doc-json">{{ request.base_url }}api/v1</div>
|
||||
</div>
|
||||
|
||||
<div class="doc-section">
|
||||
<h2>{{ 'api_docs.auth'|t(user.language) }}</h2>
|
||||
<p class="doc-desc">{{ 'api_docs.auth_desc'|t(user.language) }}</p>
|
||||
<div class="doc-auth-box">
|
||||
<code>X-API-Key: mv_your_api_key_here</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════ Profiles ═══════ -->
|
||||
<div class="doc-section">
|
||||
<h2>{{ 'api_docs.profile_ops'|t(user.language) }}</h2>
|
||||
|
||||
<div class="doc-endpoint">
|
||||
<div class="ep-header">
|
||||
<span class="doc-method method-get">GET</span>
|
||||
<span class="doc-path">/profiles</span>
|
||||
</div>
|
||||
<div class="doc-desc">获取当前用户的所有 Profile 列表。</div>
|
||||
<div class="doc-json">[
|
||||
{"id": 1, "nickname": "Japan", "showcase_enabled": false, "showcase_visible": "both", "notes": "", "created_at": "2025-01-01T00:00:00"},
|
||||
...
|
||||
]</div>
|
||||
</div>
|
||||
|
||||
<div class="doc-endpoint">
|
||||
<div class="ep-header">
|
||||
<span class="doc-method method-post">POST</span>
|
||||
<span class="doc-path">/profiles</span>
|
||||
</div>
|
||||
<div class="doc-desc">创建新 Profile。</div>
|
||||
<table class="doc-table">
|
||||
<tr><th>{{ 'api_docs.body'|t(user.language) }}</th><th></th></tr>
|
||||
<tr><td>nickname</td><td>string <span class="tag-required">{{ 'misc.required'|t(user.language) }}</span></td></tr>
|
||||
</table>
|
||||
<div class="doc-json">{"id": 2, "nickname": "Europe", ...}</div>
|
||||
</div>
|
||||
|
||||
<div class="doc-endpoint">
|
||||
<div class="ep-header">
|
||||
<span class="doc-method method-get">GET</span>
|
||||
<span class="doc-path">/profiles/{id}</span>
|
||||
</div>
|
||||
<div class="doc-desc">获取单个 Profile 详情。</div>
|
||||
</div>
|
||||
|
||||
<div class="doc-endpoint">
|
||||
<div class="ep-header">
|
||||
<span class="doc-method method-put">PUT</span>
|
||||
<span class="doc-path">/profiles/{id}</span>
|
||||
</div>
|
||||
<div class="doc-desc">更新 Profile。</div>
|
||||
<table class="doc-table">
|
||||
<tr><th>{{ 'api_docs.body'|t(user.language) }}</th><th></th></tr>
|
||||
<tr><td>nickname</td><td>string(可选)</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="doc-endpoint">
|
||||
<div class="ep-header">
|
||||
<span class="doc-method method-delete">DELETE</span>
|
||||
<span class="doc-path">/profiles/{id}</span>
|
||||
</div>
|
||||
<div class="doc-desc">删除 Profile 及其下所有明信片。</div>
|
||||
<div class="doc-json">{"ok": true}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════ Postcards ═══════ -->
|
||||
<div class="doc-section">
|
||||
<h2>{{ 'api_docs.postcard_ops'|t(user.language) }}</h2>
|
||||
|
||||
<div class="doc-endpoint">
|
||||
<div class="ep-header">
|
||||
<span class="doc-method method-get">GET</span>
|
||||
<span class="doc-path">/profiles/{profile_id}/postcards</span>
|
||||
</div>
|
||||
<div class="doc-desc">获取指定 Profile 下的明信片列表。</div>
|
||||
<table class="doc-table">
|
||||
<tr><th>{{ 'api_docs.params'|t(user.language) }}</th><th></th></tr>
|
||||
<tr><td>status</td><td>string(可选)— 按状态筛选:sent / delivered / received</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="doc-endpoint">
|
||||
<div class="ep-header">
|
||||
<span class="doc-method method-post">POST</span>
|
||||
<span class="doc-path">/profiles/{profile_id}/postcards</span>
|
||||
</div>
|
||||
<div class="doc-desc">创建新明信片。</div>
|
||||
<table class="doc-table">
|
||||
<tr><th>{{ 'api_docs.body'|t(user.language) }}</th><th></th></tr>
|
||||
<tr><td>card_number</td><td>string <span class="tag-required">* {{ 'misc.required'|t(user.language) }}</span></td></tr>
|
||||
<tr><td>status</td><td>string <span class="tag-required">* {{ 'misc.required'|t(user.language) }}</span> — sent / delivered / received</td></tr>
|
||||
<tr><td>recipient_name</td><td>string(可选)</td></tr>
|
||||
<tr><td>country</td><td>string(可选)— 两个大写字母,如 JP</td></tr>
|
||||
<tr><td>send_time</td><td>string(可选)— 格式 YYYY-MM-DD 或 YYYY-MM-DDTHH:MM</td></tr>
|
||||
<tr><td>arrival_time</td><td>string(可选)</td></tr>
|
||||
<tr><td>sender_name</td><td>string(可选)</td></tr>
|
||||
<tr><td>receive_time</td><td>string(可选)</td></tr>
|
||||
<tr><td>notes</td><td>string(可选)</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="doc-endpoint">
|
||||
<div class="ep-header">
|
||||
<span class="doc-method method-get">GET</span>
|
||||
<span class="doc-path">/profiles/{profile_id}/postcards/{id}</span>
|
||||
</div>
|
||||
<div class="doc-desc">获取单张明信片详情。</div>
|
||||
</div>
|
||||
|
||||
<div class="doc-endpoint">
|
||||
<div class="ep-header">
|
||||
<span class="doc-method method-put">PUT</span>
|
||||
<span class="doc-path">/profiles/{profile_id}/postcards/{id}</span>
|
||||
</div>
|
||||
<div class="doc-desc">更新明信片。仅传入需要修改的字段。</div>
|
||||
<table class="doc-table">
|
||||
<tr><th>{{ 'api_docs.body'|t(user.language) }}</th><th></th></tr>
|
||||
<tr><td>card_number</td><td>string(可选)</td></tr>
|
||||
<tr><td>status</td><td>string(可选)</td></tr>
|
||||
<tr><td>recipient_name</td><td>string(可选)</td></tr>
|
||||
<tr><td>country</td><td>string(可选)</td></tr>
|
||||
<tr><td>send_time</td><td>string(可选)</td></tr>
|
||||
<tr><td>arrival_time</td><td>string(可选)</td></tr>
|
||||
<tr><td>sender_name</td><td>string(可选)</td></tr>
|
||||
<tr><td>receive_time</td><td>string(可选)</td></tr>
|
||||
<tr><td>notes</td><td>string(可选)</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="doc-endpoint">
|
||||
<div class="ep-header">
|
||||
<span class="doc-method method-delete">DELETE</span>
|
||||
<span class="doc-path">/profiles/{profile_id}/postcards/{id}</span>
|
||||
</div>
|
||||
<div class="doc-desc">删除单张明信片。</div>
|
||||
<div class="doc-json">{"ok": true}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════ Image Upload ═══════ -->
|
||||
<div class="doc-section">
|
||||
<h2>{{ 'api_docs.upload_op'|t(user.language) }}</h2>
|
||||
|
||||
<div class="doc-endpoint">
|
||||
<div class="ep-header">
|
||||
<span class="doc-method method-post">POST</span>
|
||||
<span class="doc-path">/profiles/{profile_id}/postcards/{id}/upload</span>
|
||||
</div>
|
||||
<div class="doc-desc">上传明信片图片(正面或背面),使用 <code>multipart/form-data</code>。</div>
|
||||
<table class="doc-table">
|
||||
<tr><th>Form Field</th><th></th></tr>
|
||||
<tr><td>file</td><td>File <span class="tag-required">* {{ 'misc.required'|t(user.language) }}</span> — 图片文件</td></tr>
|
||||
<tr><td>side</td><td>string(可选)— front 或 back,默认 front</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════ API Keys ═══════ -->
|
||||
<div class="doc-section">
|
||||
<h2>{{ 'api_docs.key_ops'|t(user.language) }}</h2>
|
||||
|
||||
<div class="doc-endpoint">
|
||||
<div class="ep-header">
|
||||
<span class="doc-method method-get">GET</span>
|
||||
<span class="doc-path">/keys</span>
|
||||
</div>
|
||||
<div class="doc-desc">获取当前用户的所有 API Key。</div>
|
||||
</div>
|
||||
|
||||
<div class="doc-endpoint">
|
||||
<div class="ep-header">
|
||||
<span class="doc-method method-post">POST</span>
|
||||
<span class="doc-path">/keys</span>
|
||||
</div>
|
||||
<div class="doc-desc">创建新的 API Key。创建后完整 Key 仅在响应中显示一次。</div>
|
||||
<table class="doc-table">
|
||||
<tr><th>{{ 'api_docs.body'|t(user.language) }}</th><th></th></tr>
|
||||
<tr><td>name</td><td>string(可选)— Key 的备注名称</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="doc-endpoint">
|
||||
<div class="ep-header">
|
||||
<span class="doc-method method-delete">DELETE</span>
|
||||
<span class="doc-path">/keys/{id}</span>
|
||||
</div>
|
||||
<div class="doc-desc">删除指定 API Key。</div>
|
||||
<div class="doc-json">{"ok": true}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════ Showcase ═══════ -->
|
||||
<div class="doc-section">
|
||||
<h2>{{ 'api_docs.showcase_ops'|t(user.language) }}</h2>
|
||||
|
||||
<div class="doc-endpoint">
|
||||
<div class="ep-header">
|
||||
<span class="doc-method method-get">GET</span>
|
||||
<span class="doc-path">/showcase/{username}</span>
|
||||
</div>
|
||||
<div class="doc-desc">{{ 'api_docs.no_auth'|t(user.language) }} — 获取用户的公开展示数据。</div>
|
||||
</div>
|
||||
|
||||
<div class="doc-endpoint">
|
||||
<div class="ep-header">
|
||||
<span class="doc-method method-get">GET</span>
|
||||
<span class="doc-path">/showcase/{username}/profile/{id}</span>
|
||||
</div>
|
||||
<div class="doc-desc">{{ 'api_docs.no_auth'|t(user.language) }} — 获取单个 Profile 的展示数据。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════ Try It ═══════ -->
|
||||
<div class="doc-section">
|
||||
<h2>{{ 'api_docs.try_it'|t(user.language) }}</h2>
|
||||
<div class="try-box">
|
||||
<input type="text" id="try-key" placeholder="{{ 'api_docs.api_key_ph'|t(user.language) }}">
|
||||
<select id="try-method" style="width:auto;flex-shrink:0">
|
||||
<option>GET</option><option>POST</option><option>PUT</option><option>DELETE</option>
|
||||
</select>
|
||||
<input type="text" id="try-url" placeholder="/profiles" style="flex:1;min-width:150px;font-family:monospace">
|
||||
<button class="btn btn-primary btn-sm" onclick="tryApi()">{{ 'api_docs.send'|t(user.language) }}</button>
|
||||
</div>
|
||||
<textarea id="try-body" rows="3" placeholder="Request body (JSON, for POST/PUT)" style="width:100%;margin-top:.5rem;font-family:monospace;font-size:.82rem;resize:vertical;display:none"></textarea>
|
||||
<div class="try-result" id="try-result"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('try-method').addEventListener('change', function() {
|
||||
document.getElementById('try-body').style.display = (this.value === 'POST' || this.value === 'PUT') ? '' : 'none';
|
||||
});
|
||||
|
||||
async function tryApi() {
|
||||
var key = document.getElementById('try-key').value.trim();
|
||||
var method = document.getElementById('try-method').value;
|
||||
var path = document.getElementById('try-url').value.trim();
|
||||
var body = document.getElementById('try-body').value.trim();
|
||||
var result = document.getElementById('try-result');
|
||||
result.className = 'try-result show';
|
||||
result.textContent = 'Loading...';
|
||||
try {
|
||||
var baseUrl = '{{ request.base_url }}api/v1';
|
||||
var opts = { method: method, headers: {} };
|
||||
if (key) opts.headers['X-API-Key'] = key;
|
||||
if (body && (method === 'POST' || method === 'PUT')) {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = body;
|
||||
}
|
||||
var resp = await fetch(baseUrl + path, opts);
|
||||
var text = await resp.text();
|
||||
try { text = JSON.stringify(JSON.parse(text), null, 2); } catch(e) {}
|
||||
result.textContent = resp.status + ' ' + resp.statusText + '\n\n' + text;
|
||||
} catch(e) {
|
||||
result.textContent = 'Error: ' + e.message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -17,6 +17,7 @@
|
||||
{% if user.is_admin %}
|
||||
<a href="/admin/invites">{{ 'nav.invites'|t(user.language) }}</a>
|
||||
{% endif %}
|
||||
<a href="/api/docs">{{ 'nav.api_docs'|t(user.language) }}</a>
|
||||
<form method="post" action="/settings/language" class="inline" style="display:inline-flex;align-items:center;gap:0">
|
||||
<input type="hidden" name="language" value="{{ 'en' if user.language == 'zh' else 'zh' }}">
|
||||
<button type="submit" class="nav-lang-btn">{{ 'EN' if user.language == 'zh' else '中' }}</button>
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
</div>
|
||||
|
||||
<div class="tabs" id="tabs">
|
||||
<button class="tab {% if active_tab != 'showcase' %}active{% endif %}" onclick="switchTab('account')">{{ 'settings.tab_account'|t(user.language) }}</button>
|
||||
<button class="tab {% if active_tab != 'showcase' and active_tab != 'apikeys' %}active{% endif %}" onclick="switchTab('account')">{{ 'settings.tab_account'|t(user.language) }}</button>
|
||||
<button class="tab {% if active_tab == 'showcase' %}active{% endif %}" onclick="switchTab('showcase')">{{ 'settings.tab_showcase'|t(user.language) }}</button>
|
||||
<button class="tab {% if active_tab == 'apikeys' %}active{% endif %}" onclick="switchTab('apikeys')">{{ 'settings.tab_apikeys'|t(user.language) }}</button>
|
||||
</div>
|
||||
|
||||
{% if success %}
|
||||
@@ -17,7 +18,7 @@
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div id="tab-account" class="tab-panel" {% if active_tab == 'showcase' %}style="display:none"{% endif %}>
|
||||
<div id="tab-account" class="tab-panel" {% if active_tab == 'showcase' or active_tab == 'apikeys' %}style="display:none"{% endif %}>
|
||||
<div class="form-card">
|
||||
<h3 style="margin-bottom:.75rem">{{ 'settings.change_username'|t(user.language) }}</h3>
|
||||
<form method="post" action="/settings/change-username">
|
||||
@@ -155,6 +156,61 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-apikeys" class="tab-panel" {% if active_tab != 'apikeys' %}style="display:none"{% endif %}>
|
||||
<div class="form-card">
|
||||
<h3 style="margin-bottom:.75rem">{{ 'apikey.title'|t(user.language) }}</h3>
|
||||
<p style="color:var(--text-muted);font-size:.85rem;margin-bottom:1rem">{{ 'apikey.hint'|t(user.language) }}</p>
|
||||
|
||||
{% if new_key %}
|
||||
<div class="alert alert-success" style="margin-bottom:1rem">
|
||||
<strong>{{ 'apikey.created'|t(user.language) }}</strong><br>
|
||||
<code style="word-break:break-all;font-size:.85rem">{{ new_key }}</code><br>
|
||||
<small style="color:var(--text-muted)">{{ 'apikey.copy_now'|t(user.language) }}</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/settings/apikeys/create" style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;margin-bottom:1rem">
|
||||
<input type="text" name="name" placeholder="{{ 'apikey.name_ph'|t(user.language) }}" style="max-width:200px">
|
||||
<button type="submit" class="btn btn-primary btn-sm">{{ 'apikey.create'|t(user.language) }}</button>
|
||||
</form>
|
||||
|
||||
{% if api_keys %}
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ 'apikey.name'|t(user.language) }}</th>
|
||||
<th>{{ 'apikey.key'|t(user.language) }}</th>
|
||||
<th>{{ 'apikey.created_at'|t(user.language) }}</th>
|
||||
<th>{{ 'apikey.last_used'|t(user.language) }}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for ak in api_keys %}
|
||||
<tr>
|
||||
<td>{{ ak.name or '—' }}</td>
|
||||
<td><code style="font-size:.8rem">{{ ak.key[:8] }}****</code></td>
|
||||
<td>{{ ak.created_at|to_local|strftime('%Y-%m-%d') }}</td>
|
||||
<td>{% if ak.last_used_at %}{{ ak.last_used_at|to_local|strftime('%Y-%m-%d') }}{% else %}—{% endif %}</td>
|
||||
<td>
|
||||
<form method="post" action="/settings/apikeys/{{ ak.id }}/delete" style="display:inline" onsubmit="return confirm('{{ 'apikey.confirm_delete'|t(user.language) }}')">
|
||||
<button type="submit" class="btn btn-danger btn-sm">{{ 'apikey.delete'|t(user.language) }}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>{{ 'apikey.empty'|t(user.language) }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function switchTab(name) {
|
||||
document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
|
||||
|
||||
Reference in New Issue
Block a user