first commit
This commit is contained in:
commit
bf6bdb1255
12
.dockerignore
Normal file
12
.dockerignore
Normal 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
94
.gitignore
vendored
Normal 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
57
README.md
Normal 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
7
backend/.dockerignore
Normal file
@ -0,0 +1,7 @@
|
||||
venv
|
||||
__pycache__
|
||||
app/**/__pycache__
|
||||
.pytest_cache
|
||||
palm_reading.db
|
||||
storage
|
||||
.env
|
||||
19
backend/.env.example
Normal file
19
backend/.env.example
Normal 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
21
backend/Dockerfile
Normal 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
24
backend/README.md
Normal 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
1
backend/app/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
1
backend/app/api/__init__.py
Normal file
1
backend/app/api/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
1
backend/app/api/v1/__init__.py
Normal file
1
backend/app/api/v1/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
1
backend/app/api/v1/endpoints/__init__.py
Normal file
1
backend/app/api/v1/endpoints/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
53
backend/app/api/v1/endpoints/auth.py
Normal file
53
backend/app/api/v1/endpoints/auth.py
Normal 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,
|
||||
)
|
||||
190
backend/app/api/v1/endpoints/reports.py
Normal file
190
backend/app/api/v1/endpoints/reports.py
Normal 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
|
||||
32
backend/app/api/v1/endpoints/uploads.py
Normal file
32
backend/app/api/v1/endpoints/uploads.py
Normal 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)
|
||||
8
backend/app/api/v1/router.py
Normal file
8
backend/app/api/v1/router.py
Normal 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
15
backend/app/cli.py
Normal 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())
|
||||
37
backend/app/core/config.py
Normal file
37
backend/app/core/config.py
Normal 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()
|
||||
31
backend/app/core/database.py
Normal file
31
backend/app/core/database.py
Normal 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)
|
||||
37
backend/app/core/security.py
Normal file
37
backend/app/core/security.py
Normal 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
32
backend/app/main.py
Normal 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"}
|
||||
1
backend/app/models/__init__.py
Normal file
1
backend/app/models/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
24
backend/app/models/palm_report.py
Normal file
24
backend/app/models/palm_report.py
Normal 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")
|
||||
20
backend/app/models/share_image_job.py
Normal file
20
backend/app/models/share_image_job.py
Normal 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)
|
||||
22
backend/app/models/uploaded_image.py
Normal file
22
backend/app/models/uploaded_image.py
Normal 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")
|
||||
19
backend/app/models/user.py
Normal file
19
backend/app/models/user.py
Normal 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")
|
||||
17
backend/app/schemas/auth.py
Normal file
17
backend/app/schemas/auth.py
Normal 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
|
||||
39
backend/app/schemas/report.py
Normal file
39
backend/app/schemas/report.py
Normal 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
|
||||
14
backend/app/schemas/share_image.py
Normal file
14
backend/app/schemas/share_image.py
Normal 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
|
||||
9
backend/app/schemas/upload.py
Normal file
9
backend/app/schemas/upload.py
Normal 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
|
||||
18
backend/app/services/cleanup_service.py
Normal file
18
backend/app/services/cleanup_service.py
Normal 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)
|
||||
78
backend/app/services/image_service.py
Normal file
78
backend/app/services/image_service.py
Normal 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]
|
||||
184
backend/app/services/palm_analyzer.py
Normal file
184
backend/app/services/palm_analyzer.py
Normal 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,
|
||||
}
|
||||
32
backend/app/services/report_service.py
Normal file
32
backend/app/services/report_service.py
Normal 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()
|
||||
355
backend/app/services/share_poster_service.py
Normal file
355
backend/app/services/share_poster_service.py
Normal 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] + "…"
|
||||
54
backend/app/services/wechat_service.py
Normal file
54
backend/app/services/wechat_service.py
Normal 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
14
backend/requirements.txt
Normal 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
|
||||
4
backend/tests/conftest.py
Normal file
4
backend/tests/conftest.py
Normal file
@ -0,0 +1,4 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
17
backend/tests/test_auth.py
Normal file
17
backend/tests/test_auth.py
Normal 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"]
|
||||
35
backend/tests/test_image_service.py
Normal file
35
backend/tests/test_image_service.py
Normal 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"]
|
||||
16
backend/tests/test_palm_analyzer.py
Normal file
16
backend/tests/test_palm_analyzer.py
Normal 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
37
docker-compose.yml
Normal 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
13
miniprogram/app.js
Normal 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
38
miniprogram/app.json
Normal 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
48
miniprogram/app.wxss
Normal 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;
|
||||
}
|
||||
BIN
miniprogram/assets/tabbar/archive-active.png
Normal file
BIN
miniprogram/assets/tabbar/archive-active.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 424 B |
BIN
miniprogram/assets/tabbar/archive-normal.png
Normal file
BIN
miniprogram/assets/tabbar/archive-normal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 428 B |
BIN
miniprogram/assets/tabbar/ask-active.png
Normal file
BIN
miniprogram/assets/tabbar/ask-active.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 617 B |
BIN
miniprogram/assets/tabbar/ask-normal.png
Normal file
BIN
miniprogram/assets/tabbar/ask-normal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 622 B |
47
miniprogram/pages/generating/generating.js
Normal file
47
miniprogram/pages/generating/generating.js
Normal 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' })
|
||||
}
|
||||
})
|
||||
3
miniprogram/pages/generating/generating.json
Normal file
3
miniprogram/pages/generating/generating.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "生成中"
|
||||
}
|
||||
6
miniprogram/pages/generating/generating.wxml
Normal file
6
miniprogram/pages/generating/generating.wxml
Normal 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>
|
||||
47
miniprogram/pages/generating/generating.wxss
Normal file
47
miniprogram/pages/generating/generating.wxss
Normal 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);
|
||||
}
|
||||
}
|
||||
52
miniprogram/pages/history/history.js
Normal file
52
miniprogram/pages/history/history.js
Normal 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' })
|
||||
}
|
||||
})
|
||||
3
miniprogram/pages/history/history.json
Normal file
3
miniprogram/pages/history/history.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "解读档案"
|
||||
}
|
||||
43
miniprogram/pages/history/history.wxml
Normal file
43
miniprogram/pages/history/history.wxml
Normal 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>
|
||||
183
miniprogram/pages/history/history.wxss
Normal file
183
miniprogram/pages/history/history.wxss
Normal 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;
|
||||
}
|
||||
31
miniprogram/pages/index/index.js
Normal file
31
miniprogram/pages/index/index.js
Normal 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' })
|
||||
}
|
||||
})
|
||||
3
miniprogram/pages/index/index.json
Normal file
3
miniprogram/pages/index/index.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "赛博先生"
|
||||
}
|
||||
31
miniprogram/pages/index/index.wxml
Normal file
31
miniprogram/pages/index/index.wxml
Normal 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>
|
||||
186
miniprogram/pages/index/index.wxss
Normal file
186
miniprogram/pages/index/index.wxss
Normal 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;
|
||||
}
|
||||
1
miniprogram/pages/legal/legal.js
Normal file
1
miniprogram/pages/legal/legal.js
Normal file
@ -0,0 +1 @@
|
||||
Page({})
|
||||
3
miniprogram/pages/legal/legal.json
Normal file
3
miniprogram/pages/legal/legal.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "协议与隐私"
|
||||
}
|
||||
18
miniprogram/pages/legal/legal.wxml
Normal file
18
miniprogram/pages/legal/legal.wxml
Normal 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>
|
||||
23
miniprogram/pages/legal/legal.wxss
Normal file
23
miniprogram/pages/legal/legal.wxss
Normal 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;
|
||||
}
|
||||
96
miniprogram/pages/palm/palm.js
Normal file
96
miniprogram/pages/palm/palm.js
Normal 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' })
|
||||
}
|
||||
})
|
||||
3
miniprogram/pages/palm/palm.json
Normal file
3
miniprogram/pages/palm/palm.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "手相报告"
|
||||
}
|
||||
31
miniprogram/pages/palm/palm.wxml
Normal file
31
miniprogram/pages/palm/palm.wxml
Normal 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>
|
||||
104
miniprogram/pages/palm/palm.wxss
Normal file
104
miniprogram/pages/palm/palm.wxss
Normal 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;
|
||||
}
|
||||
128
miniprogram/pages/report/report.js
Normal file
128
miniprogram/pages/report/report.js
Normal 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'
|
||||
}
|
||||
}
|
||||
})
|
||||
4
miniprogram/pages/report/report.json
Normal file
4
miniprogram/pages/report/report.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "手相报告",
|
||||
"enableShareAppMessage": true
|
||||
}
|
||||
99
miniprogram/pages/report/report.wxml
Normal file
99
miniprogram/pages/report/report.wxml
Normal 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>
|
||||
298
miniprogram/pages/report/report.wxss
Normal file
298
miniprogram/pages/report/report.wxss
Normal 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;
|
||||
}
|
||||
39
miniprogram/project.config.json
Normal file
39
miniprogram/project.config.json
Normal 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
8
miniprogram/sitemap.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"action": "allow",
|
||||
"page": "*"
|
||||
}
|
||||
]
|
||||
}
|
||||
5
miniprogram/utils/config.js
Normal file
5
miniprogram/utils/config.js
Normal file
@ -0,0 +1,5 @@
|
||||
const API_BASE_URL = 'http://127.0.0.1:8000/api/v1'
|
||||
|
||||
module.exports = {
|
||||
API_BASE_URL
|
||||
}
|
||||
30
miniprogram/utils/modules.js
Normal file
30
miniprogram/utils/modules.js
Normal 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
|
||||
}
|
||||
61
miniprogram/utils/request.js
Normal file
61
miniprogram/utils/request.js
Normal 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
4
web/.dockerignore
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
.next
|
||||
.env.local
|
||||
npm-debug.log
|
||||
1
web/.env.example
Normal file
1
web/.env.example
Normal file
@ -0,0 +1 @@
|
||||
NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8000/api/v1
|
||||
21
web/Dockerfile
Normal file
21
web/Dockerfile
Normal 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
703
web/app/globals.css
Normal 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
15
web/app/layout.tsx
Normal 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
5
web/app/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import PalmWebApp from "@/components/PalmWebApp";
|
||||
|
||||
export default function Home() {
|
||||
return <PalmWebApp />;
|
||||
}
|
||||
300
web/components/PalmWebApp.tsx
Normal file
300
web/components/PalmWebApp.tsx
Normal 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
106
web/lib/api.ts
Normal 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
6
web/next-env.d.ts
vendored
Normal 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
7
web/next.config.ts
Normal 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
5958
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
web/package.json
Normal file
24
web/package.json
Normal 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
41
web/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user