update、
9
.gitignore
vendored
@ -76,15 +76,6 @@ pnpm-debug.log*
|
|||||||
# Keep package lock tracked
|
# Keep package lock tracked
|
||||||
!web/package-lock.json
|
!web/package-lock.json
|
||||||
|
|
||||||
# WeChat Mini Program local files
|
|
||||||
miniprogram/project.private.config.json
|
|
||||||
miniprogram/private.*
|
|
||||||
miniprogram/miniprogram_npm/
|
|
||||||
miniprogram/npm/
|
|
||||||
miniprogram/node_modules/
|
|
||||||
miniprogram/dist/
|
|
||||||
miniprogram/.idea/
|
|
||||||
|
|
||||||
# Docker / generated local artifacts
|
# Docker / generated local artifacts
|
||||||
*.pid
|
*.pid
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
13
README.md
@ -1,11 +1,10 @@
|
|||||||
# AI 手相报告 MVP
|
# 赛博先生 Web App
|
||||||
|
|
||||||
原生微信小程序 + Python FastAPI 后端的娱乐型手相报告应用。
|
面向 Web 的 AI 命理娱乐应用,当前支持手相、面相、八字三类报告生成。产品定位为“娱乐占卜 + 自我反思”,不做确定性人生判断。
|
||||||
|
|
||||||
## 目录
|
## 目录
|
||||||
|
|
||||||
- `backend/`: FastAPI API、数据库模型、OpenAI 分析服务、图片存储。
|
- `backend/`: FastAPI API、数据库模型、OpenAI 分析服务、图片存储。
|
||||||
- `miniprogram/`: 原生微信小程序页面与请求封装。
|
|
||||||
- `web/`: Next.js Web App 主产品入口。
|
- `web/`: Next.js Web App 主产品入口。
|
||||||
|
|
||||||
## 本地运行后端
|
## 本地运行后端
|
||||||
@ -18,16 +17,12 @@ cp .env.example .env
|
|||||||
venv/bin/uvicorn app.main:app --reload
|
venv/bin/uvicorn app.main:app --reload
|
||||||
```
|
```
|
||||||
|
|
||||||
默认使用 SQLite 和 mock 微信登录,便于本地开发。生产环境配置 `DATABASE_URL` 为 Postgres,配置微信、OpenAI 和对象存储参数。
|
默认使用 SQLite 和匿名 Web 登录,便于本地开发。生产环境可配置 `DATABASE_URL` 为 Postgres,并配置 OpenAI 和对象存储参数。
|
||||||
|
|
||||||
## 环境变量
|
## 环境变量
|
||||||
|
|
||||||
见 `backend/.env.example`。
|
见 `backend/.env.example`。
|
||||||
|
|
||||||
## 小程序
|
|
||||||
|
|
||||||
用微信开发者工具打开 `miniprogram/`,把 `utils/config.js` 中的 `API_BASE_URL` 改成后端地址。
|
|
||||||
|
|
||||||
## Web App
|
## Web App
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -37,7 +32,7 @@ cp .env.example .env.local
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
默认连接 `http://127.0.0.1:8000/api/v1`。第一版 Web 使用匿名会话,浏览器会自动获取 token 并保存在 `localStorage`。
|
默认连接 `http://127.0.0.1:8000/api/v1`。Web 使用匿名会话,浏览器会自动获取 token 并保存在 `localStorage`。
|
||||||
|
|
||||||
## Docker Compose 一键部署
|
## Docker Compose 一键部署
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@ ENVIRONMENT=development
|
|||||||
DATABASE_URL=sqlite+aiosqlite:///./palm_reading.db
|
DATABASE_URL=sqlite+aiosqlite:///./palm_reading.db
|
||||||
SECRET_KEY=change-me-in-production
|
SECRET_KEY=change-me-in-production
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES=43200
|
ACCESS_TOKEN_EXPIRE_MINUTES=43200
|
||||||
|
DAILY_READING_LIMIT=5
|
||||||
|
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
OPENAI_BASE_URL=
|
OPENAI_BASE_URL=
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# Backend
|
# Backend
|
||||||
|
|
||||||
FastAPI 后端提供微信登录、图片上传、手相报告生成和历史报告管理。
|
FastAPI 后端提供匿名登录、图片上传、手相/面相/八字报告生成和历史报告管理。
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
||||||
@ -21,4 +21,4 @@ venv/bin/python -m app.cli
|
|||||||
|
|
||||||
所有业务 API 位于 `/api/v1`。
|
所有业务 API 位于 `/api/v1`。
|
||||||
|
|
||||||
本地开发默认 `WECHAT_MOCK_LOGIN=true`,`OPENAI_API_KEY` 为空时会返回 mock 报告,便于先联调小程序流程。
|
本地开发默认支持匿名登录,`OPENAI_API_KEY` 为空时会返回 mock 报告,便于先联调 Web 流程。
|
||||||
|
|||||||
@ -8,8 +8,10 @@ from app.core.security import get_current_user
|
|||||||
from app.models.reading import Reading
|
from app.models.reading import Reading
|
||||||
from app.models.uploaded_image import UploadedImage
|
from app.models.uploaded_image import UploadedImage
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.schemas.quota import QuotaResponse
|
||||||
from app.schemas.reading import BaziInput, ReadingCreate, ReadingDetail, ReadingSummary
|
from app.schemas.reading import BaziInput, ReadingCreate, ReadingDetail, ReadingSummary
|
||||||
from app.services.image_service import ImageService
|
from app.services.image_service import ImageService
|
||||||
|
from app.services.quota_service import QuotaService
|
||||||
from app.services.reading_service import ReadingService
|
from app.services.reading_service import ReadingService
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@ -20,6 +22,11 @@ async def generate_reading_task(reading_id: str) -> None:
|
|||||||
await ReadingService().generate(session, reading_id)
|
await ReadingService().generate(session, reading_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/quota", response_model=QuotaResponse)
|
||||||
|
async def get_reading_quota(user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)):
|
||||||
|
return await QuotaService().get_quota(db, user.id)
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=ReadingDetail, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=ReadingDetail, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_reading(
|
async def create_reading(
|
||||||
payload: ReadingCreate,
|
payload: ReadingCreate,
|
||||||
@ -41,6 +48,7 @@ async def create_reading(
|
|||||||
except ValidationError as exc:
|
except ValidationError as exc:
|
||||||
raise HTTPException(status_code=422, detail=exc.errors()) from exc
|
raise HTTPException(status_code=422, detail=exc.errors()) from exc
|
||||||
|
|
||||||
|
await QuotaService().consume(db, user.id)
|
||||||
reading = Reading(
|
reading = Reading(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
reading_type=payload.reading_type,
|
reading_type=payload.reading_type,
|
||||||
|
|||||||
@ -13,6 +13,7 @@ from app.models.user import User
|
|||||||
from app.schemas.share_image import ShareImageJobResponse
|
from app.schemas.share_image import ShareImageJobResponse
|
||||||
from app.schemas.report import ReportCreate, ReportDetail, ReportSummary
|
from app.schemas.report import ReportCreate, ReportDetail, ReportSummary
|
||||||
from app.services.image_service import ImageService
|
from app.services.image_service import ImageService
|
||||||
|
from app.services.quota_service import QuotaService
|
||||||
from app.services.report_service import ReportService
|
from app.services.report_service import ReportService
|
||||||
from app.services.share_poster_service import SharePosterService
|
from app.services.share_poster_service import SharePosterService
|
||||||
|
|
||||||
@ -66,6 +67,7 @@ async def create_report(
|
|||||||
if image is None:
|
if image is None:
|
||||||
raise HTTPException(status_code=404, detail="Image not found")
|
raise HTTPException(status_code=404, detail="Image not found")
|
||||||
|
|
||||||
|
await QuotaService().consume(db, user.id)
|
||||||
report = PalmReport(user_id=user.id, image_id=image.id, hand_side=payload.hand_side, status="pending")
|
report = PalmReport(user_id=user.id, image_id=image.id, hand_side=payload.hand_side, status="pending")
|
||||||
db.add(report)
|
db.add(report)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|||||||
@ -11,6 +11,7 @@ class Settings(BaseSettings):
|
|||||||
secret_key: str = "change-me-in-production"
|
secret_key: str = "change-me-in-production"
|
||||||
access_token_expire_minutes: int = 60 * 24 * 30
|
access_token_expire_minutes: int = 60 * 24 * 30
|
||||||
cors_origins: list[str] = Field(default_factory=lambda: ["*"])
|
cors_origins: list[str] = Field(default_factory=lambda: ["*"])
|
||||||
|
daily_reading_limit: int = 5
|
||||||
|
|
||||||
openai_api_key: str | None = None
|
openai_api_key: str | None = None
|
||||||
openai_base_url: str | None = None
|
openai_base_url: str | None = None
|
||||||
|
|||||||
@ -25,7 +25,7 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
|||||||
|
|
||||||
|
|
||||||
async def init_db() -> None:
|
async def init_db() -> None:
|
||||||
from app.models import palm_report, reading, share_image_job, uploaded_image, user # noqa: F401
|
from app.models import daily_usage, palm_report, reading, share_image_job, uploaded_image, user # noqa: F401
|
||||||
|
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|||||||
19
backend/app/models/daily_usage.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
from datetime import date, datetime
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class DailyUsage(Base):
|
||||||
|
__tablename__ = "daily_usages"
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", "usage_date", name="uq_daily_usage_user_date"),)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid4()))
|
||||||
|
user_id: Mapped[str] = mapped_column(String(36), ForeignKey("users.id"), index=True)
|
||||||
|
usage_date: Mapped[date] = mapped_column(Date, index=True)
|
||||||
|
count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
10
backend/app/schemas/quota.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class QuotaResponse(BaseModel):
|
||||||
|
limit: int
|
||||||
|
used: int
|
||||||
|
remaining: int
|
||||||
|
reset_at: datetime
|
||||||
@ -5,14 +5,17 @@ from app.services.analyzer_common import DISCLAIMER, BaseAnalyzer
|
|||||||
USER_PROMPT_TEMPLATE = (
|
USER_PROMPT_TEMPLATE = (
|
||||||
"请基于下面这份八字排盘结果,生成中文八字娱乐报告。"
|
"请基于下面这份八字排盘结果,生成中文八字娱乐报告。"
|
||||||
"排盘结果由后端计算,不能自行改动四柱:{chart_json}"
|
"排盘结果由后端计算,不能自行改动四柱:{chart_json}"
|
||||||
"必须覆盖四柱结构、日主与五行、十神关系、学习成长、事业节奏、关系沟通、近期行动。"
|
"必须覆盖四柱结构、日主与五行、五行分布、十神关系、学习成长、事业节奏、关系沟通、近期行动。"
|
||||||
"写作要求:"
|
"写作要求:"
|
||||||
"1. overall_summary 用 2 到 4 句话,像给用户本人看的开场结论。"
|
"1. dimensions 固定输出 7 项:四柱底色、日主与五行、十神互动、学习成长、事业节奏、关系沟通、近期行动。"
|
||||||
"2. dimensions 中每个 interpretation 必须落到现实场景,例如学习效率、职业节奏、沟通方式、情绪恢复、计划执行。"
|
"2. overall_summary 用 2 到 4 句话,像给用户本人看的开场结论,不要像百科解释。"
|
||||||
"3. 每个 advice 必须是今天或本周能做的小建议。"
|
"3. 每个 interpretation 必须落到现实场景,例如学习效率、职业节奏、沟通方式、情绪恢复、计划执行。"
|
||||||
"4. 可以少量使用日主、五行、十神等术语,但必须用普通人能听懂的话解释。"
|
"4. observations 可以写排盘证据,但不要只堆术语;每条都要让普通用户看得懂。"
|
||||||
"5. 不预测寿命、灾祸、婚姻成败、财富结果,不做宿命论。"
|
"5. 每个 advice 必须是今天或本周能做的小建议,避免空话。"
|
||||||
"6. 如果时辰不详,要诚实降低相关维度 confidence,并说明分析会更偏概览。"
|
"6. strengths、challenges、suggestions 要分别覆盖生活、学习、事业、关系,语言要具体。"
|
||||||
|
"7. 可以少量使用日主、五行、十神等术语,但必须用普通人能听懂的话解释。"
|
||||||
|
"8. 不预测寿命、灾祸、婚姻成败、财富结果,不做宿命论。"
|
||||||
|
"9. 如果时辰不详,要诚实降低相关维度 confidence,并说明分析会更偏概览。"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -49,8 +52,8 @@ class BaziAnalyzer(BaseAnalyzer):
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "日主与五行",
|
"name": "日主与五行",
|
||||||
"observations": [f"日主:{day_master}", f"五行线索:{chart.get('wuxing', {})}"],
|
"observations": [f"日主:{day_master}", f"五行分布:{chart.get('wuxing_balance', {})}"],
|
||||||
"interpretation": "日主象征你处理世界的核心方式。你适合找到自己的稳定补给,再去应对学习、工作和关系里的变化。",
|
"interpretation": "日主象征你处理世界的核心方式,五行分布则像精力使用习惯。现实里,你适合先找到稳定补给,再去应对学习、工作和关系里的变化。",
|
||||||
"confidence": 0.7,
|
"confidence": 0.7,
|
||||||
"advice": "这周给自己固定一个恢复时间,别把精力全部交给外界安排。",
|
"advice": "这周给自己固定一个恢复时间,别把精力全部交给外界安排。",
|
||||||
},
|
},
|
||||||
|
|||||||
@ -7,6 +7,35 @@ except ImportError: # pragma: no cover - production image installs the package
|
|||||||
Solar = None
|
Solar = None
|
||||||
|
|
||||||
|
|
||||||
|
STEM_WUXING = {
|
||||||
|
"甲": "木",
|
||||||
|
"乙": "木",
|
||||||
|
"丙": "火",
|
||||||
|
"丁": "火",
|
||||||
|
"戊": "土",
|
||||||
|
"己": "土",
|
||||||
|
"庚": "金",
|
||||||
|
"辛": "金",
|
||||||
|
"壬": "水",
|
||||||
|
"癸": "水",
|
||||||
|
}
|
||||||
|
|
||||||
|
BRANCH_WUXING = {
|
||||||
|
"子": "水",
|
||||||
|
"丑": "土",
|
||||||
|
"寅": "木",
|
||||||
|
"卯": "木",
|
||||||
|
"辰": "土",
|
||||||
|
"巳": "火",
|
||||||
|
"午": "火",
|
||||||
|
"未": "土",
|
||||||
|
"申": "金",
|
||||||
|
"酉": "金",
|
||||||
|
"戌": "土",
|
||||||
|
"亥": "水",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class BaziCalculator:
|
class BaziCalculator:
|
||||||
def calculate(self, payload: dict) -> dict:
|
def calculate(self, payload: dict) -> dict:
|
||||||
birth_date = payload["birth_date"]
|
birth_date = payload["birth_date"]
|
||||||
@ -25,6 +54,12 @@ class BaziCalculator:
|
|||||||
solar = Solar.fromYmdHms(year, month, day, hour, minute, 0)
|
solar = Solar.fromYmdHms(year, month, day, hour, minute, 0)
|
||||||
lunar = solar.getLunar()
|
lunar = solar.getLunar()
|
||||||
eight = lunar.getEightChar()
|
eight = lunar.getEightChar()
|
||||||
|
pillars = {
|
||||||
|
"year": eight.getYear(),
|
||||||
|
"month": eight.getMonth(),
|
||||||
|
"day": eight.getDay(),
|
||||||
|
"time": eight.getTime() if not time_unknown else "时辰不详",
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
"calendar_type": calendar_type,
|
"calendar_type": calendar_type,
|
||||||
"is_leap_month": bool(payload.get("is_leap_month")),
|
"is_leap_month": bool(payload.get("is_leap_month")),
|
||||||
@ -36,12 +71,7 @@ class BaziCalculator:
|
|||||||
"gender": payload.get("gender"),
|
"gender": payload.get("gender"),
|
||||||
"solar_date": f"{solar.getYear():04d}-{solar.getMonth():02d}-{solar.getDay():02d}",
|
"solar_date": f"{solar.getYear():04d}-{solar.getMonth():02d}-{solar.getDay():02d}",
|
||||||
"lunar_date": f"{lunar.getYear()}年{lunar.getMonth()}月{lunar.getDay()}日",
|
"lunar_date": f"{lunar.getYear()}年{lunar.getMonth()}月{lunar.getDay()}日",
|
||||||
"pillars": {
|
"pillars": pillars,
|
||||||
"year": eight.getYear(),
|
|
||||||
"month": eight.getMonth(),
|
|
||||||
"day": eight.getDay(),
|
|
||||||
"time": eight.getTime() if not time_unknown else "时辰不详",
|
|
||||||
},
|
|
||||||
"wuxing": {
|
"wuxing": {
|
||||||
"year": eight.getYearWuXing(),
|
"year": eight.getYearWuXing(),
|
||||||
"month": eight.getMonthWuXing(),
|
"month": eight.getMonthWuXing(),
|
||||||
@ -54,7 +84,14 @@ class BaziCalculator:
|
|||||||
"day": "日主",
|
"day": "日主",
|
||||||
"time": eight.getTimeShiShenGan() if not time_unknown else "时辰不详",
|
"time": eight.getTimeShiShenGan() if not time_unknown else "时辰不详",
|
||||||
},
|
},
|
||||||
|
"wuxing_balance": self._count_wuxing(pillars),
|
||||||
"day_master": eight.getDayGan(),
|
"day_master": eight.getDayGan(),
|
||||||
|
"chart_notes": [
|
||||||
|
"出生地仅用于报告语境,不参与经度校正。",
|
||||||
|
"时辰不详时,时柱相关判断会降低权重。",
|
||||||
|
]
|
||||||
|
if time_unknown
|
||||||
|
else ["出生地仅用于报告语境,不参与经度校正。"],
|
||||||
}
|
}
|
||||||
|
|
||||||
return self._fallback_chart(payload, year, month, day, hour, minute, time_unknown)
|
return self._fallback_chart(payload, year, month, day, hour, minute, time_unknown)
|
||||||
@ -67,6 +104,12 @@ class BaziCalculator:
|
|||||||
def pillar(offset: int) -> str:
|
def pillar(offset: int) -> str:
|
||||||
return stems[(seed + offset) % 10] + branches[(seed + offset) % 12]
|
return stems[(seed + offset) % 10] + branches[(seed + offset) % 12]
|
||||||
|
|
||||||
|
pillars = {
|
||||||
|
"year": pillar(0),
|
||||||
|
"month": pillar(13),
|
||||||
|
"day": pillar(27),
|
||||||
|
"time": pillar(41) if not time_unknown else "时辰不详",
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
"calendar_type": payload.get("calendar_type", "solar"),
|
"calendar_type": payload.get("calendar_type", "solar"),
|
||||||
"is_leap_month": bool(payload.get("is_leap_month")),
|
"is_leap_month": bool(payload.get("is_leap_month")),
|
||||||
@ -78,15 +121,12 @@ class BaziCalculator:
|
|||||||
"gender": payload.get("gender"),
|
"gender": payload.get("gender"),
|
||||||
"solar_date": payload["birth_date"],
|
"solar_date": payload["birth_date"],
|
||||||
"lunar_date": "本地未安装 lunar_python,暂用娱乐排盘",
|
"lunar_date": "本地未安装 lunar_python,暂用娱乐排盘",
|
||||||
"pillars": {
|
"pillars": pillars,
|
||||||
"year": pillar(0),
|
|
||||||
"month": pillar(13),
|
|
||||||
"day": pillar(27),
|
|
||||||
"time": pillar(41) if not time_unknown else "时辰不详",
|
|
||||||
},
|
|
||||||
"wuxing": {"year": "参考", "month": "参考", "day": "参考", "time": "参考"},
|
"wuxing": {"year": "参考", "month": "参考", "day": "参考", "time": "参考"},
|
||||||
"shi_shen": {"year": "参考", "month": "参考", "day": "日主", "time": "参考"},
|
"shi_shen": {"year": "参考", "month": "参考", "day": "日主", "time": "参考"},
|
||||||
|
"wuxing_balance": self._count_wuxing(pillars),
|
||||||
"day_master": pillar(27)[0],
|
"day_master": pillar(27)[0],
|
||||||
|
"chart_notes": ["本地未安装 lunar_python,当前为兜底娱乐排盘。"],
|
||||||
}
|
}
|
||||||
|
|
||||||
def _parse_date(self, value: str) -> tuple[int, int, int]:
|
def _parse_date(self, value: str) -> tuple[int, int, int]:
|
||||||
@ -96,3 +136,14 @@ class BaziCalculator:
|
|||||||
def _parse_time(self, value: str) -> tuple[int, int]:
|
def _parse_time(self, value: str) -> tuple[int, int]:
|
||||||
parsed = datetime.strptime(value, "%H:%M")
|
parsed = datetime.strptime(value, "%H:%M")
|
||||||
return parsed.hour, parsed.minute
|
return parsed.hour, parsed.minute
|
||||||
|
|
||||||
|
def _count_wuxing(self, pillars: dict) -> dict:
|
||||||
|
counts = {"木": 0, "火": 0, "土": 0, "金": 0, "水": 0}
|
||||||
|
for pillar in pillars.values():
|
||||||
|
if not isinstance(pillar, str) or pillar == "时辰不详":
|
||||||
|
continue
|
||||||
|
for char in pillar:
|
||||||
|
element = STEM_WUXING.get(char) or BRANCH_WUXING.get(char)
|
||||||
|
if element:
|
||||||
|
counts[element] += 1
|
||||||
|
return counts
|
||||||
|
|||||||
63
backend/app/services/quota_service.py
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
from datetime import datetime, time, timedelta
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models.daily_usage import DailyUsage
|
||||||
|
|
||||||
|
SHANGHAI_TZ = ZoneInfo("Asia/Shanghai")
|
||||||
|
|
||||||
|
|
||||||
|
class QuotaService:
|
||||||
|
def today(self):
|
||||||
|
return datetime.now(SHANGHAI_TZ).date()
|
||||||
|
|
||||||
|
def reset_at(self) -> datetime:
|
||||||
|
tomorrow = self.today() + timedelta(days=1)
|
||||||
|
return datetime.combine(tomorrow, time.min, tzinfo=SHANGHAI_TZ)
|
||||||
|
|
||||||
|
async def get_quota(self, db: AsyncSession, user_id: str) -> dict:
|
||||||
|
usage = await self._get_usage(db, user_id)
|
||||||
|
used = usage.count if usage else 0
|
||||||
|
return self._quota_payload(used)
|
||||||
|
|
||||||
|
async def consume(self, db: AsyncSession, user_id: str) -> dict:
|
||||||
|
usage = await self._get_usage(db, user_id)
|
||||||
|
if usage is None:
|
||||||
|
usage = DailyUsage(user_id=user_id, usage_date=self.today(), count=0)
|
||||||
|
db.add(usage)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
if usage.count >= settings.daily_reading_limit:
|
||||||
|
quota = self._quota_payload(usage.count)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail=f"今日 {settings.daily_reading_limit} 次解读机会已用完,请明天 0 点后再来。",
|
||||||
|
headers={
|
||||||
|
"X-RateLimit-Limit": str(quota["limit"]),
|
||||||
|
"X-RateLimit-Remaining": str(quota["remaining"]),
|
||||||
|
"X-RateLimit-Reset": quota["reset_at"].isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
usage.count += 1
|
||||||
|
await db.flush()
|
||||||
|
return self._quota_payload(usage.count)
|
||||||
|
|
||||||
|
async def _get_usage(self, db: AsyncSession, user_id: str) -> DailyUsage | None:
|
||||||
|
result = await db.execute(
|
||||||
|
select(DailyUsage).where(DailyUsage.user_id == user_id, DailyUsage.usage_date == self.today())
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
def _quota_payload(self, used: int) -> dict:
|
||||||
|
limit = settings.daily_reading_limit
|
||||||
|
return {
|
||||||
|
"limit": limit,
|
||||||
|
"used": used,
|
||||||
|
"remaining": max(0, limit - used),
|
||||||
|
"reset_at": self.reset_at(),
|
||||||
|
}
|
||||||
@ -16,8 +16,11 @@ class ReadingService:
|
|||||||
reading = result.scalar_one()
|
reading = result.scalar_one()
|
||||||
reading.status = "processing"
|
reading.status = "processing"
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
result = await db.execute(select(Reading).where(Reading.id == reading_id))
|
||||||
|
reading = result.scalar_one()
|
||||||
if reading.reading_type == "palm":
|
if reading.reading_type == "palm":
|
||||||
data = await self._generate_palm(db, reading)
|
data = await self._generate_palm(db, reading)
|
||||||
elif reading.reading_type == "face":
|
elif reading.reading_type == "face":
|
||||||
|
|||||||
@ -52,7 +52,7 @@ class SharePosterService:
|
|||||||
response = await self.client.images.generate(
|
response = await self.client.images.generate(
|
||||||
model=settings.openai_image_model,
|
model=settings.openai_image_model,
|
||||||
prompt=(
|
prompt=(
|
||||||
"生成一张竖版高端小程序分享海报背景,不要任何文字、不要数字、不要 logo。"
|
"生成一张竖版高端移动端分享海报背景,不要任何文字、不要数字、不要 logo。"
|
||||||
"主题:赛博先生 AI 命理实验室,手相报告。"
|
"主题:赛博先生 AI 命理实验室,手相报告。"
|
||||||
"必须包含精美的发光手掌、掌纹扫描线、AI 电路线、东方玄学圆形符号。"
|
"必须包含精美的发光手掌、掌纹扫描线、AI 电路线、东方玄学圆形符号。"
|
||||||
"设计风格:深墨黑背景 #080d10,青绿色 AI 发光线条 #00e0b8,少量金色点缀 #d8a84e;"
|
"设计风格:深墨黑背景 #080d10,青绿色 AI 发光线条 #00e0b8,少量金色点缀 #d8a84e;"
|
||||||
@ -134,7 +134,7 @@ class SharePosterService:
|
|||||||
hand_side = {"left": "左手", "right": "右手", "unknown": "未知手"}.get(report.hand_side, "未知手")
|
hand_side = {"left": "左手", "right": "右手", "unknown": "未知手"}.get(report.hand_side, "未知手")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
"生成一张竖版中文小程序分享海报,比例 2:3,精美、高级、可读性很高。"
|
"生成一张竖版中文移动端分享海报,比例 2:3,精美、高级、可读性很高。"
|
||||||
"品牌是“赛博先生”,定位是 AI 命理实验室,功能是手相报告。"
|
"品牌是“赛博先生”,定位是 AI 命理实验室,功能是手相报告。"
|
||||||
"设计风格:深墨黑背景 #080d10,青绿色 AI 发光线条 #00e0b8,少量金色点缀 #d8a84e;"
|
"设计风格:深墨黑背景 #080d10,青绿色 AI 发光线条 #00e0b8,少量金色点缀 #d8a84e;"
|
||||||
"现代、东方玄学、AI 扫描终端感;不要卡通,不要恐怖,不要传统庙宇风,不要紫色渐变。"
|
"现代、东方玄学、AI 扫描终端感;不要卡通,不要恐怖,不要传统庙宇风,不要紫色渐变。"
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from httpx import ASGITransport, AsyncClient
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from app.core.database import init_db
|
from app.core.database import init_db
|
||||||
from app.main import app
|
from app.main import app
|
||||||
@ -11,7 +12,7 @@ async def test_bazi_reading_lifecycle(monkeypatch):
|
|||||||
await init_db()
|
await init_db()
|
||||||
transport = ASGITransport(app=app)
|
transport = ASGITransport(app=app)
|
||||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
auth = await client.post("/api/v1/auth/anonymous-login", json={"client_id": "readings-bazi"})
|
auth = await client.post("/api/v1/auth/anonymous-login", json={"client_id": f"readings-bazi-{uuid4()}"})
|
||||||
token = auth.json()["access_token"]
|
token = auth.json()["access_token"]
|
||||||
headers = {"authorization": f"Bearer {token}"}
|
headers = {"authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
@ -43,3 +44,37 @@ async def test_bazi_reading_lifecycle(monkeypatch):
|
|||||||
|
|
||||||
deleted = await client.delete(f"/api/v1/readings/{reading_id}", headers=headers)
|
deleted = await client.delete(f"/api/v1/readings/{reading_id}", headers=headers)
|
||||||
assert deleted.status_code == 200
|
assert deleted.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_daily_reading_quota_blocks_sixth_request(monkeypatch):
|
||||||
|
monkeypatch.setattr("app.services.analyzer_common.settings.openai_api_key", None)
|
||||||
|
await init_db()
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
auth = await client.post("/api/v1/auth/anonymous-login", json={"client_id": f"readings-quota-{uuid4()}"})
|
||||||
|
token = auth.json()["access_token"]
|
||||||
|
headers = {"authorization": f"Bearer {token}"}
|
||||||
|
payload = {
|
||||||
|
"reading_type": "bazi",
|
||||||
|
"input_data": {
|
||||||
|
"calendar_type": "solar",
|
||||||
|
"birth_date": "1992-08-18",
|
||||||
|
"birth_time": "09:30",
|
||||||
|
"time_unknown": False,
|
||||||
|
"birth_place": "广东深圳",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
quota_before = await client.get("/api/v1/readings/quota", headers=headers)
|
||||||
|
assert quota_before.status_code == 200
|
||||||
|
|
||||||
|
for _ in range(5):
|
||||||
|
response = await client.post("/api/v1/readings", headers=headers, json=payload)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
blocked = await client.post("/api/v1/readings", headers=headers, json=payload)
|
||||||
|
assert blocked.status_code == 429
|
||||||
|
|
||||||
|
quota_after = await client.get("/api/v1/readings/quota", headers=headers)
|
||||||
|
assert quota_after.json()["remaining"] == 0
|
||||||
|
|||||||
@ -1,13 +0,0 @@
|
|||||||
App({
|
|
||||||
globalData: {
|
|
||||||
token: wx.getStorageSync('token') || ''
|
|
||||||
},
|
|
||||||
setToken(token) {
|
|
||||||
this.globalData.token = token
|
|
||||||
wx.setStorageSync('token', token)
|
|
||||||
},
|
|
||||||
clearToken() {
|
|
||||||
this.globalData.token = ''
|
|
||||||
wx.removeStorageSync('token')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
{
|
|
||||||
"pages": [
|
|
||||||
"pages/index/index",
|
|
||||||
"pages/palm/palm",
|
|
||||||
"pages/generating/generating",
|
|
||||||
"pages/report/report",
|
|
||||||
"pages/history/history",
|
|
||||||
"pages/legal/legal"
|
|
||||||
],
|
|
||||||
"window": {
|
|
||||||
"navigationBarTitleText": "赛博先生",
|
|
||||||
"navigationBarBackgroundColor": "#080d10",
|
|
||||||
"navigationBarTextStyle": "white",
|
|
||||||
"backgroundColor": "#080d10"
|
|
||||||
},
|
|
||||||
"tabBar": {
|
|
||||||
"color": "#728389",
|
|
||||||
"selectedColor": "#00e0b8",
|
|
||||||
"backgroundColor": "#0b1215",
|
|
||||||
"borderStyle": "black",
|
|
||||||
"list": [
|
|
||||||
{
|
|
||||||
"pagePath": "pages/index/index",
|
|
||||||
"text": "问先生",
|
|
||||||
"iconPath": "assets/tabbar/ask-normal.png",
|
|
||||||
"selectedIconPath": "assets/tabbar/ask-active.png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"pagePath": "pages/history/history",
|
|
||||||
"text": "档案",
|
|
||||||
"iconPath": "assets/tabbar/archive-normal.png",
|
|
||||||
"selectedIconPath": "assets/tabbar/archive-active.png"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"style": "v2",
|
|
||||||
"sitemapLocation": "sitemap.json"
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
page {
|
|
||||||
background: #080d10;
|
|
||||||
color: #f2e9d8;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page {
|
|
||||||
min-height: 100vh;
|
|
||||||
padding: 32rpx;
|
|
||||||
box-sizing: border-box;
|
|
||||||
background:
|
|
||||||
linear-gradient(180deg, rgba(0, 224, 184, 0.08), rgba(8, 13, 16, 0) 320rpx),
|
|
||||||
repeating-linear-gradient(90deg, rgba(242, 233, 216, 0.035) 0, rgba(242, 233, 216, 0.035) 1rpx, transparent 1rpx, transparent 48rpx),
|
|
||||||
#080d10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel {
|
|
||||||
background: rgba(16, 25, 28, 0.92);
|
|
||||||
border: 1rpx solid rgba(0, 224, 184, 0.22);
|
|
||||||
border-radius: 16rpx;
|
|
||||||
padding: 28rpx;
|
|
||||||
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.24);
|
|
||||||
}
|
|
||||||
|
|
||||||
.primary-btn {
|
|
||||||
background: #00e0b8;
|
|
||||||
color: #06100e;
|
|
||||||
border-radius: 12rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ghost-btn {
|
|
||||||
background: rgba(8, 13, 16, 0.72);
|
|
||||||
color: #00e0b8;
|
|
||||||
border: 1rpx solid rgba(0, 224, 184, 0.56);
|
|
||||||
border-radius: 12rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.muted {
|
|
||||||
color: #8da0a4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
font-size: 34rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
margin: 32rpx 0 16rpx;
|
|
||||||
color: #f2e9d8;
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 424 B |
|
Before Width: | Height: | Size: 428 B |
|
Before Width: | Height: | Size: 617 B |
|
Before Width: | Height: | Size: 622 B |
@ -1,47 +0,0 @@
|
|||||||
const { request } = require('../../utils/request')
|
|
||||||
|
|
||||||
Page({
|
|
||||||
data: {
|
|
||||||
id: '',
|
|
||||||
timer: null
|
|
||||||
},
|
|
||||||
|
|
||||||
onLoad(query) {
|
|
||||||
this.setData({ id: query.id })
|
|
||||||
this.poll()
|
|
||||||
const timer = setInterval(() => this.poll(), 2500)
|
|
||||||
this.setData({ timer })
|
|
||||||
},
|
|
||||||
|
|
||||||
onUnload() {
|
|
||||||
if (this.data.timer) {
|
|
||||||
clearInterval(this.data.timer)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async poll() {
|
|
||||||
if (!this.data.id) return
|
|
||||||
try {
|
|
||||||
const report = await request({ url: `/reports/${this.data.id}` })
|
|
||||||
if (report.status === 'completed') {
|
|
||||||
clearInterval(this.data.timer)
|
|
||||||
wx.redirectTo({ url: `/pages/report/report?id=${this.data.id}` })
|
|
||||||
}
|
|
||||||
if (report.status === 'failed') {
|
|
||||||
clearInterval(this.data.timer)
|
|
||||||
wx.showModal({
|
|
||||||
title: '生成失败',
|
|
||||||
content: report.error_message || '照片暂时无法分析,请换一张更清晰的照片。',
|
|
||||||
showCancel: false,
|
|
||||||
success: () => wx.switchTab({ url: '/pages/index/index' })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
wx.showToast({ title: error.message || '查询失败', icon: 'none' })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
backHome() {
|
|
||||||
wx.switchTab({ url: '/pages/index/index' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"navigationBarTitleText": "生成中"
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
<view class="page center">
|
|
||||||
<view class="pulse"></view>
|
|
||||||
<text class="title">先生正在起卦</text>
|
|
||||||
<text class="subtitle">大约需要十几秒。赛博先生正在整理生命线、智慧线、感情线和整体倾向。</text>
|
|
||||||
<button class="ghost-btn action" bindtap="backHome">返回首页</button>
|
|
||||||
</view>
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
.center {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pulse {
|
|
||||||
width: 132rpx;
|
|
||||||
height: 132rpx;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #00e0b8;
|
|
||||||
opacity: 0.88;
|
|
||||||
animation: pulse 1.4s ease-in-out infinite;
|
|
||||||
box-shadow: 0 0 72rpx rgba(0, 224, 184, 0.44);
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
margin-top: 44rpx;
|
|
||||||
font-size: 40rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
margin-top: 20rpx;
|
|
||||||
color: #9db0b4;
|
|
||||||
font-size: 28rpx;
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action {
|
|
||||||
margin-top: 48rpx;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0% {
|
|
||||||
transform: scale(0.86);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: scale(0.86);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
const { request } = require('../../utils/request')
|
|
||||||
|
|
||||||
const STATUS_TEXT = {
|
|
||||||
pending: '等待中',
|
|
||||||
processing: '生成中',
|
|
||||||
completed: '已完成',
|
|
||||||
failed: '失败'
|
|
||||||
}
|
|
||||||
|
|
||||||
Page({
|
|
||||||
data: {
|
|
||||||
reports: [],
|
|
||||||
reportCount: 0,
|
|
||||||
completedCount: 0
|
|
||||||
},
|
|
||||||
|
|
||||||
onShow() {
|
|
||||||
this.loadReports()
|
|
||||||
},
|
|
||||||
|
|
||||||
async loadReports() {
|
|
||||||
if (!getApp().globalData.token) {
|
|
||||||
this.setData({ reports: [], reportCount: 0, completedCount: 0 })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const reports = await request({ url: '/reports' })
|
|
||||||
const mappedReports = reports.map((item) => ({
|
|
||||||
...item,
|
|
||||||
statusText: STATUS_TEXT[item.status] || item.status,
|
|
||||||
createdDate: (item.created_at || '').replace('T', ' ').slice(0, 16),
|
|
||||||
fallbackSummary: item.status === 'completed' ? '报告已完成,点击查看完整解读。' : '先生正在整理这份报告。'
|
|
||||||
}))
|
|
||||||
this.setData({
|
|
||||||
reports: mappedReports,
|
|
||||||
reportCount: mappedReports.length,
|
|
||||||
completedCount: mappedReports.filter((item) => item.status === 'completed').length
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
wx.showToast({ title: error.message || '加载失败', icon: 'none' })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
openReport(event) {
|
|
||||||
const id = event.currentTarget.dataset.id
|
|
||||||
wx.navigateTo({ url: `/pages/report/report?id=${id}` })
|
|
||||||
},
|
|
||||||
|
|
||||||
goHome() {
|
|
||||||
wx.navigateTo({ url: '/pages/palm/palm' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"navigationBarTitleText": "解读档案"
|
|
||||||
}
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
<view class="page">
|
|
||||||
<view class="top">
|
|
||||||
<text class="eyebrow">ARCHIVE</text>
|
|
||||||
<text class="title">解读档案</text>
|
|
||||||
<text class="subtitle">每一次请先生解读,都会在这里沉淀成一份记录。</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="stats">
|
|
||||||
<view class="stat-card">
|
|
||||||
<text class="stat-value">{{reportCount}}</text>
|
|
||||||
<text class="stat-label">累计报告</text>
|
|
||||||
</view>
|
|
||||||
<view class="stat-card">
|
|
||||||
<text class="stat-value">{{completedCount}}</text>
|
|
||||||
<text class="stat-label">已完成</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view wx:if="{{reports.length}}" class="archive-list">
|
|
||||||
<view wx:for="{{reports}}" wx:key="id" class="record" bindtap="openReport" data-id="{{item.id}}">
|
|
||||||
<view class="record-mark">
|
|
||||||
<text>掌</text>
|
|
||||||
</view>
|
|
||||||
<view class="record-main">
|
|
||||||
<view class="record-head">
|
|
||||||
<view>
|
|
||||||
<text class="record-title">手相报告</text>
|
|
||||||
<text class="record-date">{{item.createdDate}}</text>
|
|
||||||
</view>
|
|
||||||
<text class="status {{item.status}}">{{item.statusText}}</text>
|
|
||||||
</view>
|
|
||||||
<text class="summary">{{item.overall_summary || item.fallbackSummary}}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view wx:else class="empty">
|
|
||||||
<view class="empty-orb">掌</view>
|
|
||||||
<text class="empty-title">还没有解读档案</text>
|
|
||||||
<text class="empty-copy">先从手相报告开始,让赛博先生留下第一条记录。</text>
|
|
||||||
<button class="primary-btn action" bindtap="goHome">请先生看掌</button>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
@ -1,183 +0,0 @@
|
|||||||
.top {
|
|
||||||
padding: 28rpx 0 20rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.eyebrow {
|
|
||||||
display: block;
|
|
||||||
color: #d8a84e;
|
|
||||||
font-size: 22rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
display: block;
|
|
||||||
margin-top: 10rpx;
|
|
||||||
color: #f2e9d8;
|
|
||||||
font-size: 48rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
display: block;
|
|
||||||
margin-top: 14rpx;
|
|
||||||
color: #9db0b4;
|
|
||||||
font-size: 27rpx;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 16rpx;
|
|
||||||
margin: 8rpx 0 28rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card {
|
|
||||||
padding: 22rpx;
|
|
||||||
border: 1rpx solid rgba(242, 233, 216, 0.1);
|
|
||||||
border-radius: 16rpx;
|
|
||||||
background: rgba(16, 25, 28, 0.78);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value,
|
|
||||||
.stat-label {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value {
|
|
||||||
color: #00e0b8;
|
|
||||||
font-size: 38rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
margin-top: 6rpx;
|
|
||||||
color: #8da0a4;
|
|
||||||
font-size: 23rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.archive-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 18rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.record {
|
|
||||||
display: flex;
|
|
||||||
gap: 20rpx;
|
|
||||||
padding: 24rpx;
|
|
||||||
border: 1rpx solid rgba(0, 224, 184, 0.2);
|
|
||||||
border-radius: 18rpx;
|
|
||||||
background: rgba(16, 25, 28, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.record-mark {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 72rpx;
|
|
||||||
height: 72rpx;
|
|
||||||
border-radius: 50%;
|
|
||||||
color: #06100e;
|
|
||||||
background: #00e0b8;
|
|
||||||
font-size: 30rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
box-shadow: 0 0 32rpx rgba(0, 224, 184, 0.24);
|
|
||||||
}
|
|
||||||
|
|
||||||
.record-main {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.record-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.record-title,
|
|
||||||
.record-date,
|
|
||||||
.summary {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.record-title {
|
|
||||||
color: #f2e9d8;
|
|
||||||
font-size: 30rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.record-date {
|
|
||||||
margin-top: 6rpx;
|
|
||||||
color: #728389;
|
|
||||||
font-size: 22rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: #f2e9d8;
|
|
||||||
background: #728389;
|
|
||||||
border-radius: 999rpx;
|
|
||||||
padding: 7rpx 16rpx;
|
|
||||||
font-size: 22rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.completed {
|
|
||||||
color: #06100e;
|
|
||||||
background: #00e0b8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.failed {
|
|
||||||
background: #9b3d2e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary {
|
|
||||||
margin-top: 18rpx;
|
|
||||||
color: #b8c7c8;
|
|
||||||
font-size: 26rpx;
|
|
||||||
line-height: 1.65;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
margin-top: 86rpx;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-orb {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 128rpx;
|
|
||||||
height: 128rpx;
|
|
||||||
margin: 0 auto 28rpx;
|
|
||||||
border-radius: 50%;
|
|
||||||
color: #06100e;
|
|
||||||
background: #00e0b8;
|
|
||||||
font-size: 46rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
box-shadow: 0 0 60rpx rgba(0, 224, 184, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-title {
|
|
||||||
display: block;
|
|
||||||
color: #f2e9d8;
|
|
||||||
font-size: 34rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-copy {
|
|
||||||
display: block;
|
|
||||||
margin-top: 14rpx;
|
|
||||||
color: #9db0b4;
|
|
||||||
font-size: 26rpx;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action {
|
|
||||||
margin-top: 36rpx;
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
const { MODULES } = require('../../utils/modules')
|
|
||||||
|
|
||||||
Page({
|
|
||||||
data: {
|
|
||||||
hasToken: false,
|
|
||||||
modules: MODULES
|
|
||||||
},
|
|
||||||
|
|
||||||
onShow() {
|
|
||||||
this.setData({ hasToken: Boolean(getApp().globalData.token) })
|
|
||||||
},
|
|
||||||
|
|
||||||
tapModule(event) {
|
|
||||||
const moduleId = event.currentTarget.dataset.id
|
|
||||||
const module = this.data.modules.find((item) => item.id === moduleId)
|
|
||||||
if (module && module.status === 'available' && module.path) {
|
|
||||||
wx.navigateTo({ url: module.path })
|
|
||||||
} else {
|
|
||||||
wx.showToast({ title: '这个功能即将开放', icon: 'none' })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
openPalm() {
|
|
||||||
const palm = this.data.modules.find((item) => item.id === 'palm')
|
|
||||||
wx.navigateTo({ url: palm.path })
|
|
||||||
},
|
|
||||||
|
|
||||||
openLegal() {
|
|
||||||
wx.navigateTo({ url: '/pages/legal/legal' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"navigationBarTitleText": "赛博先生"
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
<view class="page">
|
|
||||||
<view class="hero">
|
|
||||||
<text class="eyebrow">CYBER FORTUNE STUDIO</text>
|
|
||||||
<text class="title">赛博先生</text>
|
|
||||||
<text class="subtitle">AI 命理实验室。选择一个入口,让先生开始解读。</text>
|
|
||||||
<view class="signal">
|
|
||||||
<text class="signal-dot"></text>
|
|
||||||
<text class="signal-text">ONLINE · 玄学模型已接入</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="module-grid">
|
|
||||||
<view wx:for="{{modules}}" wx:key="id" class="module {{item.status === 'available' ? 'active' : 'disabled'}}" bindtap="tapModule" data-id="{{item.id}}">
|
|
||||||
<text class="module-mark">{{item.mark}}</text>
|
|
||||||
<view class="module-copy">
|
|
||||||
<text class="module-title">{{item.title}}</text>
|
|
||||||
<text class="module-desc">{{item.description}}</text>
|
|
||||||
</view>
|
|
||||||
<text class="module-code">0{{index + 1}}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="panel today">
|
|
||||||
<text class="today-label">今日可问</text>
|
|
||||||
<text class="today-title">先看掌心里的行动节奏</text>
|
|
||||||
<text class="today-copy">上传一张清晰掌心照片,先生会从生命线、智慧线、感情线、命运线等维度生成娱乐向报告。</text>
|
|
||||||
<button class="primary-btn today-btn" bindtap="openPalm">请先生看掌</button>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<text class="legal muted" bindtap="openLegal">继续使用即表示你同意用户协议与隐私政策。赛博先生只提供娱乐与自我反思内容。</text>
|
|
||||||
</view>
|
|
||||||
@ -1,186 +0,0 @@
|
|||||||
.hero {
|
|
||||||
position: relative;
|
|
||||||
padding: 38rpx 0 28rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.eyebrow {
|
|
||||||
display: block;
|
|
||||||
color: #d8a84e;
|
|
||||||
font-size: 22rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
display: block;
|
|
||||||
margin-top: 10rpx;
|
|
||||||
font-size: 64rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
color: #f2e9d8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
display: block;
|
|
||||||
margin-top: 16rpx;
|
|
||||||
font-size: 28rpx;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: #9db0b4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.signal {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
margin-top: 24rpx;
|
|
||||||
padding: 10rpx 18rpx;
|
|
||||||
border: 1rpx solid rgba(0, 224, 184, 0.34);
|
|
||||||
border-radius: 999rpx;
|
|
||||||
background: rgba(0, 224, 184, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.signal-dot {
|
|
||||||
width: 12rpx;
|
|
||||||
height: 12rpx;
|
|
||||||
margin-right: 12rpx;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #00e0b8;
|
|
||||||
box-shadow: 0 0 18rpx #00e0b8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.signal-text {
|
|
||||||
color: #bcefe5;
|
|
||||||
font-size: 22rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.module-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 16rpx;
|
|
||||||
margin: 18rpx 0 28rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.module {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 118rpx;
|
|
||||||
padding: 22rpx;
|
|
||||||
box-sizing: border-box;
|
|
||||||
background: rgba(16, 25, 28, 0.9);
|
|
||||||
border: 1rpx solid rgba(242, 233, 216, 0.12);
|
|
||||||
border-radius: 16rpx;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.module.active {
|
|
||||||
background: linear-gradient(135deg, rgba(0, 224, 184, 0.2), rgba(16, 25, 28, 0.96));
|
|
||||||
border-color: rgba(0, 224, 184, 0.58);
|
|
||||||
color: #f2e9d8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.module.active::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
left: 18rpx;
|
|
||||||
right: 18rpx;
|
|
||||||
top: 0;
|
|
||||||
height: 2rpx;
|
|
||||||
background: linear-gradient(90deg, transparent, #00e0b8, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.module.disabled {
|
|
||||||
opacity: 0.54;
|
|
||||||
}
|
|
||||||
|
|
||||||
.module-mark {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 72rpx;
|
|
||||||
height: 72rpx;
|
|
||||||
margin-right: 18rpx;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: rgba(242, 233, 216, 0.08);
|
|
||||||
color: #d8a84e;
|
|
||||||
border: 1rpx solid rgba(216, 168, 78, 0.34);
|
|
||||||
font-size: 32rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.module.active .module-mark {
|
|
||||||
background: #00e0b8;
|
|
||||||
color: #06100e;
|
|
||||||
border-color: #00e0b8;
|
|
||||||
box-shadow: 0 0 32rpx rgba(0, 224, 184, 0.32);
|
|
||||||
}
|
|
||||||
|
|
||||||
.module-copy {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.module-code {
|
|
||||||
color: rgba(242, 233, 216, 0.28);
|
|
||||||
font-size: 24rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.module-title,
|
|
||||||
.module-desc {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.module-title {
|
|
||||||
font-size: 30rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.module-desc {
|
|
||||||
margin-top: 8rpx;
|
|
||||||
color: #8da0a4;
|
|
||||||
font-size: 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.module.active .module-desc {
|
|
||||||
color: #bcefe5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.today {
|
|
||||||
margin-top: 28rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.today-label,
|
|
||||||
.today-title,
|
|
||||||
.today-copy {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.today-label {
|
|
||||||
color: #d8a84e;
|
|
||||||
font-size: 23rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.today-title {
|
|
||||||
margin-top: 10rpx;
|
|
||||||
font-size: 32rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.today-copy {
|
|
||||||
margin-top: 12rpx;
|
|
||||||
color: #9db0b4;
|
|
||||||
font-size: 26rpx;
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.today-btn {
|
|
||||||
margin-top: 22rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legal {
|
|
||||||
display: block;
|
|
||||||
margin-top: 28rpx;
|
|
||||||
font-size: 24rpx;
|
|
||||||
line-height: 1.6;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
Page({})
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"navigationBarTitleText": "协议与隐私"
|
|
||||||
}
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
<view class="page">
|
|
||||||
<text class="title">赛博先生协议与隐私说明</text>
|
|
||||||
|
|
||||||
<view class="panel block">
|
|
||||||
<text class="heading">服务定位</text>
|
|
||||||
<text class="body">赛博先生提供手相、面相、八字等娱乐占卜与自我反思类内容。报告不构成医学、心理、职业、财务、投资或任何人生决策建议。</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="panel block">
|
|
||||||
<text class="heading">照片用途</text>
|
|
||||||
<text class="body">你上传的手掌照片仅用于生成本次手相报告、质量校验和必要的问题排查。原始照片默认短期保存,并会按服务端策略自动清理。</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="panel block">
|
|
||||||
<text class="heading">数据删除</text>
|
|
||||||
<text class="body">你可以在报告详情页删除报告。删除后,报告内容和关联照片会被清理,无法恢复。</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
.title {
|
|
||||||
display: block;
|
|
||||||
padding: 24rpx 0;
|
|
||||||
color: #f2e9d8;
|
|
||||||
font-size: 42rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block {
|
|
||||||
margin-bottom: 20rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.heading {
|
|
||||||
display: block;
|
|
||||||
font-weight: 800;
|
|
||||||
margin-bottom: 14rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.body {
|
|
||||||
display: block;
|
|
||||||
color: #9db0b4;
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
@ -1,96 +0,0 @@
|
|||||||
const { request, uploadPalm } = require('../../utils/request')
|
|
||||||
|
|
||||||
Page({
|
|
||||||
data: {
|
|
||||||
hasToken: false,
|
|
||||||
imagePath: '',
|
|
||||||
handSide: 'unknown',
|
|
||||||
submitting: false
|
|
||||||
},
|
|
||||||
|
|
||||||
onShow() {
|
|
||||||
this.setData({ hasToken: Boolean(getApp().globalData.token) })
|
|
||||||
},
|
|
||||||
|
|
||||||
chooseLeft() {
|
|
||||||
this.setData({ handSide: 'left' })
|
|
||||||
},
|
|
||||||
|
|
||||||
chooseRight() {
|
|
||||||
this.setData({ handSide: 'right' })
|
|
||||||
},
|
|
||||||
|
|
||||||
chooseUnknown() {
|
|
||||||
this.setData({ handSide: 'unknown' })
|
|
||||||
},
|
|
||||||
|
|
||||||
async loginDev() {
|
|
||||||
try {
|
|
||||||
const login = await wx.login()
|
|
||||||
const data = await request({ url: '/auth/wechat-login', method: 'POST', data: { code: login.code || 'dev' } })
|
|
||||||
getApp().setToken(data.access_token)
|
|
||||||
this.setData({ hasToken: true })
|
|
||||||
wx.showToast({ title: '已登录' })
|
|
||||||
} catch (error) {
|
|
||||||
wx.showToast({ title: error.message || '登录失败', icon: 'none' })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async loginWithPhone(event) {
|
|
||||||
try {
|
|
||||||
const login = await wx.login()
|
|
||||||
const phoneCode = event.detail && event.detail.code
|
|
||||||
const data = await request({
|
|
||||||
url: '/auth/wechat-login',
|
|
||||||
method: 'POST',
|
|
||||||
data: { code: login.code || 'dev', phone_code: phoneCode || null }
|
|
||||||
})
|
|
||||||
getApp().setToken(data.access_token)
|
|
||||||
this.setData({ hasToken: true })
|
|
||||||
wx.showToast({ title: '已登录' })
|
|
||||||
} catch (error) {
|
|
||||||
wx.showToast({ title: error.message || '登录失败', icon: 'none' })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async chooseImage() {
|
|
||||||
try {
|
|
||||||
const res = await wx.chooseMedia({
|
|
||||||
count: 1,
|
|
||||||
mediaType: ['image'],
|
|
||||||
sourceType: ['album', 'camera'],
|
|
||||||
sizeType: ['compressed']
|
|
||||||
})
|
|
||||||
this.setData({ imagePath: res.tempFiles[0].tempFilePath })
|
|
||||||
} catch (error) {
|
|
||||||
if (error.errMsg && !error.errMsg.includes('cancel')) {
|
|
||||||
wx.showToast({ title: '选择照片失败', icon: 'none' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async submit() {
|
|
||||||
if (!getApp().globalData.token) {
|
|
||||||
await this.loginDev()
|
|
||||||
}
|
|
||||||
if (!this.data.imagePath) return
|
|
||||||
this.setData({ submitting: true })
|
|
||||||
try {
|
|
||||||
const upload = await uploadPalm(this.data.imagePath)
|
|
||||||
const report = await request({
|
|
||||||
url: '/reports',
|
|
||||||
method: 'POST',
|
|
||||||
data: { image_id: upload.image_id, hand_side: this.data.handSide }
|
|
||||||
})
|
|
||||||
wx.navigateTo({ url: `/pages/generating/generating?id=${report.id}` })
|
|
||||||
} catch (error) {
|
|
||||||
wx.showToast({ title: error.message || '生成失败', icon: 'none' })
|
|
||||||
} finally {
|
|
||||||
this.setData({ submitting: false })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
openLegal() {
|
|
||||||
wx.navigateTo({ url: '/pages/legal/legal' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"navigationBarTitleText": "手相报告"
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
<view class="page">
|
|
||||||
<view class="hero">
|
|
||||||
<text class="eyebrow">PALM READING</text>
|
|
||||||
<text class="title">手相报告</text>
|
|
||||||
<text class="subtitle">上传掌心照片,先生会扫描掌纹主线并生成娱乐向自我反思报告。</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="panel guide">
|
|
||||||
<text class="guide-title">拍摄要求</text>
|
|
||||||
<text class="guide-line">掌心完整入镜,纹路清晰</text>
|
|
||||||
<text class="guide-line">光线充足,避免强反光和阴影</text>
|
|
||||||
<text class="guide-line">手掌自然伸展,不要遮挡主线</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="section-label">这张照片是哪只手?</view>
|
|
||||||
<view class="side-picker">
|
|
||||||
<button class="side {{handSide === 'left' ? 'active' : ''}}" bindtap="chooseLeft">左手</button>
|
|
||||||
<button class="side {{handSide === 'right' ? 'active' : ''}}" bindtap="chooseRight">右手</button>
|
|
||||||
<button class="side {{handSide === 'unknown' ? 'active' : ''}}" bindtap="chooseUnknown">不确定</button>
|
|
||||||
</view>
|
|
||||||
<text class="side-note muted">可选项:知道左右手会让报告措辞更细;不确定也可以直接生成。</text>
|
|
||||||
|
|
||||||
<image wx:if="{{imagePath}}" class="preview" mode="aspectFill" src="{{imagePath}}" />
|
|
||||||
|
|
||||||
<button wx:if="{{!hasToken}}" class="primary-btn action" open-type="getPhoneNumber" bindgetphonenumber="loginWithPhone">手机号登录</button>
|
|
||||||
<button wx:if="{{!hasToken}}" class="ghost-btn action" bindtap="loginDev">开发模式登录</button>
|
|
||||||
<button class="ghost-btn action" bindtap="chooseImage">接入掌纹照片</button>
|
|
||||||
<button class="primary-btn action" loading="{{submitting}}" disabled="{{!imagePath || submitting}}" bindtap="submit">请先生解读</button>
|
|
||||||
|
|
||||||
<text class="legal muted" bindtap="openLegal">报告仅用于娱乐与自我反思,不构成任何现实决策建议。</text>
|
|
||||||
</view>
|
|
||||||
@ -1,104 +0,0 @@
|
|||||||
.hero {
|
|
||||||
padding: 34rpx 0 28rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.eyebrow {
|
|
||||||
display: block;
|
|
||||||
color: #d8a84e;
|
|
||||||
font-size: 22rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
display: block;
|
|
||||||
margin-top: 10rpx;
|
|
||||||
font-size: 52rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
color: #f2e9d8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
display: block;
|
|
||||||
margin-top: 16rpx;
|
|
||||||
font-size: 28rpx;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: #9db0b4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.guide {
|
|
||||||
margin-top: 8rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.guide-title,
|
|
||||||
.guide-line {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.guide-title {
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: 14rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.guide-line {
|
|
||||||
color: #9db0b4;
|
|
||||||
line-height: 1.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-label {
|
|
||||||
margin-top: 28rpx;
|
|
||||||
font-size: 28rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-picker {
|
|
||||||
display: flex;
|
|
||||||
gap: 16rpx;
|
|
||||||
margin: 16rpx 0 10rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.side {
|
|
||||||
flex: 1;
|
|
||||||
height: 72rpx;
|
|
||||||
line-height: 72rpx;
|
|
||||||
padding: 0;
|
|
||||||
font-size: 26rpx;
|
|
||||||
color: #9db0b4;
|
|
||||||
background: rgba(16, 25, 28, 0.92);
|
|
||||||
border: 1rpx solid rgba(242, 233, 216, 0.12);
|
|
||||||
border-radius: 12rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.side.active {
|
|
||||||
color: #06100e;
|
|
||||||
background: #00e0b8;
|
|
||||||
border-color: #00e0b8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.side-note {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 24rpx;
|
|
||||||
font-size: 24rpx;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview {
|
|
||||||
width: 100%;
|
|
||||||
height: 520rpx;
|
|
||||||
border-radius: 16rpx;
|
|
||||||
margin-bottom: 24rpx;
|
|
||||||
background: #10191c;
|
|
||||||
border: 1rpx solid rgba(0, 224, 184, 0.24);
|
|
||||||
}
|
|
||||||
|
|
||||||
.action {
|
|
||||||
margin-top: 20rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legal {
|
|
||||||
display: block;
|
|
||||||
margin-top: 28rpx;
|
|
||||||
font-size: 24rpx;
|
|
||||||
line-height: 1.6;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
@ -1,128 +0,0 @@
|
|||||||
const { API_BASE_URL, authHeader, request } = require('../../utils/request')
|
|
||||||
|
|
||||||
Page({
|
|
||||||
data: {
|
|
||||||
id: '',
|
|
||||||
report: null,
|
|
||||||
data: null,
|
|
||||||
qualityPercent: 0,
|
|
||||||
dimensionCount: 0,
|
|
||||||
shareLoading: false,
|
|
||||||
shareStatusText: ''
|
|
||||||
},
|
|
||||||
|
|
||||||
onLoad(query) {
|
|
||||||
this.setData({ id: query.id })
|
|
||||||
this.loadReport()
|
|
||||||
},
|
|
||||||
|
|
||||||
async loadReport() {
|
|
||||||
try {
|
|
||||||
const report = await request({ url: `/reports/${this.data.id}` })
|
|
||||||
const data = report.report_data || {}
|
|
||||||
if (data.dimensions) {
|
|
||||||
data.dimensions = data.dimensions.map((item) => ({
|
|
||||||
...item,
|
|
||||||
confidencePercent: Math.round((item.confidence || 0) * 100)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
const handSideText = {
|
|
||||||
left: '左手',
|
|
||||||
right: '右手',
|
|
||||||
unknown: '未知手'
|
|
||||||
}[report.hand_side] || '未知手'
|
|
||||||
this.setData({
|
|
||||||
report: {
|
|
||||||
...report,
|
|
||||||
handSideText,
|
|
||||||
createdDate: (report.created_at || '').replace('T', ' ').slice(0, 16)
|
|
||||||
},
|
|
||||||
data,
|
|
||||||
qualityPercent: Math.round(((data.quality_check && data.quality_check.confidence) || 0) * 100),
|
|
||||||
dimensionCount: data.dimensions ? data.dimensions.length : 0
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
wx.showToast({ title: error.message || '加载失败', icon: 'none' })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteReport() {
|
|
||||||
wx.showModal({
|
|
||||||
title: '删除报告',
|
|
||||||
content: '删除后将无法恢复,关联照片也会被清理。',
|
|
||||||
success: async (res) => {
|
|
||||||
if (!res.confirm) return
|
|
||||||
try {
|
|
||||||
await request({ url: `/reports/${this.data.id}`, method: 'DELETE' })
|
|
||||||
wx.showToast({ title: '已删除' })
|
|
||||||
wx.switchTab({ url: '/pages/history/history' })
|
|
||||||
} catch (error) {
|
|
||||||
wx.showToast({ title: error.message || '删除失败', icon: 'none' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
generateShareImage() {
|
|
||||||
this.setData({ shareLoading: true, shareStatusText: '分享图生成中,通常需要 30-120 秒。你可以先继续查看报告。' })
|
|
||||||
request({
|
|
||||||
url: `/reports/${this.data.id}/share-image-jobs`,
|
|
||||||
method: 'POST'
|
|
||||||
})
|
|
||||||
.then((job) => this.pollShareImageJob(job.id, 0))
|
|
||||||
.catch((error) => {
|
|
||||||
this.setData({ shareLoading: false, shareStatusText: '' })
|
|
||||||
wx.showToast({ title: error.message || '创建任务失败', icon: 'none' })
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
pollShareImageJob(jobId, count) {
|
|
||||||
if (count > 80) {
|
|
||||||
this.setData({ shareLoading: false, shareStatusText: '' })
|
|
||||||
wx.showToast({ title: '生成时间较长,请稍后再试', icon: 'none' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
request({ url: `/reports/share-image-jobs/${jobId}` })
|
|
||||||
.then((job) => {
|
|
||||||
if (job.status === 'completed') {
|
|
||||||
this.downloadShareImage(jobId)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (job.status === 'failed') {
|
|
||||||
this.setData({ shareLoading: false, shareStatusText: '' })
|
|
||||||
wx.showToast({ title: job.error_message || '分享图生成失败', icon: 'none' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setTimeout(() => this.pollShareImageJob(jobId, count + 1), 2000)
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
this.setData({ shareLoading: false, shareStatusText: '' })
|
|
||||||
wx.showToast({ title: error.message || '查询任务失败', icon: 'none' })
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
downloadShareImage(jobId) {
|
|
||||||
wx.downloadFile({
|
|
||||||
url: `${API_BASE_URL}/reports/share-image-jobs/${jobId}/image`,
|
|
||||||
header: authHeader(),
|
|
||||||
success: (res) => {
|
|
||||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
||||||
wx.previewImage({ current: res.tempFilePath, urls: [res.tempFilePath] })
|
|
||||||
} else {
|
|
||||||
wx.showToast({ title: '分享图下载失败', icon: 'none' })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
fail: () => wx.showToast({ title: '分享图下载失败', icon: 'none' }),
|
|
||||||
complete: () => {
|
|
||||||
this.setData({ shareLoading: false, shareStatusText: '' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
onShareAppMessage() {
|
|
||||||
return {
|
|
||||||
title: '我在赛博先生生成了一份手相报告',
|
|
||||||
path: '/pages/index/index'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"navigationBarTitleText": "手相报告",
|
|
||||||
"enableShareAppMessage": true
|
|
||||||
}
|
|
||||||
@ -1,99 +0,0 @@
|
|||||||
<view class="page" wx:if="{{report}}">
|
|
||||||
<view class="header">
|
|
||||||
<text class="eyebrow">PALM REPORT · {{report.handSideText}}</text>
|
|
||||||
<text class="title">赛博先生手相报告</text>
|
|
||||||
<text class="time">{{report.createdDate}}</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="panel insight-card">
|
|
||||||
<text class="insight-label">先生结论</text>
|
|
||||||
<text class="summary">{{data.overall_summary}}</text>
|
|
||||||
<view class="keywords inline">
|
|
||||||
<text wx:for="{{data.lucky_keywords}}" wx:key="*this" class="keyword">{{item}}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="metrics">
|
|
||||||
<view class="metric">
|
|
||||||
<text class="metric-value">{{qualityPercent}}%</text>
|
|
||||||
<text class="metric-label">照片可读性</text>
|
|
||||||
</view>
|
|
||||||
<view class="metric">
|
|
||||||
<text class="metric-value">{{dimensionCount}}</text>
|
|
||||||
<text class="metric-label">解读维度</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="quality-note">
|
|
||||||
<text>{{data.quality_check.reason}}</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="section-head">
|
|
||||||
<text class="section-title">核心维度</text>
|
|
||||||
<text class="section-subtitle">观察 · 解读 · 建议</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view wx:for="{{data.dimensions}}" wx:key="name" class="panel dimension">
|
|
||||||
<view class="dimension-head">
|
|
||||||
<view>
|
|
||||||
<text class="dimension-index">0{{index + 1}}</text>
|
|
||||||
<text class="dimension-name">{{item.name}}</text>
|
|
||||||
</view>
|
|
||||||
<view class="confidence-pill">
|
|
||||||
<text>{{item.confidencePercent}}%</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="block">
|
|
||||||
<text class="block-label">观察</text>
|
|
||||||
<view class="chips">
|
|
||||||
<text wx:for="{{item.observations}}" wx:for-item="obs" wx:key="*this" class="chip">{{obs}}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="block">
|
|
||||||
<text class="block-label">解读</text>
|
|
||||||
<text class="body">{{item.interpretation}}</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="advice-box">
|
|
||||||
<text class="block-label">先生建议</text>
|
|
||||||
<text class="advice">{{item.advice}}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="section-head">
|
|
||||||
<text class="section-title">倾向总结</text>
|
|
||||||
<text class="section-subtitle">优势与提醒</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="panel summary-grid">
|
|
||||||
<view class="summary-column">
|
|
||||||
<text class="column-title">优势倾向</text>
|
|
||||||
<text wx:for="{{data.strengths}}" wx:key="*this" class="list-item">{{item}}</text>
|
|
||||||
</view>
|
|
||||||
<view class="divider"></view>
|
|
||||||
<view class="summary-column">
|
|
||||||
<text class="column-title">近期提醒</text>
|
|
||||||
<text wx:for="{{data.suggestions}}" wx:key="*this" class="list-item">{{item}}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view wx:if="{{data.challenges && data.challenges.length}}" class="panel challenge-card">
|
|
||||||
<text class="column-title">需要留意</text>
|
|
||||||
<view class="chips">
|
|
||||||
<text wx:for="{{data.challenges}}" wx:key="*this" class="chip warn">{{item}}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="disclaimer-box">
|
|
||||||
<text class="disclaimer">{{data.disclaimer}}</text>
|
|
||||||
</view>
|
|
||||||
<button class="primary-btn action" loading="{{shareLoading}}" disabled="{{shareLoading}}" bindtap="generateShareImage">生成分享图</button>
|
|
||||||
<text wx:if="{{shareStatusText}}" class="share-status">{{shareStatusText}}</text>
|
|
||||||
<button class="ghost-btn action" bindtap="deleteReport">删除报告</button>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view wx:else class="page">
|
|
||||||
<text class="muted">正在加载报告...</text>
|
|
||||||
</view>
|
|
||||||
@ -1,298 +0,0 @@
|
|||||||
.header {
|
|
||||||
padding: 28rpx 0 18rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.eyebrow {
|
|
||||||
display: block;
|
|
||||||
color: #d8a84e;
|
|
||||||
font-size: 22rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
display: block;
|
|
||||||
margin-top: 10rpx;
|
|
||||||
color: #f2e9d8;
|
|
||||||
font-size: 46rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time {
|
|
||||||
display: block;
|
|
||||||
margin-top: 12rpx;
|
|
||||||
color: #728389;
|
|
||||||
font-size: 23rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.insight-card {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
border-color: rgba(0, 224, 184, 0.42);
|
|
||||||
background: linear-gradient(135deg, rgba(0, 224, 184, 0.16), rgba(16, 25, 28, 0.95) 46%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.insight-card::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 26rpx;
|
|
||||||
right: 26rpx;
|
|
||||||
height: 2rpx;
|
|
||||||
background: linear-gradient(90deg, transparent, #00e0b8, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.insight-label,
|
|
||||||
.block-label,
|
|
||||||
.column-title {
|
|
||||||
display: block;
|
|
||||||
color: #d8a84e;
|
|
||||||
font-size: 23rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary {
|
|
||||||
display: block;
|
|
||||||
margin-top: 16rpx;
|
|
||||||
color: #d9e4e1;
|
|
||||||
font-size: 30rpx;
|
|
||||||
line-height: 1.75;
|
|
||||||
}
|
|
||||||
|
|
||||||
.keywords {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 12rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.keywords.inline {
|
|
||||||
margin-top: 22rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyword,
|
|
||||||
.chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 44rpx;
|
|
||||||
box-sizing: border-box;
|
|
||||||
border-radius: 999rpx;
|
|
||||||
padding: 8rpx 18rpx;
|
|
||||||
font-size: 24rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyword {
|
|
||||||
color: #06100e;
|
|
||||||
background: #00e0b8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip {
|
|
||||||
color: #bcefe5;
|
|
||||||
background: rgba(0, 224, 184, 0.1);
|
|
||||||
border: 1rpx solid rgba(0, 224, 184, 0.24);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip.warn {
|
|
||||||
color: #f2d3c9;
|
|
||||||
background: rgba(255, 107, 74, 0.1);
|
|
||||||
border-color: rgba(255, 107, 74, 0.28);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metrics {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 16rpx;
|
|
||||||
margin-top: 18rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric {
|
|
||||||
padding: 22rpx;
|
|
||||||
border: 1rpx solid rgba(242, 233, 216, 0.1);
|
|
||||||
border-radius: 16rpx;
|
|
||||||
background: rgba(16, 25, 28, 0.78);
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-value,
|
|
||||||
.metric-label {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-value {
|
|
||||||
color: #00e0b8;
|
|
||||||
font-size: 38rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-label {
|
|
||||||
margin-top: 6rpx;
|
|
||||||
color: #8da0a4;
|
|
||||||
font-size: 23rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.quality-note {
|
|
||||||
margin-top: 14rpx;
|
|
||||||
color: #8da0a4;
|
|
||||||
font-size: 24rpx;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-end;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin: 38rpx 0 16rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title,
|
|
||||||
.section-subtitle {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
color: #f2e9d8;
|
|
||||||
font-size: 34rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-subtitle {
|
|
||||||
color: #728389;
|
|
||||||
font-size: 22rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dimension {
|
|
||||||
margin-bottom: 18rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dimension-head {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 20rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dimension-index {
|
|
||||||
display: block;
|
|
||||||
color: rgba(242, 233, 216, 0.28);
|
|
||||||
font-size: 22rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dimension-name {
|
|
||||||
display: block;
|
|
||||||
margin-top: 4rpx;
|
|
||||||
color: #f2e9d8;
|
|
||||||
font-size: 32rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confidence-pill {
|
|
||||||
flex-shrink: 0;
|
|
||||||
min-width: 88rpx;
|
|
||||||
padding: 8rpx 14rpx;
|
|
||||||
border: 1rpx solid rgba(0, 224, 184, 0.3);
|
|
||||||
border-radius: 999rpx;
|
|
||||||
color: #00e0b8;
|
|
||||||
font-size: 23rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block {
|
|
||||||
margin-top: 22rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.body,
|
|
||||||
.advice,
|
|
||||||
.list-item,
|
|
||||||
.disclaimer {
|
|
||||||
display: block;
|
|
||||||
line-height: 1.75;
|
|
||||||
}
|
|
||||||
|
|
||||||
.body {
|
|
||||||
margin-top: 10rpx;
|
|
||||||
color: #c5d4d3;
|
|
||||||
font-size: 28rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chips {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 12rpx;
|
|
||||||
margin-top: 12rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.advice-box {
|
|
||||||
margin-top: 22rpx;
|
|
||||||
padding: 22rpx;
|
|
||||||
border-radius: 14rpx;
|
|
||||||
background: rgba(216, 168, 78, 0.08);
|
|
||||||
border: 1rpx solid rgba(216, 168, 78, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.advice {
|
|
||||||
margin-top: 8rpx;
|
|
||||||
color: #eadcc1;
|
|
||||||
font-size: 27rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 22rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.summary-column {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.divider {
|
|
||||||
height: 1rpx;
|
|
||||||
background: rgba(242, 233, 216, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-item {
|
|
||||||
position: relative;
|
|
||||||
margin-top: 12rpx;
|
|
||||||
padding-left: 26rpx;
|
|
||||||
color: #c5d4d3;
|
|
||||||
font-size: 27rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-item::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 20rpx;
|
|
||||||
width: 9rpx;
|
|
||||||
height: 9rpx;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #00e0b8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.challenge-card {
|
|
||||||
margin-top: 18rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.disclaimer-box {
|
|
||||||
margin-top: 34rpx;
|
|
||||||
padding-top: 22rpx;
|
|
||||||
border-top: 1rpx solid rgba(242, 233, 216, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.disclaimer {
|
|
||||||
color: #728389;
|
|
||||||
font-size: 23rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action {
|
|
||||||
margin-top: 28rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.share-status {
|
|
||||||
display: block;
|
|
||||||
margin-top: 16rpx;
|
|
||||||
color: #9db0b4;
|
|
||||||
font-size: 24rpx;
|
|
||||||
line-height: 1.6;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
{
|
|
||||||
"description": "AI Palm Reading Mini Program",
|
|
||||||
"packOptions": {
|
|
||||||
"ignore": [],
|
|
||||||
"include": []
|
|
||||||
},
|
|
||||||
"setting": {
|
|
||||||
"urlCheck": false,
|
|
||||||
"es6": true,
|
|
||||||
"enhance": true,
|
|
||||||
"postcss": true,
|
|
||||||
"minified": true,
|
|
||||||
"compileWorklet": false,
|
|
||||||
"uglifyFileName": false,
|
|
||||||
"uploadWithSourceMap": true,
|
|
||||||
"packNpmManually": false,
|
|
||||||
"packNpmRelationList": [],
|
|
||||||
"minifyWXSS": true,
|
|
||||||
"minifyWXML": true,
|
|
||||||
"localPlugins": false,
|
|
||||||
"disableUseStrict": false,
|
|
||||||
"useCompilerPlugins": false,
|
|
||||||
"condition": false,
|
|
||||||
"swc": false,
|
|
||||||
"disableSWC": true,
|
|
||||||
"babelSetting": {
|
|
||||||
"ignore": [],
|
|
||||||
"disablePlugins": [],
|
|
||||||
"outputPath": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"compileType": "miniprogram",
|
|
||||||
"libVersion": "3.6.4",
|
|
||||||
"appid": "wxe871ab859de77797",
|
|
||||||
"projectname": "people-reading",
|
|
||||||
"condition": {},
|
|
||||||
"simulatorPluginLibVersion": {},
|
|
||||||
"editorSetting": {}
|
|
||||||
}
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"rules": [
|
|
||||||
{
|
|
||||||
"action": "allow",
|
|
||||||
"page": "*"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
const API_BASE_URL = 'http://127.0.0.1:8000/api/v1'
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
API_BASE_URL
|
|
||||||
}
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
const MODULES = [
|
|
||||||
{
|
|
||||||
id: 'palm',
|
|
||||||
mark: '掌',
|
|
||||||
title: '手相报告',
|
|
||||||
description: '已开放 · 上传掌心照片',
|
|
||||||
status: 'available',
|
|
||||||
path: '/pages/palm/palm'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'face',
|
|
||||||
mark: '面',
|
|
||||||
title: '面相解读',
|
|
||||||
description: '即将开放',
|
|
||||||
status: 'coming',
|
|
||||||
path: ''
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'bazi',
|
|
||||||
mark: '字',
|
|
||||||
title: '八字简批',
|
|
||||||
description: '即将开放',
|
|
||||||
status: 'coming',
|
|
||||||
path: ''
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
MODULES
|
|
||||||
}
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
const { API_BASE_URL } = require('./config')
|
|
||||||
|
|
||||||
function authHeader(extra = {}) {
|
|
||||||
const app = getApp()
|
|
||||||
return {
|
|
||||||
...(app.globalData.token ? { Authorization: `Bearer ${app.globalData.token}` } : {}),
|
|
||||||
...extra
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function request(options) {
|
|
||||||
const app = getApp()
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
wx.request({
|
|
||||||
url: `${API_BASE_URL}${options.url}`,
|
|
||||||
method: options.method || 'GET',
|
|
||||||
data: options.data || {},
|
|
||||||
header: authHeader({ 'content-type': 'application/json', ...(options.header || {}) }),
|
|
||||||
success(res) {
|
|
||||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
||||||
resolve(res.data)
|
|
||||||
} else {
|
|
||||||
reject(new Error(res.data && res.data.detail ? res.data.detail : '请求失败'))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
fail: reject
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function uploadPalm(filePath) {
|
|
||||||
const app = getApp()
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
wx.uploadFile({
|
|
||||||
url: `${API_BASE_URL}/uploads/palm`,
|
|
||||||
filePath,
|
|
||||||
name: 'file',
|
|
||||||
header: authHeader(),
|
|
||||||
success(res) {
|
|
||||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
||||||
resolve(JSON.parse(res.data))
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(res.data)
|
|
||||||
reject(new Error(data.detail || '上传失败'))
|
|
||||||
} catch (error) {
|
|
||||||
reject(new Error('上传失败'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
fail: reject
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
API_BASE_URL,
|
|
||||||
authHeader,
|
|
||||||
request,
|
|
||||||
uploadPalm
|
|
||||||
}
|
|
||||||
BIN
palm_reading.db
@ -177,7 +177,7 @@ p {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 0.95fr) minmax(420px, 1.05fr);
|
grid-template-columns: minmax(0, 0.95fr) minmax(420px, 1.05fr);
|
||||||
gap: 28px;
|
gap: 28px;
|
||||||
align-items: end;
|
align-items: stretch;
|
||||||
min-height: calc(100vh - 150px);
|
min-height: calc(100vh - 150px);
|
||||||
padding: 38px;
|
padding: 38px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
@ -259,10 +259,21 @@ p {
|
|||||||
opacity: 0.68;
|
opacity: 0.68;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-row {
|
.home-stats {
|
||||||
display: flex;
|
display: grid;
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
gap: 12px;
|
grid-template-columns: minmax(220px, 0.75fr) minmax(0, 1.25fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid rgba(54, 216, 189, 0.18);
|
||||||
|
border-radius: 22px;
|
||||||
|
background: rgba(8, 13, 12, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-stats h2 {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace {
|
.workspace {
|
||||||
@ -501,30 +512,44 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.archive-item {
|
.archive-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(8, 13, 12, 0.44);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-item.is-failed {
|
||||||
|
border-color: rgba(255, 107, 74, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-open {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
border: 1px solid var(--line);
|
border: 0;
|
||||||
border-radius: 18px;
|
|
||||||
color: var(--paper);
|
color: var(--paper);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
background: rgba(8, 13, 12, 0.44);
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.archive-item strong,
|
.archive-open strong,
|
||||||
.archive-item small,
|
.archive-open small,
|
||||||
.archive-item b {
|
.archive-open b {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.archive-item small {
|
.archive-open small {
|
||||||
margin-top: 5px;
|
margin-top: 5px;
|
||||||
color: var(--paper-dim);
|
color: var(--paper-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
.archive-item b {
|
.archive-open b {
|
||||||
max-width: 760px;
|
max-width: 760px;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
color: rgba(245, 236, 216, 0.76);
|
color: rgba(245, 236, 216, 0.76);
|
||||||
@ -532,13 +557,49 @@ p {
|
|||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
.archive-item em {
|
.archive-actions {
|
||||||
flex: 0 0 auto;
|
display: grid;
|
||||||
|
align-content: center;
|
||||||
|
justify-items: end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px 16px 14px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-actions em {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid rgba(54, 216, 189, 0.18);
|
||||||
|
border-radius: 999px;
|
||||||
color: var(--cyan);
|
color: var(--cyan);
|
||||||
|
background: rgba(54, 216, 189, 0.08);
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.archive-item.is-failed .archive-actions em {
|
||||||
|
border-color: rgba(255, 107, 74, 0.2);
|
||||||
|
color: #ff8a70;
|
||||||
|
background: rgba(255, 107, 74, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-delete {
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: rgba(245, 236, 216, 0.42);
|
||||||
|
background: transparent;
|
||||||
|
font-weight: 700;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-delete:hover {
|
||||||
|
border-color: rgba(255, 107, 74, 0.24);
|
||||||
|
color: #ffad9d;
|
||||||
|
background: rgba(255, 107, 74, 0.08);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.report-panel {
|
.report-panel {
|
||||||
margin-top: 18px;
|
margin-top: 18px;
|
||||||
}
|
}
|
||||||
@ -596,6 +657,97 @@ p {
|
|||||||
background: var(--cyan-soft);
|
background: var(--cyan-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bazi-chart {
|
||||||
|
margin-top: 18px;
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid rgba(213, 168, 85, 0.2);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(15, 22, 19, 0.62);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bazi-chart-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bazi-chart-head h4 {
|
||||||
|
color: var(--paper);
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bazi-chart-head > span {
|
||||||
|
padding: 7px 12px;
|
||||||
|
border: 1px solid rgba(213, 168, 85, 0.24);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--gold);
|
||||||
|
background: var(--gold-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pillar-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pillar {
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
text-align: center;
|
||||||
|
background: rgba(8, 13, 12, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pillar span,
|
||||||
|
.chart-facts span,
|
||||||
|
.wuxing-bar span,
|
||||||
|
.wuxing-bar em {
|
||||||
|
color: var(--paper-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pillar strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--paper);
|
||||||
|
font-size: 24px;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-facts {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-facts span {
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(245, 236, 216, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wuxing-bars {
|
||||||
|
display: grid;
|
||||||
|
gap: 9px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wuxing-bar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px 1fr 20px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wuxing-bar i {
|
||||||
|
display: block;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: linear-gradient(90deg, var(--gold), var(--cyan));
|
||||||
|
}
|
||||||
|
|
||||||
.dimension-grid {
|
.dimension-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
@ -709,6 +861,10 @@ p {
|
|||||||
min-height: auto;
|
min-height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home-stats {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.service-grid {
|
.service-grid {
|
||||||
grid-template-columns: repeat(3, minmax(180px, 1fr));
|
grid-template-columns: repeat(3, minmax(180px, 1fr));
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
@ -824,9 +980,14 @@ p {
|
|||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.quick-row {
|
.home-stats {
|
||||||
display: grid;
|
gap: 12px;
|
||||||
grid-template-columns: 1fr 1fr;
|
padding: 15px;
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-stats h2 {
|
||||||
|
font-size: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace {
|
.workspace {
|
||||||
@ -902,16 +1063,26 @@ p {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.archive-item {
|
.archive-open {
|
||||||
padding: 14px;
|
padding: 14px 0 14px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.archive-item b {
|
.archive-open b {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.archive-item small,
|
.archive-open small,
|
||||||
.archive-item em {
|
.archive-actions em {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-actions {
|
||||||
|
padding: 12px 12px 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.archive-delete {
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0 9px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -943,6 +1114,22 @@ p {
|
|||||||
padding: 15px;
|
padding: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bazi-chart {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bazi-chart-head {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pillar-row {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.pillar strong {
|
||||||
|
font-size: 21px;
|
||||||
|
}
|
||||||
|
|
||||||
.dimension-head {
|
.dimension-head {
|
||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,8 +2,40 @@ import type { Metadata } from "next";
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "赛博先生 | AI 手相报告",
|
metadataBase: new URL("https://m.xclaw.ren"),
|
||||||
description: "上传手相照片,生成一份面向生活、学习、事业与关系的娱乐向 AI 手相报告。",
|
title: "赛博先生 | AI 玄学档案",
|
||||||
|
description: "手相、面相、八字三合一 AI 娱乐解读,把日常困惑翻译成生活、学习、事业与关系里的具体提醒。",
|
||||||
|
applicationName: "赛博先生",
|
||||||
|
icons: {
|
||||||
|
icon: "/icon.svg",
|
||||||
|
apple: "/icon.svg",
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
type: "website",
|
||||||
|
url: "https://m.xclaw.ren",
|
||||||
|
siteName: "赛博先生",
|
||||||
|
title: "赛博先生 | AI 玄学档案",
|
||||||
|
description: "手相、面相、八字三合一 AI 娱乐解读,把日常困惑翻译成具体提醒。",
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: "/share-card.png",
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
alt: "赛博先生 AI 玄学档案",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
title: "赛博先生 | AI 玄学档案",
|
||||||
|
description: "手相、面相、八字三合一 AI 娱乐解读。",
|
||||||
|
images: ["/share-card.png"],
|
||||||
|
},
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
title: "赛博先生",
|
||||||
|
statusBarStyle: "black-translucent",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { ChangeEvent, ReactNode, useEffect, useMemo, useState } from "react";
|
|||||||
import {
|
import {
|
||||||
Dimension,
|
Dimension,
|
||||||
HandSide,
|
HandSide,
|
||||||
|
Quota,
|
||||||
Reading,
|
Reading,
|
||||||
ReadingSummary,
|
ReadingSummary,
|
||||||
ReadingType,
|
ReadingType,
|
||||||
@ -62,6 +63,17 @@ type BaziForm = {
|
|||||||
birth_place: string;
|
birth_place: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type BaziChart = {
|
||||||
|
solar_date?: string;
|
||||||
|
lunar_date?: string;
|
||||||
|
birth_time?: string | null;
|
||||||
|
time_unknown?: boolean;
|
||||||
|
birth_place?: string | null;
|
||||||
|
pillars?: Record<string, string>;
|
||||||
|
day_master?: string;
|
||||||
|
wuxing_balance?: Record<string, number>;
|
||||||
|
};
|
||||||
|
|
||||||
const defaultBaziForm: BaziForm = {
|
const defaultBaziForm: BaziForm = {
|
||||||
nickname: "",
|
nickname: "",
|
||||||
gender: "",
|
gender: "",
|
||||||
@ -84,6 +96,7 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
|
|||||||
const [busyText, setBusyText] = useState("");
|
const [busyText, setBusyText] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [readings, setReadings] = useState<ReadingSummary[]>([]);
|
const [readings, setReadings] = useState<ReadingSummary[]>([]);
|
||||||
|
const [quota, setQuota] = useState<Quota | null>(null);
|
||||||
const [activeReading, setActiveReading] = useState<Reading | null>(null);
|
const [activeReading, setActiveReading] = useState<Reading | null>(null);
|
||||||
const [activeView, setActiveView] = useState<View>(initialView);
|
const [activeView, setActiveView] = useState<View>(initialView);
|
||||||
const [activeJobId, setActiveJobId] = useState("");
|
const [activeJobId, setActiveJobId] = useState("");
|
||||||
@ -95,7 +108,7 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
|
|||||||
window.addEventListener("popstate", onPopState);
|
window.addEventListener("popstate", onPopState);
|
||||||
|
|
||||||
ensureToken()
|
ensureToken()
|
||||||
.then(() => loadReadings())
|
.then(() => Promise.all([loadReadings(), loadQuota()]))
|
||||||
.then(() => setReady(true))
|
.then(() => setReady(true))
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
setError(err.message || "初始化失败");
|
setError(err.message || "初始化失败");
|
||||||
@ -113,7 +126,6 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
|
|||||||
}, [palmPreview, facePreview]);
|
}, [palmPreview, facePreview]);
|
||||||
|
|
||||||
const completedReadings = readings.filter((item) => item.status === "completed").length;
|
const completedReadings = readings.filter((item) => item.status === "completed").length;
|
||||||
const latestReading = readings[0];
|
|
||||||
const hasActiveJob = Boolean(activeJobId);
|
const hasActiveJob = Boolean(activeJobId);
|
||||||
|
|
||||||
function pickFile(event: ChangeEvent<HTMLInputElement>, type: "palm" | "face") {
|
function pickFile(event: ChangeEvent<HTMLInputElement>, type: "palm" | "face") {
|
||||||
@ -137,7 +149,16 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
|
|||||||
setReadings(data);
|
setReadings(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadQuota() {
|
||||||
|
const data = await apiFetch<Quota>("/readings/quota");
|
||||||
|
setQuota(data);
|
||||||
|
}
|
||||||
|
|
||||||
async function startImageReading(type: "palm" | "face") {
|
async function startImageReading(type: "palm" | "face") {
|
||||||
|
if (quota && quota.remaining <= 0) {
|
||||||
|
setError("今日 5 次解读机会已用完,请明天 0 点后再来。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const file = type === "palm" ? palmFile : faceFile;
|
const file = type === "palm" ? palmFile : faceFile;
|
||||||
if (!file) {
|
if (!file) {
|
||||||
setError(type === "palm" ? "请先选择一张清晰的掌心照片。" : "请先选择一张清晰的单人正脸照片。");
|
setError(type === "palm" ? "请先选择一张清晰的掌心照片。" : "请先选择一张清晰的单人正脸照片。");
|
||||||
@ -170,6 +191,10 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startBaziReading() {
|
async function startBaziReading() {
|
||||||
|
if (quota && quota.remaining <= 0) {
|
||||||
|
setError("今日 5 次解读机会已用完,请明天 0 点后再来。");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!baziForm.birth_date) {
|
if (!baziForm.birth_date) {
|
||||||
setError("请先填写出生日期。");
|
setError("请先填写出生日期。");
|
||||||
return;
|
return;
|
||||||
@ -210,6 +235,7 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
|
|||||||
.catch((err) => setError(err instanceof Error ? err.message : "报告生成失败"))
|
.catch((err) => setError(err instanceof Error ? err.message : "报告生成失败"))
|
||||||
.finally(() => setActiveJobId(""));
|
.finally(() => setActiveJobId(""));
|
||||||
void loadReadings();
|
void loadReadings();
|
||||||
|
void loadQuota();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollReading(readingId: string) {
|
async function pollReading(readingId: string) {
|
||||||
@ -293,12 +319,7 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="quick-row">
|
<HomeStats total={readings.length} completed={completedReadings} quota={quota} />
|
||||||
<button className="primary-action" onClick={() => navigate("palm")}>开始手相测试</button>
|
|
||||||
<button className="ghost-action" onClick={() => navigate(latestReading ? "archive" : "bazi")}>
|
|
||||||
{latestReading ? "查看最近档案" : "试试八字"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@ -309,6 +330,7 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
|
|||||||
busyText={busyText}
|
busyText={busyText}
|
||||||
ready={ready}
|
ready={ready}
|
||||||
error={error}
|
error={error}
|
||||||
|
quota={quota}
|
||||||
onPickFile={(event) => pickFile(event, "palm")}
|
onPickFile={(event) => pickFile(event, "palm")}
|
||||||
onSubmit={() => startImageReading("palm")}
|
onSubmit={() => startImageReading("palm")}
|
||||||
extra={
|
extra={
|
||||||
@ -320,7 +342,6 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
stats={<ReadingStats total={readings.length} completed={completedReadings} />}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@ -331,10 +352,10 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
|
|||||||
busyText={busyText}
|
busyText={busyText}
|
||||||
ready={ready}
|
ready={ready}
|
||||||
error={error}
|
error={error}
|
||||||
|
quota={quota}
|
||||||
onPickFile={(event) => pickFile(event, "face")}
|
onPickFile={(event) => pickFile(event, "face")}
|
||||||
onSubmit={() => startImageReading("face")}
|
onSubmit={() => startImageReading("face")}
|
||||||
extra={null}
|
extra={null}
|
||||||
stats={<ReadingStats total={readings.length} completed={completedReadings} />}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@ -345,8 +366,8 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
|
|||||||
busyText={busyText}
|
busyText={busyText}
|
||||||
ready={ready}
|
ready={ready}
|
||||||
error={error}
|
error={error}
|
||||||
|
quota={quota}
|
||||||
onSubmit={startBaziReading}
|
onSubmit={startBaziReading}
|
||||||
stats={<ReadingStats total={readings.length} completed={completedReadings} />}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@ -362,14 +383,21 @@ export default function PalmWebApp({ initialView = "home" }: { initialView?: Vie
|
|||||||
<div className="report-list">
|
<div className="report-list">
|
||||||
{readings.length ? (
|
{readings.length ? (
|
||||||
readings.map((item) => (
|
readings.map((item) => (
|
||||||
<button key={item.id} className="archive-item" onClick={() => openReading(item.id)}>
|
<div key={item.id} className={`archive-item ${item.status === "failed" ? "is-failed" : ""}`}>
|
||||||
<span>
|
<button className="archive-open" onClick={() => openReading(item.id)}>
|
||||||
|
<span>
|
||||||
<strong>{readingMeta[item.reading_type].name}报告</strong>
|
<strong>{readingMeta[item.reading_type].name}报告</strong>
|
||||||
<small>{formatDate(item.created_at)}</small>
|
<small>{formatDate(item.created_at)}</small>
|
||||||
{item.overall_summary ? <b>{item.overall_summary}</b> : null}
|
{item.overall_summary ? <b>{item.overall_summary}</b> : null}
|
||||||
</span>
|
</span>
|
||||||
<em>{statusText[item.status]}</em>
|
</button>
|
||||||
</button>
|
<div className="archive-actions">
|
||||||
|
<em>{statusText[item.status]}</em>
|
||||||
|
<button className="archive-delete" onClick={() => deleteReading(item.id)} aria-label={`删除${readingMeta[item.reading_type].name}报告`}>
|
||||||
|
移除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<p className="empty">暂无档案。生成第一份报告后会出现在这里。</p>
|
<p className="empty">暂无档案。生成第一份报告后会出现在这里。</p>
|
||||||
@ -397,8 +425,8 @@ function ImageReadingForm({
|
|||||||
busyText,
|
busyText,
|
||||||
ready,
|
ready,
|
||||||
error,
|
error,
|
||||||
|
quota,
|
||||||
extra,
|
extra,
|
||||||
stats,
|
|
||||||
onPickFile,
|
onPickFile,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: {
|
}: {
|
||||||
@ -407,8 +435,8 @@ function ImageReadingForm({
|
|||||||
busyText: string;
|
busyText: string;
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
error: string;
|
error: string;
|
||||||
|
quota: Quota | null;
|
||||||
extra: ReactNode;
|
extra: ReactNode;
|
||||||
stats: ReactNode;
|
|
||||||
onPickFile: (event: ChangeEvent<HTMLInputElement>) => void;
|
onPickFile: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
}) {
|
}) {
|
||||||
@ -433,8 +461,8 @@ function ImageReadingForm({
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
{extra}
|
{extra}
|
||||||
<button className="primary-action" disabled={!ready || Boolean(busyText)} onClick={onSubmit}>
|
<button className="primary-action" disabled={!ready || Boolean(busyText) || quota?.remaining === 0} onClick={onSubmit}>
|
||||||
{busyText || "提交分析"}
|
{busyText || (quota?.remaining === 0 ? "今日次数已用完" : "提交分析")}
|
||||||
</button>
|
</button>
|
||||||
{error ? <p className="error">{error}</p> : null}
|
{error ? <p className="error">{error}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
@ -445,7 +473,6 @@ function ImageReadingForm({
|
|||||||
<h3>提交后可离开等待</h3>
|
<h3>提交后可离开等待</h3>
|
||||||
<p>提交成功后,系统会在后台生成报告。你可以继续看档案,完成后自动刷新。</p>
|
<p>提交成功后,系统会在后台生成报告。你可以继续看档案,完成后自动刷新。</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mini-card">{stats}</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@ -457,7 +484,7 @@ function BaziFormView({
|
|||||||
busyText,
|
busyText,
|
||||||
ready,
|
ready,
|
||||||
error,
|
error,
|
||||||
stats,
|
quota,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: {
|
}: {
|
||||||
form: BaziForm;
|
form: BaziForm;
|
||||||
@ -465,7 +492,7 @@ function BaziFormView({
|
|||||||
busyText: string;
|
busyText: string;
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
error: string;
|
error: string;
|
||||||
stats: ReactNode;
|
quota: Quota | null;
|
||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@ -473,7 +500,7 @@ function BaziFormView({
|
|||||||
<div className="upload-card">
|
<div className="upload-card">
|
||||||
<div className="section-label">BAZI READING</div>
|
<div className="section-label">BAZI READING</div>
|
||||||
<h2>填写生辰信息</h2>
|
<h2>填写生辰信息</h2>
|
||||||
<p>第一版按标准专业排盘,不做真太阳时校正。出生地会用于报告语境,不用于经度校正。</p>
|
<p>输入出生日期与时间,赛博先生会先排出四柱,再把格局翻译成贴近日常的提醒。</p>
|
||||||
|
|
||||||
<div className="form-grid">
|
<div className="form-grid">
|
||||||
<label className="field">
|
<label className="field">
|
||||||
@ -520,8 +547,8 @@ function BaziFormView({
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button className="primary-action" disabled={!ready || Boolean(busyText)} onClick={onSubmit}>
|
<button className="primary-action" disabled={!ready || Boolean(busyText) || quota?.remaining === 0} onClick={onSubmit}>
|
||||||
{busyText || "提交排盘"}
|
{busyText || (quota?.remaining === 0 ? "今日次数已用完" : "提交排盘")}
|
||||||
</button>
|
</button>
|
||||||
{error ? <p className="error">{error}</p> : null}
|
{error ? <p className="error">{error}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
@ -532,17 +559,24 @@ function BaziFormView({
|
|||||||
<h3>先排盘,再解读</h3>
|
<h3>先排盘,再解读</h3>
|
||||||
<p>后端会先计算四柱、五行和十神线索,再让赛博先生生成更接地气的生活化报告。</p>
|
<p>后端会先计算四柱、五行和十神线索,再让赛博先生生成更接地气的生活化报告。</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mini-card">{stats}</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ReadingStats({ total, completed }: { total: number; completed: number }) {
|
function HomeStats({ total, completed, quota }: { total: number; completed: number; quota: Quota | null }) {
|
||||||
return (
|
return (
|
||||||
<div className="stats">
|
<div className="home-stats">
|
||||||
<Stat label="累计报告" value={total} />
|
<div>
|
||||||
<Stat label="已完成" value={completed} />
|
<p className="section-label">TODAY STATUS</p>
|
||||||
|
<h2>今日解读状态</h2>
|
||||||
|
</div>
|
||||||
|
<div className="stats">
|
||||||
|
<Stat label="今日剩余" value={quota?.remaining ?? 0} />
|
||||||
|
<Stat label="每日上限" value={quota?.limit ?? 5} />
|
||||||
|
<Stat label="累计报告" value={total} />
|
||||||
|
<Stat label="已完成" value={completed} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -557,6 +591,7 @@ function ReportPanel({ reading, onDelete }: { reading: Reading; onDelete: () =>
|
|||||||
<section className="report-panel">
|
<section className="report-panel">
|
||||||
<h3>{statusText[reading.status]}</h3>
|
<h3>{statusText[reading.status]}</h3>
|
||||||
<p>{reading.error_message || "先生正在整理报告。"}</p>
|
<p>{reading.error_message || "先生正在整理报告。"}</p>
|
||||||
|
<button className="delete-action" onClick={onDelete}>删除这份报告</button>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -584,6 +619,8 @@ function ReportPanel({ reading, onDelete }: { reading: Reading; onDelete: () =>
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{type === "bazi" ? <BaziChartPanel chart={getBaziChart(reading)} /> : null}
|
||||||
|
|
||||||
<div className="dimension-grid">
|
<div className="dimension-grid">
|
||||||
{data.dimensions.map((dimension, index) => (
|
{data.dimensions.map((dimension, index) => (
|
||||||
<DimensionCard key={`${dimension.name}-${index}`} dimension={dimension} index={index} />
|
<DimensionCard key={`${dimension.name}-${index}`} dimension={dimension} index={index} />
|
||||||
@ -602,6 +639,56 @@ function ReportPanel({ reading, onDelete }: { reading: Reading; onDelete: () =>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function BaziChartPanel({ chart }: { chart: BaziChart | null }) {
|
||||||
|
if (!chart) return null;
|
||||||
|
const pillars = chart.pillars || {};
|
||||||
|
const wuxing = chart.wuxing_balance || {};
|
||||||
|
const pillarItems = [
|
||||||
|
["年柱", pillars.year],
|
||||||
|
["月柱", pillars.month],
|
||||||
|
["日柱", pillars.day],
|
||||||
|
["时柱", pillars.time],
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bazi-chart">
|
||||||
|
<div className="bazi-chart-head">
|
||||||
|
<div>
|
||||||
|
<p className="section-label">CHART</p>
|
||||||
|
<h4>排盘核心</h4>
|
||||||
|
</div>
|
||||||
|
<span>{chart.time_unknown ? "时辰不详" : chart.birth_time || "已记录时辰"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="pillar-row">
|
||||||
|
{pillarItems.map(([label, value]) => (
|
||||||
|
<div className="pillar" key={label}>
|
||||||
|
<span>{label}</span>
|
||||||
|
<strong>{value || "-"}</strong>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="chart-facts">
|
||||||
|
<span>公历:{chart.solar_date || "-"}</span>
|
||||||
|
<span>农历:{chart.lunar_date || "-"}</span>
|
||||||
|
<span>日主:{chart.day_master || "-"}</span>
|
||||||
|
{chart.birth_place ? <span>出生地:{chart.birth_place}</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className="wuxing-bars">
|
||||||
|
{(["木", "火", "土", "金", "水"] as const).map((name) => {
|
||||||
|
const value = wuxing[name] || 0;
|
||||||
|
return (
|
||||||
|
<div className="wuxing-bar" key={name}>
|
||||||
|
<span>{name}</span>
|
||||||
|
<i style={{ width: `${Math.max(8, value * 14)}%` }} />
|
||||||
|
<em>{value}</em>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function DimensionCard({ dimension, index }: { dimension: Dimension; index: number }) {
|
function DimensionCard({ dimension, index }: { dimension: Dimension; index: number }) {
|
||||||
return (
|
return (
|
||||||
<article className="dimension-card">
|
<article className="dimension-card">
|
||||||
@ -621,6 +708,12 @@ function DimensionCard({ dimension, index }: { dimension: Dimension; index: numb
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getBaziChart(reading: Reading): BaziChart | null {
|
||||||
|
const chart = reading.input_data?.chart;
|
||||||
|
if (!chart || typeof chart !== "object" || Array.isArray(chart)) return null;
|
||||||
|
return chart as BaziChart;
|
||||||
|
}
|
||||||
|
|
||||||
function SummaryList({ title, items }: { title: string; items: string[] }) {
|
function SummaryList({ title, items }: { title: string; items: string[] }) {
|
||||||
return (
|
return (
|
||||||
<article className="summary-list">
|
<article className="summary-list">
|
||||||
|
|||||||
@ -48,6 +48,13 @@ export type ReadingSummary = {
|
|||||||
overall_summary?: string | null;
|
overall_summary?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Quota = {
|
||||||
|
limit: number;
|
||||||
|
used: number;
|
||||||
|
remaining: number;
|
||||||
|
reset_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type Report = Reading & { hand_side?: HandSide };
|
export type Report = Reading & { hand_side?: HandSide };
|
||||||
export type ReportSummary = ReadingSummary & { hand_side?: HandSide };
|
export type ReportSummary = ReadingSummary & { hand_side?: HandSide };
|
||||||
|
|
||||||
|
|||||||
7
web/public/icon.svg
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" rx="112" fill="#080d0c"/>
|
||||||
|
<circle cx="256" cy="256" r="168" fill="#101d19" stroke="#d5a855" stroke-width="14"/>
|
||||||
|
<circle cx="256" cy="256" r="112" fill="none" stroke="#36d8bd" stroke-opacity=".42" stroke-width="6"/>
|
||||||
|
<path d="M256 118v276M162 214h188M184 296h144M201 170c36 56 73 89 121 111" fill="none" stroke="#f5ecd8" stroke-width="22" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M350 156c-14 83-56 154-128 213" fill="none" stroke="#d5a855" stroke-width="18" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 610 B |
BIN
web/public/share-card.png
Normal file
|
After Width: | Height: | Size: 152 KiB |
25
web/public/share-card.svg
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="g1" cx="20%" cy="10%" r="80%">
|
||||||
|
<stop offset="0" stop-color="#2b2415"/>
|
||||||
|
<stop offset=".48" stop-color="#101d19"/>
|
||||||
|
<stop offset="1" stop-color="#080d0c"/>
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="g2" cx="78%" cy="25%" r="54%">
|
||||||
|
<stop offset="0" stop-color="#36d8bd" stop-opacity=".26"/>
|
||||||
|
<stop offset="1" stop-color="#36d8bd" stop-opacity="0"/>
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1200" height="630" fill="url(#g1)"/>
|
||||||
|
<rect width="1200" height="630" fill="url(#g2)"/>
|
||||||
|
<rect x="54" y="54" width="1092" height="522" rx="42" fill="none" stroke="#d5a855" stroke-opacity=".34" stroke-width="2"/>
|
||||||
|
<circle cx="910" cy="315" r="166" fill="none" stroke="#36d8bd" stroke-opacity=".38" stroke-width="4"/>
|
||||||
|
<circle cx="910" cy="315" r="108" fill="none" stroke="#d5a855" stroke-opacity=".42" stroke-width="4"/>
|
||||||
|
<path d="M910 158v314M804 262h212M830 354h166M850 214c48 74 91 112 150 143" fill="none" stroke="#f5ecd8" stroke-width="22" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M1010 208c-18 96-66 178-152 246" fill="none" stroke="#d5a855" stroke-width="17" stroke-linecap="round"/>
|
||||||
|
<text x="110" y="156" fill="#d5a855" font-family="Georgia, 'Songti SC', serif" font-size="28" font-weight="700" letter-spacing="5">CYBER MISTER</text>
|
||||||
|
<text x="108" y="270" fill="#f5ecd8" font-family="'Songti SC', 'Noto Serif SC', serif" font-size="86" font-weight="700">赛博先生</text>
|
||||||
|
<text x="112" y="356" fill="#f5ecd8" fill-opacity=".92" font-family="'Songti SC', 'Noto Serif SC', serif" font-size="42">AI 玄学档案</text>
|
||||||
|
<text x="112" y="430" fill="#c8b99a" font-family="'Songti SC', 'Noto Serif SC', serif" font-size="32">手相 · 面相 · 八字</text>
|
||||||
|
<text x="112" y="486" fill="#c8b99a" font-family="'Songti SC', 'Noto Serif SC', serif" font-size="28">把日常困惑,翻译成生活里的具体提醒</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |