first commit

This commit is contained in:
aaron 2026-05-11 23:26:11 +08:00
commit bf6bdb1255
90 changed files with 10558 additions and 0 deletions

12
.dockerignore Normal file
View File

@ -0,0 +1,12 @@
.DS_Store
.git
backend/venv
backend/__pycache__
backend/app/**/__pycache__
backend/.pytest_cache
backend/palm_reading.db
backend/storage
web/node_modules
web/.next
web/.env.local
miniprogram

94
.gitignore vendored Normal file
View File

@ -0,0 +1,94 @@
# macOS / editor
.DS_Store
.AppleDouble
.LSOverride
Thumbs.db
*.swp
*.swo
*.swn
.idea/
.vscode/
# Environment and secrets
.env
.env.*
!.env.example
backend/.env
backend/.env.*
!backend/.env.example
web/.env
web/.env.*
!web/.env.example
web/.env.local
web/.env.*.local
# Python
__pycache__/
*.py[cod]
*$py.class
.Python
.venv/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.pyre/
.coverage
.coverage.*
coverage.xml
htmlcov/
backend/venv/
backend/.venv/
venv/
# Backend runtime data
backend/palm_reading.db
backend/*.db
backend/*.sqlite
backend/*.sqlite3
backend/storage/
backend/data/
storage/
# Node / Next.js
node_modules/
web/node_modules/
web/.next/
web/out/
web/dist/
web/build/
web/.turbo/
web/*.tsbuildinfo
.next/
out/
dist/
build/
*.tsbuildinfo
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.pnpm-store/
# Keep package lock tracked
!web/package-lock.json
# WeChat Mini Program local files
miniprogram/project.private.config.json
miniprogram/private.*
miniprogram/miniprogram_npm/
miniprogram/npm/
miniprogram/node_modules/
miniprogram/dist/
miniprogram/.idea/
# Docker / generated local artifacts
*.pid
*.log
logs/
tmp/
temp/
.cache/
# Generated media
*.tmp.png
*.tmp.jpg

57
README.md Normal file
View File

@ -0,0 +1,57 @@
# AI 手相报告 MVP
原生微信小程序 + Python FastAPI 后端的娱乐型手相报告应用。
## 目录
- `backend/`: FastAPI API、数据库模型、OpenAI 分析服务、图片存储。
- `miniprogram/`: 原生微信小程序页面与请求封装。
- `web/`: Next.js Web App 主产品入口。
## 本地运行后端
```bash
cd backend
python3 -m venv venv
venv/bin/pip install -r requirements.txt
cp .env.example .env
venv/bin/uvicorn app.main:app --reload
```
默认使用 SQLite 和 mock 微信登录,便于本地开发。生产环境配置 `DATABASE_URL` 为 Postgres配置微信、OpenAI 和对象存储参数。
## 环境变量
`backend/.env.example`
## 小程序
用微信开发者工具打开 `miniprogram/`,把 `utils/config.js` 中的 `API_BASE_URL` 改成后端地址。
## Web App
```bash
cd web
npm install
cp .env.example .env.local
npm run dev
```
默认连接 `http://127.0.0.1:8000/api/v1`。第一版 Web 使用匿名会话,浏览器会自动获取 token 并保存在 `localStorage`
## Docker Compose 一键部署
```bash
docker compose up --build
```
启动后访问:
- Web App: `http://127.0.0.1:3000`
- 后端 API: `http://127.0.0.1:8000`
Compose 默认读取 `backend/.env` 中的大模型配置,并使用 Docker volume 持久化 SQLite 数据、上传图片和生成文件。
## 免责声明
本项目报告定位为娱乐占卜与自我反思,不构成医学、心理、职业、财务、投资或任何人生决策建议。

7
backend/.dockerignore Normal file
View File

@ -0,0 +1,7 @@
venv
__pycache__
app/**/__pycache__
.pytest_cache
palm_reading.db
storage
.env

19
backend/.env.example Normal file
View File

@ -0,0 +1,19 @@
APP_NAME="AI Palm Reading"
ENVIRONMENT=development
DATABASE_URL=sqlite+aiosqlite:///./palm_reading.db
SECRET_KEY=change-me-in-production
ACCESS_TOKEN_EXPIRE_MINUTES=43200
OPENAI_API_KEY=
OPENAI_BASE_URL=
OPENAI_MODEL=gpt-4.1-mini
OPENAI_IMAGE_MODEL=gpt-image-2
SHARE_IMAGE_MODE=ai
WECHAT_APP_ID=
WECHAT_APP_SECRET=
WECHAT_MOCK_LOGIN=true
UPLOAD_DIR=./storage/uploads
IMAGE_RETENTION_DAYS=7
MAX_IMAGE_MB=8

21
backend/Dockerfile Normal file
View File

@ -0,0 +1,21 @@
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends fontconfig fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
RUN mkdir -p /app/storage/uploads /app/storage/share_images /app/data
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

24
backend/README.md Normal file
View File

@ -0,0 +1,24 @@
# Backend
FastAPI 后端提供微信登录、图片上传、手相报告生成和历史报告管理。
## Run
```bash
python3 -m venv venv
venv/bin/pip install -r requirements.txt
cp .env.example .env
venv/bin/uvicorn app.main:app --reload
```
## Cleanup Expired Images
```bash
venv/bin/python -m app.cli
```
## API Prefix
所有业务 API 位于 `/api/v1`
本地开发默认 `WECHAT_MOCK_LOGIN=true``OPENAI_API_KEY` 为空时会返回 mock 报告,便于先联调小程序流程。

1
backend/app/__init__.py Normal file
View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,53 @@
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import create_access_token
from app.models.user import User
from app.schemas.auth import AnonymousLoginRequest, AuthResponse, WechatLoginRequest
from app.services.wechat_service import WechatService
router = APIRouter()
@router.post("/anonymous-login", response_model=AuthResponse)
async def anonymous_login(payload: AnonymousLoginRequest, db: AsyncSession = Depends(get_db)):
client_id = payload.client_id or str(uuid4())
openid = f"web-anon-{client_id}"
result = await db.execute(select(User).where(User.openid == openid))
user = result.scalar_one_or_none()
if user is None:
user = User(openid=openid)
db.add(user)
await db.flush()
await db.refresh(user)
return AuthResponse(access_token=create_access_token(user.id), user_id=user.id)
@router.post("/wechat-login", response_model=AuthResponse)
async def wechat_login(payload: WechatLoginRequest, db: AsyncSession = Depends(get_db)):
try:
openid, phone_number = await WechatService().login(payload.code, payload.phone_code)
except RuntimeError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
result = await db.execute(select(User).where(User.openid == openid))
user = result.scalar_one_or_none()
if user is None:
user = User(openid=openid, phone_number=phone_number)
db.add(user)
await db.flush()
await db.refresh(user)
elif phone_number and user.phone_number != phone_number:
user.phone_number = phone_number
return AuthResponse(
access_token=create_access_token(user.id),
user_id=user.id,
phone_number=user.phone_number,
)

View File

