update
This commit is contained in:
parent
2256ded518
commit
f89f5ef6e5
4
.gitignore
vendored
4
.gitignore
vendored
@ -41,6 +41,10 @@ backend/.venv/
|
||||
venv/
|
||||
|
||||
# Backend runtime data
|
||||
palm_reading.db
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
backend/palm_reading.db
|
||||
backend/*.db
|
||||
backend/*.sqlite
|
||||
|
||||
@ -8,6 +8,7 @@ OPENAI_API_KEY=
|
||||
OPENAI_BASE_URL=
|
||||
OPENAI_MODEL=gpt-4.1-mini
|
||||
OPENAI_IMAGE_MODEL=gpt-image-2
|
||||
OPENAI_TIMEOUT_SECONDS=180
|
||||
SHARE_IMAGE_MODE=ai
|
||||
|
||||
WECHAT_APP_ID=
|
||||
|
||||
102
backend/app/api/v1/endpoints/readings.py
Normal file
102
backend/app/api/v1/endpoints/readings.py
Normal file
@ -0,0 +1,102 @@
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
|
||||
from pydantic import ValidationError
|
||||
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.reading import Reading
|
||||
from app.models.uploaded_image import UploadedImage
|
||||
from app.models.user import User
|
||||
from app.schemas.reading import BaziInput, ReadingCreate, ReadingDetail, ReadingSummary
|
||||
from app.services.image_service import ImageService
|
||||
from app.services.reading_service import ReadingService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def generate_reading_task(reading_id: str) -> None:
|
||||
async with AsyncSessionLocal() as session:
|
||||
await ReadingService().generate(session, reading_id)
|
||||
|
||||
|
||||
@router.post("", response_model=ReadingDetail, status_code=status.HTTP_201_CREATED)
|
||||
async def create_reading(
|
||||
payload: ReadingCreate,
|
||||
background_tasks: BackgroundTasks,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
input_data = dict(payload.input_data)
|
||||
image_id = payload.image_id
|
||||
|
||||
if payload.reading_type in {"palm", "face"}:
|
||||
image_result = await db.execute(select(UploadedImage).where(UploadedImage.id == 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")
|
||||
elif payload.reading_type == "bazi":
|
||||
try:
|
||||
input_data = BaziInput.model_validate(input_data).model_dump()
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=422, detail=exc.errors()) from exc
|
||||
|
||||
reading = Reading(
|
||||
user_id=user.id,
|
||||
reading_type=payload.reading_type,
|
||||
image_id=image_id,
|
||||
input_data=input_data,
|
||||
status="pending",
|
||||
)
|
||||
db.add(reading)
|
||||
await db.flush()
|
||||
await db.refresh(reading)
|
||||
await db.commit()
|
||||
background_tasks.add_task(generate_reading_task, reading.id)
|
||||
return reading
|
||||
|
||||
|
||||
@router.get("", response_model=list[ReadingSummary])
|
||||
async def list_readings(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
result = await db.execute(
|
||||
select(Reading).where(Reading.user_id == user.id).order_by(desc(Reading.created_at)).limit(80)
|
||||
)
|
||||
readings = result.scalars().all()
|
||||
return [
|
||||
ReadingSummary(
|
||||
id=reading.id,
|
||||
reading_type=reading.reading_type,
|
||||
status=reading.status,
|
||||
created_at=reading.created_at,
|
||||
overall_summary=(reading.report_data or {}).get("overall_summary") if reading.report_data else None,
|
||||
)
|
||||
for reading in readings
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{reading_id}", response_model=ReadingDetail)
|
||||
async def get_reading(reading_id: str, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
return await _get_owned_reading(db, reading_id, user.id)
|
||||
|
||||
|
||||
@router.delete("/{reading_id}")
|
||||
async def delete_reading(reading_id: str, user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||
reading = await _get_owned_reading(db, reading_id, user.id)
|
||||
image = None
|
||||
if reading.image_id:
|
||||
image_result = await db.execute(select(UploadedImage).where(UploadedImage.id == reading.image_id))
|
||||
image = image_result.scalar_one_or_none()
|
||||
await db.delete(reading)
|
||||
await db.flush()
|
||||
if image:
|
||||
ImageService().delete(image.storage_key)
|
||||
await db.delete(image)
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
async def _get_owned_reading(db: AsyncSession, reading_id: str, user_id: str) -> Reading:
|
||||
result = await db.execute(select(Reading).where(Reading.id == reading_id, Reading.user_id == user_id))
|
||||
reading = result.scalar_one_or_none()
|
||||
if reading is None:
|
||||
raise HTTPException(status_code=404, detail="Reading not found")
|
||||
return reading
|
||||
@ -70,6 +70,7 @@ async def create_report(
|
||||
db.add(report)
|
||||
await db.flush()
|
||||
await db.refresh(report)
|
||||
await db.commit()
|
||||
background_tasks.add_task(generate_report_task, report.id)
|
||||
return report
|
||||
|
||||
@ -137,6 +138,7 @@ async def create_share_image_job(
|
||||
db.add(job)
|
||||
await db.flush()
|
||||
await db.refresh(job)
|
||||
await db.commit()
|
||||
background_tasks.add_task(generate_share_image_task, job.id)
|
||||
return job
|
||||
|
||||
|
||||
@ -17,11 +17,24 @@ async def upload_palm_image(
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await _store_image(file, user, db, "palm")
|
||||
|
||||
|
||||
@router.post("/face", response_model=UploadResponse)
|
||||
async def upload_face_image(
|
||||
file: UploadFile = File(...),
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await _store_image(file, user, db, "face")
|
||||
|
||||
|
||||
async def _store_image(file: UploadFile, user: User, db: AsyncSession, prefix: str) -> UploadResponse:
|
||||
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",
|
||||
original_filename=file.filename or prefix,
|
||||
content_type=file.content_type or "application/octet-stream",
|
||||
size_bytes=size_bytes,
|
||||
expires_at=expires_at,
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1.endpoints import auth, reports, uploads
|
||||
from app.api.v1.endpoints import auth, readings, 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"])
|
||||
api_router.include_router(readings.router, prefix="/readings", tags=["readings"])
|
||||
|
||||
@ -16,6 +16,7 @@ class Settings(BaseSettings):
|
||||
openai_base_url: str | None = None
|
||||
openai_model: str = "gpt-4.1-mini"
|
||||
openai_image_model: str = "gpt-image-2"
|
||||
openai_timeout_seconds: float = 180
|
||||
share_image_mode: str = "ai"
|
||||
|
||||
wechat_app_id: str | None = None
|
||||
|
||||
@ -25,7 +25,7 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
from app.models import palm_report, share_image_job, uploaded_image, user # noqa: F401
|
||||
from app.models import palm_report, reading, share_image_job, uploaded_image, user # noqa: F401
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
25
backend/app/models/reading.py
Normal file
25
backend/app/models/reading.py
Normal file
@ -0,0 +1,25 @@
|
||||
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 Reading(Base):
|
||||
__tablename__ = "readings"
|
||||
|
||||
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)
|
||||
reading_type: Mapped[str] = mapped_column(String(24), index=True)
|
||||
image_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("uploaded_images.id"), nullable=True)
|
||||
input_data: Mapped[dict] = mapped_column(JSON, default=dict)
|
||||
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="readings")
|
||||
image = relationship("UploadedImage", back_populates="readings")
|
||||
@ -20,3 +20,4 @@ class UploadedImage(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
|
||||
reports = relationship("PalmReport", back_populates="image")
|
||||
readings = relationship("Reading", back_populates="image")
|
||||
|
||||
@ -17,3 +17,4 @@ class User(Base):
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
reports = relationship("PalmReport", back_populates="user")
|
||||
readings = relationship("Reading", back_populates="user")
|
||||
|
||||
56
backend/app/schemas/reading.py
Normal file
56
backend/app/schemas/reading.py
Normal file
@ -0,0 +1,56 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
ReadingType = Literal["palm", "face", "bazi"]
|
||||
ReadingStatus = Literal["pending", "processing", "completed", "failed"]
|
||||
|
||||
|
||||
class BaziInput(BaseModel):
|
||||
nickname: str | None = None
|
||||
gender: str | None = None
|
||||
calendar_type: Literal["solar", "lunar"] = "solar"
|
||||
is_leap_month: bool = False
|
||||
birth_date: str
|
||||
birth_time: str | None = None
|
||||
time_unknown: bool = False
|
||||
birth_place: str | None = None
|
||||
|
||||
|
||||
class ReadingCreate(BaseModel):
|
||||
reading_type: ReadingType
|
||||
image_id: str | None = None
|
||||
input_data: dict = Field(default_factory=dict)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_payload(self):
|
||||
if self.reading_type in {"palm", "face"} and not self.image_id:
|
||||
raise ValueError("image_id is required for image readings")
|
||||
if self.reading_type == "bazi":
|
||||
BaziInput.model_validate(self.input_data)
|
||||
return self
|
||||
|
||||
|
||||
class ReadingSummary(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
reading_type: str
|
||||
status: str
|
||||
created_at: datetime
|
||||
overall_summary: str | None = None
|
||||
|
||||
|
||||
class ReadingDetail(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
reading_type: str
|
||||
status: str
|
||||
input_data: dict
|
||||
error_message: str | None = None
|
||||
report_data: dict | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
122
backend/app/services/analyzer_common.py
Normal file
122
backend/app/services/analyzer_common.py
Normal file
@ -0,0 +1,122 @@
|
||||
import base64
|
||||
import json
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
DISCLAIMER = "本报告仅用于娱乐占卜与自我反思,不构成医学、心理、职业、财务、投资或任何人生决策建议。"
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"你是“赛博先生”,一个面向普通人的娱乐型 AI 命理解读助手。"
|
||||
"你的风格要像一个会聊天、懂生活的朋友:温和、具体、接地气,有一点玄学仪式感,但不要装神秘。"
|
||||
"所有表达都必须使用“可能、倾向、适合、提醒你”这类非确定性措辞。"
|
||||
"禁止给出医疗、心理诊断、投资、职业成败、婚恋结果、寿命、灾祸等确定性判断。"
|
||||
"不要堆砌专业术语,不要写得像教材;每个结论都要落到生活、学习、事业、关系或近期行动里的具体场景。"
|
||||
)
|
||||
|
||||
REPORT_SCHEMA = {
|
||||
"name": "reading_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 BaseAnalyzer:
|
||||
def __init__(self) -> None:
|
||||
self.client = (
|
||||
AsyncOpenAI(
|
||||
api_key=settings.openai_api_key,
|
||||
base_url=settings.openai_base_url,
|
||||
timeout=settings.openai_timeout_seconds,
|
||||
)
|
||||
if settings.openai_api_key
|
||||
else None
|
||||
)
|
||||
|
||||
async def _create_text_report(self, user_prompt: str) -> dict:
|
||||
if not self.client:
|
||||
raise RuntimeError("OpenAI client is not configured")
|
||||
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}]},
|
||||
],
|
||||
text={"format": {"type": "json_schema", **REPORT_SCHEMA}},
|
||||
)
|
||||
data = json.loads(response.output_text)
|
||||
data["disclaimer"] = DISCLAIMER
|
||||
return data
|
||||
|
||||
async def _create_image_report(self, user_prompt: str, image_bytes: bytes, content_type: str) -> dict:
|
||||
if not self.client:
|
||||
raise RuntimeError("OpenAI client is not configured")
|
||||
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},
|
||||
{
|
||||
"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
|
||||
91
backend/app/services/bazi_analyzer.py
Normal file
91
backend/app/services/bazi_analyzer.py
Normal file
@ -0,0 +1,91 @@
|
||||
import json
|
||||
|
||||
from app.services.analyzer_common import DISCLAIMER, BaseAnalyzer
|
||||
|
||||
USER_PROMPT_TEMPLATE = (
|
||||
"请基于下面这份八字排盘结果,生成中文八字娱乐报告。"
|
||||
"排盘结果由后端计算,不能自行改动四柱:{chart_json}"
|
||||
"必须覆盖四柱结构、日主与五行、十神关系、学习成长、事业节奏、关系沟通、近期行动。"
|
||||
"写作要求:"
|
||||
"1. overall_summary 用 2 到 4 句话,像给用户本人看的开场结论。"
|
||||
"2. dimensions 中每个 interpretation 必须落到现实场景,例如学习效率、职业节奏、沟通方式、情绪恢复、计划执行。"
|
||||
"3. 每个 advice 必须是今天或本周能做的小建议。"
|
||||
"4. 可以少量使用日主、五行、十神等术语,但必须用普通人能听懂的话解释。"
|
||||
"5. 不预测寿命、灾祸、婚姻成败、财富结果,不做宿命论。"
|
||||
"6. 如果时辰不详,要诚实降低相关维度 confidence,并说明分析会更偏概览。"
|
||||
)
|
||||
|
||||
|
||||
class BaziAnalyzer(BaseAnalyzer):
|
||||
async def analyze(self, chart: dict) -> dict:
|
||||
if not self.client:
|
||||
return self._mock_report(chart)
|
||||
prompt = USER_PROMPT_TEMPLATE.format(chart_json=json.dumps(chart, ensure_ascii=False))
|
||||
try:
|
||||
return await self._create_text_report(prompt)
|
||||
except Exception as exc:
|
||||
report = self._mock_report(chart)
|
||||
report["quality_check"] = {
|
||||
"can_analyze": True,
|
||||
"reason": f"模型响应较慢,已先基于后端排盘生成本地报告:{exc}",
|
||||
"confidence": 0.62,
|
||||
}
|
||||
report["overall_summary"] = "模型响应较慢,先生先根据后端排出的四柱给你一份基础娱乐解读。等模型接口稳定后,可以再次生成更细腻的版本。" + report["overall_summary"]
|
||||
return report
|
||||
|
||||
def _mock_report(self, chart: dict) -> dict:
|
||||
pillars = chart.get("pillars", {})
|
||||
day_master = chart.get("day_master", "日主")
|
||||
return {
|
||||
"quality_check": {"can_analyze": True, "reason": "mock mode", "confidence": 0.76},
|
||||
"overall_summary": f"这是一份基于 {pillars.get('year', '')} {pillars.get('month', '')} {pillars.get('day', '')} {pillars.get('time', '')} 的娱乐八字报告。整体看,你的日主线索偏向“{day_master}”,适合先建立稳定节奏,再去放大自己的表达和行动力。近期最重要的是别急着求大结果,先把可执行的小计划跑起来。",
|
||||
"dimensions": [
|
||||
{
|
||||
"name": "四柱结构",
|
||||
"observations": [f"年柱:{pillars.get('year')}", f"月柱:{pillars.get('month')}", f"日柱:{pillars.get('day')}", f"时柱:{pillars.get('time')}"],
|
||||
"interpretation": "四柱结构象征一个人的底色、节奏和外部互动方式。放到现实里,你更适合把目标拆成阶段,不必要求自己一步到位。",
|
||||
"confidence": 0.72,
|
||||
"advice": "今天先写下一个 7 天目标,只保留最关键的三件事。",
|
||||
},
|
||||
{
|
||||
"name": "日主与五行",
|
||||
"observations": [f"日主:{day_master}", f"五行线索:{chart.get('wuxing', {})}"],
|
||||
"interpretation": "日主象征你处理世界的核心方式。你适合找到自己的稳定补给,再去应对学习、工作和关系里的变化。",
|
||||
"confidence": 0.7,
|
||||
"advice": "这周给自己固定一个恢复时间,别把精力全部交给外界安排。",
|
||||
},
|
||||
{
|
||||
"name": "十神关系",
|
||||
"observations": [f"十神线索:{chart.get('shi_shen', {})}"],
|
||||
"interpretation": "十神关系可以理解为你和资源、表达、规则、压力之间的互动方式。现实里,适合把压力转成清单,而不是只在脑子里反复想。",
|
||||
"confidence": 0.66,
|
||||
"advice": "遇到复杂任务时,先写下“我能控制的部分”。",
|
||||
},
|
||||
{
|
||||
"name": "学习成长",
|
||||
"observations": ["适合阶段式吸收", "需要稳定反馈"],
|
||||
"interpretation": "学习上你更适合循序渐进。比起临时爆发,持续复盘更能帮你看到进步。",
|
||||
"confidence": 0.68,
|
||||
"advice": "今天结束前写 5 行复盘,记录一个学到的点。",
|
||||
},
|
||||
{
|
||||
"name": "事业节奏",
|
||||
"observations": ["适合沉淀方法", "不宜频繁自我推翻"],
|
||||
"interpretation": "事业上适合先做稳定交付,再逐步争取主动权。越是焦虑时,越需要用作品和结果给自己定心。",
|
||||
"confidence": 0.65,
|
||||
"advice": "本周整理一个能展示的小成果,哪怕只是文档或案例。",
|
||||
},
|
||||
{
|
||||
"name": "关系沟通",
|
||||
"observations": ["需要清晰边界", "适合具体表达"],
|
||||
"interpretation": "关系里你可能会先照顾气氛,但真正有效的是把需求讲清楚。温和不等于什么都自己消化。",
|
||||
"confidence": 0.63,
|
||||
"advice": "把一个模糊的不舒服,改写成一句具体请求。",
|
||||
},
|
||||
],
|
||||
"strengths": ["适合长期积累", "能在变化里调整节奏", "对关系氛围比较敏感"],
|
||||
"challenges": ["压力大时容易想太多", "计划过满会消耗行动力"],
|
||||
"suggestions": ["生活上固定恢复时间", "学习成长上做短复盘", "事业工作上沉淀可展示成果", "关系沟通上把需求说具体"],
|
||||
"lucky_keywords": ["阶段推进", "稳定补给", "具体表达"],
|
||||
"disclaimer": DISCLAIMER,
|
||||
}
|
||||
98
backend/app/services/bazi_calculator.py
Normal file
98
backend/app/services/bazi_calculator.py
Normal file
@ -0,0 +1,98 @@
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
from lunar_python import Lunar, Solar
|
||||
except ImportError: # pragma: no cover - production image installs the package
|
||||
Lunar = None
|
||||
Solar = None
|
||||
|
||||
|
||||
class BaziCalculator:
|
||||
def calculate(self, payload: dict) -> dict:
|
||||
birth_date = payload["birth_date"]
|
||||
time_unknown = bool(payload.get("time_unknown"))
|
||||
birth_time = payload.get("birth_time") if not time_unknown else "12:00"
|
||||
hour, minute = self._parse_time(birth_time or "12:00")
|
||||
year, month, day = self._parse_date(birth_date)
|
||||
calendar_type = payload.get("calendar_type", "solar")
|
||||
|
||||
if Solar and Lunar:
|
||||
if calendar_type == "lunar":
|
||||
lunar_month = -month if payload.get("is_leap_month") else month
|
||||
lunar = Lunar.fromYmdHms(year, lunar_month, day, hour, minute, 0)
|
||||
solar = lunar.getSolar()
|
||||
else:
|
||||
solar = Solar.fromYmdHms(year, month, day, hour, minute, 0)
|
||||
lunar = solar.getLunar()
|
||||
eight = lunar.getEightChar()
|
||||
return {
|
||||
"calendar_type": calendar_type,
|
||||
"is_leap_month": bool(payload.get("is_leap_month")),
|
||||
"birth_date": birth_date,
|
||||
"birth_time": None if time_unknown else f"{hour:02d}:{minute:02d}",
|
||||
"time_unknown": time_unknown,
|
||||
"birth_place": payload.get("birth_place"),
|
||||
"nickname": payload.get("nickname"),
|
||||
"gender": payload.get("gender"),
|
||||
"solar_date": f"{solar.getYear():04d}-{solar.getMonth():02d}-{solar.getDay():02d}",
|
||||
"lunar_date": f"{lunar.getYear()}年{lunar.getMonth()}月{lunar.getDay()}日",
|
||||
"pillars": {
|
||||
"year": eight.getYear(),
|
||||
"month": eight.getMonth(),
|
||||
"day": eight.getDay(),
|
||||
"time": eight.getTime() if not time_unknown else "时辰不详",
|
||||
},
|
||||
"wuxing": {
|
||||
"year": eight.getYearWuXing(),
|
||||
"month": eight.getMonthWuXing(),
|
||||
"day": eight.getDayWuXing(),
|
||||
"time": eight.getTimeWuXing() if not time_unknown else "时辰不详",
|
||||
},
|
||||
"shi_shen": {
|
||||
"year": eight.getYearShiShenGan(),
|
||||
"month": eight.getMonthShiShenGan(),
|
||||
"day": "日主",
|
||||
"time": eight.getTimeShiShenGan() if not time_unknown else "时辰不详",
|
||||
},
|
||||
"day_master": eight.getDayGan(),
|
||||
}
|
||||
|
||||
return self._fallback_chart(payload, year, month, day, hour, minute, time_unknown)
|
||||
|
||||
def _fallback_chart(self, payload: dict, year: int, month: int, day: int, hour: int, minute: int, time_unknown: bool) -> dict:
|
||||
stems = "甲乙丙丁戊己庚辛壬癸"
|
||||
branches = "子丑寅卯辰巳午未申酉戌亥"
|
||||
seed = year * 372 + month * 31 + day + hour
|
||||
|
||||
def pillar(offset: int) -> str:
|
||||
return stems[(seed + offset) % 10] + branches[(seed + offset) % 12]
|
||||
|
||||
return {
|
||||
"calendar_type": payload.get("calendar_type", "solar"),
|
||||
"is_leap_month": bool(payload.get("is_leap_month")),
|
||||
"birth_date": payload["birth_date"],
|
||||
"birth_time": None if time_unknown else f"{hour:02d}:{minute:02d}",
|
||||
"time_unknown": time_unknown,
|
||||
"birth_place": payload.get("birth_place"),
|
||||
"nickname": payload.get("nickname"),
|
||||
"gender": payload.get("gender"),
|
||||
"solar_date": payload["birth_date"],
|
||||
"lunar_date": "本地未安装 lunar_python,暂用娱乐排盘",
|
||||
"pillars": {
|
||||
"year": pillar(0),
|
||||
"month": pillar(13),
|
||||
"day": pillar(27),
|
||||
"time": pillar(41) if not time_unknown else "时辰不详",
|
||||
},
|
||||
"wuxing": {"year": "参考", "month": "参考", "day": "参考", "time": "参考"},
|
||||
"shi_shen": {"year": "参考", "month": "参考", "day": "日主", "time": "参考"},
|
||||
"day_master": pillar(27)[0],
|
||||
}
|
||||
|
||||
def _parse_date(self, value: str) -> tuple[int, int, int]:
|
||||
parsed = datetime.strptime(value, "%Y-%m-%d")
|
||||
return parsed.year, parsed.month, parsed.day
|
||||
|
||||
def _parse_time(self, value: str) -> tuple[int, int]:
|
||||
parsed = datetime.strptime(value, "%H:%M")
|
||||
return parsed.hour, parsed.minute
|
||||
75
backend/app/services/face_analyzer.py
Normal file
75
backend/app/services/face_analyzer.py
Normal file
@ -0,0 +1,75 @@
|
||||
from app.services.analyzer_common import DISCLAIMER, BaseAnalyzer
|
||||
|
||||
USER_PROMPT = (
|
||||
"请分析这张单人正脸照片,生成中文面相娱乐报告。"
|
||||
"必须先判断照片是否适合分析:是否单人、正脸、五官无遮挡、光线清晰。"
|
||||
"必须覆盖脸型轮廓、三庭五眼、眉眼神态、鼻唇表达、气色状态、整体气质。"
|
||||
"写作要求:"
|
||||
"1. overall_summary 用 2 到 4 句话,像给用户本人看的开场结论,要贴近日常生活。"
|
||||
"2. dimensions 中每个 interpretation 必须关联现实场景,例如学习专注、工作表达、社交沟通、情绪恢复、生活节奏。"
|
||||
"3. 每个 advice 必须是今天或本周能做的小建议,不要空泛。"
|
||||
"4. 不评价美丑,不做健康诊断,不判断年龄、种族、身份、财富、婚姻结局。"
|
||||
"5. 如果照片不够正脸、多人、遮挡或不清晰,要把 quality_check.can_analyze 设为 false,并说明原因。"
|
||||
)
|
||||
|
||||
|
||||
class FaceAnalyzer(BaseAnalyzer):
|
||||
async def analyze(self, image_bytes: bytes, content_type: str) -> dict:
|
||||
if not self.client:
|
||||
return self._mock_report()
|
||||
return await self._create_image_report(USER_PROMPT, image_bytes, content_type)
|
||||
|
||||
def _mock_report(self) -> dict:
|
||||
return {
|
||||
"quality_check": {"can_analyze": True, "reason": "mock mode", "confidence": 0.71},
|
||||
"overall_summary": "这是一份娱乐向面相报告。整体看,你给人的第一感觉偏稳,适合用清楚、温和的表达建立信任。最近如果在学习、工作或关系里需要推进事情,先把状态收拾利落,会更容易进入节奏。",
|
||||
"dimensions": [
|
||||
{
|
||||
"name": "脸型轮廓",
|
||||
"observations": ["整体轮廓较均衡", "面部线条有稳定感"],
|
||||
"interpretation": "这类轮廓象征做事方式偏稳,现实里可能更适合长期推进,而不是靠一阵热情冲刺。",
|
||||
"confidence": 0.68,
|
||||
"advice": "这周选一个最重要的目标,每天只推进一个小动作。",
|
||||
},
|
||||
{
|
||||
"name": "三庭五眼",
|
||||
"observations": ["比例观感较协调", "中庭存在一定表达重心"],
|
||||
"interpretation": "比例协调象征你在计划和表达之间有平衡感。学习或工作中,适合先梳理框架,再输出观点。",
|
||||
"confidence": 0.64,
|
||||
"advice": "开会或沟通前,先写下 3 个要点,避免临场发散。",
|
||||
},
|
||||
{
|
||||
"name": "眉眼神态",
|
||||
"observations": ["眉眼状态较集中", "眼神表达偏内敛"],
|
||||
"interpretation": "眉眼偏集中,象征观察力不错。你可能容易先看懂气氛,再决定怎么表达。",
|
||||
"confidence": 0.66,
|
||||
"advice": "有想法时别只观察,试着先说一个小判断。",
|
||||
},
|
||||
{
|
||||
"name": "鼻唇表达",
|
||||
"observations": ["表达区域较清晰", "唇部状态偏平和"],
|
||||
"interpretation": "这象征你适合用稳定语气处理分歧。关系里,不必一次讲完所有委屈,先说最具体的一件事就好。",
|
||||
"confidence": 0.61,
|
||||
"advice": "本周沟通需求时,用“我希望下次可以……”开头。",
|
||||
},
|
||||
{
|
||||
"name": "气色状态",
|
||||
"observations": ["画面气色受光线影响", "整体状态可读性尚可"],
|
||||
"interpretation": "气色象征当下状态。照片里呈现出的状态提醒你,近期要少一点硬撑,多一点规律恢复。",
|
||||
"confidence": 0.55,
|
||||
"advice": "今晚把睡前 30 分钟留给放松,不再继续刷任务。",
|
||||
},
|
||||
{
|
||||
"name": "整体气质",
|
||||
"observations": ["气质偏克制", "亲和感与边界感并存"],
|
||||
"interpretation": "整体气质象征你适合走可靠路线。事业或学习里,稳定交付比强行表现更能给你加分。",
|
||||
"confidence": 0.67,
|
||||
"advice": "把一个小成果整理出来发给该看到的人。",
|
||||
},
|
||||
],
|
||||
"strengths": ["容易给人可靠感", "适合先观察再行动", "沟通时有稳定气场"],
|
||||
"challenges": ["想得多时表达会慢半拍", "容易把疲惫藏起来"],
|
||||
"suggestions": ["生活上先整理作息和精神状态", "学习成长上先列框架再输出", "事业工作上用稳定交付建立信任", "关系沟通上把需求说具体"],
|
||||
"lucky_keywords": ["稳住气场", "先说重点", "整理状态"],
|
||||
"disclaimer": DISCLAIMER,
|
||||
}
|
||||
@ -1,18 +1,4 @@
|
||||
import base64
|
||||
import json
|
||||
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
DISCLAIMER = "本报告仅用于娱乐占卜与自我反思,不构成医学、心理、职业、财务、投资或任何人生决策建议。"
|
||||
SYSTEM_PROMPT = (
|
||||
"你是“赛博先生”,一个面向普通人的娱乐型 AI 命理解读助手。"
|
||||
"你的风格要像一个会聊天、懂生活的朋友:温和、具体、接地气,有一点玄学仪式感,但不要装神秘。"
|
||||
"你会根据掌心照片做象征性手相解读,但所有表达都必须使用“可能、倾向、适合、提醒你”这类非确定性措辞。"
|
||||
"禁止给出医疗、心理诊断、投资、职业成败、婚恋结果、寿命、灾祸等确定性判断。"
|
||||
"不要堆砌专业术语,不要写得像教材;每个结论都要落到生活、学习、事业、关系或近期行动里的具体场景。"
|
||||
)
|
||||
from app.services.analyzer_common import DISCLAIMER, BaseAnalyzer
|
||||
|
||||
USER_PROMPT_TEMPLATE = (
|
||||
"请分析这张{hand_side}手掌照片,生成中文手相报告。"
|
||||
@ -28,105 +14,16 @@ USER_PROMPT_TEMPLATE = (
|
||||
"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
|
||||
)
|
||||
|
||||
class PalmAnalyzer(BaseAnalyzer):
|
||||
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}},
|
||||
return await self._create_image_report(
|
||||
USER_PROMPT_TEMPLATE.format(hand_side=hand_side),
|
||||
image_bytes,
|
||||
content_type,
|
||||
)
|
||||
data = json.loads(response.output_text)
|
||||
data["disclaimer"] = DISCLAIMER
|
||||
return data
|
||||
|
||||
def _mock_report(self, hand_side: str) -> dict:
|
||||
return {
|
||||
|
||||
59
backend/app/services/reading_service.py
Normal file
59
backend/app/services/reading_service.py
Normal file
@ -0,0 +1,59 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.reading import Reading
|
||||
from app.models.uploaded_image import UploadedImage
|
||||
from app.services.bazi_analyzer import BaziAnalyzer
|
||||
from app.services.bazi_calculator import BaziCalculator
|
||||
from app.services.face_analyzer import FaceAnalyzer
|
||||
from app.services.image_service import ImageService
|
||||
from app.services.palm_analyzer import PalmAnalyzer
|
||||
|
||||
|
||||
class ReadingService:
|
||||
async def generate(self, db: AsyncSession, reading_id: str) -> None:
|
||||
result = await db.execute(select(Reading).where(Reading.id == reading_id))
|
||||
reading = result.scalar_one()
|
||||
reading.status = "processing"
|
||||
await db.flush()
|
||||
|
||||
try:
|
||||
if reading.reading_type == "palm":
|
||||
data = await self._generate_palm(db, reading)
|
||||
elif reading.reading_type == "face":
|
||||
data = await self._generate_face(db, reading)
|
||||
elif reading.reading_type == "bazi":
|
||||
data = await self._generate_bazi(reading)
|
||||
else:
|
||||
raise ValueError(f"Unsupported reading type: {reading.reading_type}")
|
||||
|
||||
reading.report_data = data
|
||||
reading.status = "failed" if not data["quality_check"]["can_analyze"] else "completed"
|
||||
reading.error_message = None if reading.status == "completed" else data["quality_check"]["reason"]
|
||||
except Exception as exc:
|
||||
reading.status = "failed"
|
||||
reading.error_message = str(exc)
|
||||
await db.commit()
|
||||
|
||||
async def _generate_palm(self, db: AsyncSession, reading: Reading) -> dict:
|
||||
image = await self._get_image(db, reading)
|
||||
image_bytes = ImageService().read_bytes(image.storage_key)
|
||||
hand_side = reading.input_data.get("hand_side", "unknown")
|
||||
return await PalmAnalyzer().analyze(image_bytes, image.content_type, hand_side)
|
||||
|
||||
async def _generate_face(self, db: AsyncSession, reading: Reading) -> dict:
|
||||
image = await self._get_image(db, reading)
|
||||
image_bytes = ImageService().read_bytes(image.storage_key)
|
||||
return await FaceAnalyzer().analyze(image_bytes, image.content_type)
|
||||
|
||||
async def _generate_bazi(self, reading: Reading) -> dict:
|
||||
chart = BaziCalculator().calculate(reading.input_data)
|
||||
reading.input_data = {**reading.input_data, "chart": chart}
|
||||
return await BaziAnalyzer().analyze(chart)
|
||||
|
||||
async def _get_image(self, db: AsyncSession, reading: Reading) -> UploadedImage:
|
||||
result = await db.execute(select(UploadedImage).where(UploadedImage.id == reading.image_id))
|
||||
image = result.scalar_one_or_none()
|
||||
if image is None:
|
||||
raise ValueError("Image not found")
|
||||
return image
|
||||
@ -10,5 +10,6 @@ httpx==0.28.1
|
||||
openai==2.34.0
|
||||
Pillow==11.1.0
|
||||
PyJWT==2.10.1
|
||||
lunar_python==1.4.8
|
||||
pytest==8.3.4
|
||||
pytest-asyncio==0.25.2
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.core.database import init_db
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anonymous_login_returns_reusable_user_token():
|
||||
await init_db()
|
||||
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"})
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
import pytest
|
||||
|
||||
from app.services.palm_analyzer import DISCLAIMER, PalmAnalyzer
|
||||
from app.services.analyzer_common import DISCLAIMER
|
||||
from app.services.palm_analyzer import PalmAnalyzer
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mock_report_has_required_shape(monkeypatch):
|
||||
monkeypatch.setattr("app.services.palm_analyzer.settings.openai_api_key", None)
|
||||
monkeypatch.setattr("app.services.analyzer_common.settings.openai_api_key", None)
|
||||
analyzer = PalmAnalyzer()
|
||||
|
||||
report = await analyzer.analyze(b"fake", "image/jpeg", "left")
|
||||
|
||||
36
backend/tests/test_reading_analyzers.py
Normal file
36
backend/tests/test_reading_analyzers.py
Normal file
@ -0,0 +1,36 @@
|
||||
import pytest
|
||||
|
||||
from app.services.analyzer_common import DISCLAIMER
|
||||
from app.services.bazi_analyzer import BaziAnalyzer
|
||||
from app.services.bazi_calculator import BaziCalculator
|
||||
from app.services.face_analyzer import FaceAnalyzer
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_face_mock_report_has_required_shape(monkeypatch):
|
||||
monkeypatch.setattr("app.services.analyzer_common.settings.openai_api_key", None)
|
||||
report = await FaceAnalyzer().analyze(b"fake", "image/jpeg")
|
||||
|
||||
assert report["quality_check"]["can_analyze"] is True
|
||||
assert len(report["dimensions"]) >= 6
|
||||
assert report["disclaimer"] == DISCLAIMER
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bazi_mock_report_has_required_shape(monkeypatch):
|
||||
monkeypatch.setattr("app.services.analyzer_common.settings.openai_api_key", None)
|
||||
chart = BaziCalculator().calculate(
|
||||
{
|
||||
"calendar_type": "solar",
|
||||
"birth_date": "1992-08-18",
|
||||
"birth_time": "09:30",
|
||||
"time_unknown": False,
|
||||
"birth_place": "广东深圳",
|
||||
}
|
||||
)
|
||||
report = await BaziAnalyzer().analyze(chart)
|
||||
|
||||
assert chart["pillars"]["year"]
|
||||
assert report["quality_check"]["can_analyze"] is True
|
||||
assert len(report["dimensions"]) >= 6
|
||||
assert report["disclaimer"] == DISCLAIMER
|
||||
45
backend/tests/test_readings_api.py
Normal file
45
backend/tests/test_readings_api.py
Normal file
@ -0,0 +1,45 @@
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.core.database import init_db
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bazi_reading_lifecycle(monkeypatch):
|
||||
monkeypatch.setattr("app.services.analyzer_common.settings.openai_api_key", None)
|
||||
await init_db()
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
auth = await client.post("/api/v1/auth/anonymous-login", json={"client_id": "readings-bazi"})
|
||||
token = auth.json()["access_token"]
|
||||
headers = {"authorization": f"Bearer {token}"}
|
||||
|
||||
created = await client.post(
|
||||
"/api/v1/readings",
|
||||
headers=headers,
|
||||
json={
|
||||
"reading_type": "bazi",
|
||||
"input_data": {
|
||||
"calendar_type": "solar",
|
||||
"birth_date": "1992-08-18",
|
||||
"birth_time": "09:30",
|
||||
"time_unknown": False,
|
||||
"birth_place": "广东深圳",
|
||||
},
|
||||
},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
reading_id = created.json()["id"]
|
||||
|
||||
detail = await client.get(f"/api/v1/readings/{reading_id}", headers=headers)
|
||||
assert detail.status_code == 200
|
||||
assert detail.json()["reading_type"] == "bazi"
|
||||
assert detail.json()["status"] in {"pending", "processing", "completed"}
|
||||
|
||||
listing = await client.get("/api/v1/readings", headers=headers)
|
||||
assert listing.status_code == 200
|
||||
assert any(item["id"] == reading_id for item in listing.json())
|
||||
|
||||
deleted = await client.delete(f"/api/v1/readings/{reading_id}", headers=headers)
|
||||
assert deleted.status_code == 200
|
||||
BIN
palm_reading.db
BIN
palm_reading.db
Binary file not shown.
5
web/app/archive/page.tsx
Normal file
5
web/app/archive/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import PalmWebApp from "@/components/PalmWebApp";
|
||||
|
||||
export default function ArchivePage() {
|
||||
return <PalmWebApp initialView="archive" />;
|
||||
}
|
||||
5
web/app/bazi/page.tsx
Normal file
5
web/app/bazi/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import PalmWebApp from "@/components/PalmWebApp";
|
||||
|
||||
export default function BaziPage() {
|
||||
return <PalmWebApp initialView="bazi" />;
|
||||
}
|
||||
5
web/app/face/page.tsx
Normal file
5
web/app/face/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import PalmWebApp from "@/components/PalmWebApp";
|
||||
|
||||
export default function FacePage() {
|
||||
return <PalmWebApp initialView="face" />;
|
||||
}
|
||||
@ -33,7 +33,8 @@ body {
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
@ -394,6 +395,58 @@ p {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.field,
|
||||
.checkbox-line {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.field span {
|
||||
color: var(--gold);
|
||||
font: 700 12px ui-sans-serif, system-ui, sans-serif;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select {
|
||||
width: 100%;
|
||||
min-height: 46px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
color: var(--paper);
|
||||
background: rgba(8, 13, 12, 0.48);
|
||||
padding: 0 13px;
|
||||
}
|
||||
|
||||
.field input:disabled {
|
||||
color: rgba(245, 236, 216, 0.42);
|
||||
}
|
||||
|
||||
.checkbox-line {
|
||||
align-content: end;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
min-height: 46px;
|
||||
color: var(--paper-dim);
|
||||
}
|
||||
|
||||
.checkbox-line input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--gold);
|
||||
}
|
||||
|
||||
.primary-action:disabled {
|
||||
opacity: 0.72;
|
||||
cursor: wait;
|
||||
@ -825,6 +878,21 @@ p {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.field-wide {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select {
|
||||
min-height: 44px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.archive-head {
|
||||
align-items: flex-start;
|
||||
}
|
||||
@ -900,7 +968,7 @@ p {
|
||||
left: 12px;
|
||||
z-index: 30;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--line);
|
||||
@ -919,7 +987,7 @@ p {
|
||||
border-radius: 16px;
|
||||
color: var(--paper-dim);
|
||||
background: transparent;
|
||||
font: 700 12px ui-sans-serif, system-ui, sans-serif;
|
||||
font: 700 11px ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.mobile-tabbar span {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import PalmWebApp from "@/components/PalmWebApp";
|
||||
|
||||
export default function Home() {
|
||||
return <PalmWebApp />;
|
||||
return <PalmWebApp initialView="home" />;
|
||||
}
|
||||
|
||||
5
web/app/palm/page.tsx
Normal file
5
web/app/palm/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import PalmWebApp from "@/components/PalmWebApp";
|
||||
|
||||
export default function PalmPage() {
|
||||
return <PalmWebApp initialView="palm" />;
|
||||
}
|
||||
@ -1,14 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { ChangeEvent, useEffect, useMemo, useState } from "react";
|
||||
import { ChangeEvent, ReactNode, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Dimension,
|
||||
HandSide,
|
||||
Report,
|
||||
Reading,
|
||||
ReadingSummary,
|
||||
ReadingType,
|
||||
ReportData,
|
||||
ReportSummary,
|
||||
apiFetch,
|
||||
ensureToken,
|
||||
uploadFaceImage,
|
||||
uploadPalmImage,
|
||||
} from "@/lib/api";
|
||||
|
||||
@ -18,140 +20,237 @@ const handText: Record<HandSide, string> = {
|
||||
unknown: "不确定",
|
||||
};
|
||||
|
||||
const statusText: Record<Report["status"], string> = {
|
||||
const statusText: Record<Reading["status"], string> = {
|
||||
pending: "等待中",
|
||||
processing: "生成中",
|
||||
completed: "已完成",
|
||||
failed: "失败",
|
||||
};
|
||||
|
||||
const services = [
|
||||
{
|
||||
id: "palm",
|
||||
const readingMeta: Record<ReadingType, { name: string; title: string; label: string; description: string }> = {
|
||||
palm: {
|
||||
name: "手相",
|
||||
title: "掌心解读",
|
||||
label: "PALM READING",
|
||||
description: "上传掌心照片,生成生活、学习、事业与关系提醒。",
|
||||
status: "已开放",
|
||||
},
|
||||
{
|
||||
id: "face",
|
||||
face: {
|
||||
name: "面相",
|
||||
title: "气色与五官",
|
||||
description: "未来支持面部特征与状态观察,适合做日常运势入口。",
|
||||
status: "规划中",
|
||||
label: "FACE READING",
|
||||
description: "上传单人正脸照,观察脸型、五官、气色与表达状态。",
|
||||
},
|
||||
{
|
||||
id: "bazi",
|
||||
bazi: {
|
||||
name: "八字",
|
||||
title: "生辰格局",
|
||||
description: "未来支持出生信息推演,沉淀长期个人档案。",
|
||||
status: "规划中",
|
||||
label: "BAZI READING",
|
||||
description: "填写出生信息,排出四柱后生成生活化八字报告。",
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export default function PalmWebApp() {
|
||||
type View = "home" | ReadingType | "archive";
|
||||
const views: View[] = ["home", "palm", "face", "bazi", "archive"];
|
||||
|
||||
type BaziForm = {
|
||||
nickname: string;
|
||||
gender: string;
|
||||
calendar_type: "solar" | "lunar";
|
||||
is_leap_month: boolean;
|
||||
birth_date: string;
|
||||
birth_time: string;
|
||||
time_unknown: boolean;
|
||||
birth_place: string;
|
||||
};
|
||||
|
||||
const defaultBaziForm: BaziForm = {
|
||||
nickname: "",
|
||||
gender: "",
|
||||
calendar_type: "solar",
|
||||
is_leap_month: false,
|
||||
birth_date: "",
|
||||
birth_time: "12:00",
|
||||
time_unknown: false,
|
||||
birth_place: "",
|
||||
};
|
||||
|
||||
export default function PalmWebApp({ initialView = "home" }: { initialView?: View }) {
|
||||
const [ready, setReady] = useState(false);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [preview, setPreview] = useState("");
|
||||
const [palmFile, setPalmFile] = useState<File | null>(null);
|
||||
const [faceFile, setFaceFile] = useState<File | null>(null);
|
||||
const [palmPreview, setPalmPreview] = useState("");
|
||||
const [facePreview, setFacePreview] = useState("");
|
||||
const [handSide, setHandSide] = useState<HandSide>("unknown");
|
||||
const [baziForm, setBaziForm] = useState<BaziForm>(defaultBaziForm);
|
||||
const [busyText, setBusyText] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [reports, setReports] = useState<ReportSummary[]>([]);
|
||||
const [activeReport, setActiveReport] = useState<Report | null>(null);
|
||||
const [activeView, setActiveView] = useState<"home" | "palm" | "archive">("home");
|
||||
const [readings, setReadings] = useState<ReadingSummary[]>([]);
|
||||
const [activeReading, setActiveReading] = useState<Reading | null>(null);
|
||||
const [activeView, setActiveView] = useState<View>(initialView);
|
||||
const [activeJobId, setActiveJobId] = useState("");
|
||||
const [activeJobType, setActiveJobType] = useState<ReadingType>("palm");
|
||||
|
||||
useEffect(() => {
|
||||
setActiveView(readViewFromPath(initialView));
|
||||
const onPopState = () => setActiveView(readViewFromPath(initialView));
|
||||
window.addEventListener("popstate", onPopState);
|
||||
|
||||
ensureToken()
|
||||
.then(() => Promise.all([loadReports()]))
|
||||
.then(() => loadReadings())
|
||||
.then(() => setReady(true))
|
||||
.catch((err) => {
|
||||
setError(err.message || "初始化失败");
|
||||
setReady(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return () => window.removeEventListener("popstate", onPopState);
|
||||
}, [initialView]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (preview) URL.revokeObjectURL(preview);
|
||||
if (palmPreview) URL.revokeObjectURL(palmPreview);
|
||||
if (facePreview) URL.revokeObjectURL(facePreview);
|
||||
};
|
||||
}, [preview]);
|
||||
}, [palmPreview, facePreview]);
|
||||
|
||||
const completedReports = reports.filter((item) => item.status === "completed").length;
|
||||
const latestReport = reports[0];
|
||||
const completedReadings = readings.filter((item) => item.status === "completed").length;
|
||||
const latestReading = readings[0];
|
||||
const hasActiveJob = Boolean(activeJobId);
|
||||
|
||||
function onPickFile(event: ChangeEvent<HTMLInputElement>) {
|
||||
function pickFile(event: ChangeEvent<HTMLInputElement>, type: "palm" | "face") {
|
||||
const selected = event.target.files?.[0];
|
||||
if (!selected) return;
|
||||
setFile(selected);
|
||||
setError("");
|
||||
if (preview) URL.revokeObjectURL(preview);
|
||||
setPreview(URL.createObjectURL(selected));
|
||||
const url = URL.createObjectURL(selected);
|
||||
if (type === "palm") {
|
||||
if (palmPreview) URL.revokeObjectURL(palmPreview);
|
||||
setPalmFile(selected);
|
||||
setPalmPreview(url);
|
||||
} else {
|
||||
if (facePreview) URL.revokeObjectURL(facePreview);
|
||||
setFaceFile(selected);
|
||||
setFacePreview(url);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadReports() {
|
||||
const data = await apiFetch<ReportSummary[]>("/reports");
|
||||
setReports(data);
|
||||
async function loadReadings() {
|
||||
const data = await apiFetch<ReadingSummary[]>("/readings");
|
||||
setReadings(data);
|
||||
}
|
||||
|
||||
async function startReport() {
|
||||
async function startImageReading(type: "palm" | "face") {
|
||||
const file = type === "palm" ? palmFile : faceFile;
|
||||
if (!file) {
|
||||
setError("请先选择一张清晰的掌心照片。");
|
||||
setError(type === "palm" ? "请先选择一张清晰的掌心照片。" : "请先选择一张清晰的单人正脸照片。");
|
||||
return;
|
||||
}
|
||||
setBusyText("正在接入掌纹照片...");
|
||||
setBusyText(type === "palm" ? "正在接入掌纹照片..." : "正在接入正脸照片...");
|
||||
setError("");
|
||||
try {
|
||||
const upload = await uploadPalmImage(file);
|
||||
const report = await apiFetch<Report>("/reports", {
|
||||
const upload = type === "palm" ? await uploadPalmImage(file) : await uploadFaceImage(file);
|
||||
const reading = await apiFetch<Reading>("/readings", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ image_id: upload.image_id, hand_side: handSide }),
|
||||
body: JSON.stringify({
|
||||
reading_type: type,
|
||||
image_id: upload.image_id,
|
||||
input_data: type === "palm" ? { hand_side: handSide } : { photo_style: "front_single" },
|
||||
}),
|
||||
});
|
||||
setActiveReport(report);
|
||||
setActiveJobId(report.id);
|
||||
setBusyText("");
|
||||
setFile(null);
|
||||
setPreview("");
|
||||
void pollReport(report.id)
|
||||
.then(() => loadReports())
|
||||
.catch((err) => setError(err instanceof Error ? err.message : "报告生成失败"))
|
||||
.finally(() => setActiveJobId(""));
|
||||
await loadReports();
|
||||
afterTaskCreated(reading);
|
||||
if (type === "palm") {
|
||||
setPalmFile(null);
|
||||
setPalmPreview("");
|
||||
} else {
|
||||
setFaceFile(null);
|
||||
setFacePreview("");
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "生成失败");
|
||||
setError(err instanceof Error ? err.message : "提交失败");
|
||||
setBusyText("");
|
||||
}
|
||||
}
|
||||
|
||||
async function pollReport(reportId: string) {
|
||||
async function startBaziReading() {
|
||||
if (!baziForm.birth_date) {
|
||||
setError("请先填写出生日期。");
|
||||
return;
|
||||
}
|
||||
if (!baziForm.time_unknown && !baziForm.birth_time) {
|
||||
setError("请选择出生时间,或勾选“不确定时辰”。");
|
||||
return;
|
||||
}
|
||||
setBusyText("正在排出四柱...");
|
||||
setError("");
|
||||
try {
|
||||
const reading = await apiFetch<Reading>("/readings", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
reading_type: "bazi",
|
||||
input_data: {
|
||||
...baziForm,
|
||||
nickname: baziForm.nickname || null,
|
||||
gender: baziForm.gender || null,
|
||||
birth_place: baziForm.birth_place || null,
|
||||
},
|
||||
}),
|
||||
});
|
||||
afterTaskCreated(reading);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "提交失败");
|
||||
setBusyText("");
|
||||
}
|
||||
}
|
||||
|
||||
function afterTaskCreated(reading: Reading) {
|
||||
setActiveReading(reading);
|
||||
setActiveJobId(reading.id);
|
||||
setActiveJobType(reading.reading_type);
|
||||
setBusyText("");
|
||||
void pollReading(reading.id)
|
||||
.then(() => loadReadings())
|
||||
.catch((err) => setError(err instanceof Error ? err.message : "报告生成失败"))
|
||||
.finally(() => setActiveJobId(""));
|
||||
void loadReadings();
|
||||
}
|
||||
|
||||
async function pollReading(readingId: 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 || "报告生成失败");
|
||||
const reading = await apiFetch<Reading>(`/readings/${readingId}`);
|
||||
setActiveReading(reading);
|
||||
if (reading.status === "completed") return reading;
|
||||
if (reading.status === "failed") throw new Error(reading.error_message || "报告生成失败");
|
||||
await sleep(2200);
|
||||
}
|
||||
throw new Error("生成时间较长,请稍后在档案中查看。");
|
||||
}
|
||||
|
||||
async function openReport(reportId: string) {
|
||||
async function openReading(readingId: string) {
|
||||
setError("");
|
||||
const report = await apiFetch<Report>(`/reports/${reportId}`);
|
||||
setActiveReport(report);
|
||||
const reading = await apiFetch<Reading>(`/readings/${readingId}`);
|
||||
setActiveReading(reading);
|
||||
navigate("archive");
|
||||
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();
|
||||
async function deleteReading(readingId: string) {
|
||||
await apiFetch(`/readings/${readingId}`, { method: "DELETE" });
|
||||
if (activeReading?.id === readingId) setActiveReading(null);
|
||||
await loadReadings();
|
||||
}
|
||||
|
||||
function navigate(view: View) {
|
||||
setError("");
|
||||
setActiveView(view);
|
||||
window.history.pushState(null, "", pathForView(view));
|
||||
if (view !== "archive") {
|
||||
setActiveReading(null);
|
||||
}
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="shell">
|
||||
<header className="app-topbar">
|
||||
<button className="brand compact-button" onClick={() => setActiveView("home")}>
|
||||
<button className="brand compact-button" onClick={() => navigate("home")}>
|
||||
<span className="seal">先</span>
|
||||
<span>
|
||||
<small>CYBER MISTER</small>
|
||||
@ -159,17 +258,19 @@ export default function PalmWebApp() {
|
||||
</span>
|
||||
</button>
|
||||
<nav className="desktop-nav" aria-label="主导航">
|
||||
<button className={activeView === "home" ? "active" : ""} onClick={() => setActiveView("home")}>首页</button>
|
||||
<button className={activeView === "palm" ? "active" : ""} onClick={() => setActiveView("palm")}>手相</button>
|
||||
<button className={activeView === "archive" ? "active" : ""} onClick={() => setActiveView("archive")}>档案</button>
|
||||
{views.map((view) => (
|
||||
<button key={view} className={activeView === view ? "active" : ""} onClick={() => navigate(view)}>
|
||||
{view === "home" ? "首页" : view === "archive" ? "档案" : readingMeta[view].name}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{hasActiveJob ? (
|
||||
<button className="job-banner" onClick={() => activeJobId && openReport(activeJobId)}>
|
||||
<button className="job-banner" onClick={() => activeJobId && openReading(activeJobId)}>
|
||||
<span className="pulse-dot" />
|
||||
<span>先生正在分析掌纹,完成后会自动放入档案。你可以继续浏览。</span>
|
||||
<strong>查看进度</strong>
|
||||
<span>先生正在分析{readingMeta[activeJobType].name},完成后会自动放入档案。你可以继续浏览。</span>
|
||||
<strong>去档案</strong>
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
@ -178,52 +279,39 @@ export default function PalmWebApp() {
|
||||
<div className="hero-copy">
|
||||
<p className="eyebrow">AI 玄学档案 · 娱乐占卜 · 自我反思</p>
|
||||
<h1>把日常困惑,交给先生慢慢看。</h1>
|
||||
<p>从手相开始,逐步扩展到面相、八字与个人长期档案。每一次解读都更贴近生活、学习、事业与关系。</p>
|
||||
<p>从手相、面相到八字,每一次解读都更贴近生活、学习、事业与关系。</p>
|
||||
</div>
|
||||
|
||||
<div className="service-grid">
|
||||
{services.map((service) => (
|
||||
<button
|
||||
key={service.id}
|
||||
className={`service-card ${service.id !== "palm" ? "muted" : ""}`}
|
||||
onClick={() => service.id === "palm" && setActiveView("palm")}
|
||||
>
|
||||
<span>{service.status}</span>
|
||||
<strong>{service.name}</strong>
|
||||
<em>{service.title}</em>
|
||||
<small>{service.description}</small>
|
||||
{(["palm", "face", "bazi"] as ReadingType[]).map((type) => (
|
||||
<button key={type} className="service-card" onClick={() => navigate(type)}>
|
||||
<span>已开放</span>
|
||||
<strong>{readingMeta[type].name}</strong>
|
||||
<em>{readingMeta[type].title}</em>
|
||||
<small>{readingMeta[type].description}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="quick-row">
|
||||
<button className="primary-action" onClick={() => setActiveView("palm")}>开始手相测试</button>
|
||||
<button className="ghost-action" onClick={() => setActiveView("archive")}>
|
||||
{latestReport ? "查看最近档案" : "查看档案"}
|
||||
<button className="primary-action" onClick={() => navigate("palm")}>开始手相测试</button>
|
||||
<button className="ghost-action" onClick={() => navigate(latestReading ? "archive" : "bazi")}>
|
||||
{latestReading ? "查看最近档案" : "试试八字"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{activeView === "palm" ? (
|
||||
<section className="workspace">
|
||||
<div className="upload-card">
|
||||
<div className="section-label">PALM READING</div>
|
||||
<h2>上传掌心照片</h2>
|
||||
<p>掌心完整入镜、光线充足、纹路清晰。左右手不确定可以选“不确定”。</p>
|
||||
|
||||
<label className={`drop-zone ${preview ? "has-preview" : ""}`}>
|
||||
{preview ? (
|
||||
<img src={preview} alt="掌心照片预览" />
|
||||
) : (
|
||||
<span>
|
||||
<strong>选择照片</strong>
|
||||
<small>支持 JPG / PNG / WEBP</small>
|
||||
</span>
|
||||
)}
|
||||
<input type="file" accept="image/png,image/jpeg,image/webp" onChange={onPickFile} />
|
||||
</label>
|
||||
|
||||
<ImageReadingForm
|
||||
type="palm"
|
||||
preview={palmPreview}
|
||||
busyText={busyText}
|
||||
ready={ready}
|
||||
error={error}
|
||||
onPickFile={(event) => pickFile(event, "palm")}
|
||||
onSubmit={() => startImageReading("palm")}
|
||||
extra={
|
||||
<div className="segmented" aria-label="选择左右手">
|
||||
{(["left", "right", "unknown"] as HandSide[]).map((side) => (
|
||||
<button key={side} className={handSide === side ? "active" : ""} onClick={() => setHandSide(side)}>
|
||||
@ -231,27 +319,35 @@ export default function PalmWebApp() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
stats={<ReadingStats total={readings.length} completed={completedReadings} />}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<button className="primary-action" disabled={!ready || Boolean(busyText)} onClick={startReport}>
|
||||
{busyText || "提交分析"}
|
||||
</button>
|
||||
{error ? <p className="error">{error}</p> : null}
|
||||
</div>
|
||||
{activeView === "face" ? (
|
||||
<ImageReadingForm
|
||||
type="face"
|
||||
preview={facePreview}
|
||||
busyText={busyText}
|
||||
ready={ready}
|
||||
error={error}
|
||||
onPickFile={(event) => pickFile(event, "face")}
|
||||
onSubmit={() => startImageReading("face")}
|
||||
extra={null}
|
||||
stats={<ReadingStats total={readings.length} completed={completedReadings} />}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<aside className="side-panel">
|
||||
<div className="mini-card">
|
||||
<div className="section-label">生成方式</div>
|
||||
<h3>提交后可离开等待</h3>
|
||||
<p>照片上传成功后,系统会在后台生成报告。你可以继续看档案,完成后自动刷新。</p>
|
||||
</div>
|
||||
<div className="mini-card">
|
||||
<div className="stats">
|
||||
<Stat label="累计报告" value={reports.length} />
|
||||
<Stat label="已完成" value={completedReports} />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
{activeView === "bazi" ? (
|
||||
<BaziFormView
|
||||
form={baziForm}
|
||||
setForm={setBaziForm}
|
||||
busyText={busyText}
|
||||
ready={ready}
|
||||
error={error}
|
||||
onSubmit={startBaziReading}
|
||||
stats={<ReadingStats total={readings.length} completed={completedReadings} />}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{activeView === "archive" ? (
|
||||
@ -261,14 +357,14 @@ export default function PalmWebApp() {
|
||||
<p className="section-label">MISTER ARCHIVE</p>
|
||||
<h2>个人玄学档案</h2>
|
||||
</div>
|
||||
<button className="ghost-action" onClick={() => setActiveView("palm")}>新建手相</button>
|
||||
<button className="ghost-action" onClick={() => navigate("home")}>新建解读</button>
|
||||
</div>
|
||||
<div className="report-list">
|
||||
{reports.length ? (
|
||||
reports.map((item) => (
|
||||
<button key={item.id} className="archive-item" onClick={() => openReport(item.id)}>
|
||||
{readings.length ? (
|
||||
readings.map((item) => (
|
||||
<button key={item.id} className="archive-item" onClick={() => openReading(item.id)}>
|
||||
<span>
|
||||
<strong>手相报告</strong>
|
||||
<strong>{readingMeta[item.reading_type].name}报告</strong>
|
||||
<small>{formatDate(item.created_at)}</small>
|
||||
{item.overall_summary ? <b>{item.overall_summary}</b> : null}
|
||||
</span>
|
||||
@ -279,48 +375,188 @@ export default function PalmWebApp() {
|
||||
<p className="empty">暂无档案。生成第一份报告后会出现在这里。</p>
|
||||
)}
|
||||
</div>
|
||||
{activeReading ? <ReportPanel reading={activeReading} onDelete={() => deleteReading(activeReading.id)} /> : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{activeReport ? (
|
||||
<ReportPanel
|
||||
report={activeReport}
|
||||
onDelete={() => deleteReport(activeReport.id)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<nav className="mobile-tabbar" aria-label="移动端导航">
|
||||
<button className={activeView === "home" ? "active" : ""} onClick={() => setActiveView("home")}>
|
||||
<span>⌂</span>
|
||||
首页
|
||||
</button>
|
||||
<button className={activeView === "palm" ? "active" : ""} onClick={() => setActiveView("palm")}>
|
||||
<span>掌</span>
|
||||
手相
|
||||
</button>
|
||||
<button className={activeView === "archive" ? "active" : ""} onClick={() => setActiveView("archive")}>
|
||||
<span>册</span>
|
||||
档案
|
||||
</button>
|
||||
{views.map((view) => (
|
||||
<button key={view} className={activeView === view ? "active" : ""} onClick={() => navigate(view)}>
|
||||
<span>{view === "home" ? "⌂" : view === "archive" ? "册" : readingMeta[view].name.slice(0, 1)}</span>
|
||||
{view === "home" ? "首页" : view === "archive" ? "档案" : readingMeta[view].name}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportPanel({
|
||||
report,
|
||||
onDelete,
|
||||
function ImageReadingForm({
|
||||
type,
|
||||
preview,
|
||||
busyText,
|
||||
ready,
|
||||
error,
|
||||
extra,
|
||||
stats,
|
||||
onPickFile,
|
||||
onSubmit,
|
||||
}: {
|
||||
report: Report;
|
||||
onDelete: () => void;
|
||||
type: "palm" | "face";
|
||||
preview: string;
|
||||
busyText: string;
|
||||
ready: boolean;
|
||||
error: string;
|
||||
extra: ReactNode;
|
||||
stats: ReactNode;
|
||||
onPickFile: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
onSubmit: () => void;
|
||||
}) {
|
||||
const data = report.report_data;
|
||||
const isPalm = type === "palm";
|
||||
return (
|
||||
<section className="workspace">
|
||||
<div className="upload-card">
|
||||
<div className="section-label">{readingMeta[type].label}</div>
|
||||
<h2>{isPalm ? "上传掌心照片" : "上传正脸照片"}</h2>
|
||||
<p>{isPalm ? "掌心完整入镜、光线充足、纹路清晰。左右手不确定可以选“不确定”。" : "请上传单人正脸照,五官无遮挡,光线自然清晰。"}</p>
|
||||
|
||||
<label className={`drop-zone ${preview ? "has-preview" : ""}`}>
|
||||
{preview ? (
|
||||
<img src={preview} alt={isPalm ? "掌心照片预览" : "正脸照片预览"} />
|
||||
) : (
|
||||
<span>
|
||||
<strong>选择照片</strong>
|
||||
<small>支持 JPG / PNG / WEBP</small>
|
||||
</span>
|
||||
)}
|
||||
<input type="file" accept="image/png,image/jpeg,image/webp" onChange={onPickFile} />
|
||||
</label>
|
||||
|
||||
{extra}
|
||||
<button className="primary-action" disabled={!ready || Boolean(busyText)} onClick={onSubmit}>
|
||||
{busyText || "提交分析"}
|
||||
</button>
|
||||
{error ? <p className="error">{error}</p> : null}
|
||||
</div>
|
||||
|
||||
<aside className="side-panel">
|
||||
<div className="mini-card">
|
||||
<div className="section-label">生成方式</div>
|
||||
<h3>提交后可离开等待</h3>
|
||||
<p>提交成功后,系统会在后台生成报告。你可以继续看档案,完成后自动刷新。</p>
|
||||
</div>
|
||||
<div className="mini-card">{stats}</div>
|
||||
</aside>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function BaziFormView({
|
||||
form,
|
||||
setForm,
|
||||
busyText,
|
||||
ready,
|
||||
error,
|
||||
stats,
|
||||
onSubmit,
|
||||
}: {
|
||||
form: BaziForm;
|
||||
setForm: (form: BaziForm) => void;
|
||||
busyText: string;
|
||||
ready: boolean;
|
||||
error: string;
|
||||
stats: ReactNode;
|
||||
onSubmit: () => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="workspace">
|
||||
<div className="upload-card">
|
||||
<div className="section-label">BAZI READING</div>
|
||||
<h2>填写生辰信息</h2>
|
||||
<p>第一版按标准专业排盘,不做真太阳时校正。出生地会用于报告语境,不用于经度校正。</p>
|
||||
|
||||
<div className="form-grid">
|
||||
<label className="field">
|
||||
<span>昵称</span>
|
||||
<input value={form.nickname} onChange={(event) => setForm({ ...form, nickname: event.target.value })} placeholder="可不填" />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>性别/称谓</span>
|
||||
<select value={form.gender} onChange={(event) => setForm({ ...form, gender: event.target.value })}>
|
||||
<option value="">不填写</option>
|
||||
<option value="female">女</option>
|
||||
<option value="male">男</option>
|
||||
<option value="other">不限定</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>历法</span>
|
||||
<select value={form.calendar_type} onChange={(event) => setForm({ ...form, calendar_type: event.target.value as "solar" | "lunar" })}>
|
||||
<option value="solar">公历</option>
|
||||
<option value="lunar">农历</option>
|
||||
</select>
|
||||
</label>
|
||||
{form.calendar_type === "lunar" ? (
|
||||
<label className="checkbox-line">
|
||||
<input type="checkbox" checked={form.is_leap_month} onChange={(event) => setForm({ ...form, is_leap_month: event.target.checked })} />
|
||||
闰月
|
||||
</label>
|
||||
) : null}
|
||||
<label className="field">
|
||||
<span>出生日期</span>
|
||||
<input type="date" value={form.birth_date} onChange={(event) => setForm({ ...form, birth_date: event.target.value })} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>出生时间</span>
|
||||
<input type="time" value={form.birth_time} disabled={form.time_unknown} onChange={(event) => setForm({ ...form, birth_time: event.target.value })} />
|
||||
</label>
|
||||
<label className="checkbox-line">
|
||||
<input type="checkbox" checked={form.time_unknown} onChange={(event) => setForm({ ...form, time_unknown: event.target.checked })} />
|
||||
不确定时辰
|
||||
</label>
|
||||
<label className="field field-wide">
|
||||
<span>出生地</span>
|
||||
<input value={form.birth_place} onChange={(event) => setForm({ ...form, birth_place: event.target.value })} placeholder="例如:广东深圳" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button className="primary-action" disabled={!ready || Boolean(busyText)} onClick={onSubmit}>
|
||||
{busyText || "提交排盘"}
|
||||
</button>
|
||||
{error ? <p className="error">{error}</p> : null}
|
||||
</div>
|
||||
|
||||
<aside className="side-panel">
|
||||
<div className="mini-card">
|
||||
<div className="section-label">排盘说明</div>
|
||||
<h3>先排盘,再解读</h3>
|
||||
<p>后端会先计算四柱、五行和十神线索,再让赛博先生生成更接地气的生活化报告。</p>
|
||||
</div>
|
||||
<div className="mini-card">{stats}</div>
|
||||
</aside>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadingStats({ total, completed }: { total: number; completed: number }) {
|
||||
return (
|
||||
<div className="stats">
|
||||
<Stat label="累计报告" value={total} />
|
||||
<Stat label="已完成" value={completed} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportPanel({ reading, onDelete }: { reading: Reading; onDelete: () => void }) {
|
||||
const data = reading.report_data;
|
||||
const score = useMemo(() => getScore(data), [data]);
|
||||
const type = reading.reading_type;
|
||||
const handSide = (reading.input_data?.hand_side as HandSide | undefined) || "unknown";
|
||||
if (!data) {
|
||||
return (
|
||||
<section className="report-panel">
|
||||
<h3>{statusText[report.status]}</h3>
|
||||
<p>{report.error_message || "先生正在整理报告。"}</p>
|
||||
<h3>{statusText[reading.status]}</h3>
|
||||
<p>{reading.error_message || "先生正在整理报告。"}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -329,8 +565,11 @@ function ReportPanel({
|
||||
<section className="report-panel">
|
||||
<div className="report-hero">
|
||||
<div>
|
||||
<p className="section-label">PALM REPORT · {handText[report.hand_side]}</p>
|
||||
<h3>赛博先生手相报告</h3>
|
||||
<p className="section-label">
|
||||
{readingMeta[type].label}
|
||||
{type === "palm" ? ` · ${handText[handSide]}` : ""}
|
||||
</p>
|
||||
<h3>赛博先生{readingMeta[type].name}报告</h3>
|
||||
<p>{data.overall_summary}</p>
|
||||
</div>
|
||||
<div className="score">
|
||||
@ -413,6 +652,16 @@ function formatDate(value: string) {
|
||||
return value.replace("T", " ").slice(0, 16);
|
||||
}
|
||||
|
||||
function readViewFromPath(fallback: View): View {
|
||||
if (typeof window === "undefined") return "home";
|
||||
const segment = window.location.pathname.replace(/^\/+/, "").split("/")[0] || "home";
|
||||
return views.includes(segment as View) ? (segment as View) : fallback;
|
||||
}
|
||||
|
||||
function pathForView(view: View) {
|
||||
return view === "home" ? "/" : `/${view}`;
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ const TOKEN_KEY = "cyber_mister_token";
|
||||
const CLIENT_KEY = "cyber_mister_client_id";
|
||||
|
||||
export type HandSide = "left" | "right" | "unknown";
|
||||
export type ReadingType = "palm" | "face" | "bazi";
|
||||
|
||||
export type Dimension = {
|
||||
name: string;
|
||||
@ -28,24 +29,28 @@ export type ReportData = {
|
||||
disclaimer: string;
|
||||
};
|
||||
|
||||
export type Report = {
|
||||
export type Reading = {
|
||||
id: string;
|
||||
reading_type: ReadingType;
|
||||
status: "pending" | "processing" | "completed" | "failed";
|
||||
hand_side: HandSide;
|
||||
input_data: Record<string, unknown>;
|
||||
error_message?: string | null;
|
||||
report_data?: ReportData | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ReportSummary = {
|
||||
export type ReadingSummary = {
|
||||
id: string;
|
||||
status: Report["status"];
|
||||
hand_side: HandSide;
|
||||
reading_type: ReadingType;
|
||||
status: Reading["status"];
|
||||
created_at: string;
|
||||
overall_summary?: string | null;
|
||||
};
|
||||
|
||||
export type Report = Reading & { hand_side?: HandSide };
|
||||
export type ReportSummary = ReadingSummary & { hand_side?: HandSide };
|
||||
|
||||
export function getStoredToken() {
|
||||
if (typeof window === "undefined") return "";
|
||||
return localStorage.getItem(TOKEN_KEY) || "";
|
||||
@ -89,11 +94,19 @@ export async function apiFetch<T>(path: string, options: RequestInit = {}): Prom
|
||||
}
|
||||
|
||||
export async function uploadPalmImage(file: File) {
|
||||
return uploadImage(file, "palm");
|
||||
}
|
||||
|
||||
export async function uploadFaceImage(file: File) {
|
||||
return uploadImage(file, "face");
|
||||
}
|
||||
|
||||
async function uploadImage(file: File, type: "palm" | "face") {
|
||||
const token = await ensureToken();
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/uploads/palm`, {
|
||||
const response = await fetch(`${API_BASE_URL}/uploads/${type}`, {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
body: formData,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user