update、

This commit is contained in:
aaron 2026-05-12 20:50:15 +08:00
parent f89f5ef6e5
commit 01bc803ebb
60 changed files with 633 additions and 1785 deletions

9
.gitignore vendored
View File

@ -76,15 +76,6 @@ pnpm-debug.log*
# Keep package lock tracked # Keep package lock tracked
!web/package-lock.json !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 # Docker / generated local artifacts
*.pid *.pid
*.log *.log

View File

@ -1,11 +1,10 @@
# AI 手相报告 MVP # 赛博先生 Web App
原生微信小程序 + Python FastAPI 后端的娱乐型手相报告应用 面向 Web 的 AI 命理娱乐应用,当前支持手相、面相、八字三类报告生成。产品定位为“娱乐占卜 + 自我反思”,不做确定性人生判断
## 目录 ## 目录
- `backend/`: FastAPI API、数据库模型、OpenAI 分析服务、图片存储。 - `backend/`: FastAPI API、数据库模型、OpenAI 分析服务、图片存储。
- `miniprogram/`: 原生微信小程序页面与请求封装。
- `web/`: Next.js Web App 主产品入口。 - `web/`: Next.js Web App 主产品入口。
## 本地运行后端 ## 本地运行后端
@ -18,16 +17,12 @@ cp .env.example .env
venv/bin/uvicorn app.main:app --reload venv/bin/uvicorn app.main:app --reload
``` ```
默认使用 SQLite 和 mock 微信登录,便于本地开发。生产环境配置 `DATABASE_URL` 为 Postgres配置微信、OpenAI 和对象存储参数。 默认使用 SQLite 和匿名 Web 登录,便于本地开发。生产环境可配置 `DATABASE_URL` 为 Postgres并配置 OpenAI 和对象存储参数。
## 环境变量 ## 环境变量
`backend/.env.example` `backend/.env.example`
## 小程序
用微信开发者工具打开 `miniprogram/`,把 `utils/config.js` 中的 `API_BASE_URL` 改成后端地址。
## Web App ## Web App
```bash ```bash
@ -37,7 +32,7 @@ cp .env.example .env.local
npm run dev npm run dev
``` ```
默认连接 `http://127.0.0.1:8000/api/v1`第一版 Web 使用匿名会话,浏览器会自动获取 token 并保存在 `localStorage` 默认连接 `http://127.0.0.1:8000/api/v1`。Web 使用匿名会话,浏览器会自动获取 token 并保存在 `localStorage`
## Docker Compose 一键部署 ## Docker Compose 一键部署

View File

@ -3,6 +3,7 @@ ENVIRONMENT=development
DATABASE_URL=sqlite+aiosqlite:///./palm_reading.db DATABASE_URL=sqlite+aiosqlite:///./palm_reading.db
SECRET_KEY=change-me-in-production SECRET_KEY=change-me-in-production
ACCESS_TOKEN_EXPIRE_MINUTES=43200 ACCESS_TOKEN_EXPIRE_MINUTES=43200
DAILY_READING_LIMIT=5
OPENAI_API_KEY= OPENAI_API_KEY=
OPENAI_BASE_URL= OPENAI_BASE_URL=

View File

@ -1,6 +1,6 @@
# Backend # Backend
FastAPI 后端提供微信登录、图片上传、手相报告生成和历史报告管理。 FastAPI 后端提供匿名登录、图片上传、手相/面相/八字报告生成和历史报告管理。
## Run ## Run
@ -21,4 +21,4 @@ venv/bin/python -m app.cli
所有业务 API 位于 `/api/v1` 所有业务 API 位于 `/api/v1`
本地开发默认 `WECHAT_MOCK_LOGIN=true``OPENAI_API_KEY` 为空时会返回 mock 报告,便于先联调小程序流程。 本地开发默认支持匿名登录,`OPENAI_API_KEY` 为空时会返回 mock 报告,便于先联调 Web 流程。

View File