@ -0,0 +1,190 @@
from pathlib import Path
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Response, status
from sqlalchemy import desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import AsyncSessionLocal, get_db
from app.core.security import get_current_user
from app.models.palm_report import PalmReport
from app.models.share_image_job import ShareImageJob
from app.models.uploaded_image import UploadedImage
from app.models.user import User
from app.schemas.share_image import ShareImageJobResponse
from app.schemas.report import ReportCreate, ReportDetail, ReportSummary
from app.services.image_service import ImageService
from app.services.report_service import ReportService
from app.services.share_poster_service import SharePosterService
router = APIRouter()
async def generate_report_task(report_id: str) -> None:
async with AsyncSessionLocal() as session:
await ReportService().generate(session, report_id)
async def generate_share_image_task(job_id: str) -> None:
async with AsyncSessionLocal() as session:
result = await session.execute(select(ShareImageJob).where(ShareImageJob.id == job_id))
job = result.scalar_one()
job.status = "processing"
await session.flush()
try:
report_result = await session.execute(select(PalmReport).where(PalmReport.id == job.report_id))
report = report_result.scalar_one()
image_result = await session.execute(select(UploadedImage).where(UploadedImage.id == report.image_id))
image = image_result.scalar_one_or_none()
png = await SharePosterService().render_ai_or_fallback(report, image)
share_dir = Path("storage/share_images")
share_dir.mkdir(parents=True, exist_ok=True)
storage_key = f"{job.id}.png"
(share_dir / storage_key).write_bytes(png)
job.storage_key = storage_key
job.status = "completed"
job.error_message = None
except Exception as exc:
job.status = "failed"
job.error_message = str(exc)
await session.commit()
@router.post("", response_model=ReportDetail, status_code=status.HTTP_201_CREATED)
async def create_report(
payload: ReportCreate,
background_tasks: BackgroundTasks,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
image_result = await db.execute(
select(UploadedImage).where(UploadedImage.id == payload.image_id, UploadedImage.user_id == user.id)
)
image = image_result.scalar_one_or_none()
if image is None:
raise HTTPException(status_code=404, detail="Image not found")
report = PalmReport(user_id=user.id, image_id=image.id, hand_side=payload.hand_side, status="pending")
db.add(report)
await db.flush()
await db.refresh(report)
background_tasks.add_task(generate_report_task, report.id)
return report
@router.get("/share-image-jobs/{job_id}", response_model=ShareImageJobResponse)
async def get_share_image_job(
job_id: str,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
job = await _get_owned_share_job(db, job_id, user.id)
return job
@router.get("/share-image-jobs/{job_id}/image")
async def get_share_image_job_image(
job_id: str,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
job = await _get_owned_share_job(db, job_id, user.id)
if job.status != "completed" or not job.storage_key:
raise HTTPException(status_code=400, detail="Share image is not ready")
path = Path("storage/share_images") / job.storage_key
if not path.exists():
raise HTTPException(status_code=404, detail="Share image file not found")
return Response(content=path.read_bytes(), media_type="image/png")
@router.get("", response_model=list[ReportSummary])
async def list_reports(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(PalmReport).where(PalmReport.user_id == user.id).order_by(desc(PalmReport.created_at)).limit(50)
)
reports = result.scalars().all()
return [
ReportSummary(
id=report.id,
status=report.status,
hand_side=report.hand_side,
created_at=report.created_at,
overall_summary=(report.report_data or {}).get("overall_summary") if report.report_data else None,
)
for report in reports
]
@router.get("/{report_id}", response_model=ReportDetail)
async def get_report(report_id: str, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
report = await _get_owned_report(db, report_id, user.id)
return report
@router.post("/{report_id}/share-image-jobs", response_model=ShareImageJobResponse, status_code=status.HTTP_201_CREATED)
async def create_share_image_job(
report_id: str,
background_tasks: BackgroundTasks,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
report = await _get_owned_report(db, report_id, user.id)
if report.status != "completed" or not report.report_data:
raise HTTPException(status_code=400, detail="Report is not ready")
job = ShareImageJob(user_id=user.id, report_id=report.id, status="pending")
db.add(job)
await db.flush()
await db.refresh(job)
background_tasks.add_task(generate_share_image_task, job.id)
return job
@router.get("/{report_id}/share-image")
async def get_report_share_image(
report_id: str,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
report = await _get_owned_report(db, report_id, user.id)
if report.status != "completed" or not report.report_data:
raise HTTPException(status_code=400, detail="Report is not ready")
image_result = await db.execute(select(UploadedImage).where(UploadedImage.id == report.image_id))
image = image_result.scalar_one_or_none()
png = await SharePosterService().render_ai_or_fallback(report, image)
return Response(
content=png,
media_type="image/png",
headers={"Content-Disposition": f'inline; filename="palm-report-{report.id}.png"'},
)
@router.delete("/{report_id}")
async def delete_report(report_id: str, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
report = await _get_owned_report(db, report_id, user.id)
image_result = await db.execute(select(UploadedImage).where(UploadedImage.id == report.image_id))
image = image_result.scalar_one_or_none()
await db.delete(report)
await db.flush()
if image:
ImageService().delete(image.storage_key)
await db.delete(image)
return {"status": "deleted"}
async def _get_owned_report(db: AsyncSession, report_id: str, user_id: str) -> PalmReport:
result = await db.execute(select(PalmReport).where(PalmReport.id == report_id, PalmReport.user_id == user_id))
report = result.scalar_one_or_none()
if report is None:
raise HTTPException(status_code=404, detail="Report not found")
return report
async def _get_owned_share_job(db: AsyncSession, job_id: str, user_id: str) -> ShareImageJob:
result = await db.execute(select(ShareImageJob).where(ShareImageJob.id == job_id, ShareImageJob.user_id == user_id))
job = result.scalar_one_or_none()
if job is None:
raise HTTPException(status_code=404, detail="Share image job not found")
return job

View File

@ -0,0 +1,32 @@
from fastapi import APIRouter, Depends, File, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_user
from app.models.uploaded_image import UploadedImage
from app.models.user import User
from app.schemas.upload import UploadResponse
from app.services.image_service import ImageService
router = APIRouter()
@router.post("/palm", response_model=UploadResponse)
async def upload_palm_image(
file: UploadFile = File(...),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
storage_key, size_bytes, expires_at, quality_check = await ImageService().validate_and_store(file, user.id)
image = UploadedImage(
user_id=user.id,
storage_key=storage_key,
original_filename=file.filename or "palm",
content_type=file.content_type or "application/octet-stream",
size_bytes=size_bytes,
expires_at=expires_at,
)
db.add(image)
await db.flush()
await db.refresh(image)
return UploadResponse(image_id=image.id, expires_at=image.expires_at, quality_check=quality_check)

View File

@ -0,0 +1,8 @@
from fastapi import APIRouter
from app.api.v1.endpoints import auth, reports, uploads
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(uploads.router, prefix="/uploads", tags=["uploads"])
api_router.include_router(reports.router, prefix="/reports", tags=["reports"])

15
backend/app/cli.py Normal file
View File

@ -0,0 +1,15 @@
import asyncio
from app.core.database import AsyncSessionLocal, init_db
from app.services.cleanup_service import cleanup_expired_images
async def main() -> None:
await init_db()
async with AsyncSessionLocal() as session:
count = await cleanup_expired_images(session)
print(f"cleaned {count} expired images")
if __name__ == "__main__":
asyncio.run(main())

View File

@ -0,0 +1,37 @@
from functools import lru_cache
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "AI Palm Reading"
environment: str = "development"
database_url: str = "sqlite+aiosqlite:///./palm_reading.db"
secret_key: str = "change-me-in-production"
access_token_expire_minutes: int = 60 * 24 * 30
cors_origins: list[str] = Field(default_factory=lambda: ["*"])
openai_api_key: str | None = None
openai_base_url: str | None = None
openai_model: str = "gpt-4.1-mini"
openai_image_model: str = "gpt-image-2"
share_image_mode: str = "ai"
wechat_app_id: str | None = None
wechat_app_secret: str | None = None
wechat_mock_login: bool = True
upload_dir: str = "./storage/uploads"
image_retention_days: int = 7
max_image_mb: int = 8
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()

View File

@ -0,0 +1,31 @@
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
class Base(DeclarativeBase):
pass
engine = create_async_engine(settings.database_url, echo=False, future=True)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
async def init_db() -> None:
from app.models import palm_report, share_image_job, uploaded_image, user # noqa: F401
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)

View File

@ -0,0 +1,37 @@
from datetime import datetime, timedelta, timezone
from uuid import uuid4
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.models.user import User
bearer_scheme = HTTPBearer()
def create_access_token(user_id: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
payload = {"sub": user_id, "exp": expire, "jti": str(uuid4())}
return jwt.encode(payload, settings.secret_key, algorithm="HS256")
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
db: AsyncSession = Depends(get_db),
) -> User:
try:
payload = jwt.decode(credentials.credentials, settings.secret_key, algorithms=["HS256"])
user_id = payload.get("sub")
except jwt.PyJWTError as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return user

32
backend/app/main.py Normal file
View File

@ -0,0 +1,32 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.v1.router import api_router
from app.core.config import settings
from app.core.database import init_db
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
yield
app = FastAPI(title=settings.app_name, version="0.1.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router, prefix="/api/v1")
@app.get("/health")
async def health():
return {"status": "ok"}

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,24 @@
from datetime import datetime
from uuid import uuid4
from sqlalchemy import DateTime, ForeignKey, JSON, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class PalmReport(Base):
__tablename__ = "palm_reports"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), index=True)
image_id: Mapped[str] = mapped_column(String(36), ForeignKey("uploaded_images.id"))
hand_side: Mapped[str] = mapped_column(String(16), default="unknown")
status: Mapped[str] = mapped_column(String(24), default="pending", index=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
report_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user = relationship("User", back_populates="reports")
image = relationship("UploadedImage", back_populates="reports")

View File

@ -0,0 +1,20 @@
from datetime import datetime
from uuid import uuid4
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class ShareImageJob(Base):
__tablename__ = "share_image_jobs"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), index=True)
report_id: Mapped[str] = mapped_column(String(36), ForeignKey("palm_reports.id"), index=True)
status: Mapped[str] = mapped_column(String(24), default="pending", index=True)
storage_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

View File

@ -0,0 +1,22 @@
from datetime import datetime
from uuid import uuid4
from sqlalchemy import DateTime, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class UploadedImage(Base):
__tablename__ = "uploaded_images"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), index=True)
storage_key: Mapped[str] = mapped_column(String(512))
original_filename: Mapped[str] = mapped_column(String(255))
content_type: Mapped[str] = mapped_column(String(80))
size_bytes: Mapped[int]
expires_at: Mapped[datetime] = mapped_column(DateTime, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
reports = relationship("PalmReport", back_populates="image")

View File

@ -0,0 +1,19 @@
from datetime import datetime
from uuid import uuid4
from sqlalchemy import DateTime, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
openid: Mapped[str] = mapped_column(String(128), unique=True, index=True)
phone_number: Mapped[str | None] = mapped_column(String(32), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
reports = relationship("PalmReport", back_populates="user")

View File

@ -0,0 +1,17 @@
from pydantic import BaseModel, Field
class AnonymousLoginRequest(BaseModel):
client_id: str | None = None
class WechatLoginRequest(BaseModel):
code: str = Field(min_length=1)
phone_code: str | None = None
class AuthResponse(BaseModel):
access_token: str
token_type: str = "bearer"
user_id: str
phone_number: str | None = None

View File

@ -0,0 +1,39 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
class ReportCreate(BaseModel):
image_id: str
hand_side: Literal["left", "right", "unknown"] = "unknown"
class ReportSummary(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
status: str
hand_side: str
created_at: datetime
overall_summary: str | None = None
class ReportDetail(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
status: str
hand_side: str
error_message: str | None = None
report_data: dict | None = None
created_at: datetime
updated_at: datetime
class ReportDimension(BaseModel):
name: str
observations: list[str] = Field(default_factory=list)
interpretation: str
confidence: float = Field(ge=0, le=1)
advice: str

View File

@ -0,0 +1,14 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class ShareImageJobResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
report_id: str
status: str
error_message: str | None = None
created_at: datetime
updated_at: datetime

View File

@ -0,0 +1,9 @@
from datetime import datetime
from pydantic import BaseModel
class UploadResponse(BaseModel):
image_id: str
expires_at: datetime
quality_check: dict

View File

@ -0,0 +1,18 @@
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.uploaded_image import UploadedImage
from app.services.image_service import ImageService
async def cleanup_expired_images(db: AsyncSession) -> int:
service = ImageService()
result = await db.execute(select(UploadedImage).where(UploadedImage.expires_at < datetime.utcnow()))
images = result.scalars().all()
for image in images:
service.delete(image.storage_key)
await db.delete(image)
await db.commit()
return len(images)

View File

@ -0,0 +1,78 @@
from datetime import datetime, timedelta
from io import BytesIO
from pathlib import Path
from uuid import uuid4
from fastapi import HTTPException, UploadFile, status
from PIL import Image, ImageStat, UnidentifiedImageError
from app.core.config import settings
ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp"}
class ImageService:
def __init__(self) -> None:
self.upload_dir = Path(settings.upload_dir)
self.upload_dir.mkdir(parents=True, exist_ok=True)
async def validate_and_store(self, file: UploadFile, user_id: str) -> tuple[str, int, datetime, dict]:
content_type = file.content_type or ""
if content_type not in ALLOWED_CONTENT_TYPES:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Only jpg, png and webp images are supported")
data = await file.read()
max_bytes = settings.max_image_mb * 1024 * 1024
if len(data) > max_bytes:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Image must be smaller than {settings.max_image_mb}MB")
quality_check = self._inspect_image(data)
if not quality_check["is_acceptable"]:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=quality_check["reason"])
suffix = self._suffix_for_type(content_type)
storage_key = f"{user_id}/{uuid4()}{suffix}"
path = self.upload_dir / storage_key
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(data)
expires_at = datetime.utcnow() + timedelta(days=settings.image_retention_days)
return storage_key, len(data), expires_at, quality_check
def read_bytes(self, storage_key: str) -> bytes:
return (self.upload_dir / storage_key).read_bytes()
def delete(self, storage_key: str) -> None:
path = self.upload_dir / storage_key
if path.exists():
path.unlink()
def _inspect_image(self, data: bytes) -> dict:
try:
with Image.open(BytesIO(data)) as image:
image.verify()
with Image.open(BytesIO(data)) as image:
width, height = image.size
rgb = image.convert("RGB")
gray = image.convert("L")
brightness = ImageStat.Stat(gray).mean[0]
resized = gray.resize((64, 64))
variance = ImageStat.Stat(resized).var[0]
except UnidentifiedImageError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid image file") from exc
checks = {
"width": width,
"height": height,
"brightness": round(brightness, 2),
"sharpness_score": round(variance, 2),
}
if width < 640 or height < 640:
return {**checks, "is_acceptable": False, "reason": "照片分辨率太低,请上传更清晰的掌心照片"}
if brightness < 35:
return {**checks, "is_acceptable": False, "reason": "照片过暗,请在光线更充足的环境重拍"}
if variance < 80:
return {**checks, "is_acceptable": False, "reason": "照片可能过于模糊,请保持手掌和镜头稳定后重拍"}
return {**checks, "is_acceptable": True, "reason": "ok"}
def _suffix_for_type(self, content_type: str) -> str:
return {"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}[content_type]

View File

@ -0,0 +1,184 @@
import base64
import json
from openai import AsyncOpenAI
from app.core.config import settings
DISCLAIMER = "本报告仅用于娱乐占卜与自我反思,不构成医学、心理、职业、财务、投资或任何人生决策建议。"
SYSTEM_PROMPT = (
"你是“赛博先生”,一个面向普通人的娱乐型 AI 命理解读助手。"
"你的风格要像一个会聊天、懂生活的朋友:温和、具体、接地气,有一点玄学仪式感,但不要装神秘。"
"你会根据掌心照片做象征性手相解读,但所有表达都必须使用“可能、倾向、适合、提醒你”这类非确定性措辞。"
"禁止给出医疗、心理诊断、投资、职业成败、婚恋结果、寿命、灾祸等确定性判断。"
"不要堆砌专业术语,不要写得像教材;每个结论都要落到生活、学习、事业、关系或近期行动里的具体场景。"
)
USER_PROMPT_TEMPLATE = (
"请分析这张{hand_side}手掌照片,生成中文手相报告。"
"必须覆盖生命线、智慧线、感情线、命运线、手型与手指比例、特殊纹路与丘位特征。"
"写作要求:"
"1. overall_summary 用 2 到 4 句话,像给用户本人看的开场结论,要贴近日常生活。"
"2. dimensions 中每个 interpretation 不要只解释纹路含义,必须关联一个现实场景,例如学习效率、工作节奏、沟通方式、情绪恢复、计划执行、关系相处。"
"3. 每个 advice 必须是用户今天或本周能做的小建议,不要空泛。"
"4. strengths 写成用户容易感受到的优势,例如做事方式、学习方式、职场协作、情感表达。"
"5. challenges 写成温和提醒,不要吓人,不要宿命论。"
"6. suggestions 必须包含生活、学习/成长、事业/工作、关系沟通中的至少三个方向。"
"7. lucky_keywords 要短、有记忆点、生活化。"
"8. 如果照片不够真实或不够清晰,要诚实降低 confidence并在 reason 里说明。"
)
REPORT_SCHEMA = {
"name": "palm_report",
"schema": {
"type": "object",
"additionalProperties": False,
"properties": {
"quality_check": {
"type": "object",
"additionalProperties": False,
"properties": {
"can_analyze": {"type": "boolean"},
"reason": {"type": "string"},
"confidence": {"type": "number"},
},
"required": ["can_analyze", "reason", "confidence"],
},
"overall_summary": {"type": "string"},
"dimensions": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": False,
"properties": {
"name": {"type": "string"},
"observations": {"type": "array", "items": {"type": "string"}},
"interpretation": {"type": "string"},
"confidence": {"type": "number"},
"advice": {"type": "string"},
},
"required": ["name", "observations", "interpretation", "confidence", "advice"],
},
},
"strengths": {"type": "array", "items": {"type": "string"}},
"challenges": {"type": "array", "items": {"type": "string"}},
"suggestions": {"type": "array", "items": {"type": "string"}},
"lucky_keywords": {"type": "array", "items": {"type": "string"}},
"disclaimer": {"type": "string"},
},
"required": [
"quality_check",
"overall_summary",
"dimensions",
"strengths",
"challenges",
"suggestions",
"lucky_keywords",
"disclaimer",
],
},
"strict": True,
}
class PalmAnalyzer:
def __init__(self) -> None:
self.client = (
AsyncOpenAI(api_key=settings.openai_api_key, base_url=settings.openai_base_url)
if settings.openai_api_key
else None
)
async def analyze(self, image_bytes: bytes, content_type: str, hand_side: str) -> dict:
if not self.client:
return self._mock_report(hand_side)
image_data = base64.b64encode(image_bytes).decode("ascii")
response = await self.client.responses.create(
model=settings.openai_model,
input=[
{
"role": "system",
"content": [
{
"type": "input_text",
"text": SYSTEM_PROMPT,
}
],
},
{
"role": "user",
"content": [
{
"type": "input_text",
"text": USER_PROMPT_TEMPLATE.format(hand_side=hand_side),
},
{
"type": "input_image",
"image_url": f"data:{content_type};base64,{image_data}",
"detail": "high",
},
],
},
],
text={"format": {"type": "json_schema", **REPORT_SCHEMA}},
)
data = json.loads(response.output_text)
data["disclaimer"] = DISCLAIMER
return data
def _mock_report(self, hand_side: str) -> dict:
return {
"quality_check": {"can_analyze": True, "reason": "mock mode", "confidence": 0.72},
"overall_summary": f"这是一份{hand_side}手娱乐手相报告。整体看,你像是那种遇到事情会先稳住节奏的人,适合把目标拆小、慢慢推进。最近如果在学习、工作或关系里有点卡,不必急着推翻重来,先把手边一件具体的小事做好,会更容易找回状态。",
"dimensions": [
{
"name": "生命线",
"observations": ["弧度较完整", "线条延展感较强"],
"interpretation": "这类线条象征恢复力还不错。放到生活里,你可能不是一直满电的人,但只要睡眠、饮食和节奏回到正轨,状态通常能慢慢补回来。",
"confidence": 0.68,
"advice": "这周先固定一个睡前时间,别把恢复力浪费在反复熬夜上。",
},
{
"name": "智慧线",
"observations": ["走向偏平稳", "中段纹理较清晰"],
"interpretation": "智慧线偏稳,象征你适合用步骤感处理问题。学习或工作上,如果任务太大,你更适合列清单推进,而不是靠临场爆发。",
"confidence": 0.66,
"advice": "今天把最烦的一件事拆成 3 步,只完成第一步就算开局。",
},
{
"name": "感情线",
"observations": ["线条柔和", "末端略有分支感"],
"interpretation": "感情线柔和,象征你在关系里比较在意感受,也容易替别人想。好处是共情强,提醒是别把所有情绪都自己消化。",
"confidence": 0.64,
"advice": "这周有不舒服的地方,试着用一句具体的话说出来:我希望你下次可以怎样。",
},
{
"name": "命运线",
"observations": ["可见纵向纹理", "深浅变化较明显"],
"interpretation": "命运线有阶段变化感,象征你的事业或成长路径可能不是一条直线。你适合边做边调整,在变化里积累自己的方法。",
"confidence": 0.58,
"advice": "工作或学习上,优先做一件能留下作品、笔记或案例的事。",
},
{
"name": "手型与手指比例",
"observations": ["掌形比例均衡", "手指伸展感自然"],
"interpretation": "手型比例均衡,象征你在规则和灵感之间都有一点能力。适合做既要审美判断、也要实际落地的任务。",
"confidence": 0.61,
"advice": "如果最近有想法,别只停在脑子里,先做一个草稿或简单版本。",
},
{
"name": "特殊纹路与丘位特征",
"observations": ["局部细纹较丰富", "掌丘起伏需更清晰照片确认"],
"interpretation": "细纹较丰富,象征你对环境和他人反馈比较敏感。学习、工作或社交里,你可能很会察觉气氛,但也容易被杂音影响。",
"confidence": 0.52,
"advice": "给自己设一个免打扰时段,把注意力留给真正重要的任务。",
},
],
"strengths": ["适合按计划推进学习和工作", "能照顾到别人的感受", "遇到变化时有重新调整的能力"],
"challenges": ["想得多的时候,行动容易变慢", "太在意反馈时,会消耗自己的专注力"],
"suggestions": ["生活上先把作息拉回稳定线", "学习成长上把大目标拆成今天能完成的一页笔记", "事业工作上优先沉淀一个可展示的小成果", "关系沟通上把需求说具体,不要只等别人猜"],
"lucky_keywords": ["先做一步", "稳住节奏", "说清需求"],
"disclaimer": DISCLAIMER,
}

View File

@ -0,0 +1,32 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.palm_report import PalmReport
from app.models.uploaded_image import UploadedImage
from app.services.image_service import ImageService
from app.services.palm_analyzer import PalmAnalyzer
class ReportService:
def __init__(self) -> None:
self.images = ImageService()
self.analyzer = PalmAnalyzer()
async def generate(self, db: AsyncSession, report_id: str) -> None:
result = await db.execute(select(PalmReport).where(PalmReport.id == report_id))
report = result.scalar_one()
image_result = await db.execute(select(UploadedImage).where(UploadedImage.id == report.image_id))
image = image_result.scalar_one()
report.status = "processing"
await db.flush()
try:
image_bytes = self.images.read_bytes(image.storage_key)
data = await self.analyzer.analyze(image_bytes, image.content_type, report.hand_side)
report.report_data = data
report.status = "failed" if not data["quality_check"]["can_analyze"] else "completed"
report.error_message = None if report.status == "completed" else data["quality_check"]["reason"]
except Exception as exc:
report.status = "failed"
report.error_message = str(exc)
await db.commit()

View File

@ -0,0 +1,355 @@
import base64
import asyncio
from io import BytesIO
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont, ImageOps
from openai import AsyncOpenAI
from app.core.config import settings
from app.models.palm_report import PalmReport
from app.models.uploaded_image import UploadedImage
from app.services.image_service import ImageService
POSTER_W = 1080
POSTER_H = 2400
INK = "#080d10"
PANEL = "#10191c"
PANEL_2 = "#162326"
TEXT = "#f2e9d8"
MUTED = "#9db0b4"
DIM = "#728389"
CYAN = "#00e0b8"
GOLD = "#d8a84e"
WARN = "#ff6b4a"
class SharePosterService:
def __init__(self) -> None:
self.image_service = ImageService()
self.client = (
AsyncOpenAI(api_key=settings.openai_api_key, base_url=settings.openai_base_url)
if settings.openai_api_key
else None
)
self.font_regular = _font(34)
self.font_small = _font(26)
self.font_tiny = _font(22)
self.font_medium = _font(40)
self.font_title = _font(70)
self.font_score = _font(98)
async def render_ai_or_fallback(self, report: PalmReport, image: UploadedImage | None) -> bytes:
if settings.share_image_mode == "ai" and self.client:
try:
background = await asyncio.wait_for(self.render_ai_background(), timeout=120)
return self.render(report, image, background=background)
except Exception:
return self.render(report, image)
return self.render(report, image)
async def render_ai_background(self) -> Image.Image:
response = await self.client.images.generate(
model=settings.openai_image_model,
prompt=(
"生成一张竖版高端小程序分享海报背景,不要任何文字、不要数字、不要 logo。"
"主题:赛博先生 AI 命理实验室,手相报告。"
"必须包含精美的发光手掌、掌纹扫描线、AI 电路线、东方玄学圆形符号。"
"设计风格:深墨黑背景 #080d10青绿色 AI 发光线条 #00e0b8少量金色点缀 #d8a84e"
"现代、东方玄学、AI 扫描终端感;不要卡通,不要恐怖,不要传统庙宇风,不要紫色渐变。"
"画面中预留干净的暗色区域,方便后续叠加中文报告文字。"
),
size="1024x1536",
quality="high",
output_format="png",
response_format="b64_json",
)
b64 = response.data[0].b64_json
if not b64:
raise RuntimeError("Image model returned no background data")
return Image.open(BytesIO(base64.b64decode(b64))).convert("RGB")
async def render_ai(self, report: PalmReport) -> bytes:
data = report.report_data or {}
response = await self.client.images.generate(
model=settings.openai_image_model,
prompt=self._build_ai_prompt(report, data),
size="1024x1536",
quality="high",
output_format="png",
response_format="b64_json",
)
b64 = response.data[0].b64_json
if not b64:
raise RuntimeError("Image model returned no image data")
return base64.b64decode(b64)
def render(self, report: PalmReport, image: UploadedImage | None, background: Image.Image | None = None) -> bytes:
data = report.report_data or {}
poster_h = self._estimate_height(data)
poster = self._base_canvas(poster_h, background)
draw = ImageDraw.Draw(poster)
self._draw_background(draw, poster_h)
y = 70
draw.text((64, y), "CYBER FORTUNE REPORT", font=self.font_tiny, fill=GOLD)
y += 42
draw.text((64, y), "赛博先生手相报告", font=self.font_title, fill=TEXT)
y += 96
score = self._score(data)
y = self._draw_score_card(poster, draw, y, score, data)
y = self._draw_palm_card(poster, draw, y, image)
y = self._draw_dimensions(draw, y, data)
y = self._draw_suggestions(draw, y, data)
self._draw_footer(draw, poster_h)
out = BytesIO()
poster.save(out, format="PNG", optimize=True)
return out.getvalue()
def _base_canvas(self, poster_h: int, background: Image.Image | None) -> Image.Image:
if not background:
return Image.new("RGB", (POSTER_W, poster_h), INK)
base = ImageOps.fit(background, (POSTER_W, poster_h), method=Image.Resampling.LANCZOS)
overlay = Image.new("RGBA", (POSTER_W, poster_h), (8, 13, 16, 188))
base = base.convert("RGBA")
base.alpha_composite(overlay)
return base.convert("RGB")
def _build_ai_prompt(self, report: PalmReport, data: dict) -> str:
score = self._score(data)
dimensions = data.get("dimensions") or []
dimension_lines = []
for item in dimensions[:6]:
name = item.get("name", "维度")
confidence = int((item.get("confidence") or 0) * 100)
interpretation = _trim(item.get("interpretation") or "", 48)
advice = _trim(item.get("advice") or "", 34)
dimension_lines.append(f"- {name}{confidence}分|{interpretation}|建议:{advice}")
suggestions = data.get("suggestions") or []
keywords = data.get("lucky_keywords") or []
summary = _trim(data.get("overall_summary") or "", 90)
hand_side = {"left": "左手", "right": "右手", "unknown": "未知手"}.get(report.hand_side, "未知手")
return (
"生成一张竖版中文小程序分享海报,比例 2:3精美、高级、可读性很高。"
"品牌是“赛博先生”,定位是 AI 命理实验室,功能是手相报告。"
"设计风格:深墨黑背景 #080d10青绿色 AI 发光线条 #00e0b8少量金色点缀 #d8a84e"
"现代、东方玄学、AI 扫描终端感;不要卡通,不要恐怖,不要传统庙宇风,不要紫色渐变。"
"必须有一个清晰的手掌视觉元素,可以是发光掌纹图、掌心扫描线或手掌轮廓。"
"文字必须清楚、中文可读、排版整齐,像高端 App 分享长图。"
"禁止使用省略号,禁止把任何维度分析或建议截断;如果内容多,就把版面做成长图。"
"请在画面中包含以下内容:"
f"标题:赛博先生手相报告;副标题:{hand_side} · AI 掌纹解读;"
f"综合能量分:{score}/100"
f"先生结论:{summary}"
f"幸运关键词:{''.join(keywords[:5])}"
"核心维度分析:"
+ "".join(dimension_lines)
+ ";近期建议:"
+ "".join(_trim(item, 42) for item in suggestions[:4])
+ ";底部小字:仅供娱乐与自我反思,不构成现实决策建议。"
"版式要求:顶部品牌标题,中部综合分和手掌图,下面是 6 个维度卡片,底部是建议和免责声明。"
"不要遗漏分数、手掌图、维度分析和建议,不要出现“...”或“…”省略。"
)
def _estimate_height(self, data: dict) -> int:
dimensions = data.get("dimensions") or []
suggestions = data.get("suggestions") or []
dim_h = 0
scratch = Image.new("RGB", (POSTER_W, 200), INK)
draw = ImageDraw.Draw(scratch)
for item in dimensions:
interpretation_lines = self._wrap_text(draw, item.get("interpretation") or "", 850, self.font_tiny)
advice_lines = self._wrap_text(draw, f"建议:{item.get('advice') or ''}", 850, self.font_tiny)
dim_h += 102 + len(interpretation_lines) * 33 + len(advice_lines) * 33 + 30
suggestion_h = 120
for suggestion in suggestions:
suggestion_h += len(self._wrap_text(draw, suggestion, 824, self.font_small)) * 40 + 12
summary_lines = self._wrap_text(draw, data.get("overall_summary") or "", 440, self.font_small)
score_h = max(300, 168 + len(summary_lines) * 42 + 68)
return max(POSTER_H, 70 + 42 + 96 + score_h + 32 + 422 + 72 + dim_h + suggestion_h + 180)
def _draw_background(self, draw: ImageDraw.ImageDraw, poster_h: int) -> None:
for x in range(0, POSTER_W, 64):
draw.line((x, 0, x, poster_h), fill=(15, 34, 35), width=1)
for y in range(0, poster_h, 64):
draw.line((0, y, POSTER_W, y), fill=(15, 34, 35), width=1)
draw.rectangle((0, 0, POSTER_W, 280), fill=(8, 21, 22))
draw.line((64, 232, POSTER_W - 64, 232), fill=CYAN, width=2)
def _draw_score_card(self, poster: Image.Image, draw: ImageDraw.ImageDraw, y: int, score: int, data: dict) -> int:
summary = (data.get("overall_summary") or "这份报告正在整理中。").replace("\n", "")
summary_lines = self._wrap_text(draw, summary, 440, self.font_small)
card_h = max(300, 168 + len(summary_lines) * 42 + 68)
self._round_rect(draw, (64, y, POSTER_W - 64, y + card_h), 34, PANEL)
draw.text((104, y + 40), "综合能量分", font=self.font_small, fill=MUTED)
draw.text((104, y + 78), str(score), font=self.font_score, fill=CYAN)
draw.text((250, y + 120), "/ 100", font=self.font_small, fill=DIM)
self._multiline(draw, summary, 520, y + 42, 440, self.font_small, TEXT, 99, line_gap=10)
keywords = data.get("lucky_keywords") or []
x = 104
ky = y + card_h - 68
for keyword in keywords:
w = self._text_w(draw, keyword, self.font_tiny) + 34
if x + w > 480:
break
self._pill(draw, (x, ky, x + w, ky + 42), keyword, self.font_tiny, CYAN, "#06201c")
x += w + 12
return y + card_h + 32
def _draw_palm_card(self, poster: Image.Image, draw: ImageDraw.ImageDraw, y: int, image: UploadedImage | None) -> int:
card_h = 390
self._round_rect(draw, (64, y, POSTER_W - 64, y + card_h), 34, PANEL)
draw.text((104, y + 34), "掌纹照片", font=self.font_small, fill=GOLD)
box = (104, y + 86, 486, y + 344)
palm = self._load_palm_image(image)
if palm:
palm = ImageOps.fit(palm.convert("RGB"), (box[2] - box[0], box[3] - box[1]), method=Image.Resampling.LANCZOS)
mask = Image.new("L", palm.size, 0)
mask_draw = ImageDraw.Draw(mask)
mask_draw.rounded_rectangle((0, 0, palm.size[0], palm.size[1]), radius=26, fill=255)
poster.paste(palm, box[:2], mask)
else:
self._round_rect(draw, box, 26, PANEL_2)
draw.text((216, y + 190), "", font=self.font_score, fill=CYAN)
draw.text((532, y + 94), "先生观察", font=self.font_medium, fill=TEXT)
copy = "掌纹主线会被用于生成娱乐向解读。报告更适合当作近期生活、学习和工作节奏的提醒。"
self._multiline(draw, copy, 532, y + 156, 390, self.font_small, MUTED, 4, line_gap=10)
return y + card_h + 32
def _draw_dimensions(self, draw: ImageDraw.ImageDraw, y: int, data: dict) -> int:
draw.text((64, y), "核心维度", font=self.font_medium, fill=TEXT)
y += 62
dimensions = data.get("dimensions") or []
for idx, item in enumerate(dimensions, start=1):
interpretation = item.get("interpretation") or ""
advice = item.get("advice") or ""
interpretation_lines = self._wrap_text(draw, interpretation, 850, self.font_tiny)
advice_lines = self._wrap_text(draw, f"建议:{advice}", 850, self.font_tiny)
card_h = 102 + len(interpretation_lines) * 33 + len(advice_lines) * 33 + 24
self._round_rect(draw, (64, y, POSTER_W - 64, y + card_h), 28, PANEL)
draw.text((104, y + 28), f"0{idx}", font=self.font_tiny, fill=DIM)
draw.text((156, y + 22), item.get("name", "维度"), font=self.font_medium, fill=TEXT)
confidence = int((item.get("confidence") or 0) * 100)
self._pill(draw, (820, y + 28, 976, y + 70), f"{confidence}%", self.font_tiny, CYAN, "#06201c")
self._multiline(draw, interpretation, 104, y + 78, 850, self.font_tiny, MUTED, 99, line_gap=7)
self._multiline(draw, f"建议:{advice}", 104, y + 78 + len(interpretation_lines) * 33 + 10, 850, self.font_tiny, "#eadcc1", 99, line_gap=7)
y += card_h + 18
return y + 10
def _draw_suggestions(self, draw: ImageDraw.ImageDraw, y: int, data: dict) -> int:
suggestions = data.get("suggestions") or []
scratch_lines = [self._wrap_text(draw, suggestion, 824, self.font_small) for suggestion in suggestions]
bottom = y + 110 + sum(len(lines) * 40 + 12 for lines in scratch_lines)
self._round_rect(draw, (64, y, POSTER_W - 64, bottom), 30, PANEL)
draw.text((104, y + 30), "近期建议", font=self.font_medium, fill=GOLD)
sy = y + 92
for suggestion in suggestions:
draw.ellipse((104, sy + 10, 116, sy + 22), fill=CYAN)
sy = self._multiline(draw, suggestion, 132, sy, 824, self.font_small, TEXT, 99, line_gap=8) + 12
return sy
def _draw_footer(self, draw: ImageDraw.ImageDraw, poster_h: int) -> None:
draw.line((64, poster_h - 118, POSTER_W - 64, poster_h - 118), fill=(34, 56, 58), width=1)
draw.text((64, poster_h - 88), "赛博先生 · AI 命理实验室", font=self.font_small, fill=TEXT)
draw.text((64, poster_h - 48), "仅供娱乐与自我反思,不构成现实决策建议", font=self.font_tiny, fill=DIM)
draw.text((POSTER_W - 180, poster_h - 88), "", font=self.font_medium, fill=CYAN)
def _score(self, data: dict) -> int:
confidences = [item.get("confidence", 0) for item in data.get("dimensions", []) if isinstance(item, dict)]
base = sum(confidences) / len(confidences) if confidences else 0.68
quality = (data.get("quality_check") or {}).get("confidence", 0.7)
return max(60, min(96, round((base * 0.65 + quality * 0.35) * 100)))
def _load_palm_image(self, image: UploadedImage | None) -> Image.Image | None:
if not image:
return None
try:
return Image.open(BytesIO(self.image_service.read_bytes(image.storage_key)))
except Exception:
return None
def _round_rect(self, draw: ImageDraw.ImageDraw, xy: tuple[int, int, int, int], radius: int, fill: str) -> None:
draw.rounded_rectangle(xy, radius=radius, fill=fill, outline=(0, 224, 184, 54), width=1)
def _pill(
self,
draw: ImageDraw.ImageDraw,
xy: tuple[int, int, int, int],
text: str,
font: ImageFont.ImageFont,
fill: str,
bg: str,
) -> None:
draw.rounded_rectangle(xy, radius=(xy[3] - xy[1]) // 2, fill=bg, outline=fill, width=1)
tw = self._text_w(draw, text, font)
th = self._text_h(draw, text, font)
draw.text((xy[0] + (xy[2] - xy[0] - tw) / 2, xy[1] + (xy[3] - xy[1] - th) / 2 - 1), text, font=font, fill=fill)
def _multiline(
self,
draw: ImageDraw.ImageDraw,
text: str,
x: int,
y: int,
max_width: int,
font: ImageFont.ImageFont,
fill: str,
max_lines: int,
line_gap: int,
) -> int:
lines = self._wrap_text(draw, text, max_width, font)
if len(lines) > max_lines:
lines = lines[:max_lines]
lines[-1] = lines[-1].rstrip(",。,. ") + "..."
line_h = self._text_h(draw, "", font) + line_gap
for i, line in enumerate(lines):
draw.text((x, y + i * line_h), line, font=font, fill=fill)
return y + len(lines) * line_h
def _wrap_text(self, draw: ImageDraw.ImageDraw, text: str, max_width: int, font: ImageFont.ImageFont) -> list[str]:
lines: list[str] = []
current = ""
for char in text:
candidate = current + char
if self._text_w(draw, candidate, font) <= max_width:
current = candidate
else:
if current:
lines.append(current)
current = char
if current:
lines.append(current)
return lines or [""]
def _text_w(self, draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont) -> int:
return int(draw.textbbox((0, 0), text, font=font)[2])
def _text_h(self, draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont) -> int:
box = draw.textbbox((0, 0), text, font=font)
return int(box[3] - box[1])
def _font(size: int) -> ImageFont.ImageFont:
candidates = [
"/System/Library/Fonts/Hiragino Sans GB.ttc",
"/System/Library/Fonts/STHeiti Medium.ttc",
"/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
"/System/Library/Fonts/Supplemental/Songti.ttc",
]
for candidate in candidates:
if Path(candidate).exists():
return ImageFont.truetype(candidate, size=size)
return ImageFont.load_default(size=size)
def _trim(text: str, max_chars: int) -> str:
text = text.replace("\n", " ").strip()
return text if len(text) <= max_chars else text[: max_chars - 1] + ""

View File

@ -0,0 +1,54 @@
import httpx
from app.core.config import settings
class WechatService:
async def login(self, code: str, phone_code: str | None = None) -> tuple[str, str | None]:
if settings.wechat_mock_login:
return f"mock-openid-{code}", "13800000000" if phone_code else None
if not settings.wechat_app_id or not settings.wechat_app_secret:
raise RuntimeError("Wechat credentials are not configured")
async with httpx.AsyncClient(timeout=8) as client:
session_resp = await client.get(
"https://api.weixin.qq.com/sns/jscode2session",
params={
"appid": settings.wechat_app_id,
"secret": settings.wechat_app_secret,
"js_code": code,
"grant_type": "authorization_code",
},
)
session_data = session_resp.json()
openid = session_data.get("openid")
if not openid:
raise RuntimeError(session_data.get("errmsg", "Wechat login failed"))
phone_number = None
if phone_code:
access_token = await self._get_access_token(client)
phone_resp = await client.post(
f"https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token={access_token}",
json={"code": phone_code},
)
phone_data = phone_resp.json()
phone_number = phone_data.get("phone_info", {}).get("phoneNumber")
return openid, phone_number
async def _get_access_token(self, client: httpx.AsyncClient) -> str:
token_resp = await client.get(
"https://api.weixin.qq.com/cgi-bin/token",
params={
"grant_type": "client_credential",
"appid": settings.wechat_app_id,
"secret": settings.wechat_app_secret,
},
)
token_data = token_resp.json()
access_token = token_data.get("access_token")
if not access_token:
raise RuntimeError(token_data.get("errmsg", "Wechat access token failed"))
return access_token

14
backend/requirements.txt Normal file
View File

@ -0,0 +1,14 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy[asyncio]==2.0.36
aiosqlite==0.20.0
asyncpg==0.30.0
pydantic-settings==2.7.1
pydantic==2.10.6
python-multipart==0.0.20
httpx==0.28.1
openai==2.34.0
Pillow==11.1.0
PyJWT==2.10.1
pytest==8.3.4
pytest-asyncio==0.25.2

View File

@ -0,0 +1,4 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))

View File

@ -0,0 +1,17 @@
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
@pytest.mark.asyncio
async def test_anonymous_login_returns_reusable_user_token():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
first = await client.post("/api/v1/auth/anonymous-login", json={"client_id": "browser-1"})
second = await client.post("/api/v1/auth/anonymous-login", json={"client_id": "browser-1"})
assert first.status_code == 200
assert second.status_code == 200
assert first.json()["user_id"] == second.json()["user_id"]
assert first.json()["access_token"]

View File

@ -0,0 +1,35 @@
from io import BytesIO
import pytest
from fastapi import HTTPException, UploadFile
from PIL import Image
from starlette.datastructures import Headers
from app.services.image_service import ImageService
def make_image(size=(800, 800), color=(230, 210, 190)):
image = Image.new("RGB", size, color=color)
buffer = BytesIO()
image.save(buffer, format="JPEG")
buffer.seek(0)
return buffer
@pytest.mark.asyncio
async def test_rejects_unsupported_content_type():
service = ImageService()
upload = UploadFile(filename="palm.txt", file=BytesIO(b"not image"), headers=Headers({"content-type": "text/plain"}))
with pytest.raises(HTTPException):
await service.validate_and_store(upload, "user-1")
def test_inspect_rejects_low_resolution():
service = ImageService()
data = make_image(size=(300, 300)).getvalue()
result = service._inspect_image(data)
assert result["is_acceptable"] is False
assert "分辨率" in result["reason"]

View File

@ -0,0 +1,16 @@
import pytest
from app.services.palm_analyzer import DISCLAIMER, PalmAnalyzer
@pytest.mark.asyncio
async def test_mock_report_has_required_shape(monkeypatch):
monkeypatch.setattr("app.services.palm_analyzer.settings.openai_api_key", None)
analyzer = PalmAnalyzer()
report = await analyzer.analyze(b"fake", "image/jpeg", "left")
assert report["quality_check"]["can_analyze"] is True
assert report["overall_summary"]
assert len(report["dimensions"]) >= 6
assert report["disclaimer"] == DISCLAIMER

37
docker-compose.yml Normal file
View File

@ -0,0 +1,37 @@
services:
backend:
build:
context: ./backend
env_file:
- ./backend/.env
environment:
DATABASE_URL: sqlite+aiosqlite:////app/data/palm_reading.db
UPLOAD_DIR: /app/storage/uploads
CORS_ORIGINS: '["http://127.0.0.1:3000","http://localhost:3000"]'
ports:
- "8000:8000"
volumes:
- backend_data:/app/data
- backend_storage:/app/storage
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health').read()"]
interval: 15s
timeout: 5s
retries: 10
web:
build:
context: ./web
args:
NEXT_PUBLIC_API_BASE_URL: http://127.0.0.1:8000/api/v1
environment:
NEXT_PUBLIC_API_BASE_URL: http://127.0.0.1:8000/api/v1
ports:
- "3000:3000"
depends_on:
backend:
condition: service_healthy
volumes:
backend_data:
backend_storage:

13
miniprogram/app.js Normal file
View File

@ -0,0 +1,13 @@
App({
globalData: {
token: wx.getStorageSync('token') || ''
},
setToken(token) {
this.globalData.token = token
wx.setStorageSync('token', token)
},
clearToken() {
this.globalData.token = ''
wx.removeStorageSync('token')
}
})

38
miniprogram/app.json Normal file
View File

@ -0,0 +1,38 @@
{
"pages": [
"pages/index/index",
"pages/palm/palm",
"pages/generating/generating",
"pages/report/report",
"pages/history/history",
"pages/legal/legal"
],
"window": {
"navigationBarTitleText": "赛博先生",
"navigationBarBackgroundColor": "#080d10",
"navigationBarTextStyle": "white",
"backgroundColor": "#080d10"
},
"tabBar": {
"color": "#728389",
"selectedColor": "#00e0b8",
"backgroundColor": "#0b1215",
"borderStyle": "black",
"list": [
{
"pagePath": "pages/index/index",
"text": "问先生",
"iconPath": "assets/tabbar/ask-normal.png",
"selectedIconPath": "assets/tabbar/ask-active.png"
},
{
"pagePath": "pages/history/history",
"text": "档案",
"iconPath": "assets/tabbar/archive-normal.png",
"selectedIconPath": "assets/tabbar/archive-active.png"
}
]
},
"style": "v2",
"sitemapLocation": "sitemap.json"
}

48
miniprogram/app.wxss Normal file
View File

@ -0,0 +1,48 @@
page {
background: #080d10;
color: #f2e9d8;
font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif;
}
.page {
min-height: 100vh;
padding: 32rpx;
box-sizing: border-box;
background:
linear-gradient(180deg, rgba(0, 224, 184, 0.08), rgba(8, 13, 16, 0) 320rpx),
repeating-linear-gradient(90deg, rgba(242, 233, 216, 0.035) 0, rgba(242, 233, 216, 0.035) 1rpx, transparent 1rpx, transparent 48rpx),
#080d10;
}
.panel {
background: rgba(16, 25, 28, 0.92);
border: 1rpx solid rgba(0, 224, 184, 0.22);
border-radius: 16rpx;
padding: 28rpx;
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.24);
}
.primary-btn {
background: #00e0b8;
color: #06100e;
border-radius: 12rpx;
font-weight: 800;
}
.ghost-btn {
background: rgba(8, 13, 16, 0.72);
color: #00e0b8;
border: 1rpx solid rgba(0, 224, 184, 0.56);
border-radius: 12rpx;
}
.muted {
color: #8da0a4;
}
.section-title {
font-size: 34rpx;
font-weight: 700;
margin: 32rpx 0 16rpx;
color: #f2e9d8;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 617 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 622 B

View File

@ -0,0 +1,47 @@
const { request } = require('../../utils/request')
Page({
data: {
id: '',
timer: null
},
onLoad(query) {
this.setData({ id: query.id })
this.poll()
const timer = setInterval(() => this.poll(), 2500)
this.setData({ timer })
},
onUnload() {
if (this.data.timer) {
clearInterval(this.data.timer)
}
},
async poll() {
if (!this.data.id) return
try {
const report = await request({ url: `/reports/${this.data.id}` })
if (report.status === 'completed') {
clearInterval(this.data.timer)
wx.redirectTo({ url: `/pages/report/report?id=${this.data.id}` })
}
if (report.status === 'failed') {
clearInterval(this.data.timer)
wx.showModal({
title: '生成失败',
content: report.error_message || '照片暂时无法分析,请换一张更清晰的照片。',
showCancel: false,
success: () => wx.switchTab({ url: '/pages/index/index' })
})
}
} catch (error) {
wx.showToast({ title: error.message || '查询失败', icon: 'none' })
}
},
backHome() {
wx.switchTab({ url: '/pages/index/index' })
}
})

View File

@ -0,0 +1,3 @@
{
"navigationBarTitleText": "生成中"
}

View File

@ -0,0 +1,6 @@
<view class="page center">
<view class="pulse"></view>
<text class="title">先生正在起卦</text>
<text class="subtitle">大约需要十几秒。赛博先生正在整理生命线、智慧线、感情线和整体倾向。</text>
<button class="ghost-btn action" bindtap="backHome">返回首页</button>
</view>

View File

@ -0,0 +1,47 @@
.center {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
.pulse {
width: 132rpx;
height: 132rpx;
border-radius: 50%;
background: #00e0b8;
opacity: 0.88;
animation: pulse 1.4s ease-in-out infinite;
box-shadow: 0 0 72rpx rgba(0, 224, 184, 0.44);
}
.title {
margin-top: 44rpx;
font-size: 40rpx;
font-weight: 800;
}
.subtitle {
margin-top: 20rpx;
color: #9db0b4;
font-size: 28rpx;
line-height: 1.7;
}
.action {
margin-top: 48rpx;
width: 100%;
}
@keyframes pulse {
0% {
transform: scale(0.86);
}
50% {
transform: scale(1);
}
100% {
transform: scale(0.86);
}
}

View File

@ -0,0 +1,52 @@
const { request } = require('../../utils/request')
const STATUS_TEXT = {
pending: '等待中',
processing: '生成中',
completed: '已完成',
failed: '失败'
}
Page({
data: {
reports: [],
reportCount: 0,
completedCount: 0
},
onShow() {
this.loadReports()
},
async loadReports() {
if (!getApp().globalData.token) {
this.setData({ reports: [], reportCount: 0, completedCount: 0 })
return
}
try {
const reports = await request({ url: '/reports' })
const mappedReports = reports.map((item) => ({
...item,
statusText: STATUS_TEXT[item.status] || item.status,
createdDate: (item.created_at || '').replace('T', ' ').slice(0, 16),
fallbackSummary: item.status === 'completed' ? '报告已完成,点击查看完整解读。' : '先生正在整理这份报告。'
}))
this.setData({
reports: mappedReports,
reportCount: mappedReports.length,
completedCount: mappedReports.filter((item) => item.status === 'completed').length
})
} catch (error) {
wx.showToast({ title: error.message || '加载失败', icon: 'none' })
}
},
openReport(event) {
const id = event.currentTarget.dataset.id
wx.navigateTo({ url: `/pages/report/report?id=${id}` })
},
goHome() {
wx.navigateTo({ url: '/pages/palm/palm' })
}
})

View File

@ -0,0 +1,3 @@
{
"navigationBarTitleText": "解读档案"
}

View File

@ -0,0 +1,43 @@
<view class="page">
<view class="top">
<text class="eyebrow">ARCHIVE</text>
<text class="title">解读档案</text>
<text class="subtitle">每一次请先生解读,都会在这里沉淀成一份记录。</text>
</view>
<view class="stats">
<view class="stat-card">
<text class="stat-value">{{reportCount}}</text>
<text class="stat-label">累计报告</text>
</view>
<view class="stat-card">
<text class="stat-value">{{completedCount}}</text>
<text class="stat-label">已完成</text>
</view>
</view>
<view wx:if="{{reports.length}}" class="archive-list">
<view wx:for="{{reports}}" wx:key="id" class="record" bindtap="openReport" data-id="{{item.id}}">
<view class="record-mark">
<text>掌</text>
</view>
<view class="record-main">
<view class="record-head">
<view>
<text class="record-title">手相报告</text>
<text class="record-date">{{item.createdDate}}</text>
</view>
<text class="status {{item.status}}">{{item.statusText}}</text>
</view>
<text class="summary">{{item.overall_summary || item.fallbackSummary}}</text>
</view>
</view>
</view>
<view wx:else class="empty">
<view class="empty-orb">掌</view>
<text class="empty-title">还没有解读档案</text>
<text class="empty-copy">先从手相报告开始,让赛博先生留下第一条记录。</text>
<button class="primary-btn action" bindtap="goHome">请先生看掌</button>
</view>
</view>

View File

@ -0,0 +1,183 @@
.top {
padding: 28rpx 0 20rpx;
}
.eyebrow {
display: block;
color: #d8a84e;
font-size: 22rpx;
font-weight: 800;
}
.title {
display: block;
margin-top: 10rpx;
color: #f2e9d8;
font-size: 48rpx;
font-weight: 800;
line-height: 1.2;
}
.subtitle {
display: block;
margin-top: 14rpx;
color: #9db0b4;
font-size: 27rpx;
line-height: 1.6;
}
.stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16rpx;
margin: 8rpx 0 28rpx;
}
.stat-card {
padding: 22rpx;
border: 1rpx solid rgba(242, 233, 216, 0.1);
border-radius: 16rpx;
background: rgba(16, 25, 28, 0.78);
}
.stat-value,
.stat-label {
display: block;
}
.stat-value {
color: #00e0b8;
font-size: 38rpx;
font-weight: 800;
}
.stat-label {
margin-top: 6rpx;
color: #8da0a4;
font-size: 23rpx;
}
.archive-list {
display: flex;
flex-direction: column;
gap: 18rpx;
}
.record {
display: flex;
gap: 20rpx;
padding: 24rpx;
border: 1rpx solid rgba(0, 224, 184, 0.2);
border-radius: 18rpx;
background: rgba(16, 25, 28, 0.9);
}
.record-mark {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 72rpx;
height: 72rpx;
border-radius: 50%;
color: #06100e;
background: #00e0b8;
font-size: 30rpx;
font-weight: 800;
box-shadow: 0 0 32rpx rgba(0, 224, 184, 0.24);
}
.record-main {
flex: 1;
min-width: 0;
}
.record-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16rpx;
}
.record-title,
.record-date,
.summary {
display: block;
}
.record-title {
color: #f2e9d8;
font-size: 30rpx;
font-weight: 800;
}
.record-date {
margin-top: 6rpx;
color: #728389;
font-size: 22rpx;
}
.status {
flex-shrink: 0;
color: #f2e9d8;
background: #728389;
border-radius: 999rpx;
padding: 7rpx 16rpx;
font-size: 22rpx;
font-weight: 700;
}
.status.completed {
color: #06100e;
background: #00e0b8;
}
.status.failed {
background: #9b3d2e;
}
.summary {
margin-top: 18rpx;
color: #b8c7c8;
font-size: 26rpx;
line-height: 1.65;
}
.empty {
margin-top: 86rpx;
text-align: center;
}
.empty-orb {
display: flex;
align-items: center;
justify-content: center;
width: 128rpx;
height: 128rpx;
margin: 0 auto 28rpx;
border-radius: 50%;
color: #06100e;
background: #00e0b8;
font-size: 46rpx;
font-weight: 800;
box-shadow: 0 0 60rpx rgba(0, 224, 184, 0.3);
}
.empty-title {
display: block;
color: #f2e9d8;
font-size: 34rpx;
font-weight: 800;
}
.empty-copy {
display: block;
margin-top: 14rpx;
color: #9db0b4;
font-size: 26rpx;
line-height: 1.6;
}
.action {
margin-top: 36rpx;
}

View File

@ -0,0 +1,31 @@
const { MODULES } = require('../../utils/modules')
Page({
data: {
hasToken: false,
modules: MODULES
},
onShow() {
this.setData({ hasToken: Boolean(getApp().globalData.token) })
},
tapModule(event) {
const moduleId = event.currentTarget.dataset.id
const module = this.data.modules.find((item) => item.id === moduleId)
if (module && module.status === 'available' && module.path) {
wx.navigateTo({ url: module.path })
} else {
wx.showToast({ title: '这个功能即将开放', icon: 'none' })
}
},
openPalm() {
const palm = this.data.modules.find((item) => item.id === 'palm')
wx.navigateTo({ url: palm.path })
},
openLegal() {
wx.navigateTo({ url: '/pages/legal/legal' })
}
})

View File

@ -0,0 +1,3 @@
{
"navigationBarTitleText": "赛博先生"
}

View File

@ -0,0 +1,31 @@
<view class="page">
<view class="hero">
<text class="eyebrow">CYBER FORTUNE STUDIO</text>
<text class="title">赛博先生</text>
<text class="subtitle">AI 命理实验室。选择一个入口,让先生开始解读。</text>
<view class="signal">
<text class="signal-dot"></text>
<text class="signal-text">ONLINE · 玄学模型已接入</text>
</view>
</view>
<view class="module-grid">
<view wx:for="{{modules}}" wx:key="id" class="module {{item.status === 'available' ? 'active' : 'disabled'}}" bindtap="tapModule" data-id="{{item.id}}">
<text class="module-mark">{{item.mark}}</text>
<view class="module-copy">
<text class="module-title">{{item.title}}</text>
<text class="module-desc">{{item.description}}</text>
</view>
<text class="module-code">0{{index + 1}}</text>
</view>
</view>
<view class="panel today">
<text class="today-label">今日可问</text>
<text class="today-title">先看掌心里的行动节奏</text>
<text class="today-copy">上传一张清晰掌心照片,先生会从生命线、智慧线、感情线、命运线等维度生成娱乐向报告。</text>
<button class="primary-btn today-btn" bindtap="openPalm">请先生看掌</button>
</view>
<text class="legal muted" bindtap="openLegal">继续使用即表示你同意用户协议与隐私政策。赛博先生只提供娱乐与自我反思内容。</text>
</view>

View File

@ -0,0 +1,186 @@
.hero {
position: relative;
padding: 38rpx 0 28rpx;
}
.eyebrow {
display: block;
color: #d8a84e;
font-size: 22rpx;
font-weight: 800;
letter-spacing: 0;
}
.title {
display: block;
margin-top: 10rpx;
font-size: 64rpx;
font-weight: 800;
color: #f2e9d8;
}
.subtitle {
display: block;
margin-top: 16rpx;
font-size: 28rpx;
line-height: 1.6;
color: #9db0b4;
}
.signal {
display: inline-flex;
align-items: center;
margin-top: 24rpx;
padding: 10rpx 18rpx;
border: 1rpx solid rgba(0, 224, 184, 0.34);
border-radius: 999rpx;
background: rgba(0, 224, 184, 0.08);
}
.signal-dot {
width: 12rpx;
height: 12rpx;
margin-right: 12rpx;
border-radius: 50%;
background: #00e0b8;
box-shadow: 0 0 18rpx #00e0b8;
}
.signal-text {
color: #bcefe5;
font-size: 22rpx;
font-weight: 700;
}
.module-grid {
display: grid;
grid-template-columns: 1fr;
gap: 16rpx;
margin: 18rpx 0 28rpx;
}
.module {
position: relative;
display: flex;
align-items: center;
min-height: 118rpx;
padding: 22rpx;
box-sizing: border-box;
background: rgba(16, 25, 28, 0.9);
border: 1rpx solid rgba(242, 233, 216, 0.12);
border-radius: 16rpx;
overflow: hidden;
}
.module.active {
background: linear-gradient(135deg, rgba(0, 224, 184, 0.2), rgba(16, 25, 28, 0.96));
border-color: rgba(0, 224, 184, 0.58);
color: #f2e9d8;
}
.module.active::after {
content: "";
position: absolute;
left: 18rpx;
right: 18rpx;
top: 0;
height: 2rpx;
background: linear-gradient(90deg, transparent, #00e0b8, transparent);
}
.module.disabled {
opacity: 0.54;
}
.module-mark {
display: flex;
align-items: center;
justify-content: center;
width: 72rpx;
height: 72rpx;
margin-right: 18rpx;
border-radius: 50%;
background: rgba(242, 233, 216, 0.08);
color: #d8a84e;
border: 1rpx solid rgba(216, 168, 78, 0.34);
font-size: 32rpx;
font-weight: 800;
}
.module.active .module-mark {
background: #00e0b8;
color: #06100e;
border-color: #00e0b8;
box-shadow: 0 0 32rpx rgba(0, 224, 184, 0.32);
}
.module-copy {
flex: 1;
}
.module-code {
color: rgba(242, 233, 216, 0.28);
font-size: 24rpx;
font-weight: 800;
}
.module-title,
.module-desc {
display: block;
}
.module-title {
font-size: 30rpx;
font-weight: 800;
}
.module-desc {
margin-top: 8rpx;
color: #8da0a4;
font-size: 24rpx;
}
.module.active .module-desc {
color: #bcefe5;
}
.today {
margin-top: 28rpx;
}
.today-label,
.today-title,
.today-copy {
display: block;
}
.today-label {
color: #d8a84e;
font-size: 23rpx;
font-weight: 800;
}
.today-title {
margin-top: 10rpx;
font-size: 32rpx;
font-weight: 800;
}
.today-copy {
margin-top: 12rpx;
color: #9db0b4;
font-size: 26rpx;
line-height: 1.7;
}
.today-btn {
margin-top: 22rpx;
}
.legal {
display: block;
margin-top: 28rpx;
font-size: 24rpx;
line-height: 1.6;
text-align: center;
}

View File

@ -0,0 +1 @@
Page({})

View File

@ -0,0 +1,3 @@
{
"navigationBarTitleText": "协议与隐私"
}

View File

@ -0,0 +1,18 @@
<view class="page">
<text class="title">赛博先生协议与隐私说明</text>
<view class="panel block">
<text class="heading">服务定位</text>
<text class="body">赛博先生提供手相、面相、八字等娱乐占卜与自我反思类内容。报告不构成医学、心理、职业、财务、投资或任何人生决策建议。</text>
</view>
<view class="panel block">
<text class="heading">照片用途</text>
<text class="body">你上传的手掌照片仅用于生成本次手相报告、质量校验和必要的问题排查。原始照片默认短期保存,并会按服务端策略自动清理。</text>
</view>
<view class="panel block">
<text class="heading">数据删除</text>
<text class="body">你可以在报告详情页删除报告。删除后,报告内容和关联照片会被清理,无法恢复。</text>
</view>
</view>

View File

@ -0,0 +1,23 @@
.title {
display: block;
padding: 24rpx 0;
color: #f2e9d8;
font-size: 42rpx;
font-weight: 800;
}
.block {
margin-bottom: 20rpx;
}
.heading {
display: block;
font-weight: 800;
margin-bottom: 14rpx;
}
.body {
display: block;
color: #9db0b4;
line-height: 1.7;
}

View File

@ -0,0 +1,96 @@
const { request, uploadPalm } = require('../../utils/request')
Page({
data: {
hasToken: false,
imagePath: '',
handSide: 'unknown',
submitting: false
},
onShow() {
this.setData({ hasToken: Boolean(getApp().globalData.token) })
},
chooseLeft() {
this.setData({ handSide: 'left' })
},
chooseRight() {
this.setData({ handSide: 'right' })
},
chooseUnknown() {
this.setData({ handSide: 'unknown' })
},
async loginDev() {
try {
const login = await wx.login()
const data = await request({ url: '/auth/wechat-login', method: 'POST', data: { code: login.code || 'dev' } })
getApp().setToken(data.access_token)
this.setData({ hasToken: true })
wx.showToast({ title: '已登录' })
} catch (error) {
wx.showToast({ title: error.message || '登录失败', icon: 'none' })
}
},
async loginWithPhone(event) {
try {
const login = await wx.login()
const phoneCode = event.detail && event.detail.code
const data = await request({
url: '/auth/wechat-login',
method: 'POST',
data: { code: login.code || 'dev', phone_code: phoneCode || null }
})
getApp().setToken(data.access_token)
this.setData({ hasToken: true })
wx.showToast({ title: '已登录' })
} catch (error) {
wx.showToast({ title: error.message || '登录失败', icon: 'none' })
}
},
async chooseImage() {
try {
const res = await wx.chooseMedia({
count: 1,
mediaType: ['image'],
sourceType: ['album', 'camera'],
sizeType: ['compressed']
})
this.setData({ imagePath: res.tempFiles[0].tempFilePath })
} catch (error) {
if (error.errMsg && !error.errMsg.includes('cancel')) {
wx.showToast({ title: '选择照片失败', icon: 'none' })
}
}
},
async submit() {
if (!getApp().globalData.token) {
await this.loginDev()
}
if (!this.data.imagePath) return
this.setData({ submitting: true })
try {
const upload = await uploadPalm(this.data.imagePath)
const report = await request({
url: '/reports',
method: 'POST',
data: { image_id: upload.image_id, hand_side: this.data.handSide }
})
wx.navigateTo({ url: `/pages/generating/generating?id=${report.id}` })
} catch (error) {
wx.showToast({ title: error.message || '生成失败', icon: 'none' })
} finally {
this.setData({ submitting: false })
}
},
openLegal() {
wx.navigateTo({ url: '/pages/legal/legal' })
}
})

View File

@ -0,0 +1,3 @@
{
"navigationBarTitleText": "手相报告"
}

View File

@ -0,0 +1,31 @@
<view class="page">
<view class="hero">
<text class="eyebrow">PALM READING</text>
<text class="title">手相报告</text>
<text class="subtitle">上传掌心照片,先生会扫描掌纹主线并生成娱乐向自我反思报告。</text>
</view>
<view class="panel guide">
<text class="guide-title">拍摄要求</text>
<text class="guide-line">掌心完整入镜,纹路清晰</text>
<text class="guide-line">光线充足,避免强反光和阴影</text>
<text class="guide-line">手掌自然伸展,不要遮挡主线</text>
</view>
<view class="section-label">这张照片是哪只手?</view>
<view class="side-picker">
<button class="side {{handSide === 'left' ? 'active' : ''}}" bindtap="chooseLeft">左手</button>
<button class="side {{handSide === 'right' ? 'active' : ''}}" bindtap="chooseRight">右手</button>
<button class="side {{handSide === 'unknown' ? 'active' : ''}}" bindtap="chooseUnknown">不确定</button>
</view>
<text class="side-note muted">可选项:知道左右手会让报告措辞更细;不确定也可以直接生成。</text>
<image wx:if="{{imagePath}}" class="preview" mode="aspectFill" src="{{imagePath}}" />
<button wx:if="{{!hasToken}}" class="primary-btn action" open-type="getPhoneNumber" bindgetphonenumber="loginWithPhone">手机号登录</button>
<button wx:if="{{!hasToken}}" class="ghost-btn action" bindtap="loginDev">开发模式登录</button>
<button class="ghost-btn action" bindtap="chooseImage">接入掌纹照片</button>
<button class="primary-btn action" loading="{{submitting}}" disabled="{{!imagePath || submitting}}" bindtap="submit">请先生解读</button>
<text class="legal muted" bindtap="openLegal">报告仅用于娱乐与自我反思,不构成任何现实决策建议。</text>
</view>

View File

@ -0,0 +1,104 @@
.hero {
padding: 34rpx 0 28rpx;
}
.eyebrow {
display: block;
color: #d8a84e;
font-size: 22rpx;
font-weight: 800;
letter-spacing: 0;
}
.title {
display: block;
margin-top: 10rpx;
font-size: 52rpx;
font-weight: 800;
color: #f2e9d8;
}
.subtitle {
display: block;
margin-top: 16rpx;
font-size: 28rpx;
line-height: 1.6;
color: #9db0b4;
}
.guide {
margin-top: 8rpx;
}
.guide-title,
.guide-line {
display: block;
}
.guide-title {
font-weight: 700;
margin-bottom: 14rpx;
}
.guide-line {
color: #9db0b4;
line-height: 1.8;
}
.section-label {
margin-top: 28rpx;
font-size: 28rpx;
font-weight: 800;
}
.side-picker {
display: flex;
gap: 16rpx;
margin: 16rpx 0 10rpx;
}
.side {
flex: 1;
height: 72rpx;
line-height: 72rpx;
padding: 0;
font-size: 26rpx;
color: #9db0b4;
background: rgba(16, 25, 28, 0.92);
border: 1rpx solid rgba(242, 233, 216, 0.12);
border-radius: 12rpx;
}
.side.active {
color: #06100e;
background: #00e0b8;
border-color: #00e0b8;
}
.side-note {
display: block;
margin-bottom: 24rpx;
font-size: 24rpx;
line-height: 1.6;
}
.preview {
width: 100%;
height: 520rpx;
border-radius: 16rpx;
margin-bottom: 24rpx;
background: #10191c;
border: 1rpx solid rgba(0, 224, 184, 0.24);
}
.action {
margin-top: 20rpx;
}
.legal {
display: block;
margin-top: 28rpx;
font-size: 24rpx;
line-height: 1.6;
text-align: center;
}

View File

@ -0,0 +1,128 @@
const { API_BASE_URL, authHeader, request } = require('../../utils/request')
Page({
data: {
id: '',
report: null,
data: null,
qualityPercent: 0,
dimensionCount: 0,
shareLoading: false,
shareStatusText: ''
},
onLoad(query) {
this.setData({ id: query.id })
this.loadReport()
},
async loadReport() {
try {
const report = await request({ url: `/reports/${this.data.id}` })
const data = report.report_data || {}
if (data.dimensions) {
data.dimensions = data.dimensions.map((item) => ({
...item,
confidencePercent: Math.round((item.confidence || 0) * 100)
}))
}
const handSideText = {
left: '左手',
right: '右手',
unknown: '未知手'
}[report.hand_side] || '未知手'
this.setData({
report: {
...report,
handSideText,
createdDate: (report.created_at || '').replace('T', ' ').slice(0, 16)
},
data,
qualityPercent: Math.round(((data.quality_check && data.quality_check.confidence) || 0) * 100),
dimensionCount: data.dimensions ? data.dimensions.length : 0
})
} catch (error) {
wx.showToast({ title: error.message || '加载失败', icon: 'none' })
}
},
deleteReport() {
wx.showModal({
title: '删除报告',
content: '删除后将无法恢复,关联照片也会被清理。',
success: async (res) => {
if (!res.confirm) return
try {
await request({ url: `/reports/${this.data.id}`, method: 'DELETE' })
wx.showToast({ title: '已删除' })
wx.switchTab({ url: '/pages/history/history' })
} catch (error) {
wx.showToast({ title: error.message || '删除失败', icon: 'none' })
}
}
})
},
generateShareImage() {
this.setData({ shareLoading: true, shareStatusText: '分享图生成中,通常需要 30-120 秒。你可以先继续查看报告。' })
request({
url: `/reports/${this.data.id}/share-image-jobs`,
method: 'POST'
})
.then((job) => this.pollShareImageJob(job.id, 0))
.catch((error) => {
this.setData({ shareLoading: false, shareStatusText: '' })
wx.showToast({ title: error.message || '创建任务失败', icon: 'none' })
})
},
pollShareImageJob(jobId, count) {
if (count > 80) {
this.setData({ shareLoading: false, shareStatusText: '' })
wx.showToast({ title: '生成时间较长,请稍后再试', icon: 'none' })
return
}
request({ url: `/reports/share-image-jobs/${jobId}` })
.then((job) => {
if (job.status === 'completed') {
this.downloadShareImage(jobId)
return
}
if (job.status === 'failed') {
this.setData({ shareLoading: false, shareStatusText: '' })
wx.showToast({ title: job.error_message || '分享图生成失败', icon: 'none' })
return
}
setTimeout(() => this.pollShareImageJob(jobId, count + 1), 2000)
})
.catch((error) => {
this.setData({ shareLoading: false, shareStatusText: '' })
wx.showToast({ title: error.message || '查询任务失败', icon: 'none' })
})
},
downloadShareImage(jobId) {
wx.downloadFile({
url: `${API_BASE_URL}/reports/share-image-jobs/${jobId}/image`,
header: authHeader(),
success: (res) => {
if (res.statusCode >= 200 && res.statusCode < 300) {
wx.previewImage({ current: res.tempFilePath, urls: [res.tempFilePath] })
} else {
wx.showToast({ title: '分享图下载失败', icon: 'none' })
}
},
fail: () => wx.showToast({ title: '分享图下载失败', icon: 'none' }),
complete: () => {
this.setData({ shareLoading: false, shareStatusText: '' })
}
})
},
onShareAppMessage() {
return {
title: '我在赛博先生生成了一份手相报告',
path: '/pages/index/index'
}
}
})

View File

@ -0,0 +1,4 @@
{
"navigationBarTitleText": "手相报告",
"enableShareAppMessage": true
}

View File

@ -0,0 +1,99 @@
<view class="page" wx:if="{{report}}">
<view class="header">
<text class="eyebrow">PALM REPORT · {{report.handSideText}}</text>
<text class="title">赛博先生手相报告</text>
<text class="time">{{report.createdDate}}</text>
</view>
<view class="panel insight-card">
<text class="insight-label">先生结论</text>
<text class="summary">{{data.overall_summary}}</text>
<view class="keywords inline">
<text wx:for="{{data.lucky_keywords}}" wx:key="*this" class="keyword">{{item}}</text>
</view>
</view>
<view class="metrics">
<view class="metric">
<text class="metric-value">{{qualityPercent}}%</text>
<text class="metric-label">照片可读性</text>
</view>
<view class="metric">
<text class="metric-value">{{dimensionCount}}</text>
<text class="metric-label">解读维度</text>
</view>
</view>
<view class="quality-note">
<text>{{data.quality_check.reason}}</text>
</view>
<view class="section-head">
<text class="section-title">核心维度</text>
<text class="section-subtitle">观察 · 解读 · 建议</text>
</view>
<view wx:for="{{data.dimensions}}" wx:key="name" class="panel dimension">
<view class="dimension-head">
<view>
<text class="dimension-index">0{{index + 1}}</text>
<text class="dimension-name">{{item.name}}</text>
</view>
<view class="confidence-pill">
<text>{{item.confidencePercent}}%</text>
</view>
</view>
<view class="block">
<text class="block-label">观察</text>
<view class="chips">
<text wx:for="{{item.observations}}" wx:for-item="obs" wx:key="*this" class="chip">{{obs}}</text>
</view>
</view>
<view class="block">
<text class="block-label">解读</text>
<text class="body">{{item.interpretation}}</text>
</view>
<view class="advice-box">
<text class="block-label">先生建议</text>
<text class="advice">{{item.advice}}</text>
</view>
</view>
<view class="section-head">
<text class="section-title">倾向总结</text>
<text class="section-subtitle">优势与提醒</text>
</view>
<view class="panel summary-grid">
<view class="summary-column">
<text class="column-title">优势倾向</text>
<text wx:for="{{data.strengths}}" wx:key="*this" class="list-item">{{item}}</text>
</view>
<view class="divider"></view>
<view class="summary-column">
<text class="column-title">近期提醒</text>
<text wx:for="{{data.suggestions}}" wx:key="*this" class="list-item">{{item}}</text>
</view>
</view>
<view wx:if="{{data.challenges && data.challenges.length}}" class="panel challenge-card">
<text class="column-title">需要留意</text>
<view class="chips">
<text wx:for="{{data.challenges}}" wx:key="*this" class="chip warn">{{item}}</text>
</view>
</view>
<view class="disclaimer-box">
<text class="disclaimer">{{data.disclaimer}}</text>
</view>
<button class="primary-btn action" loading="{{shareLoading}}" disabled="{{shareLoading}}" bindtap="generateShareImage">生成分享图</button>
<text wx:if="{{shareStatusText}}" class="share-status">{{shareStatusText}}</text>
<button class="ghost-btn action" bindtap="deleteReport">删除报告</button>
</view>
<view wx:else class="page">
<text class="muted">正在加载报告...</text>
</view>

View File

@ -0,0 +1,298 @@
.header {
padding: 28rpx 0 18rpx;
}
.eyebrow {
display: block;
color: #d8a84e;
font-size: 22rpx;
font-weight: 800;
}
.title {
display: block;
margin-top: 10rpx;
color: #f2e9d8;
font-size: 46rpx;
font-weight: 800;
line-height: 1.2;
}
.time {
display: block;
margin-top: 12rpx;
color: #728389;
font-size: 23rpx;
}
.insight-card {
position: relative;
overflow: hidden;
border-color: rgba(0, 224, 184, 0.42);
background: linear-gradient(135deg, rgba(0, 224, 184, 0.16), rgba(16, 25, 28, 0.95) 46%);
}
.insight-card::before {
content: "";
position: absolute;
top: 0;
left: 26rpx;
right: 26rpx;
height: 2rpx;
background: linear-gradient(90deg, transparent, #00e0b8, transparent);
}
.insight-label,
.block-label,
.column-title {
display: block;
color: #d8a84e;
font-size: 23rpx;
font-weight: 800;
}
.summary {
display: block;
margin-top: 16rpx;
color: #d9e4e1;
font-size: 30rpx;
line-height: 1.75;
}
.keywords {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.keywords.inline {
margin-top: 22rpx;
}
.keyword,
.chip {
display: inline-flex;
align-items: center;
min-height: 44rpx;
box-sizing: border-box;
border-radius: 999rpx;
padding: 8rpx 18rpx;
font-size: 24rpx;
}
.keyword {
color: #06100e;
background: #00e0b8;
}
.chip {
color: #bcefe5;
background: rgba(0, 224, 184, 0.1);
border: 1rpx solid rgba(0, 224, 184, 0.24);
}
.chip.warn {
color: #f2d3c9;
background: rgba(255, 107, 74, 0.1);
border-color: rgba(255, 107, 74, 0.28);
}
.metrics {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16rpx;
margin-top: 18rpx;
}
.metric {
padding: 22rpx;
border: 1rpx solid rgba(242, 233, 216, 0.1);
border-radius: 16rpx;
background: rgba(16, 25, 28, 0.78);
}
.metric-value,
.metric-label {
display: block;
}
.metric-value {
color: #00e0b8;
font-size: 38rpx;
font-weight: 800;
}
.metric-label {
margin-top: 6rpx;
color: #8da0a4;
font-size: 23rpx;
}
.quality-note {
margin-top: 14rpx;
color: #8da0a4;
font-size: 24rpx;
line-height: 1.6;
}
.section-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin: 38rpx 0 16rpx;
}
.section-title,
.section-subtitle {
display: block;
}
.section-title {
color: #f2e9d8;
font-size: 34rpx;
font-weight: 800;
}
.section-subtitle {
color: #728389;
font-size: 22rpx;
}
.dimension {
margin-bottom: 18rpx;
}
.dimension-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20rpx;
}
.dimension-index {
display: block;
color: rgba(242, 233, 216, 0.28);
font-size: 22rpx;
font-weight: 800;
}
.dimension-name {
display: block;
margin-top: 4rpx;
color: #f2e9d8;
font-size: 32rpx;
font-weight: 800;
}
.confidence-pill {
flex-shrink: 0;
min-width: 88rpx;
padding: 8rpx 14rpx;
border: 1rpx solid rgba(0, 224, 184, 0.3);
border-radius: 999rpx;
color: #00e0b8;
font-size: 23rpx;
font-weight: 800;
text-align: center;
}
.block {
margin-top: 22rpx;
}
.body,
.advice,
.list-item,
.disclaimer {
display: block;
line-height: 1.75;
}
.body {
margin-top: 10rpx;
color: #c5d4d3;
font-size: 28rpx;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 12rpx;
}
.advice-box {
margin-top: 22rpx;
padding: 22rpx;
border-radius: 14rpx;
background: rgba(216, 168, 78, 0.08);
border: 1rpx solid rgba(216, 168, 78, 0.18);
}
.advice {
margin-top: 8rpx;
color: #eadcc1;
font-size: 27rpx;
}
.summary-grid {
display: grid;
grid-template-columns: 1fr;
gap: 22rpx;
}
.summary-column {
min-width: 0;
}
.divider {
height: 1rpx;
background: rgba(242, 233, 216, 0.1);
}
.list-item {
position: relative;
margin-top: 12rpx;
padding-left: 26rpx;
color: #c5d4d3;
font-size: 27rpx;
}
.list-item::before {
content: "";
position: absolute;
left: 0;
top: 20rpx;
width: 9rpx;
height: 9rpx;
border-radius: 50%;
background: #00e0b8;
}
.challenge-card {
margin-top: 18rpx;
}
.disclaimer-box {
margin-top: 34rpx;
padding-top: 22rpx;
border-top: 1rpx solid rgba(242, 233, 216, 0.1);
}
.disclaimer {
color: #728389;
font-size: 23rpx;
}
.action {
margin-top: 28rpx;
}
.share-status {
display: block;
margin-top: 16rpx;
color: #9db0b4;
font-size: 24rpx;
line-height: 1.6;
text-align: center;
}

View File

@ -0,0 +1,39 @@
{
"description": "AI Palm Reading Mini Program",
"packOptions": {
"ignore": [],
"include": []
},
"setting": {
"urlCheck": false,
"es6": true,
"enhance": true,
"postcss": true,
"minified": true,
"compileWorklet": false,
"uglifyFileName": false,
"uploadWithSourceMap": true,
"packNpmManually": false,
"packNpmRelationList": [],
"minifyWXSS": true,
"minifyWXML": true,
"localPlugins": false,
"disableUseStrict": false,
"useCompilerPlugins": false,
"condition": false,
"swc": false,
"disableSWC": true,
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
}
},
"compileType": "miniprogram",
"libVersion": "3.6.4",
"appid": "wxe871ab859de77797",
"projectname": "people-reading",
"condition": {},
"simulatorPluginLibVersion": {},
"editorSetting": {}
}

8
miniprogram/sitemap.json Normal file
View File

@ -0,0 +1,8 @@
{
"rules": [
{
"action": "allow",
"page": "*"
}
]
}

View File

@ -0,0 +1,5 @@
const API_BASE_URL = 'http://127.0.0.1:8000/api/v1'
module.exports = {
API_BASE_URL
}

View File

@ -0,0 +1,30 @@
const MODULES = [
{
id: 'palm',
mark: '掌',
title: '手相报告',
description: '已开放 · 上传掌心照片',
status: 'available',
path: '/pages/palm/palm'
},
{
id: 'face',
mark: '面',
title: '面相解读',
description: '即将开放',
status: 'coming',
path: ''
},
{
id: 'bazi',
mark: '字',
title: '八字简批',
description: '即将开放',
status: 'coming',
path: ''
}
]
module.exports = {
MODULES
}

View File

@ -0,0 +1,61 @@
const { API_BASE_URL } = require('./config')
function authHeader(extra = {}) {
const app = getApp()
return {
...(app.globalData.token ? { Authorization: `Bearer ${app.globalData.token}` } : {}),
...extra
}
}
function request(options) {
const app = getApp()
return new Promise((resolve, reject) => {
wx.request({
url: `${API_BASE_URL}${options.url}`,
method: options.method || 'GET',
data: options.data || {},
header: authHeader({ 'content-type': 'application/json', ...(options.header || {}) }),
success(res) {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(res.data)
} else {
reject(new Error(res.data && res.data.detail ? res.data.detail : '请求失败'))
}
},
fail: reject
})
})
}
function uploadPalm(filePath) {
const app = getApp()
return new Promise((resolve, reject) => {
wx.uploadFile({
url: `${API_BASE_URL}/uploads/palm`,
filePath,
name: 'file',
header: authHeader(),
success(res) {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(JSON.parse(res.data))
} else {
try {
const data = JSON.parse(res.data)
reject(new Error(data.detail || '上传失败'))
} catch (error) {
reject(new Error('上传失败'))
}
}
},
fail: reject
})
})
}
module.exports = {
API_BASE_URL,
authHeader,
request,
uploadPalm
}

4
web/.dockerignore Normal file
View File

@ -0,0 +1,4 @@
node_modules
.next
.env.local
npm-debug.log

1
web/.env.example Normal file
View File

@ -0,0 +1 @@
NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8000/api/v1

21
web/Dockerfile Normal file
View File

@ -0,0 +1,21 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22-alpine AS builder
WORKDIR /app
ARG NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8000/api/v1
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV HOSTNAME=0.0.0.0
ENV PORT=3000
COPY --from=builder /app ./
EXPOSE 3000
CMD ["npm", "run", "start"]

703
web/app/globals.css Normal file
View File

@ -0,0 +1,703 @@
:root {
--ink: #090f0e;
--jade: #0d1b18;
--jade-2: #122722;
--paper: #f4ead4;
--paper-dim: #c7b999;
--gold: #d6a958;
--gold-soft: rgba(214, 169, 88, 0.18);
--cyan: #34e0bd;
--cyan-soft: rgba(52, 224, 189, 0.16);
--line: rgba(244, 234, 212, 0.13);
--danger: #ff7a5f;
}
* {
box-sizing: border-box;
}
html {
background: var(--ink);
color: var(--paper);
}
body {
margin: 0;
min-height: 100vh;
font-family: "Songti SC", "Noto Serif SC", "STSong", ui-serif, Georgia, serif;
background:
radial-gradient(circle at 18% 4%, rgba(214, 169, 88, 0.14), transparent 28rem),
radial-gradient(circle at 82% 12%, rgba(52, 224, 189, 0.12), transparent 24rem),
linear-gradient(135deg, rgba(255, 255, 255, 0.035) 0 1px, transparent 1px 32px),
var(--ink);
}
button,
input {
font: inherit;
}
button {
cursor: pointer;
}
.shell {
width: min(1180px, calc(100% - 40px));
margin: 0 auto;
padding: 36px 0 80px;
}
.hero {
position: relative;
display: grid;
grid-template-columns: 1fr minmax(260px, 420px);
gap: 38px;
min-height: 430px;
padding: 34px;
border: 1px solid var(--line);
border-radius: 28px;
overflow: hidden;
background:
linear-gradient(130deg, rgba(13, 27, 24, 0.96), rgba(9, 15, 14, 0.78)),
radial-gradient(circle at 70% 50%, rgba(52, 224, 189, 0.14), transparent 20rem);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.3);
}
.hero::after {
content: "";
position: absolute;
inset: 22px;
border: 1px solid rgba(214, 169, 88, 0.16);
border-radius: 22px;
pointer-events: none;
}
.brand {
display: flex;
align-items: center;
gap: 16px;
position: relative;
z-index: 1;
}
.seal {
display: grid;
width: 58px;
height: 58px;
place-items: center;
border: 1px solid var(--gold);
border-radius: 50%;
color: var(--gold);
font-size: 30px;
box-shadow: 0 0 36px var(--gold-soft);
}
.kicker,
.eyebrow,
.section-label {
margin: 0;
color: var(--gold);
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
h1,
h2,
h3,
h4,
p {
margin: 0;
}
h1 {
font-size: clamp(34px, 5vw, 64px);
letter-spacing: 0.08em;
}
.hero-copy {
align-self: end;
position: relative;
z-index: 1;
max-width: 680px;
}
.hero-copy h2 {
margin-top: 18px;
font-size: clamp(36px, 6vw, 82px);
line-height: 1.05;
letter-spacing: 0.02em;
}
.hero-copy p:last-child {
max-width: 620px;
margin-top: 22px;
color: var(--paper-dim);
font-size: 18px;
line-height: 1.8;
}
.hero-orbit {
position: relative;
z-index: 1;
align-self: center;
aspect-ratio: 1;
border: 1px solid rgba(52, 224, 189, 0.32);
border-radius: 50%;
background:
radial-gradient(circle, rgba(52, 224, 189, 0.22), transparent 46%),
repeating-radial-gradient(circle, rgba(214, 169, 88, 0.16) 0 1px, transparent 1px 26px);
}
.palm-mark {
position: absolute;
inset: 15%;
display: grid;
place-items: center;
border: 1px solid rgba(214, 169, 88, 0.42);
border-radius: 50%;
color: var(--cyan);
font-size: clamp(80px, 12vw, 150px);
text-shadow: 0 0 32px rgba(52, 224, 189, 0.42);
}
.workspace {
display: grid;
grid-template-columns: minmax(0, 1.3fr) minmax(320px, 0.7fr);
gap: 22px;
margin-top: 22px;
}
.upload-card,
.archive-card,
.report-panel {
border: 1px solid var(--line);
border-radius: 24px;
background: rgba(13, 27, 24, 0.82);
box-shadow: 0 18px 54px rgba(0, 0, 0, 0.22);
}
.upload-card,
.archive-card {
padding: 26px;
}
.upload-card h3,
.report-panel h3 {
margin-top: 10px;
font-size: 34px;
}
.upload-card > p {
margin-top: 12px;
color: var(--paper-dim);
line-height: 1.75;
}
.drop-zone {
position: relative;
display: grid;
min-height: 310px;
margin-top: 22px;
place-items: center;
border: 1px dashed rgba(214, 169, 88, 0.5);
border-radius: 22px;
background:
linear-gradient(135deg, rgba(214, 169, 88, 0.08), transparent),
rgba(9, 15, 14, 0.48);
overflow: hidden;
}
.drop-zone input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.drop-zone span {
color: var(--gold);
font-size: 20px;
}
.drop-zone img {
width: 100%;
height: 100%;
max-height: 420px;
object-fit: cover;
}
.segmented {
display: flex;
gap: 10px;
margin-top: 18px;
}
.segmented button,
.primary-action,
.delete-action {
min-height: 46px;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--paper);
background: rgba(255, 255, 255, 0.04);
}
.segmented button {
flex: 1;
}
.segmented .active,
.primary-action {
border-color: rgba(214, 169, 88, 0.8);
color: #1a1208;
background: linear-gradient(135deg, #f1d28c, var(--gold));
}
.primary-action {
width: 100%;
margin-top: 18px;
font-weight: 700;
}
.primary-action:disabled {
opacity: 0.7;
cursor: wait;
}
.error {
margin-top: 14px;
color: var(--danger);
}
.stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-top: 18px;
}
.stat {
padding: 18px;
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(255, 255, 255, 0.035);
}
.stat strong {
display: block;
color: var(--cyan);
font-size: 34px;
}
.stat span,
.empty {
color: var(--paper-dim);
}
.report-list {
display: grid;
gap: 12px;
margin-top: 18px;
}
.archive-item {
display: flex;
justify-content: space-between;
gap: 16px;
width: 100%;
padding: 16px;
border: 1px solid var(--line);
border-radius: 18px;
color: var(--paper);
text-align: left;
background: rgba(9, 15, 14, 0.44);
}
.archive-item strong,
.archive-item small {
display: block;
}
.archive-item small {
margin-top: 6px;
color: var(--paper-dim);
}
.archive-item em {
flex: 0 0 auto;
color: var(--cyan);
font-style: normal;
white-space: nowrap;
}
.report-panel {
margin-top: 22px;
padding: 28px;
}
.report-hero {
display: grid;
grid-template-columns: 1fr 180px;
gap: 24px;
padding: 26px;
border: 1px solid rgba(214, 169, 88, 0.22);
border-radius: 22px;
background: linear-gradient(135deg, rgba(214, 169, 88, 0.1), rgba(52, 224, 189, 0.06));
}
.report-hero p:not(.section-label) {
margin-top: 16px;
color: var(--paper-dim);
font-size: 18px;
line-height: 1.85;
}
.score {
display: grid;
place-content: center;
border: 1px solid rgba(52, 224, 189, 0.3);
border-radius: 50%;
aspect-ratio: 1;
color: var(--cyan);
text-align: center;
box-shadow: inset 0 0 40px rgba(52, 224, 189, 0.09);
}
.score strong {
font-size: 64px;
line-height: 1;
}
.score span {
color: var(--paper-dim);
}
.keywords,
.observations {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 18px;
}
.keywords span,
.observations span {
padding: 8px 13px;
border: 1px solid rgba(52, 224, 189, 0.24);
border-radius: 999px;
color: var(--cyan);
background: var(--cyan-soft);
}
.dimension-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
margin-top: 22px;
}
.dimension-card,
.summary-list {
border: 1px solid var(--line);
border-radius: 20px;
background: rgba(9, 15, 14, 0.38);
}
.dimension-card {
padding: 20px;
}
.dimension-head {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 12px;
}
.dimension-head span {
color: rgba(244, 234, 212, 0.4);
}
.dimension-head strong {
font-size: 22px;
}
.dimension-head em {
color: var(--cyan);
font-style: normal;
}
.dimension-card p {
margin-top: 14px;
color: var(--paper-dim);
line-height: 1.75;
}
.advice {
margin-top: 16px;
padding: 14px;
border-radius: 16px;
color: #f0d9a6;
background: var(--gold-soft);
}
.summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-top: 18px;
}
.summary-list {
padding: 18px;
}
.summary-list h4 {
color: var(--gold);
font-size: 18px;
}
.summary-list p {
margin-top: 12px;
color: var(--paper-dim);
line-height: 1.65;
}
.disclaimer {
margin-top: 18px;
color: rgba(244, 234, 212, 0.56);
line-height: 1.7;
}
.delete-action {
margin-top: 14px;
padding: 0 18px;
color: var(--danger);
}
@media (max-width: 860px) {
.shell {
width: min(100% - 24px, 1180px);
padding-top: 16px;
}
.hero,
.workspace,
.report-hero,
.dimension-grid,
.summary-grid,
.summary-grid {
grid-template-columns: 1fr;
}
.hero {
min-height: auto;
padding: 24px;
}
.hero-orbit {
max-width: 300px;
margin: 0 auto;
}
.report-hero {
padding: 20px;
}
.score {
width: 160px;
}
}
@media (max-width: 640px) {
body {
background:
radial-gradient(circle at 20% 0%, rgba(214, 169, 88, 0.14), transparent 18rem),
radial-gradient(circle at 88% 6%, rgba(52, 224, 189, 0.1), transparent 16rem),
var(--ink);
}
.shell {
width: 100%;
padding: 0 12px 48px;
}
.hero {
border-left: 0;
border-right: 0;
border-radius: 0 0 24px 24px;
gap: 22px;
padding: 20px 16px 22px;
}
.hero::after {
inset: 12px;
border-radius: 18px;
}
.brand {
gap: 12px;
}
.seal {
width: 48px;
height: 48px;
font-size: 24px;
}
h1 {
font-size: 34px;
}
.hero-copy h2 {
margin-top: 12px;
font-size: clamp(34px, 11vw, 48px);
line-height: 1.12;
}
.hero-copy p:last-child {
margin-top: 14px;
font-size: 15px;
line-height: 1.7;
}
.hero-orbit {
max-width: 220px;
}
.palm-mark {
font-size: 82px;
}
.workspace {
gap: 14px;
margin-top: 14px;
padding: 0 12px;
}
.upload-card,
.archive-card,
.report-panel {
border-radius: 20px;
padding: 18px;
}
.upload-card h3,
.report-panel h3 {
font-size: 27px;
}
.upload-card > p {
font-size: 14px;
}
.drop-zone {
min-height: 220px;
margin-top: 16px;
border-radius: 18px;
}
.drop-zone img {
max-height: 280px;
}
.segmented {
gap: 8px;
}
.segmented button,
.primary-action,
.delete-action {
min-height: 44px;
font-size: 14px;
}
.stats {
gap: 10px;
}
.stat {
padding: 14px;
}
.stat strong {
font-size: 28px;
}
.archive-item {
align-items: flex-start;
padding: 14px;
}
.archive-item strong {
font-size: 15px;
}
.archive-item small,
.archive-item em {
font-size: 12px;
}
.report-panel {
margin: 14px 12px 0;
}
.report-hero {
gap: 18px;
}
.report-hero p:not(.section-label) {
font-size: 15px;
line-height: 1.75;
}
.score {
width: 132px;
justify-self: start;
}
.score strong {
font-size: 48px;
}
.keywords span,
.observations span {
padding: 7px 11px;
font-size: 13px;
}
.dimension-grid {
gap: 12px;
}
.dimension-card,
.summary-list {
padding: 16px;
border-radius: 18px;
}
.dimension-head {
grid-template-columns: auto 1fr;
}
.dimension-head em {
grid-column: 2;
justify-self: start;
font-size: 13px;
}
.dimension-head strong {
font-size: 20px;
}
.dimension-card p,
.summary-list p,
.advice,
.disclaimer {
font-size: 14px;
line-height: 1.72;
}
}

