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