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
!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

View File

@ -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 一键部署

View File

@ -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=

View File

@ -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 流程。

View File

@ -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,

View File

@ -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()

View File

@ -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

View File

@ -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)

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 = (
"请基于下面这份八字排盘结果,生成中文八字娱乐报告。"
"排盘结果由后端计算,不能自行改动四柱:{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": "这周给自己固定一个恢复时间,别把精力全部交给外界安排。",
},

View File

@ -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

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.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":

View File

@ -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 扫描终端感;不要卡通,不要恐怖,不要传统庙宇风,不要紫色渐变。"

View File

@ -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

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;
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;
}

View File

@ -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 }>) {

View File

@ -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)}>
<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>
<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,18 +559,25 @@ 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="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">

View File

@ -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
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