15
web/app/layout.tsx Normal file
View File

@ -0,0 +1,15 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "赛博先生 | AI 手相报告",
description: "上传手相照片,生成一份面向生活、学习、事业与关系的娱乐向 AI 手相报告。",
};
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="zh-CN">
<body>{children}</body>
</html>
);
}

5
web/app/page.tsx Normal file
View File

@ -0,0 +1,5 @@
import PalmWebApp from "@/components/PalmWebApp";
export default function Home() {
return <PalmWebApp />;
}

View File

@ -0,0 +1,300 @@
"use client";
import { ChangeEvent, useEffect, useMemo, useState } from "react";
import {
Dimension,
HandSide,
Report,
ReportData,
ReportSummary,
apiFetch,
ensureToken,
uploadPalmImage,
} from "@/lib/api";
const handText: Record<HandSide, string> = {
left: "左手",
right: "右手",
unknown: "不确定",
};
const statusText: Record<Report["status"], string> = {
pending: "等待中",
processing: "生成中",
completed: "已完成",
failed: "失败",
};
export default function PalmWebApp() {
const [ready, setReady] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [preview, setPreview] = useState("");
const [handSide, setHandSide] = useState<HandSide>("unknown");
const [busyText, setBusyText] = useState("");
const [error, setError] = useState("");
const [reports, setReports] = useState<ReportSummary[]>([]);
const [activeReport, setActiveReport] = useState<Report | null>(null);
useEffect(() => {
ensureToken()
.then(() => Promise.all([loadReports()]))
.then(() => setReady(true))
.catch((err) => {
setError(err.message || "初始化失败");
setReady(true);
});
}, []);
const completedReports = reports.filter((item) => item.status === "completed").length;
function onPickFile(event: ChangeEvent<HTMLInputElement>) {
const selected = event.target.files?.[0];
if (!selected) return;
setFile(selected);
setError("");
if (preview) URL.revokeObjectURL(preview);
setPreview(URL.createObjectURL(selected));
}
async function loadReports() {
const data = await apiFetch<ReportSummary[]>("/reports");
setReports(data);
}
async function startReport() {
if (!file) {
setError("请先选择一张清晰的掌心照片。");
return;
}
setBusyText("正在接入掌纹照片...");
setError("");
try {
const upload = await uploadPalmImage(file);
setBusyText("先生正在解读掌纹...");
const report = await apiFetch<Report>("/reports", {
method: "POST",
body: JSON.stringify({ image_id: upload.image_id, hand_side: handSide }),
});
await pollReport(report.id);
await loadReports();
} catch (err) {
setError(err instanceof Error ? err.message : "生成失败");
} finally {
setBusyText("");
}
}
async function pollReport(reportId: string) {
for (let i = 0; i < 80; i += 1) {
const report = await apiFetch<Report>(`/reports/${reportId}`);
setActiveReport(report);
if (report.status === "completed") return report;
if (report.status === "failed") throw new Error(report.error_message || "报告生成失败");
await sleep(2200);
}
throw new Error("生成时间较长,请稍后在档案中查看。");
}
async function openReport(reportId: string) {
setError("");
const report = await apiFetch<Report>(`/reports/${reportId}`);
setActiveReport(report);
window.scrollTo({ top: 0, behavior: "smooth" });
}
async function deleteReport(reportId: string) {
await apiFetch(`/reports/${reportId}`, { method: "DELETE" });
if (activeReport?.id === reportId) setActiveReport(null);
await loadReports();
}
return (
<main className="shell">
<section className="hero">
<div className="brand">
<span className="seal"></span>
<div>
<p className="kicker">CYBER MISTER</p>
<h1></h1>
</div>
</div>
<div className="hero-copy">
<p className="eyebrow">AI · · </p>
<h2>线</h2>
<p>
AI
</p>
</div>
<div className="hero-orbit" aria-hidden="true">
<div className="palm-mark"></div>
</div>
</section>
<section className="workspace">
<div className="upload-card">
<div className="section-label">01 · </div>
<h3></h3>
<p>线</p>
<label className="drop-zone">
{preview ? <img src={preview} alt="掌心照片预览" /> : <span></span>}
<input type="file" accept="image/png,image/jpeg,image/webp" onChange={onPickFile} />
</label>
<div className="segmented">
{(["left", "right", "unknown"] as HandSide[]).map((side) => (
<button key={side} className={handSide === side ? "active" : ""} onClick={() => setHandSide(side)}>
{handText[side]}
</button>
))}
</div>
<button className="primary-action" disabled={!ready || Boolean(busyText)} onClick={startReport}>
{busyText || "生成手相报告"}
</button>
{error ? <p className="error">{error}</p> : null}
</div>
<div className="archive-card">
<div className="section-label">02 · </div>
<div className="stats">
<Stat label="累计报告" value={reports.length} />
<Stat label="已完成" value={completedReports} />
</div>
<div className="report-list">
{reports.length ? (
reports.slice(0, 6).map((item) => (
<button key={item.id} className="archive-item" onClick={() => openReport(item.id)}>
<span>
<strong></strong>
<small>{formatDate(item.created_at)}</small>
</span>
<em>{statusText[item.status]}</em>
</button>
))
) : (
<p className="empty"></p>
)}
</div>
</div>
</section>
{activeReport ? (
<ReportPanel
report={activeReport}
onDelete={() => deleteReport(activeReport.id)}
/>
) : null}
</main>
);
}
function ReportPanel({
report,
onDelete,
}: {
report: Report;
onDelete: () => void;
}) {
const data = report.report_data;
const score = useMemo(() => getScore(data), [data]);
if (!data) {
return (
<section className="report-panel">
<h3>{statusText[report.status]}</h3>
<p>{report.error_message || "先生正在整理报告。"}</p>
</section>
);
}
return (
<section className="report-panel">
<div className="report-hero">
<div>
<p className="section-label">PALM REPORT · {handText[report.hand_side]}</p>
<h3></h3>
<p>{data.overall_summary}</p>
</div>
<div className="score">
<strong>{score}</strong>
<span>/100</span>
</div>
</div>
<div className="keywords">
{data.lucky_keywords.map((word) => (
<span key={word}>{word}</span>
))}
</div>
<div className="dimension-grid">
{data.dimensions.map((dimension, index) => (
<DimensionCard key={`${dimension.name}-${index}`} dimension={dimension} index={index} />
))}
</div>
<div className="summary-grid">
<SummaryList title="优势倾向" items={data.strengths} />
<SummaryList title="需要留意" items={data.challenges} />
<SummaryList title="近期建议" items={data.suggestions} />
</div>
<p className="disclaimer">{data.disclaimer}</p>
<button className="delete-action" onClick={onDelete}></button>
</section>
);
}
function DimensionCard({ dimension, index }: { dimension: Dimension; index: number }) {
return (
<article className="dimension-card">
<div className="dimension-head">
<span>0{index + 1}</span>
<strong>{dimension.name}</strong>
<em>{Math.round(dimension.confidence * 100)}%</em>
</div>
<p>{dimension.interpretation}</p>
<div className="observations">
{dimension.observations.map((item) => (
<span key={item}>{item}</span>
))}
</div>
<div className="advice">{dimension.advice}</div>
</article>
);
}
function SummaryList({ title, items }: { title: string; items: string[] }) {
return (
<article className="summary-list">
<h4>{title}</h4>
{items.map((item) => (
<p key={item}>{item}</p>
))}
</article>
);
}
function Stat({ label, value }: { label: string; value: number }) {
return (
<div className="stat">
<strong>{value}</strong>
<span>{label}</span>
</div>
);
}
function getScore(data?: ReportData | null) {
if (!data?.dimensions?.length) return 68;
const average = data.dimensions.reduce((sum, item) => sum + item.confidence, 0) / data.dimensions.length;
const quality = data.quality_check?.confidence || 0.7;
return Math.max(60, Math.min(96, Math.round((average * 0.65 + quality * 0.35) * 100)));
}
function formatDate(value: string) {
return value.replace("T", " ").slice(0, 16);
}
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

106
web/lib/api.ts Normal file
View File

@ -0,0 +1,106 @@
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "http://127.0.0.1:8000/api/v1";
const TOKEN_KEY = "cyber_mister_token";
const CLIENT_KEY = "cyber_mister_client_id";
export type HandSide = "left" | "right" | "unknown";
export type Dimension = {
name: string;
observations: string[];
interpretation: string;
confidence: number;
advice: string;
};
export type ReportData = {
quality_check: {
can_analyze: boolean;
reason: string;
confidence: number;
};
overall_summary: string;
dimensions: Dimension[];
strengths: string[];
challenges: string[];
suggestions: string[];
lucky_keywords: string[];
disclaimer: string;
};
export type Report = {
id: string;
status: "pending" | "processing" | "completed" | "failed";
hand_side: HandSide;
error_message?: string | null;
report_data?: ReportData | null;
created_at: string;
updated_at: string;
};
export type ReportSummary = {
id: string;
status: Report["status"];
hand_side: HandSide;
created_at: string;
overall_summary?: string | null;
};
export function getStoredToken() {
if (typeof window === "undefined") return "";
return localStorage.getItem(TOKEN_KEY) || "";
}
export async function ensureToken() {
const existing = getStoredToken();
if (existing) return existing;
let clientId = localStorage.getItem(CLIENT_KEY);
if (!clientId) {
clientId = crypto.randomUUID();
localStorage.setItem(CLIENT_KEY, clientId);
}
const response = await fetch(`${API_BASE_URL}/auth/anonymous-login`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ client_id: clientId }),
});
if (!response.ok) throw new Error("匿名会话创建失败");
const data = await response.json();
localStorage.setItem(TOKEN_KEY, data.access_token);
return data.access_token as string;
}
export async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = await ensureToken();
const headers = new Headers(options.headers);
if (!(options.body instanceof FormData) && !headers.has("content-type")) {
headers.set("content-type", "application/json");
}
headers.set("authorization", `Bearer ${token}`);
const response = await fetch(`${API_BASE_URL}${path}`, { ...options, headers });
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.detail || "请求失败");
}
return response.json() as Promise<T>;
}
export async function uploadPalmImage(file: File) {
const token = await ensureToken();
const formData = new FormData();
formData.append("file", file);
const response = await fetch(`${API_BASE_URL}/uploads/palm`, {
method: "POST",
headers: { authorization: `Bearer ${token}` },
body: formData,
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.detail || "上传失败");
}
return response.json() as Promise<{ image_id: string; quality_check: Record<string, unknown>; expires_at: string }>;
}

6
web/next-env.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

7
web/next.config.ts Normal file
View File

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactStrictMode: true,
};
export default nextConfig;

5958
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
web/package.json Normal file
View File

@ -0,0 +1,24 @@
{
"name": "cyber-mister-web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --hostname 127.0.0.1 --port 3000",
"build": "next build",
"start": "next start --hostname 0.0.0.0 --port 3000",
"lint": "next lint"
},
"dependencies": {
"next": "16.2.6",
"react": "19.2.6",
"react-dom": "19.2.6"
},
"devDependencies": {
"@types/node": "22.10.5",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
"eslint": "9.17.0",
"eslint-config-next": "16.2.6",
"typescript": "5.7.2"
}
}

41
web/tsconfig.json Normal file
View File

@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}