@ -8,8 +8,10 @@ from app.core.security import get_current_user
from app.models.reading import Reading from app.models.reading import Reading
from app.models.uploaded_image import UploadedImage from app.models.uploaded_image import UploadedImage
from app.models.user import User from app.models.user import User
from app.schemas.quota import QuotaResponse
from app.schemas.reading import BaziInput, ReadingCreate, ReadingDetail, ReadingSummary from app.schemas.reading import BaziInput, ReadingCreate, ReadingDetail, ReadingSummary
from app.services.image_service import ImageService from app.services.image_service import ImageService
from app.services.quota_service import QuotaService
from app.services.reading_service import ReadingService from app.services.reading_service import ReadingService
router = APIRouter() router = APIRouter()
@ -20,6 +22,11 @@ async def generate_reading_task(reading_id: str) -> None:
await ReadingService().generate(session, reading_id) await ReadingService().generate(session, reading_id)
@router.get("/quota", response_model=QuotaResponse)
async def get_reading_quota(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
return await QuotaService().get_quota(db, user.id)
@router.post("", response_model=ReadingDetail, status_code=status.HTTP_201_CREATED) @router.post("", response_model=ReadingDetail, status_code=status.HTTP_201_CREATED)
async def create_reading( async def create_reading(
payload: ReadingCreate, payload: ReadingCreate,
@ -41,6 +48,7 @@ async def create_reading(
except ValidationError as exc: except ValidationError as exc:
raise HTTPException(status_code=422, detail=exc.errors()) from exc raise HTTPException(status_code=422, detail=exc.errors()) from exc
await QuotaService().consume(db, user.id)
reading = Reading( reading = Reading(
user_id=user.id, user_id=user.id,
reading_type=payload.reading_type, reading_type=payload.reading_type,

View File

@ -13,6 +13,7 @@ from app.models.user import User
from app.schemas.share_image import ShareImageJobResponse from app.schemas.share_image import ShareImageJobResponse
from app.schemas.report import ReportCreate, ReportDetail, ReportSummary from app.schemas.report import ReportCreate, ReportDetail, ReportSummary
from app.services.image_service import ImageService from app.services.image_service import ImageService
from app.services.quota_service import QuotaService
from app.services.report_service import ReportService from app.services.report_service import ReportService
from app.services.share_poster_service import SharePosterService from app.services.share_poster_service import SharePosterService
@ -66,6 +67,7 @@ async def create_report(
if image is None: if image is None:
raise HTTPException(status_code=404, detail="Image not found") raise HTTPException(status_code=404, detail="Image not found")
await QuotaService().consume(db, user.id)
report = PalmReport(user_id=user.id, image_id=image.id, hand_side=payload.hand_side, status="pending") report = PalmReport(user_id=user.id, image_id=image.id, hand_side=payload.hand_side, status="pending")
db.add(report) db.add(report)
await db.flush() await db.flush()

View File

@ -11,6 +11,7 @@ class Settings(BaseSettings):
secret_key: str = "change-me-in-production" secret_key: str = "change-me-in-production"
access_token_expire_minutes: int = 60 * 24 * 30 access_token_expire_minutes: int = 60 * 24 * 30
cors_origins: list[str] = Field(default_factory=lambda: ["*"]) cors_origins: list[str] = Field(default_factory=lambda: ["*"])
daily_reading_limit: int = 5
openai_api_key: str | None = None openai_api_key: str | None = None
openai_base_url: str | None = None openai_base_url: str | None = None

View File

@ -25,7 +25,7 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
async def init_db() -> None: async def init_db() -> None:
from app.models import palm_report, reading, share_image_job, uploaded_image, user # noqa: F401 from app.models import daily_usage, palm_report, reading, share_image_job, uploaded_image, user # noqa: F401
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)

View File

@ -0,0 +1,19 @@
from datetime import date, datetime
from uuid import uuid4
from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class DailyUsage(Base):
__tablename__ = "daily_usages"
__table_args__ = (UniqueConstraint("user_id", "usage_date", name="uq_daily_usage_user_date"),)
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)
usage_date: Mapped[date] = mapped_column(Date, index=True)
count: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

View File

@ -0,0 +1,10 @@
from datetime import datetime
from pydantic import BaseModel
class QuotaResponse(BaseModel):
limit: int
used: int
remaining: int
reset_at: datetime

View File

@ -5,14 +5,17 @@ from app.services.analyzer_common import DISCLAIMER, BaseAnalyzer
USER_PROMPT_TEMPLATE = ( USER_PROMPT_TEMPLATE = (
"请基于下面这份八字排盘结果,生成中文八字娱乐报告。" "请基于下面这份八字排盘结果,生成中文八字娱乐报告。"
"排盘结果由后端计算,不能自行改动四柱:{chart_json}" "排盘结果由后端计算,不能自行改动四柱:{chart_json}"
"必须覆盖四柱结构、日主与五行、十神关系、学习成长、事业节奏、关系沟通、近期行动。" "必须覆盖四柱结构、日主与五行、五行分布、十神关系、学习成长、事业节奏、关系沟通、近期行动。"
"写作要求:" "写作要求:"
"1. overall_summary 用 2 到 4 句话,像给用户本人看的开场结论。" "1. dimensions 固定输出 7 项:四柱底色、日主与五行、十神互动、学习成长、事业节奏、关系沟通、近期行动。"
"2. dimensions 中每个 interpretation 必须落到现实场景,例如学习效率、职业节奏、沟通方式、情绪恢复、计划执行。" "2. overall_summary 用 2 到 4 句话,像给用户本人看的开场结论,不要像百科解释。"
"3. 每个 advice 必须是今天或本周能做的小建议。" "3. 每个 interpretation 必须落到现实场景,例如学习效率、职业节奏、沟通方式、情绪恢复、计划执行。"
"4. 可以少量使用日主、五行、十神等术语,但必须用普通人能听懂的话解释。" "4. observations 可以写排盘证据,但不要只堆术语;每条都要让普通用户看得懂。"
"5. 不预测寿命、灾祸、婚姻成败、财富结果,不做宿命论。" "5. 每个 advice 必须是今天或本周能做的小建议,避免空话。"
"6. 如果时辰不详,要诚实降低相关维度 confidence并说明分析会更偏概览。" "6. strengths、challenges、suggestions 要分别覆盖生活、学习、事业、关系,语言要具体。"
"7. 可以少量使用日主、五行、十神等术语,但必须用普通人能听懂的话解释。"
"8. 不预测寿命、灾祸、婚姻成败、财富结果,不做宿命论。"
"9. 如果时辰不详,要诚实降低相关维度 confidence并说明分析会更偏概览。"
) )
@ -49,8 +52,8 @@ class BaziAnalyzer(BaseAnalyzer):
}, },
{ {
"name": "日主与五行", "name": "日主与五行",
"observations": [f"日主:{day_master}", f"五行线索{chart.get('wuxing', {})}"], "observations": [f"日主:{day_master}", f"五行分布{chart.get('wuxing_balance', {})}"],
"interpretation": "日主象征你处理世界的核心方式。你适合找到自己的稳定补给,再去应对学习、工作和关系里的变化。", "interpretation": "日主象征你处理世界的核心方式,五行分布则像精力使用习惯现实里,你适合找到稳定补给,再去应对学习、工作和关系里的变化。",
"confidence": 0.7, "confidence": 0.7,
"advice": "这周给自己固定一个恢复时间,别把精力全部交给外界安排。", "advice": "这周给自己固定一个恢复时间,别把精力全部交给外界安排。",
}, },

View File

@ -7,6 +7,35 @@ except ImportError: # pragma: no cover - production image installs the package
Solar = None Solar = None
STEM_WUXING = {
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
}
BRANCH_WUXING = {
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
}
class BaziCalculator: class BaziCalculator:
def calculate(self, payload: dict) -> dict: def calculate(self, payload: dict) -> dict:
birth_date = payload["birth_date"] birth_date = payload["birth_date"]
@ -25,6 +54,12 @@ class BaziCalculator:
solar = Solar.fromYmdHms(year, month, day, hour, minute, 0) solar = Solar.fromYmdHms(year, month, day, hour, minute, 0)
lunar = solar.getLunar() lunar = solar.getLunar()
eight = lunar.getEightChar() eight = lunar.getEightChar()
pillars = {
"year": eight.getYear(),
"month": eight.getMonth(),
"day": eight.getDay(),
"time": eight.getTime() if not time_unknown else "时辰不详",
}
return { return {
"calendar_type": calendar_type, "calendar_type": calendar_type,
"is_leap_month": bool(payload.get("is_leap_month")), "is_leap_month": bool(payload.get("is_leap_month")),
@ -36,12 +71,7 @@ class BaziCalculator:
"gender": payload.get("gender"), "gender": payload.get("gender"),
"solar_date": f"{solar.getYear():04d}-{solar.getMonth():02d}-{solar.getDay():02d}", "solar_date": f"{solar.getYear():04d}-{solar.getMonth():02d}-{solar.getDay():02d}",
"lunar_date": f"{lunar.getYear()}{lunar.getMonth()}{lunar.getDay()}", "lunar_date": f"{lunar.getYear()}{lunar.getMonth()}{lunar.getDay()}",
"pillars": { "pillars": pillars,
"year": eight.getYear(),
"month": eight.getMonth(),
"day": eight.getDay(),
"time": eight.getTime() if not time_unknown else "时辰不详",
},
"wuxing": { "wuxing": {
"year": eight.getYearWuXing(), "year": eight.getYearWuXing(),
"month": eight.getMonthWuXing(), "month": eight.getMonthWuXing(),
@ -54,7 +84,14 @@ class BaziCalculator:
"day": "日主", "day": "日主",
"time": eight.getTimeShiShenGan() if not time_unknown else "时辰不详", "time": eight.getTimeShiShenGan() if not time_unknown else "时辰不详",
}, },
"wuxing_balance": self._count_wuxing(pillars),
"day_master": eight.getDayGan(), "day_master": eight.getDayGan(),
"chart_notes": [
"出生地仅用于报告语境,不参与经度校正。",
"时辰不详时,时柱相关判断会降低权重。",
]
if time_unknown
else ["出生地仅用于报告语境,不参与经度校正。"],
} }
return self._fallback_chart(payload, year, month, day, hour, minute, time_unknown) return self._fallback_chart(payload, year, month, day, hour, minute, time_unknown)
@ -67,6 +104,12 @@ class BaziCalculator:
def pillar(offset: int) -> str: def pillar(offset: int) -> str:
return stems[(seed + offset) % 10] + branches[(seed + offset) % 12] return stems[(seed + offset) % 10] + branches[(seed + offset) % 12]
pillars = {
"year": pillar(0),
"month": pillar(13),
"day": pillar(27),
"time": pillar(41) if not time_unknown else "时辰不详",
}
return { return {
"calendar_type": payload.get("calendar_type", "solar"), "calendar_type": payload.get("calendar_type", "solar"),
"is_leap_month": bool(payload.get("is_leap_month")), "is_leap_month": bool(payload.get("is_leap_month")),
@ -78,15 +121,12 @@ class BaziCalculator:
"gender": payload.get("gender"), "gender": payload.get("gender"),
"solar_date": payload["birth_date"], "solar_date": payload["birth_date"],
"lunar_date": "本地未安装 lunar_python暂用娱乐排盘", "lunar_date": "本地未安装 lunar_python暂用娱乐排盘",
"pillars": { "pillars": pillars,
"year": pillar(0),
"month": pillar(13),
"day": pillar(27),
"time": pillar(41) if not time_unknown else "时辰不详",
},
"wuxing": {"year": "参考", "month": "参考", "day": "参考", "time": "参考"}, "wuxing": {"year": "参考", "month": "参考", "day": "参考", "time": "参考"},
"shi_shen": {"year": "参考", "month": "参考", "day": "日主", "time": "参考"}, "shi_shen": {"year": "参考", "month": "参考", "day": "日主", "time": "参考"},
"wuxing_balance": self._count_wuxing(pillars),
"day_master": pillar(27)[0], "day_master": pillar(27)[0],
"chart_notes": ["本地未安装 lunar_python当前为兜底娱乐排盘。"],
} }
def _parse_date(self, value: str) -> tuple[int, int, int]: def _parse_date(self, value: str) -> tuple[int, int, int]:
@ -96,3 +136,14 @@ class BaziCalculator:
def _parse_time(self, value: str) -> tuple[int, int]: def _parse_time(self, value: str) -> tuple[int, int]:
parsed = datetime.strptime(value, "%H:%M") parsed = datetime.strptime(value, "%H:%M")
return parsed.hour, parsed.minute return parsed.hour, parsed.minute
def _count_wuxing(self, pillars: dict) -> dict:
counts = {"": 0, "": 0, "": 0, "": 0, "": 0}
for pillar in pillars.values():
if not isinstance(pillar, str) or pillar == "时辰不详":
continue
for char in pillar:
element = STEM_WUXING.get(char) or BRANCH_WUXING.get(char)
if element:
counts[element] += 1
return counts

View File

@ -0,0 +1,63 @@
from datetime import datetime, time, timedelta
from zoneinfo import ZoneInfo
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.daily_usage import DailyUsage
SHANGHAI_TZ = ZoneInfo("Asia/Shanghai")
class QuotaService:
def today(self):
return datetime.now(SHANGHAI_TZ).date()
def reset_at(self) -> datetime:
tomorrow = self.today() + timedelta(days=1)
return datetime.combine(tomorrow, time.min, tzinfo=SHANGHAI_TZ)
async def get_quota(self, db: AsyncSession, user_id: str) -> dict:
usage = await self._get_usage(db, user_id)
used = usage.count if usage else 0
return self._quota_payload(used)
async def consume(self, db: AsyncSession, user_id: str) -> dict:
usage = await self._get_usage(db, user_id)
if usage is None:
usage = DailyUsage(user_id=user_id, usage_date=self.today(), count=0)
db.add(usage)
await db.flush()
if usage.count >= settings.daily_reading_limit:
quota = self._quota_payload(usage.count)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"今日 {settings.daily_reading_limit} 次解读机会已用完,请明天 0 点后再来。",
headers={
"X-RateLimit-Limit": str(quota["limit"]),
"X-RateLimit-Remaining": str(quota["remaining"]),
"X-RateLimit-Reset": quota["reset_at"].isoformat(),
},
)
usage.count += 1
await db.flush()
return self._quota_payload(usage.count)
async def _get_usage(self, db: AsyncSession, user_id: str) -> DailyUsage | None:
result = await db.execute(
select(DailyUsage).where(DailyUsage.user_id == user_id, DailyUsage.usage_date == self.today())
)
return result.scalar_one_or_none()
def _quota_payload(self, used: int) -> dict:
limit = settings.daily_reading_limit
return {
"limit": limit,
"used": used,
"remaining": max(0, limit - used),
"reset_at": self.reset_at(),
}

View File

@ -16,8 +16,11 @@ class ReadingService:
reading = result.scalar_one() reading = result.scalar_one()
reading.status = "processing" reading.status = "processing"
await db.flush() await db.flush()
await db.commit()
try: try:
result = await db.execute(select(Reading).where(Reading.id == reading_id))
reading = result.scalar_one()
if reading.reading_type == "palm": if reading.reading_type == "palm":
data = await self._generate_palm(db, reading) data = await self._generate_palm(db, reading)
elif reading.reading_type == "face": elif reading.reading_type == "face":

View File

@ -52,7 +52,7 @@ class SharePosterService:
response = await self.client.images.generate( response = await self.client.images.generate(
model=settings.openai_image_model, model=settings.openai_image_model,
prompt=( prompt=(
"生成一张竖版高端小程序分享海报背景,不要任何文字、不要数字、不要 logo。" "生成一张竖版高端移动端分享海报背景,不要任何文字、不要数字、不要 logo。"
"主题:赛博先生 AI 命理实验室,手相报告。" "主题:赛博先生 AI 命理实验室,手相报告。"
"必须包含精美的发光手掌、掌纹扫描线、AI 电路线、东方玄学圆形符号。" "必须包含精美的发光手掌、掌纹扫描线、AI 电路线、东方玄学圆形符号。"
"设计风格:深墨黑背景 #080d10青绿色 AI 发光线条 #00e0b8少量金色点缀 #d8a84e" "设计风格:深墨黑背景 #080d10青绿色 AI 发光线条 #00e0b8少量金色点缀 #d8a84e"
@ -134,7 +134,7 @@ class SharePosterService:
hand_side = {"left": "左手", "right": "右手", "unknown": "未知手"}.get(report.hand_side, "未知手") hand_side = {"left": "左手", "right": "右手", "unknown": "未知手"}.get(report.hand_side, "未知手")
return ( return (
"生成一张竖版中文小程序分享海报,比例 2:3精美、高级、可读性很高。" "生成一张竖版中文移动端分享海报,比例 2:3精美、高级、可读性很高。"
"品牌是“赛博先生”,定位是 AI 命理实验室,功能是手相报告。" "品牌是“赛博先生”,定位是 AI 命理实验室,功能是手相报告。"
"设计风格:深墨黑背景 #080d10青绿色 AI 发光线条 #00e0b8少量金色点缀 #d8a84e" "设计风格:深墨黑背景 #080d10青绿色 AI 发光线条 #00e0b8少量金色点缀 #d8a84e"
"现代、东方玄学、AI 扫描终端感;不要卡通,不要恐怖,不要传统庙宇风,不要紫色渐变。" "现代、东方玄学、AI 扫描终端感;不要卡通,不要恐怖,不要传统庙宇风,不要紫色渐变。"

View File

@ -1,5 +1,6 @@
import pytest import pytest
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from uuid import uuid4
from app.core.database import init_db from app.core.database import init_db
from app.main import app from app.main import app
@ -11,7 +12,7 @@ async def test_bazi_reading_lifecycle(monkeypatch):
await init_db() await init_db()
transport = ASGITransport(app=app) transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client: 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"}) auth = await client.post("/api/v1/auth/anonymous-login", json={"client_id": f"readings-bazi-{uuid4()}"})
token = auth.json()["access_token"] token = auth.json()["access_token"]
headers = {"authorization": f"Bearer {token}"} headers = {"authorization": f"Bearer {token}"}
@ -43,3 +44,37 @@ async def test_bazi_reading_lifecycle(monkeypatch):
deleted = await client.delete(f"/api/v1/readings/{reading_id}", headers=headers) deleted = await client.delete(f"/api/v1/readings/{reading_id}", headers=headers)
assert deleted.status_code == 200 assert deleted.status_code == 200
@pytest.mark.asyncio
async def test_daily_reading_quota_blocks_sixth_request(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": f"readings-quota-{uuid4()}"})
token = auth.json()["access_token"]
headers = {"authorization": f"Bearer {token}"}
payload = {
"reading_type": "bazi",
"input_data": {
"calendar_type": "solar",
"birth_date": "1992-08-18",
"birth_time": "09:30",
"time_unknown": False,
"birth_place": "广东深圳",
},
}
quota_before = await client.get("/api/v1/readings/quota", headers=headers)
assert quota_before.status_code == 200
for _ in range(5):
response = await client.post("/api/v1/readings", headers=headers, json=payload)
assert response.status_code == 201
blocked = await client.post("/api/v1/readings", headers=headers, json=payload)
assert blocked.status_code == 429
quota_after = await client.get("/api/v1/readings/quota", headers=headers)
assert quota_after.json()["remaining"] == 0

View File

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

View File

@ -1,38 +0,0 @@
{
"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"
}

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 424 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 428 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 617 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 622 B

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,39 +0,0 @@
{
"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": {}
}

View File

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

View File

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

View File

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

View File

@ -1,61 +0,0 @@
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
}

Binary file not shown.

View File

@ -177,7 +177,7 @@ p {
display: grid; display: grid;
grid-template-columns: minmax(0, 0.95fr) minmax(420px, 1.05fr); grid-template-columns: minmax(0, 0.95fr) minmax(420px, 1.05fr);
gap: 28px; gap: 28px;
align-items: end; align-items: stretch;
min-height: calc(100vh - 150px); min-height: calc(100vh - 150px);
padding: 38px; padding: 38px;
border: 1px solid var(--line); border: 1px solid var(--line);
@ -259,10 +259,21 @@ p {
opacity: 0.68; opacity: 0.68;
} }
.quick-row { .home-stats {
display: flex; display: grid;
grid-column: 1 / -1; grid-column: 1 / -1;
gap: 12px; grid-template-columns: minmax(220px, 0.75fr) minmax(0, 1.25fr);
align-items: center;
gap: 18px;
padding: 18px;
border: 1px solid rgba(54, 216, 189, 0.18);
border-radius: 22px;
background: rgba(8, 13, 12, 0.32);
}
.home-stats h2 {
margin-top: 8px;
font-size: 28px;
} }
.workspace { .workspace {
@ -501,30 +512,44 @@ p {
} }
.archive-item { .archive-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: stretch;
gap: 10px;
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(8, 13, 12, 0.44);
overflow: hidden;
}
.archive-item.is-failed {
border-color: rgba(255, 107, 74, 0.28);
}
.archive-open {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: 18px; gap: 18px;
width: 100%; width: 100%;
padding: 16px; padding: 16px;
border: 1px solid var(--line); border: 0;
border-radius: 18px;
color: var(--paper); color: var(--paper);
text-align: left; text-align: left;
background: rgba(8, 13, 12, 0.44); background: transparent;
} }
.archive-item strong, .archive-open strong,
.archive-item small, .archive-open small,
.archive-item b { .archive-open b {
display: block; display: block;
} }
.archive-item small { .archive-open small {
margin-top: 5px; margin-top: 5px;
color: var(--paper-dim); color: var(--paper-dim);
} }
.archive-item b { .archive-open b {
max-width: 760px; max-width: 760px;
margin-top: 8px; margin-top: 8px;
color: rgba(245, 236, 216, 0.76); color: rgba(245, 236, 216, 0.76);
@ -532,13 +557,49 @@ p {
line-height: 1.55; line-height: 1.55;
} }
.archive-item em { .archive-actions {
flex: 0 0 auto; display: grid;
align-content: center;
justify-items: end;
gap: 8px;
padding: 14px 16px 14px 0;
}
.archive-actions em {
padding: 6px 10px;
border: 1px solid rgba(54, 216, 189, 0.18);
border-radius: 999px;
color: var(--cyan); color: var(--cyan);
background: rgba(54, 216, 189, 0.08);
font-style: normal; font-style: normal;
font-weight: 700;
white-space: nowrap; white-space: nowrap;
} }
.archive-item.is-failed .archive-actions em {
border-color: rgba(255, 107, 74, 0.2);
color: #ff8a70;
background: rgba(255, 107, 74, 0.08);
}
.archive-delete {
min-height: 30px;
padding: 0 10px;
border: 1px solid transparent;
border-radius: 999px;
color: rgba(245, 236, 216, 0.42);
background: transparent;
font-weight: 700;
opacity: 0.72;
}
.archive-delete:hover {
border-color: rgba(255, 107, 74, 0.24);
color: #ffad9d;
background: rgba(255, 107, 74, 0.08);
opacity: 1;
}
.report-panel { .report-panel {
margin-top: 18px; margin-top: 18px;
} }
@ -596,6 +657,97 @@ p {
background: var(--cyan-soft); background: var(--cyan-soft);
} }
.bazi-chart {
margin-top: 18px;
padding: 18px;
border: 1px solid rgba(213, 168, 85, 0.2);
border-radius: 18px;
background: rgba(15, 22, 19, 0.62);
}
.bazi-chart-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.bazi-chart-head h4 {
color: var(--paper);
font-size: 22px;
}
.bazi-chart-head > span {
padding: 7px 12px;
border: 1px solid rgba(213, 168, 85, 0.24);
border-radius: 999px;
color: var(--gold);
background: var(--gold-soft);
}
.pillar-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-top: 14px;
}
.pillar {
padding: 14px;
border: 1px solid var(--line);
border-radius: 14px;
text-align: center;
background: rgba(8, 13, 12, 0.42);
}
.pillar span,
.chart-facts span,
.wuxing-bar span,
.wuxing-bar em {
color: var(--paper-dim);
}
.pillar strong {
display: block;
margin-top: 6px;
color: var(--paper);
font-size: 24px;
letter-spacing: 0;
}
.chart-facts {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.chart-facts span {
padding: 7px 10px;
border-radius: 10px;
background: rgba(245, 236, 216, 0.06);
}
.wuxing-bars {
display: grid;
gap: 9px;
margin-top: 14px;
}
.wuxing-bar {
display: grid;
grid-template-columns: 28px 1fr 20px;
align-items: center;
gap: 10px;
}
.wuxing-bar i {
display: block;
height: 8px;
border-radius: 999px;
background: linear-gradient(90deg, var(--gold), var(--cyan));
}
.dimension-grid { .dimension-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@ -709,6 +861,10 @@ p {
min-height: auto; min-height: auto;
} }
.home-stats {
grid-template-columns: 1fr;
}
.service-grid { .service-grid {
grid-template-columns: repeat(3, minmax(180px, 1fr)); grid-template-columns: repeat(3, minmax(180px, 1fr));
overflow-x: auto; overflow-x: auto;
@ -824,9 +980,14 @@ p {
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
} }
.quick-row { .home-stats {
display: grid; gap: 12px;
grid-template-columns: 1fr 1fr; padding: 15px;
border-radius: 18px;
}
.home-stats h2 {
font-size: 24px;
} }
.workspace { .workspace {
@ -902,16 +1063,26 @@ p {
white-space: nowrap; white-space: nowrap;
} }
.archive-item { .archive-open {
padding: 14px; padding: 14px 0 14px 14px;
} }
.archive-item b { .archive-open b {
display: none; display: none;
} }
.archive-item small, .archive-open small,
.archive-item em { .archive-actions em {
font-size: 12px;
}
.archive-actions {
padding: 12px 12px 12px 0;
}
.archive-delete {
min-height: 28px;
padding: 0 9px;
font-size: 12px; font-size: 12px;
} }
@ -943,6 +1114,22 @@ p {
padding: 15px; padding: 15px;
} }
.bazi-chart {
padding: 15px;
}
.bazi-chart-head {
display: grid;
}
.pillar-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.pillar strong {
font-size: 21px;
}
.dimension-head { .dimension-head {
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
} }

View File

@ -2,8 +2,40 @@ import type { Metadata } from "next";
import "./globals.css"; import "./globals.css";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "赛博先生 | AI 手相报告", metadataBase: new URL("https://m.xclaw.ren"),
description: "上传手相照片,生成一份面向生活、学习、事业与关系的娱乐向 AI 手相报告。", title: "赛博先生 | AI 玄学档案",
description: "手相、面相、八字三合一 AI 娱乐解读,把日常困惑翻译成生活、学习、事业与关系里的具体提醒。",
applicationName: "赛博先生",
icons: {
icon: "/icon.svg",
apple: "/icon.svg",
},
openGraph: {
type: "website",
url: "https://m.xclaw.ren",
siteName: "赛博先生",
title: "赛博先生 | AI 玄学档案",
description: "手相、面相、八字三合一 AI 娱乐解读,把日常困惑翻译成具体提醒。",
images: [
{
url: "/share-card.png",
width: 1200,
height: 630,
alt: "赛博先生 AI 玄学档案",
},
],
},
twitter: {
card: "summary_large_image",
title: "赛博先生 | AI 玄学档案",
description: "手相、面相、八字三合一 AI 娱乐解读。",
images: ["/share-card.png"],
},
appleWebApp: {
capable: true,
title: "赛博先生",
statusBarStyle: "black-translucent",
},
}; };
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {

View File

@ -4,6 +4,7 @@ import { ChangeEvent, ReactNode, useEffect, useMemo, useState } from "react";
import { import {
Dimension, Dimension,
HandSide, HandSide,
Quota,
Reading, Reading,
ReadingSummary, ReadingSummary,
ReadingType, ReadingType,
@ -62,6 +63,17 @@ type BaziForm = {
birth_place: string; birth_place: string;
}; };
type BaziChart = {
solar_date?: string;
lunar_date?: string;
birth_time?: string | null;
time_unknown?: boolean;
birth_place?: string | null;
pillars?: Record<string, string>;
day_master?: string;
wuxing_balance?: Record<string, number>;
};
const defaultBaziForm: BaziForm = { const defaultBaziForm: BaziForm = {
nickname: "", nickname: "",
gender: "", gender: "",
@ -84,6 +96,7 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
const [busyText, setBusyText] = useState(""); const [busyText, setBusyText] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [readings, setReadings] = useState<ReadingSummary[]>([]); const [readings, setReadings] = useState<ReadingSummary[]>([]);
const [quota, setQuota] = useState<Quota | null>(null);
const [activeReading, setActiveReading] = useState<Reading | null>(null); const [activeReading, setActiveReading] = useState<Reading | null>(null);
const [activeView, setActiveView] = useState<View>(initialView); const [activeView, setActiveView] = useState<View>(initialView);
const [activeJobId, setActiveJobId] = useState(""); const [activeJobId, setActiveJobId] = useState("");
@ -95,7 +108,7 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
window.addEventListener("popstate", onPopState); window.addEventListener("popstate", onPopState);
ensureToken() ensureToken()
.then(() => loadReadings()) .then(() => Promise.all([loadReadings(), loadQuota()]))
.then(() => setReady(true)) .then(() => setReady(true))
.catch((err) => { .catch((err) => {
setError(err.message || "初始化失败"); setError(err.message || "初始化失败");
@ -113,7 +126,6 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
}, [palmPreview, facePreview]); }, [palmPreview, facePreview]);
const completedReadings = readings.filter((item) => item.status === "completed").length; const completedReadings = readings.filter((item) => item.status === "completed").length;
const latestReading = readings[0];
const hasActiveJob = Boolean(activeJobId); const hasActiveJob = Boolean(activeJobId);
function pickFile(event: ChangeEvent<HTMLInputElement>, type: "palm" | "face") { function pickFile(event: ChangeEvent<HTMLInputElement>, type: "palm" | "face") {
@ -137,7 +149,16 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
setReadings(data); setReadings(data);
} }
async function loadQuota() {
const data = await apiFetch<Quota>("/readings/quota");
setQuota(data);
}
async function startImageReading(type: "palm" | "face") { async function startImageReading(type: "palm" | "face") {
if (quota && quota.remaining <= 0) {
setError("今日 5 次解读机会已用完,请明天 0 点后再来。");
return;
}
const file = type === "palm" ? palmFile : faceFile; const file = type === "palm" ? palmFile : faceFile;
if (!file) { if (!file) {
setError(type === "palm" ? "请先选择一张清晰的掌心照片。" : "请先选择一张清晰的单人正脸照片。"); setError(type === "palm" ? "请先选择一张清晰的掌心照片。" : "请先选择一张清晰的单人正脸照片。");
@ -170,6 +191,10 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
} }
async function startBaziReading() { async function startBaziReading() {
if (quota && quota.remaining <= 0) {
setError("今日 5 次解读机会已用完,请明天 0 点后再来。");
return;
}
if (!baziForm.birth_date) { if (!baziForm.birth_date) {
setError("请先填写出生日期。"); setError("请先填写出生日期。");
return; return;
@ -210,6 +235,7 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
.catch((err) => setError(err instanceof Error ? err.message : "报告生成失败")) .catch((err) => setError(err instanceof Error ? err.message : "报告生成失败"))
.finally(() => setActiveJobId("")); .finally(() => setActiveJobId(""));
void loadReadings(); void loadReadings();
void loadQuota();
} }
async function pollReading(readingId: string) { async function pollReading(readingId: string) {
@ -293,12 +319,7 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
))} ))}
</div> </div>
<div className="quick-row"> <HomeStats total={readings.length} completed={completedReadings} quota={quota} />
<button className="primary-action" onClick={() => navigate("palm")}></button>
<button className="ghost-action" onClick={() => navigate(latestReading ? "archive" : "bazi")}>
{latestReading ? "查看最近档案" : "试试八字"}
</button>
</div>
</section> </section>
) : null} ) : null}
@ -309,6 +330,7 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
busyText={busyText} busyText={busyText}
ready={ready} ready={ready}
error={error} error={error}
quota={quota}
onPickFile={(event) => pickFile(event, "palm")} onPickFile={(event) => pickFile(event, "palm")}
onSubmit={() => startImageReading("palm")} onSubmit={() => startImageReading("palm")}
extra={ extra={
@ -320,7 +342,6 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
))} ))}
</div> </div>
} }
stats={<ReadingStats total={readings.length} completed={completedReadings} />}
/> />
) : null} ) : null}
@ -331,10 +352,10 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
busyText={busyText} busyText={busyText}
ready={ready} ready={ready}
error={error} error={error}
quota={quota}
onPickFile={(event) => pickFile(event, "face")} onPickFile={(event) => pickFile(event, "face")}
onSubmit={() => startImageReading("face")} onSubmit={() => startImageReading("face")}
extra={null} extra={null}
stats={<ReadingStats total={readings.length} completed={completedReadings} />}
/> />
) : null} ) : null}
@ -345,8 +366,8 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
busyText={busyText} busyText={busyText}
ready={ready} ready={ready}
error={error} error={error}
quota={quota}
onSubmit={startBaziReading} onSubmit={startBaziReading}
stats={<ReadingStats total={readings.length} completed={completedReadings} />}
/> />
) : null} ) : null}
@ -362,14 +383,21 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
<div className="report-list"> <div className="report-list">
{readings.length ? ( {readings.length ? (
readings.map((item) => ( readings.map((item) => (
<button key={item.id} className="archive-item" onClick={() => openReading(item.id)}> <div key={item.id} className={`archive-item ${item.status === "failed" ? "is-failed" : ""}`}>
<span> <button className="archive-open" onClick={() => openReading(item.id)}>
<span>
<strong>{readingMeta[item.reading_type].name}</strong> <strong>{readingMeta[item.reading_type].name}</strong>
<small>{formatDate(item.created_at)}</small> <small>{formatDate(item.created_at)}</small>
{item.overall_summary ? <b>{item.overall_summary}</b> : null} {item.overall_summary ? <b>{item.overall_summary}</b> : null}
</span> </span>
<em>{statusText[item.status]}</em> </button>
</button> <div className="archive-actions">
<em>{statusText[item.status]}</em>
<button className="archive-delete" onClick={() => deleteReading(item.id)} aria-label={`删除${readingMeta[item.reading_type].name}报告`}>
</button>
</div>
</div>
)) ))
) : ( ) : (
<p className="empty"></p> <p className="empty"></p>
@ -397,8 +425,8 @@ function ImageReadingForm({
busyText, busyText,
ready, ready,
error, error,
quota,
extra, extra,
stats,
onPickFile, onPickFile,
onSubmit, onSubmit,
}: { }: {
@ -407,8 +435,8 @@ function ImageReadingForm({
busyText: string; busyText: string;
ready: boolean; ready: boolean;
error: string; error: string;
quota: Quota | null;
extra: ReactNode; extra: ReactNode;
stats: ReactNode;
onPickFile: (event: ChangeEvent<HTMLInputElement>) => void; onPickFile: (event: ChangeEvent<HTMLInputElement>) => void;
onSubmit: () => void; onSubmit: () => void;
}) { }) {
@ -433,8 +461,8 @@ function ImageReadingForm({
</label> </label>
{extra} {extra}
<button className="primary-action" disabled={!ready || Boolean(busyText)} onClick={onSubmit}> <button className="primary-action" disabled={!ready || Boolean(busyText) || quota?.remaining === 0} onClick={onSubmit}>
{busyText || "提交分析"} {busyText || (quota?.remaining === 0 ? "今日次数已用完" : "提交分析")}
</button> </button>
{error ? <p className="error">{error}</p> : null} {error ? <p className="error">{error}</p> : null}
</div> </div>
@ -445,7 +473,6 @@ function ImageReadingForm({
<h3></h3> <h3></h3>
<p></p> <p></p>
</div> </div>
<div className="mini-card">{stats}</div>
</aside> </aside>
</section> </section>
); );
@ -457,7 +484,7 @@ function BaziFormView({
busyText, busyText,
ready, ready,
error, error,
stats, quota,
onSubmit, onSubmit,
}: { }: {
form: BaziForm; form: BaziForm;
@ -465,7 +492,7 @@ function BaziFormView({
busyText: string; busyText: string;
ready: boolean; ready: boolean;
error: string; error: string;
stats: ReactNode; quota: Quota | null;
onSubmit: () => void; onSubmit: () => void;
}) { }) {
return ( return (
@ -473,7 +500,7 @@ function BaziFormView({
<div className="upload-card"> <div className="upload-card">
<div className="section-label">BAZI READING</div> <div className="section-label">BAZI READING</div>
<h2></h2> <h2></h2>
<p></p> <p></p>
<div className="form-grid"> <div className="form-grid">
<label className="field"> <label className="field">
@ -520,8 +547,8 @@ function BaziFormView({
</label> </label>
</div> </div>
<button className="primary-action" disabled={!ready || Boolean(busyText)} onClick={onSubmit}> <button className="primary-action" disabled={!ready || Boolean(busyText) || quota?.remaining === 0} onClick={onSubmit}>
{busyText || "提交排盘"} {busyText || (quota?.remaining === 0 ? "今日次数已用完" : "提交排盘")}
</button> </button>
{error ? <p className="error">{error}</p> : null} {error ? <p className="error">{error}</p> : null}
</div> </div>
@ -532,17 +559,24 @@ function BaziFormView({
<h3></h3> <h3></h3>
<p>线</p> <p>线</p>
</div> </div>
<div className="mini-card">{stats}</div>
</aside> </aside>
</section> </section>
); );
} }
function ReadingStats({ total, completed }: { total: number; completed: number }) { function HomeStats({ total, completed, quota }: { total: number; completed: number; quota: Quota | null }) {
return ( return (
<div className="stats"> <div className="home-stats">
<Stat label="累计报告" value={total} /> <div>
<Stat label="已完成" value={completed} /> <p className="section-label">TODAY STATUS</p>
<h2></h2>
</div>
<div className="stats">
<Stat label="今日剩余" value={quota?.remaining ?? 0} />
<Stat label="每日上限" value={quota?.limit ?? 5} />
<Stat label="累计报告" value={total} />
<Stat label="已完成" value={completed} />
</div>
</div> </div>
); );
} }
@ -557,6 +591,7 @@ function ReportPanel({ reading, onDelete }: { reading: Reading; onDelete: () =>
<section className="report-panel"> <section className="report-panel">
<h3>{statusText[reading.status]}</h3> <h3>{statusText[reading.status]}</h3>
<p>{reading.error_message || "先生正在整理报告。"}</p> <p>{reading.error_message || "先生正在整理报告。"}</p>
<button className="delete-action" onClick={onDelete}></button>
</section> </section>
); );
} }
@ -584,6 +619,8 @@ function ReportPanel({ reading, onDelete }: { reading: Reading; onDelete: () =>
))} ))}
</div> </div>
{type === "bazi" ? <BaziChartPanel chart={getBaziChart(reading)} /> : null}
<div className="dimension-grid"> <div className="dimension-grid">
{data.dimensions.map((dimension, index) => ( {data.dimensions.map((dimension, index) => (
<DimensionCard key={`${dimension.name}-${index}`} dimension={dimension} index={index} /> <DimensionCard key={`${dimension.name}-${index}`} dimension={dimension} index={index} />
@ -602,6 +639,56 @@ function ReportPanel({ reading, onDelete }: { reading: Reading; onDelete: () =>
); );
} }
function BaziChartPanel({ chart }: { chart: BaziChart | null }) {
if (!chart) return null;
const pillars = chart.pillars || {};
const wuxing = chart.wuxing_balance || {};
const pillarItems = [
["年柱", pillars.year],
["月柱", pillars.month],
["日柱", pillars.day],
["时柱", pillars.time],
];
return (
<section className="bazi-chart">
<div className="bazi-chart-head">
<div>
<p className="section-label">CHART</p>
<h4></h4>
</div>
<span>{chart.time_unknown ? "时辰不详" : chart.birth_time || "已记录时辰"}</span>
</div>
<div className="pillar-row">
{pillarItems.map(([label, value]) => (
<div className="pillar" key={label}>
<span>{label}</span>
<strong>{value || "-"}</strong>
</div>
))}
</div>
<div className="chart-facts">
<span>{chart.solar_date || "-"}</span>
<span>{chart.lunar_date || "-"}</span>
<span>{chart.day_master || "-"}</span>
{chart.birth_place ? <span>{chart.birth_place}</span> : null}
</div>
<div className="wuxing-bars">
{(["木", "火", "土", "金", "水"] as const).map((name) => {
const value = wuxing[name] || 0;
return (
<div className="wuxing-bar" key={name}>
<span>{name}</span>
<i style={{ width: `${Math.max(8, value * 14)}%` }} />
<em>{value}</em>
</div>
);
})}
</div>
</section>
);
}
function DimensionCard({ dimension, index }: { dimension: Dimension; index: number }) { function DimensionCard({ dimension, index }: { dimension: Dimension; index: number }) {
return ( return (
<article className="dimension-card"> <article className="dimension-card">
@ -621,6 +708,12 @@ function DimensionCard({ dimension, index }: { dimension: Dimension; index: numb
); );
} }
function getBaziChart(reading: Reading): BaziChart | null {
const chart = reading.input_data?.chart;
if (!chart || typeof chart !== "object" || Array.isArray(chart)) return null;
return chart as BaziChart;
}
function SummaryList({ title, items }: { title: string; items: string[] }) { function SummaryList({ title, items }: { title: string; items: string[] }) {
return ( return (
<article className="summary-list"> <article className="summary-list">

View File

@ -48,6 +48,13 @@ export type ReadingSummary = {
overall_summary?: string | null; overall_summary?: string | null;
}; };
export type Quota = {
limit: number;
used: number;
remaining: number;
reset_at: string;
};
export type Report = Reading & { hand_side?: HandSide }; export type Report = Reading & { hand_side?: HandSide };
export type ReportSummary = ReadingSummary & { hand_side?: HandSide }; export type ReportSummary = ReadingSummary & { hand_side?: HandSide };

7
web/public/icon.svg Normal file
View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="112" fill="#080d0c"/>
<circle cx="256" cy="256" r="168" fill="#101d19" stroke="#d5a855" stroke-width="14"/>
<circle cx="256" cy="256" r="112" fill="none" stroke="#36d8bd" stroke-opacity=".42" stroke-width="6"/>
<path d="M256 118v276M162 214h188M184 296h144M201 170c36 56 73 89 121 111" fill="none" stroke="#f5ecd8" stroke-width="22" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M350 156c-14 83-56 154-128 213" fill="none" stroke="#d5a855" stroke-width="18" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 610 B

BIN
web/public/share-card.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

25
web/public/share-card.svg Normal file
View File

@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
<defs>
<radialGradient id="g1" cx="20%" cy="10%" r="80%">
<stop offset="0" stop-color="#2b2415"/>
<stop offset=".48" stop-color="#101d19"/>
<stop offset="1" stop-color="#080d0c"/>
</radialGradient>
<radialGradient id="g2" cx="78%" cy="25%" r="54%">
<stop offset="0" stop-color="#36d8bd" stop-opacity=".26"/>
<stop offset="1" stop-color="#36d8bd" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="1200" height="630" fill="url(#g1)"/>
<rect width="1200" height="630" fill="url(#g2)"/>
<rect x="54" y="54" width="1092" height="522" rx="42" fill="none" stroke="#d5a855" stroke-opacity=".34" stroke-width="2"/>
<circle cx="910" cy="315" r="166" fill="none" stroke="#36d8bd" stroke-opacity=".38" stroke-width="4"/>
<circle cx="910" cy="315" r="108" fill="none" stroke="#d5a855" stroke-opacity=".42" stroke-width="4"/>
<path d="M910 158v314M804 262h212M830 354h166M850 214c48 74 91 112 150 143" fill="none" stroke="#f5ecd8" stroke-width="22" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M1010 208c-18 96-66 178-152 246" fill="none" stroke="#d5a855" stroke-width="17" stroke-linecap="round"/>
<text x="110" y="156" fill="#d5a855" font-family="Georgia, 'Songti SC', serif" font-size="28" font-weight="700" letter-spacing="5">CYBER MISTER</text>
<text x="108" y="270" fill="#f5ecd8" font-family="'Songti SC', 'Noto Serif SC', serif" font-size="86" font-weight="700">赛博先生</text>
<text x="112" y="356" fill="#f5ecd8" fill-opacity=".92" font-family="'Songti SC', 'Noto Serif SC', serif" font-size="42">AI 玄学档案</text>
<text x="112" y="430" fill="#c8b99a" font-family="'Songti SC', 'Noto Serif SC', serif" font-size="32">手相 · 面相 · 八字</text>
<text x="112" y="486" fill="#c8b99a" font-family="'Songti SC', 'Noto Serif SC', serif" font-size="28">把日常困惑,翻译成生活里的具体提醒</text>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB