diff --git a/.gitignore b/.gitignore index 27d7f1c..9227349 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index af7efdb..ab730cf 100644 --- a/README.md +++ b/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 一键部署 diff --git a/backend/.env.example b/backend/.env.example index 371117b..d83db54 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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= diff --git a/backend/README.md b/backend/README.md index cc934b3..cde36f0 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 流程。 diff --git a/backend/app/api/v1/endpoints/readings.py b/backend/app/api/v1/endpoints/readings.py index 0015493..ce3aa6a 100644 --- a/backend/app/api/v1/endpoints/readings.py +++ b/backend/app/api/v1/endpoints/readings.py @@ -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, diff --git a/backend/app/api/v1/endpoints/reports.py b/backend/app/api/v1/endpoints/reports.py index 94e656e..52f0e7f 100644 --- a/backend/app/api/v1/endpoints/reports.py +++ b/backend/app/api/v1/endpoints/reports.py @@ -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() diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 513fac7..6b0a90e 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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 diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 1a54f01..a2b8f17 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -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) diff --git a/backend/app/models/daily_usage.py b/backend/app/models/daily_usage.py new file mode 100644 index 0000000..b1ab5b0 --- /dev/null +++ b/backend/app/models/daily_usage.py @@ -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) diff --git a/backend/app/schemas/quota.py b/backend/app/schemas/quota.py new file mode 100644 index 0000000..db31131 --- /dev/null +++ b/backend/app/schemas/quota.py @@ -0,0 +1,10 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class QuotaResponse(BaseModel): + limit: int + used: int + remaining: int + reset_at: datetime diff --git a/backend/app/services/bazi_analyzer.py b/backend/app/services/bazi_analyzer.py index 3d835ec..f2ac7aa 100644 --- a/backend/app/services/bazi_analyzer.py +++ b/backend/app/services/bazi_analyzer.py @@ -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": "这周给自己固定一个恢复时间,别把精力全部交给外界安排。", }, diff --git a/backend/app/services/bazi_calculator.py b/backend/app/services/bazi_calculator.py index 7779557..26ed5f6 100644 --- a/backend/app/services/bazi_calculator.py +++ b/backend/app/services/bazi_calculator.py @@ -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 diff --git a/backend/app/services/quota_service.py b/backend/app/services/quota_service.py new file mode 100644 index 0000000..29c7654 --- /dev/null +++ b/backend/app/services/quota_service.py @@ -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(), + } diff --git a/backend/app/services/reading_service.py b/backend/app/services/reading_service.py index 1f39abf..eebd064 100644 --- a/backend/app/services/reading_service.py +++ b/backend/app/services/reading_service.py @@ -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": diff --git a/backend/app/services/share_poster_service.py b/backend/app/services/share_poster_service.py index 01fcbc0..da70d75 100644 --- a/backend/app/services/share_poster_service.py +++ b/backend/app/services/share_poster_service.py @@ -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 扫描终端感;不要卡通,不要恐怖,不要传统庙宇风,不要紫色渐变。" diff --git a/backend/tests/test_readings_api.py b/backend/tests/test_readings_api.py index 4675b36..30dfd1a 100644 --- a/backend/tests/test_readings_api.py +++ b/backend/tests/test_readings_api.py @@ -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 diff --git a/miniprogram/app.js b/miniprogram/app.js deleted file mode 100644 index c6becfe..0000000 --- a/miniprogram/app.js +++ /dev/null @@ -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') - } -}) diff --git a/miniprogram/app.json b/miniprogram/app.json deleted file mode 100644 index fd32804..0000000 --- a/miniprogram/app.json +++ /dev/null @@ -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" -} diff --git a/miniprogram/app.wxss b/miniprogram/app.wxss deleted file mode 100644 index 368c745..0000000 --- a/miniprogram/app.wxss +++ /dev/null @@ -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; -} diff --git a/miniprogram/assets/tabbar/archive-active.png b/miniprogram/assets/tabbar/archive-active.png deleted file mode 100644 index dbfa2f6..0000000 Binary files a/miniprogram/assets/tabbar/archive-active.png and /dev/null differ diff --git a/miniprogram/assets/tabbar/archive-normal.png b/miniprogram/assets/tabbar/archive-normal.png deleted file mode 100644 index 0e416cd..0000000 Binary files a/miniprogram/assets/tabbar/archive-normal.png and /dev/null differ diff --git a/miniprogram/assets/tabbar/ask-active.png b/miniprogram/assets/tabbar/ask-active.png deleted file mode 100644 index 9ccbecf..0000000 Binary files a/miniprogram/assets/tabbar/ask-active.png and /dev/null differ diff --git a/miniprogram/assets/tabbar/ask-normal.png b/miniprogram/assets/tabbar/ask-normal.png deleted file mode 100644 index ec30a12..0000000 Binary files a/miniprogram/assets/tabbar/ask-normal.png and /dev/null differ diff --git a/miniprogram/pages/generating/generating.js b/miniprogram/pages/generating/generating.js deleted file mode 100644 index 3cf2c3d..0000000 --- a/miniprogram/pages/generating/generating.js +++ /dev/null @@ -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' }) - } -}) diff --git a/miniprogram/pages/generating/generating.json b/miniprogram/pages/generating/generating.json deleted file mode 100644 index f172164..0000000 --- a/miniprogram/pages/generating/generating.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "navigationBarTitleText": "生成中" -} diff --git a/miniprogram/pages/generating/generating.wxml b/miniprogram/pages/generating/generating.wxml deleted file mode 100644 index ec3c12f..0000000 --- a/miniprogram/pages/generating/generating.wxml +++ /dev/null @@ -1,6 +0,0 @@ - - - 先生正在起卦 - 大约需要十几秒。赛博先生正在整理生命线、智慧线、感情线和整体倾向。 - - diff --git a/miniprogram/pages/generating/generating.wxss b/miniprogram/pages/generating/generating.wxss deleted file mode 100644 index 69d5c94..0000000 --- a/miniprogram/pages/generating/generating.wxss +++ /dev/null @@ -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); - } -} diff --git a/miniprogram/pages/history/history.js b/miniprogram/pages/history/history.js deleted file mode 100644 index 9a0158a..0000000 --- a/miniprogram/pages/history/history.js +++ /dev/null @@ -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' }) - } -}) diff --git a/miniprogram/pages/history/history.json b/miniprogram/pages/history/history.json deleted file mode 100644 index 94cd99c..0000000 --- a/miniprogram/pages/history/history.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "navigationBarTitleText": "解读档案" -} diff --git a/miniprogram/pages/history/history.wxml b/miniprogram/pages/history/history.wxml deleted file mode 100644 index 7ed5397..0000000 --- a/miniprogram/pages/history/history.wxml +++ /dev/null @@ -1,43 +0,0 @@ - - - ARCHIVE - 解读档案 - 每一次请先生解读,都会在这里沉淀成一份记录。 - - - - - {{reportCount}} - 累计报告 - - - {{completedCount}} - 已完成 - - - - - - - - - - - - 手相报告 - {{item.createdDate}} - - {{item.statusText}} - - {{item.overall_summary || item.fallbackSummary}} - - - - - - - 还没有解读档案 - 先从手相报告开始,让赛博先生留下第一条记录。 - - - diff --git a/miniprogram/pages/history/history.wxss b/miniprogram/pages/history/history.wxss deleted file mode 100644 index 91ad8f3..0000000 --- a/miniprogram/pages/history/history.wxss +++ /dev/null @@ -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; -} diff --git a/miniprogram/pages/index/index.js b/miniprogram/pages/index/index.js deleted file mode 100644 index 5e22882..0000000 --- a/miniprogram/pages/index/index.js +++ /dev/null @@ -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' }) - } -}) diff --git a/miniprogram/pages/index/index.json b/miniprogram/pages/index/index.json deleted file mode 100644 index ec4c7a1..0000000 --- a/miniprogram/pages/index/index.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "navigationBarTitleText": "赛博先生" -} diff --git a/miniprogram/pages/index/index.wxml b/miniprogram/pages/index/index.wxml deleted file mode 100644 index 87ba047..0000000 --- a/miniprogram/pages/index/index.wxml +++ /dev/null @@ -1,31 +0,0 @@ - - - CYBER FORTUNE STUDIO - 赛博先生 - AI 命理实验室。选择一个入口,让先生开始解读。 - - - ONLINE · 玄学模型已接入 - - - - - - {{item.mark}} - - {{item.title}} - {{item.description}} - - 0{{index + 1}} - - - - - 今日可问 - 先看掌心里的行动节奏 - 上传一张清晰掌心照片,先生会从生命线、智慧线、感情线、命运线等维度生成娱乐向报告。 - - - - 继续使用即表示你同意用户协议与隐私政策。赛博先生只提供娱乐与自我反思内容。 - diff --git a/miniprogram/pages/index/index.wxss b/miniprogram/pages/index/index.wxss deleted file mode 100644 index 708b2cd..0000000 --- a/miniprogram/pages/index/index.wxss +++ /dev/null @@ -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; -} diff --git a/miniprogram/pages/legal/legal.js b/miniprogram/pages/legal/legal.js deleted file mode 100644 index ba76804..0000000 --- a/miniprogram/pages/legal/legal.js +++ /dev/null @@ -1 +0,0 @@ -Page({}) diff --git a/miniprogram/pages/legal/legal.json b/miniprogram/pages/legal/legal.json deleted file mode 100644 index 040c7d2..0000000 --- a/miniprogram/pages/legal/legal.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "navigationBarTitleText": "协议与隐私" -} diff --git a/miniprogram/pages/legal/legal.wxml b/miniprogram/pages/legal/legal.wxml deleted file mode 100644 index a0da561..0000000 --- a/miniprogram/pages/legal/legal.wxml +++ /dev/null @@ -1,18 +0,0 @@ - - 赛博先生协议与隐私说明 - - - 服务定位 - 赛博先生提供手相、面相、八字等娱乐占卜与自我反思类内容。报告不构成医学、心理、职业、财务、投资或任何人生决策建议。 - - - - 照片用途 - 你上传的手掌照片仅用于生成本次手相报告、质量校验和必要的问题排查。原始照片默认短期保存,并会按服务端策略自动清理。 - - - - 数据删除 - 你可以在报告详情页删除报告。删除后,报告内容和关联照片会被清理,无法恢复。 - - diff --git a/miniprogram/pages/legal/legal.wxss b/miniprogram/pages/legal/legal.wxss deleted file mode 100644 index bba0f64..0000000 --- a/miniprogram/pages/legal/legal.wxss +++ /dev/null @@ -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; -} diff --git a/miniprogram/pages/palm/palm.js b/miniprogram/pages/palm/palm.js deleted file mode 100644 index b8fa4c4..0000000 --- a/miniprogram/pages/palm/palm.js +++ /dev/null @@ -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' }) - } -}) diff --git a/miniprogram/pages/palm/palm.json b/miniprogram/pages/palm/palm.json deleted file mode 100644 index 7fd1464..0000000 --- a/miniprogram/pages/palm/palm.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "navigationBarTitleText": "手相报告" -} diff --git a/miniprogram/pages/palm/palm.wxml b/miniprogram/pages/palm/palm.wxml deleted file mode 100644 index b36111e..0000000 --- a/miniprogram/pages/palm/palm.wxml +++ /dev/null @@ -1,31 +0,0 @@ - - - PALM READING - 手相报告 - 上传掌心照片,先生会扫描掌纹主线并生成娱乐向自我反思报告。 - - - - 拍摄要求 - 掌心完整入镜,纹路清晰 - 光线充足,避免强反光和阴影 - 手掌自然伸展,不要遮挡主线 - - - 这张照片是哪只手? - - - - - - 可选项:知道左右手会让报告措辞更细;不确定也可以直接生成。 - - - - - - - - - 报告仅用于娱乐与自我反思,不构成任何现实决策建议。 - diff --git a/miniprogram/pages/palm/palm.wxss b/miniprogram/pages/palm/palm.wxss deleted file mode 100644 index e23500b..0000000 --- a/miniprogram/pages/palm/palm.wxss +++ /dev/null @@ -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; -} diff --git a/miniprogram/pages/report/report.js b/miniprogram/pages/report/report.js deleted file mode 100644 index 61c617b..0000000 --- a/miniprogram/pages/report/report.js +++ /dev/null @@ -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' - } - } -}) diff --git a/miniprogram/pages/report/report.json b/miniprogram/pages/report/report.json deleted file mode 100644 index 923e240..0000000 --- a/miniprogram/pages/report/report.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "navigationBarTitleText": "手相报告", - "enableShareAppMessage": true -} diff --git a/miniprogram/pages/report/report.wxml b/miniprogram/pages/report/report.wxml deleted file mode 100644 index fc8b367..0000000 --- a/miniprogram/pages/report/report.wxml +++ /dev/null @@ -1,99 +0,0 @@ - - - PALM REPORT · {{report.handSideText}} - 赛博先生手相报告 - {{report.createdDate}} - - - - 先生结论 - {{data.overall_summary}} - - {{item}} - - - - - - {{qualityPercent}}% - 照片可读性 - - - {{dimensionCount}} - 解读维度 - - - - - {{data.quality_check.reason}} - - - - 核心维度 - 观察 · 解读 · 建议 - - - - - - 0{{index + 1}} - {{item.name}} - - - {{item.confidencePercent}}% - - - - - 观察 - - {{obs}} - - - - - 解读 - {{item.interpretation}} - - - - 先生建议 - {{item.advice}} - - - - - 倾向总结 - 优势与提醒 - - - - - 优势倾向 - {{item}} - - - - 近期提醒 - {{item}} - - - - - 需要留意 - - {{item}} - - - - - {{data.disclaimer}} - - - {{shareStatusText}} - - - - - 正在加载报告... - diff --git a/miniprogram/pages/report/report.wxss b/miniprogram/pages/report/report.wxss deleted file mode 100644 index 65ae641..0000000 --- a/miniprogram/pages/report/report.wxss +++ /dev/null @@ -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; -} diff --git a/miniprogram/project.config.json b/miniprogram/project.config.json deleted file mode 100644 index 19fa4b8..0000000 --- a/miniprogram/project.config.json +++ /dev/null @@ -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": {} -} \ No newline at end of file diff --git a/miniprogram/sitemap.json b/miniprogram/sitemap.json deleted file mode 100644 index 1de189d..0000000 --- a/miniprogram/sitemap.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "rules": [ - { - "action": "allow", - "page": "*" - } - ] -} diff --git a/miniprogram/utils/config.js b/miniprogram/utils/config.js deleted file mode 100644 index 3175d91..0000000 --- a/miniprogram/utils/config.js +++ /dev/null @@ -1,5 +0,0 @@ -const API_BASE_URL = 'http://127.0.0.1:8000/api/v1' - -module.exports = { - API_BASE_URL -} diff --git a/miniprogram/utils/modules.js b/miniprogram/utils/modules.js deleted file mode 100644 index b7ec148..0000000 --- a/miniprogram/utils/modules.js +++ /dev/null @@ -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 -} diff --git a/miniprogram/utils/request.js b/miniprogram/utils/request.js deleted file mode 100644 index c1caa76..0000000 --- a/miniprogram/utils/request.js +++ /dev/null @@ -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 -} diff --git a/palm_reading.db b/palm_reading.db deleted file mode 100644 index ac9fe14..0000000 Binary files a/palm_reading.db and /dev/null differ diff --git a/web/app/globals.css b/web/app/globals.css index b9ea790..13c7f73 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -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; } diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 9418661..d211db8 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -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 }>) { diff --git a/web/components/PalmWebApp.tsx b/web/components/PalmWebApp.tsx index 907da15..de1e14d 100644 --- a/web/components/PalmWebApp.tsx +++ b/web/components/PalmWebApp.tsx @@ -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; + day_master?: string; + wuxing_balance?: Record; +}; + 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([]); + const [quota, setQuota] = useState(null); const [activeReading, setActiveReading] = useState(null); const [activeView, setActiveView] = useState(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, type: "palm" | "face") { @@ -137,7 +149,16 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie setReadings(data); } + async function loadQuota() { + const data = await apiFetch("/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 ))} -
- - -
+ ) : 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 ))} } - stats={} /> ) : 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={} /> ) : null} @@ -345,8 +366,8 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie busyText={busyText} ready={ready} error={error} + quota={quota} onSubmit={startBaziReading} - stats={} /> ) : null} @@ -362,14 +383,21 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
{readings.length ? ( readings.map((item) => ( - + + +
+ {statusText[item.status]} + +
+
)) ) : (

暂无档案。生成第一份报告后会出现在这里。

@@ -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) => void; onSubmit: () => void; }) { @@ -433,8 +461,8 @@ function ImageReadingForm({ {extra} - {error ?

{error}

: null} @@ -445,7 +473,6 @@ function ImageReadingForm({

提交后可离开等待

提交成功后,系统会在后台生成报告。你可以继续看档案,完成后自动刷新。

-
{stats}
); @@ -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({
BAZI READING

填写生辰信息

-

第一版按标准专业排盘,不做真太阳时校正。出生地会用于报告语境,不用于经度校正。

+

输入出生日期与时间,赛博先生会先排出四柱,再把格局翻译成贴近日常的提醒。

- {error ?

{error}

: null}
@@ -532,17 +559,24 @@ function BaziFormView({

先排盘,再解读

后端会先计算四柱、五行和十神线索,再让赛博先生生成更接地气的生活化报告。

-
{stats}
); } -function ReadingStats({ total, completed }: { total: number; completed: number }) { +function HomeStats({ total, completed, quota }: { total: number; completed: number; quota: Quota | null }) { return ( -
- - +
+
+

TODAY STATUS

+

今日解读状态

+
+
+ + + + +
); } @@ -557,6 +591,7 @@ function ReportPanel({ reading, onDelete }: { reading: Reading; onDelete: () =>

{statusText[reading.status]}

{reading.error_message || "先生正在整理报告。"}

+
); } @@ -584,6 +619,8 @@ function ReportPanel({ reading, onDelete }: { reading: Reading; onDelete: () => ))}
+ {type === "bazi" ? : null} +
{data.dimensions.map((dimension, 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 ( +
+
+
+

CHART

+

排盘核心

+
+ {chart.time_unknown ? "时辰不详" : chart.birth_time || "已记录时辰"} +
+
+ {pillarItems.map(([label, value]) => ( +
+ {label} + {value || "-"} +
+ ))} +
+
+ 公历:{chart.solar_date || "-"} + 农历:{chart.lunar_date || "-"} + 日主:{chart.day_master || "-"} + {chart.birth_place ? 出生地:{chart.birth_place} : null} +
+
+ {(["木", "火", "土", "金", "水"] as const).map((name) => { + const value = wuxing[name] || 0; + return ( +
+ {name} + + {value} +
+ ); + })} +
+
+ ); +} + function DimensionCard({ dimension, index }: { dimension: Dimension; index: number }) { return (
@@ -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 (
diff --git a/web/lib/api.ts b/web/lib/api.ts index 8b51813..f185fb4 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -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 }; diff --git a/web/public/icon.svg b/web/public/icon.svg new file mode 100644 index 0000000..410c6e0 --- /dev/null +++ b/web/public/icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/web/public/share-card.png b/web/public/share-card.png new file mode 100644 index 0000000..a13dfb2 Binary files /dev/null and b/web/public/share-card.png differ diff --git a/web/public/share-card.svg b/web/public/share-card.svg new file mode 100644 index 0000000..d71f03c --- /dev/null +++ b/web/public/share-card.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + CYBER MISTER + 赛博先生 + AI 玄学档案 + 手相 · 面相 · 八字 + 把日常困惑,翻译成生活里的具体提醒 +