update、
9
.gitignore
vendored
@ -76,15 +76,6 @@ pnpm-debug.log*
|
||||
# Keep package lock tracked
|
||||
!web/package-lock.json
|
||||
|
||||
# WeChat Mini Program local files
|
||||
miniprogram/project.private.config.json
|
||||
miniprogram/private.*
|
||||
miniprogram/miniprogram_npm/
|
||||
miniprogram/npm/
|
||||
miniprogram/node_modules/
|
||||
miniprogram/dist/
|
||||
miniprogram/.idea/
|
||||
|
||||
# Docker / generated local artifacts
|
||||
*.pid
|
||||
*.log
|
||||
|
||||
13
README.md
@ -1,11 +1,10 @@
|
||||
# AI 手相报告 MVP
|
||||
# 赛博先生 Web App
|
||||
|
||||
原生微信小程序 + Python FastAPI 后端的娱乐型手相报告应用。
|
||||
面向 Web 的 AI 命理娱乐应用,当前支持手相、面相、八字三类报告生成。产品定位为“娱乐占卜 + 自我反思”,不做确定性人生判断。
|
||||
|
||||
## 目录
|
||||
|
||||
- `backend/`: FastAPI API、数据库模型、OpenAI 分析服务、图片存储。
|
||||
- `miniprogram/`: 原生微信小程序页面与请求封装。
|
||||
- `web/`: Next.js Web App 主产品入口。
|
||||
|
||||
## 本地运行后端
|
||||
@ -18,16 +17,12 @@ cp .env.example .env
|
||||
venv/bin/uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
默认使用 SQLite 和 mock 微信登录,便于本地开发。生产环境配置 `DATABASE_URL` 为 Postgres,配置微信、OpenAI 和对象存储参数。
|
||||
默认使用 SQLite 和匿名 Web 登录,便于本地开发。生产环境可配置 `DATABASE_URL` 为 Postgres,并配置 OpenAI 和对象存储参数。
|
||||
|
||||
## 环境变量
|
||||
|
||||
见 `backend/.env.example`。
|
||||
|
||||
## 小程序
|
||||
|
||||
用微信开发者工具打开 `miniprogram/`,把 `utils/config.js` 中的 `API_BASE_URL` 改成后端地址。
|
||||
|
||||
## Web App
|
||||
|
||||
```bash
|
||||
@ -37,7 +32,7 @@ cp .env.example .env.local
|
||||
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 一键部署
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ ENVIRONMENT=development
|
||||
DATABASE_URL=sqlite+aiosqlite:///./palm_reading.db
|
||||
SECRET_KEY=change-me-in-production
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=43200
|
||||
DAILY_READING_LIMIT=5
|
||||
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_BASE_URL=
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# Backend
|
||||
|
||||
FastAPI 后端提供微信登录、图片上传、手相报告生成和历史报告管理。
|
||||
FastAPI 后端提供匿名登录、图片上传、手相/面相/八字报告生成和历史报告管理。
|
||||
|
||||
## Run
|
||||
|
||||
@ -21,4 +21,4 @@ venv/bin/python -m app.cli
|
||||
|
||||
所有业务 API 位于 `/api/v1`。
|
||||
|
||||
本地开发默认 `WECHAT_MOCK_LOGIN=true`,`OPENAI_API_KEY` 为空时会返回 mock 报告,便于先联调小程序流程。
|
||||
本地开发默认支持匿名登录,`OPENAI_API_KEY` 为空时会返回 mock 报告,便于先联调 Web 流程。
|
||||
|
||||
@ -8,8 +8,10 @@ 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.quota import QuotaResponse
|
||||
from app.schemas.reading import BaziInput, ReadingCreate, ReadingDetail, ReadingSummary
|
||||
from app.services.image_service import ImageService
|
||||
from app.services.quota_service import QuotaService
|
||||
from app.services.reading_service import ReadingService
|
||||
|
||||
router = APIRouter()
|
||||
@ -20,6 +22,11 @@ async def generate_reading_task(reading_id: str) -> None:
|
||||
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)
|
||||
async def create_reading(
|
||||
payload: ReadingCreate,
|
||||
@ -41,6 +48,7 @@ async def create_reading(
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=422, detail=exc.errors()) from exc
|
||||
|
||||
await QuotaService().consume(db, user.id)
|
||||
reading = Reading(
|
||||
user_id=user.id,
|
||||
reading_type=payload.reading_type,
|
||||
|
||||
@ -13,6 +13,7 @@ from app.models.user import User
|
||||
from app.schemas.share_image import ShareImageJobResponse
|
||||
from app.schemas.report import ReportCreate, ReportDetail, ReportSummary
|
||||
from app.services.image_service import ImageService
|
||||
from app.services.quota_service import QuotaService
|
||||
from app.services.report_service import ReportService
|
||||
from app.services.share_poster_service import SharePosterService
|
||||
|
||||
@ -66,6 +67,7 @@ async def create_report(
|
||||
if image is None:
|
||||
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")
|
||||
db.add(report)
|
||||
await db.flush()
|
||||
|
||||
@ -11,6 +11,7 @@ class Settings(BaseSettings):
|
||||
secret_key: str = "change-me-in-production"
|
||||
access_token_expire_minutes: int = 60 * 24 * 30
|
||||
cors_origins: list[str] = Field(default_factory=lambda: ["*"])
|
||||
daily_reading_limit: int = 5
|
||||
|
||||
openai_api_key: str | None = None
|
||||
openai_base_url: 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, 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:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
19
backend/app/models/daily_usage.py
Normal 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)
|
||||
10
backend/app/schemas/quota.py
Normal 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
|
||||
@ -5,14 +5,17 @@ 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,并说明分析会更偏概览。"
|
||||
"1. dimensions 固定输出 7 项:四柱底色、日主与五行、十神互动、学习成长、事业节奏、关系沟通、近期行动。"
|
||||
"2. overall_summary 用 2 到 4 句话,像给用户本人看的开场结论,不要像百科解释。"
|
||||
"3. 每个 interpretation 必须落到现实场景,例如学习效率、职业节奏、沟通方式、情绪恢复、计划执行。"
|
||||
"4. observations 可以写排盘证据,但不要只堆术语;每条都要让普通用户看得懂。"
|
||||
"5. 每个 advice 必须是今天或本周能做的小建议,避免空话。"
|
||||
"6. strengths、challenges、suggestions 要分别覆盖生活、学习、事业、关系,语言要具体。"
|
||||
"7. 可以少量使用日主、五行、十神等术语,但必须用普通人能听懂的话解释。"
|
||||
"8. 不预测寿命、灾祸、婚姻成败、财富结果,不做宿命论。"
|
||||
"9. 如果时辰不详,要诚实降低相关维度 confidence,并说明分析会更偏概览。"
|
||||
)
|
||||
|
||||
|
||||
@ -49,8 +52,8 @@ class BaziAnalyzer(BaseAnalyzer):
|
||||
},
|
||||
{
|
||||
"name": "日主与五行",
|
||||
"observations": [f"日主:{day_master}", f"五行线索:{chart.get('wuxing', {})}"],
|
||||
"interpretation": "日主象征你处理世界的核心方式。你适合找到自己的稳定补给,再去应对学习、工作和关系里的变化。",
|
||||
"observations": [f"日主:{day_master}", f"五行分布:{chart.get('wuxing_balance', {})}"],
|
||||
"interpretation": "日主象征你处理世界的核心方式,五行分布则像精力使用习惯。现实里,你适合先找到稳定补给,再去应对学习、工作和关系里的变化。",
|
||||
"confidence": 0.7,
|
||||
"advice": "这周给自己固定一个恢复时间,别把精力全部交给外界安排。",
|
||||
},
|
||||
|
||||
@ -7,6 +7,35 @@ except ImportError: # pragma: no cover - production image installs the package
|
||||
Solar = None
|
||||
|
||||
|
||||
STEM_WUXING = {
|
||||
"甲": "木",
|
||||
"乙": "木",
|
||||
"丙": "火",
|
||||
"丁": "火",
|
||||
"戊": "土",
|
||||
"己": "土",
|
||||
"庚": "金",
|
||||
"辛": "金",
|
||||
"壬": "水",
|
||||
"癸": "水",
|
||||
}
|
||||
|
||||
BRANCH_WUXING = {
|
||||
"子": "水",
|
||||
"丑": "土",
|
||||
"寅": "木",
|
||||
"卯": "木",
|
||||
"辰": "土",
|
||||
"巳": "火",
|
||||
"午": "火",
|
||||
"未": "土",
|
||||
"申": "金",
|
||||
"酉": "金",
|
||||
"戌": "土",
|
||||
"亥": "水",
|
||||
}
|
||||
|
||||
|
||||
class BaziCalculator:
|
||||
def calculate(self, payload: dict) -> dict:
|
||||
birth_date = payload["birth_date"]
|
||||
@ -25,6 +54,12 @@ class BaziCalculator:
|
||||
solar = Solar.fromYmdHms(year, month, day, hour, minute, 0)
|
||||
lunar = solar.getLunar()
|
||||
eight = lunar.getEightChar()
|
||||
pillars = {
|
||||
"year": eight.getYear(),
|
||||
"month": eight.getMonth(),
|
||||
"day": eight.getDay(),
|
||||
"time": eight.getTime() if not time_unknown else "时辰不详",
|
||||
}
|
||||
return {
|
||||
"calendar_type": calendar_type,
|
||||
"is_leap_month": bool(payload.get("is_leap_month")),
|
||||
@ -36,12 +71,7 @@ class BaziCalculator:
|
||||
"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 "时辰不详",
|
||||
},
|
||||
"pillars": pillars,
|
||||
"wuxing": {
|
||||
"year": eight.getYearWuXing(),
|
||||
"month": eight.getMonthWuXing(),
|
||||
@ -54,7 +84,14 @@ class BaziCalculator:
|
||||
"day": "日主",
|
||||
"time": eight.getTimeShiShenGan() if not time_unknown else "时辰不详",
|
||||
},
|
||||
"wuxing_balance": self._count_wuxing(pillars),
|
||||
"day_master": eight.getDayGan(),
|
||||
"chart_notes": [
|
||||
"出生地仅用于报告语境,不参与经度校正。",
|
||||
"时辰不详时,时柱相关判断会降低权重。",
|
||||
]
|
||||
if time_unknown
|
||||
else ["出生地仅用于报告语境,不参与经度校正。"],
|
||||
}
|
||||
|
||||
return self._fallback_chart(payload, year, month, day, hour, minute, time_unknown)
|
||||
@ -67,6 +104,12 @@ class BaziCalculator:
|
||||
def pillar(offset: int) -> str:
|
||||
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 {
|
||||
"calendar_type": payload.get("calendar_type", "solar"),
|
||||
"is_leap_month": bool(payload.get("is_leap_month")),
|
||||
@ -78,15 +121,12 @@ class BaziCalculator:
|
||||
"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 "时辰不详",
|
||||
},
|
||||
"pillars": pillars,
|
||||
"wuxing": {"year": "参考", "month": "参考", "day": "参考", "time": "参考"},
|
||||
"shi_shen": {"year": "参考", "month": "参考", "day": "日主", "time": "参考"},
|
||||
"wuxing_balance": self._count_wuxing(pillars),
|
||||
"day_master": pillar(27)[0],
|
||||
"chart_notes": ["本地未安装 lunar_python,当前为兜底娱乐排盘。"],
|
||||
}
|
||||
|
||||
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]:
|
||||
parsed = datetime.strptime(value, "%H:%M")
|
||||
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
|
||||
|
||||
63
backend/app/services/quota_service.py
Normal 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(),
|
||||
}
|
||||
@ -16,8 +16,11 @@ class ReadingService:
|
||||
reading = result.scalar_one()
|
||||
reading.status = "processing"
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
|
||||
try:
|
||||
result = await db.execute(select(Reading).where(Reading.id == reading_id))
|
||||
reading = result.scalar_one()
|
||||
if reading.reading_type == "palm":
|
||||
data = await self._generate_palm(db, reading)
|
||||
elif reading.reading_type == "face":
|
||||
|
||||
@ -52,7 +52,7 @@ class SharePosterService:
|
||||
response = await self.client.images.generate(
|
||||
model=settings.openai_image_model,
|
||||
prompt=(
|
||||
"生成一张竖版高端小程序分享海报背景,不要任何文字、不要数字、不要 logo。"
|
||||
"生成一张竖版高端移动端分享海报背景,不要任何文字、不要数字、不要 logo。"
|
||||
"主题:赛博先生 AI 命理实验室,手相报告。"
|
||||
"必须包含精美的发光手掌、掌纹扫描线、AI 电路线、东方玄学圆形符号。"
|
||||
"设计风格:深墨黑背景 #080d10,青绿色 AI 发光线条 #00e0b8,少量金色点缀 #d8a84e;"
|
||||
@ -134,7 +134,7 @@ class SharePosterService:
|
||||
hand_side = {"left": "左手", "right": "右手", "unknown": "未知手"}.get(report.hand_side, "未知手")
|
||||
|
||||
return (
|
||||
"生成一张竖版中文小程序分享海报,比例 2:3,精美、高级、可读性很高。"
|
||||
"生成一张竖版中文移动端分享海报,比例 2:3,精美、高级、可读性很高。"
|
||||
"品牌是“赛博先生”,定位是 AI 命理实验室,功能是手相报告。"
|
||||
"设计风格:深墨黑背景 #080d10,青绿色 AI 发光线条 #00e0b8,少量金色点缀 #d8a84e;"
|
||||
"现代、东方玄学、AI 扫描终端感;不要卡通,不要恐怖,不要传统庙宇风,不要紫色渐变。"
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from uuid import uuid4
|
||||
|
||||
from app.core.database import init_db
|
||||
from app.main import app
|
||||
@ -11,7 +12,7 @@ async def test_bazi_reading_lifecycle(monkeypatch):
|
||||
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"})
|
||||
auth = await client.post("/api/v1/auth/anonymous-login", json={"client_id": f"readings-bazi-{uuid4()}"})
|
||||
token = auth.json()["access_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)
|
||||
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
|
||||
|
||||
@ -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')
|
||||
}
|
||||
})
|
||||
@ -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"
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 424 B |
|
Before Width: | Height: | Size: 428 B |
|
Before Width: | Height: | Size: 617 B |
|
Before Width: | Height: | Size: 622 B |
@ -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' })
|
||||
}
|
||||
})
|
||||
@ -1,3 +0,0 @@
|
||||
{
|
||||
"navigationBarTitleText": "生成中"
|
||||
}
|
||||
@ -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>
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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' })
|
||||
}
|
||||
})
|
||||
@ -1,3 +0,0 @@
|
||||
{
|
||||
"navigationBarTitleText": "解读档案"
|
||||
}
|
||||
@ -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>
|
||||
@ -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;
|
||||
}
|
||||
@ -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' })
|
||||
}
|
||||
})
|
||||
@ -1,3 +0,0 @@
|
||||
{
|
||||
"navigationBarTitleText": "赛博先生"
|
||||
}
|
||||
@ -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>
|
||||
@ -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;
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
Page({})
|
||||
@ -1,3 +0,0 @@
|
||||
{
|
||||
"navigationBarTitleText": "协议与隐私"
|
||||
}
|
||||
@ -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>
|
||||
@ -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;
|
||||
}
|
||||
@ -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' })
|
||||
}
|
||||
})
|
||||
@ -1,3 +0,0 @@
|
||||
{
|
||||
"navigationBarTitleText": "手相报告"
|
||||
}
|
||||
@ -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>
|
||||
@ -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;
|
||||
}
|
||||
@ -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'
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -1,4 +0,0 @@
|
||||
{
|
||||
"navigationBarTitleText": "手相报告",
|
||||
"enableShareAppMessage": true
|
||||
}
|
||||
@ -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>
|
||||
@ -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;
|
||||
}
|
||||
@ -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": {}
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"action": "allow",
|
||||
"page": "*"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
const API_BASE_URL = 'http://127.0.0.1:8000/api/v1'
|
||||
|
||||
module.exports = {
|
||||
API_BASE_URL
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
BIN
palm_reading.db
@ -177,7 +177,7 @@ p {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.95fr) minmax(420px, 1.05fr);
|
||||
gap: 28px;
|
||||
align-items: end;
|
||||
align-items: stretch;
|
||||
min-height: calc(100vh - 150px);
|
||||
padding: 38px;
|
||||
border: 1px solid var(--line);
|
||||
@ -259,10 +259,21 @@ p {
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.quick-row {
|
||||
display: flex;
|
||||
.home-stats {
|
||||
display: grid;
|
||||
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 {
|
||||
@ -501,30 +512,44 @@ p {
|
||||
}
|
||||
|
||||
.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;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
border: 0;
|
||||
color: var(--paper);
|
||||
text-align: left;
|
||||
background: rgba(8, 13, 12, 0.44);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.archive-item strong,
|
||||
.archive-item small,
|
||||
.archive-item b {
|
||||
.archive-open strong,
|
||||
.archive-open small,
|
||||
.archive-open b {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.archive-item small {
|
||||
.archive-open small {
|
||||
margin-top: 5px;
|
||||
color: var(--paper-dim);
|
||||
}
|
||||
|
||||
.archive-item b {
|
||||
.archive-open b {
|
||||
max-width: 760px;
|
||||
margin-top: 8px;
|
||||
color: rgba(245, 236, 216, 0.76);
|
||||
@ -532,13 +557,49 @@ p {
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.archive-item em {
|
||||
flex: 0 0 auto;
|
||||
.archive-actions {
|
||||
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);
|
||||
background: rgba(54, 216, 189, 0.08);
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
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 {
|
||||
margin-top: 18px;
|
||||
}
|
||||
@ -596,6 +657,97 @@ p {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@ -709,6 +861,10 @@ p {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.home-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.service-grid {
|
||||
grid-template-columns: repeat(3, minmax(180px, 1fr));
|
||||
overflow-x: auto;
|
||||
@ -824,9 +980,14 @@ p {
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.quick-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
.home-stats {
|
||||
gap: 12px;
|
||||
padding: 15px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.home-stats h2 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
@ -902,16 +1063,26 @@ p {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.archive-item {
|
||||
padding: 14px;
|
||||
.archive-open {
|
||||
padding: 14px 0 14px 14px;
|
||||
}
|
||||
|
||||
.archive-item b {
|
||||
.archive-open b {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.archive-item small,
|
||||
.archive-item em {
|
||||
.archive-open small,
|
||||
.archive-actions em {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.archive-actions {
|
||||
padding: 12px 12px 12px 0;
|
||||
}
|
||||
|
||||
.archive-delete {
|
||||
min-height: 28px;
|
||||
padding: 0 9px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@ -943,6 +1114,22 @@ p {
|
||||
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 {
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
|
||||
@ -2,8 +2,40 @@ import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "赛博先生 | AI 手相报告",
|
||||
description: "上传手相照片,生成一份面向生活、学习、事业与关系的娱乐向 AI 手相报告。",
|
||||
metadataBase: new URL("https://m.xclaw.ren"),
|
||||
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 }>) {
|
||||
|
||||
@ -4,6 +4,7 @@ import { ChangeEvent, ReactNode, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Dimension,
|
||||
HandSide,
|
||||
Quota,
|
||||
Reading,
|
||||
ReadingSummary,
|
||||
ReadingType,
|
||||
@ -62,6 +63,17 @@ type BaziForm = {
|
||||
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 = {
|
||||
nickname: "",
|
||||
gender: "",
|
||||
@ -84,6 +96,7 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
|
||||
const [busyText, setBusyText] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [readings, setReadings] = useState<ReadingSummary[]>([]);
|
||||
const [quota, setQuota] = useState<Quota | null>(null);
|
||||
const [activeReading, setActiveReading] = useState<Reading | null>(null);
|
||||
const [activeView, setActiveView] = useState<View>(initialView);
|
||||
const [activeJobId, setActiveJobId] = useState("");
|
||||
@ -95,7 +108,7 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
|
||||
window.addEventListener("popstate", onPopState);
|
||||
|
||||
ensureToken()
|
||||
.then(() => loadReadings())
|
||||
.then(() => Promise.all([loadReadings(), loadQuota()]))
|
||||
.then(() => setReady(true))
|
||||
.catch((err) => {
|
||||
setError(err.message || "初始化失败");
|
||||
@ -113,7 +126,6 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
|
||||
}, [palmPreview, facePreview]);
|
||||
|
||||
const completedReadings = readings.filter((item) => item.status === "completed").length;
|
||||
const latestReading = readings[0];
|
||||
const hasActiveJob = Boolean(activeJobId);
|
||||
|
||||
function pickFile(event: ChangeEvent<HTMLInputElement>, type: "palm" | "face") {
|
||||
@ -137,7 +149,16 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
|
||||
setReadings(data);
|
||||
}
|
||||
|
||||
async function loadQuota() {
|
||||
const data = await apiFetch<Quota>("/readings/quota");
|
||||
setQuota(data);
|
||||
}
|
||||
|
||||
async function startImageReading(type: "palm" | "face") {
|
||||
if (quota && quota.remaining <= 0) {
|
||||
setError("今日 5 次解读机会已用完,请明天 0 点后再来。");
|
||||
return;
|
||||
}
|
||||
const file = type === "palm" ? palmFile : faceFile;
|
||||
if (!file) {
|
||||
setError(type === "palm" ? "请先选择一张清晰的掌心照片。" : "请先选择一张清晰的单人正脸照片。");
|
||||
@ -170,6 +191,10 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
|
||||
}
|
||||
|
||||
async function startBaziReading() {
|
||||
if (quota && quota.remaining <= 0) {
|
||||
setError("今日 5 次解读机会已用完,请明天 0 点后再来。");
|
||||
return;
|
||||
}
|
||||
if (!baziForm.birth_date) {
|
||||
setError("请先填写出生日期。");
|
||||
return;
|
||||
@ -210,6 +235,7 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
|
||||
.catch((err) => setError(err instanceof Error ? err.message : "报告生成失败"))
|
||||
.finally(() => setActiveJobId(""));
|
||||
void loadReadings();
|
||||
void loadQuota();
|
||||
}
|
||||
|
||||
async function pollReading(readingId: string) {
|
||||
@ -293,12 +319,7 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="quick-row">
|
||||
<button className="primary-action" onClick={() => navigate("palm")}>开始手相测试</button>
|
||||
<button className="ghost-action" onClick={() => navigate(latestReading ? "archive" : "bazi")}>
|
||||
{latestReading ? "查看最近档案" : "试试八字"}
|
||||
</button>
|
||||
</div>
|
||||
<HomeStats total={readings.length} completed={completedReadings} quota={quota} />
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
@ -309,6 +330,7 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
|
||||
busyText={busyText}
|
||||
ready={ready}
|
||||
error={error}
|
||||
quota={quota}
|
||||
onPickFile={(event) => pickFile(event, "palm")}
|
||||
onSubmit={() => startImageReading("palm")}
|
||||
extra={
|
||||
@ -320,7 +342,6 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
stats={<ReadingStats total={readings.length} completed={completedReadings} />}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@ -331,10 +352,10 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
|
||||
busyText={busyText}
|
||||
ready={ready}
|
||||
error={error}
|
||||
quota={quota}
|
||||
onPickFile={(event) => pickFile(event, "face")}
|
||||
onSubmit={() => startImageReading("face")}
|
||||
extra={null}
|
||||
stats={<ReadingStats total={readings.length} completed={completedReadings} />}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@ -345,8 +366,8 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
|
||||
busyText={busyText}
|
||||
ready={ready}
|
||||
error={error}
|
||||
quota={quota}
|
||||
onSubmit={startBaziReading}
|
||||
stats={<ReadingStats total={readings.length} completed={completedReadings} />}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@ -362,14 +383,21 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
|
||||
<div className="report-list">
|
||||
{readings.length ? (
|
||||
readings.map((item) => (
|
||||
<button key={item.id} className="archive-item" onClick={() => openReading(item.id)}>
|
||||
<span>
|
||||
<div key={item.id} className={`archive-item ${item.status === "failed" ? "is-failed" : ""}`}>
|
||||
<button className="archive-open" onClick={() => openReading(item.id)}>
|
||||
<span>
|
||||
<strong>{readingMeta[item.reading_type].name}报告</strong>
|
||||
<small>{formatDate(item.created_at)}</small>
|
||||
{item.overall_summary ? <b>{item.overall_summary}</b> : null}
|
||||
</span>
|
||||
<em>{statusText[item.status]}</em>
|
||||
</button>
|
||||
</span>
|
||||
</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>
|
||||
@ -397,8 +425,8 @@ function ImageReadingForm({
|
||||
busyText,
|
||||
ready,
|
||||
error,
|
||||
quota,
|
||||
extra,
|
||||
stats,
|
||||
onPickFile,
|
||||
onSubmit,
|
||||
}: {
|
||||
@ -407,8 +435,8 @@ function ImageReadingForm({
|
||||
busyText: string;
|
||||
ready: boolean;
|
||||
error: string;
|
||||
quota: Quota | null;
|
||||
extra: ReactNode;
|
||||
stats: ReactNode;
|
||||
onPickFile: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
onSubmit: () => void;
|
||||
}) {
|
||||
@ -433,8 +461,8 @@ function ImageReadingForm({
|
||||
</label>
|
||||
|
||||
{extra}
|
||||
<button className="primary-action" disabled={!ready || Boolean(busyText)} onClick={onSubmit}>
|
||||
{busyText || "提交分析"}
|
||||
<button className="primary-action" disabled={!ready || Boolean(busyText) || quota?.remaining === 0} onClick={onSubmit}>
|
||||
{busyText || (quota?.remaining === 0 ? "今日次数已用完" : "提交分析")}
|
||||
</button>
|
||||
{error ? <p className="error">{error}</p> : null}
|
||||
</div>
|
||||
@ -445,7 +473,6 @@ function ImageReadingForm({
|
||||
<h3>提交后可离开等待</h3>
|
||||
<p>提交成功后,系统会在后台生成报告。你可以继续看档案,完成后自动刷新。</p>
|
||||
</div>
|
||||
<div className="mini-card">{stats}</div>
|
||||
</aside>
|
||||
</section>
|
||||
);
|
||||
@ -457,7 +484,7 @@ function BaziFormView({
|
||||
busyText,
|
||||
ready,
|
||||
error,
|
||||
stats,
|
||||
quota,
|
||||
onSubmit,
|
||||
}: {
|
||||
form: BaziForm;
|
||||
@ -465,7 +492,7 @@ function BaziFormView({
|
||||
busyText: string;
|
||||
ready: boolean;
|
||||
error: string;
|
||||
stats: ReactNode;
|
||||
quota: Quota | null;
|
||||
onSubmit: () => void;
|
||||
}) {
|
||||
return (
|
||||
@ -473,7 +500,7 @@ function BaziFormView({
|
||||
<div className="upload-card">
|
||||
<div className="section-label">BAZI READING</div>
|
||||
<h2>填写生辰信息</h2>
|
||||
<p>第一版按标准专业排盘,不做真太阳时校正。出生地会用于报告语境,不用于经度校正。</p>
|
||||
<p>输入出生日期与时间,赛博先生会先排出四柱,再把格局翻译成贴近日常的提醒。</p>
|
||||
|
||||
<div className="form-grid">
|
||||
<label className="field">
|
||||
@ -520,8 +547,8 @@ function BaziFormView({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button className="primary-action" disabled={!ready || Boolean(busyText)} onClick={onSubmit}>
|
||||
{busyText || "提交排盘"}
|
||||
<button className="primary-action" disabled={!ready || Boolean(busyText) || quota?.remaining === 0} onClick={onSubmit}>
|
||||
{busyText || (quota?.remaining === 0 ? "今日次数已用完" : "提交排盘")}
|
||||
</button>
|
||||
{error ? <p className="error">{error}</p> : null}
|
||||
</div>
|
||||
@ -532,17 +559,24 @@ function BaziFormView({
|
||||
<h3>先排盘,再解读</h3>
|
||||
<p>后端会先计算四柱、五行和十神线索,再让赛博先生生成更接地气的生活化报告。</p>
|
||||
</div>
|
||||
<div className="mini-card">{stats}</div>
|
||||
</aside>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadingStats({ total, completed }: { total: number; completed: number }) {
|
||||
function HomeStats({ total, completed, quota }: { total: number; completed: number; quota: Quota | null }) {
|
||||
return (
|
||||
<div className="stats">
|
||||
<Stat label="累计报告" value={total} />
|
||||
<Stat label="已完成" value={completed} />
|
||||
<div className="home-stats">
|
||||
<div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@ -557,6 +591,7 @@ function ReportPanel({ reading, onDelete }: { reading: Reading; onDelete: () =>
|
||||
<section className="report-panel">
|
||||
<h3>{statusText[reading.status]}</h3>
|
||||
<p>{reading.error_message || "先生正在整理报告。"}</p>
|
||||
<button className="delete-action" onClick={onDelete}>删除这份报告</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -584,6 +619,8 @@ function ReportPanel({ reading, onDelete }: { reading: Reading; onDelete: () =>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{type === "bazi" ? <BaziChartPanel chart={getBaziChart(reading)} /> : null}
|
||||
|
||||
<div className="dimension-grid">
|
||||
{data.dimensions.map((dimension, 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 }) {
|
||||
return (
|
||||
<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[] }) {
|
||||
return (
|
||||
<article className="summary-list">
|
||||
|
||||
@ -48,6 +48,13 @@ export type ReadingSummary = {
|
||||
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 ReportSummary = ReadingSummary & { hand_side?: HandSide };
|
||||
|
||||
|
||||
7
web/public/icon.svg
Normal 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
|
After Width: | Height: | Size: 152 KiB |
25
web/public/share-card.svg
Normal 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 |