feat: 小程序统一走 Go 后台,咨询与登录切到 /api/v1

去掉协会 WebView 和魔方账密页,微信登录与咨询接口共用同一 token 和拦截器。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-09-15 00:26:28 +08:00
co-authored by Cursor
parent 6e97f01378
commit b09d82df8d
40 changed files with 3852 additions and 285 deletions
+25
View File
@@ -47,6 +47,31 @@
<style>
/*每个页面公共css */
page {
--color-primary: #e54d42;
--color-primary-hover: #d44338;
--color-primary-soft: #ffe4e4;
--color-bg-start: #ffd1c7;
--color-bg-end: #ffc8b5;
--color-bg-sheet: #fff9f7;
--color-surface: #ffffff;
--color-text-primary: #333333;
--color-text-secondary: #999999;
--color-text-tertiary: #bbbbbb;
--color-border: #f0f0f0;
--color-accent-gold: #c8923a;
--color-accent-blue: #4a90e2;
--color-accent-green: #5cb85c;
--spacing-md: 16px;
--radius-lg: 16px;
--radius-xl: 20px;
--radius-sheet: 22px;
--shadow-card: 0 2px 10px rgba(0, 0, 0, 0.04);
--shadow-hero: 0 8px 28px rgba(229, 77, 66, 0.12);
--shadow-sheet: 0 -4px 24px rgba(180, 100, 80, 0.06);
--font-sans: SourceHanSans, -apple-system, sans-serif;
--font-display: GenYoMinJP, SourceHanSansBold, serif;
}
body{
font-family: 'SourceHanSans';
box-sizing: border-box;
+61
View File
@@ -0,0 +1,61 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
uni-app (Vue 3) WeChat Miniprogram for **愈心谷 (YuXinGu)**, a mental health and wellness platform. Built with Vite and targets `mp-weixin`. The `UI/` directory is a separate, incomplete React Native experiment — ignore it for miniprogram work.
## Build & Development
This project has **no npm scripts** in root `package.json`. It is designed for the HBuilderX IDE:
- **Dev build:** HBuilderX runs the uni-app Vite plugin automatically. Output goes to `unpackage/dist/dev/mp-weixin/`.
- **Manual build:** `npx vite build` (uses `@dcloudio/vite-plugin-uni` from `vite.config.js`)
- **WeChat DevTools:** Open `unpackage/dist/dev/mp-weixin/` to preview and debug.
Dependencies are minimal: `unplugin-auto-import`, `sass`, `sass-loader`.
## Architecture
### Routing & Navigation
- **Page routing** is defined in `pages.json`, NOT in Vue Router. All 26 routes are listed there.
- **Tab bar** (4 tabs): 首页 (`pages/index/index`), 心理咨询 (`pages/consult/consult`), 心理测评 (`pages/test-list/test-list`), 我的 (`pages/personal-center/personal-center`)
- **Global style** uses `navigationStyle: "custom"` — every page is responsible for its own navigation bar via the `<my-nav>` component.
- New pages must be registered in `pages.json` and use `<my-nav>` for the custom nav bar.
### Auto-Imports
`unplugin-auto-import` is configured in `vite.config.js` to auto-import Vue and uni-app APIs (`ref`, `reactive`, `computed`, `onLoad`, etc.) — do NOT manually import these in `.vue` files.
### HTTP Request Layer (`util/`)
- **`util/requestConfig.js`** exports a pre-configured `$http` instance (base URL: `https://miniapp.yuxingu.com.cn`). Always use this for API calls.
- **`util/apiUrl.js`** exports all API endpoint paths as a `urls` object. All miniprogram endpoints are prefixed `/app-api/psychic/`.
- **Auth:** The request interceptor reads `token` from `uni.getStorageSync('token')` and attaches it as `Authorization: Bearer <token>`.
- **Error handling:** 401/1001/1100 response codes trigger token removal and storage cleanup in `dataFactory`. Other errors auto-display toast messages when `isPrompt` is true.
- **Loading states:** Requests with `load: true` show/hide `uni.showLoading` via a request counter.
Usage pattern in pages:
```js
import $http from '@/util/requestConfig.js'
import { urls } from '@/util/apiUrl.js'
const res = await $http.get(urls.getDoctorInfo, { id: doctorId })
```
### Shared Components (`components/`)
- **`my-nav.vue`** — Custom navigation bar; used on every page since `navigationStyle: "custom"`.
- **`ShareMixin.ts`** — TypeScript mixin providing `onShareAppMessage` and `onShareTimeline` hooks.
- **`show-pop/`** — Reusable popup dialog.
- **`show-remind/`** — Reusable reminder/toast.
### Key Patterns
- **Fonts:** Three custom fonts are loaded globally in `App.vue` from CDN: `SourceHanSans`, `SourceHanSansBold`, `GenYoMinJP`. The default body font is `SourceHanSans`.
- **Share menu:** Enabled globally in `App.vue` for both `shareAppMessage` and `shareTimeline`.
- **Assets:** Static images are organized by feature in `static/`. Tab bar icons use the `n-menu/` subdirectory.
- **Platform config:** `manifest.json` contains WeChat appid (`wx6782dd88e655f1a7`) and per-platform settings for mp-weixin, mp-alipay, mp-baidu, mp-toutiao.
- **uni_modules/:** Standard uni-app plugins (uni-icons, uni-popup, etc.) — treat these as third-party code.
+111
View File
@@ -0,0 +1,111 @@
<template>
<view class="yxg-nav" :style="barStyle">
<view class="yxg-nav-row" :style="rowStyle">
<view v-if="showBack" class="yxg-nav-back" @click="onBack">
<text class="yxg-nav-back-ico"></text>
</view>
<image
v-if="logo"
class="yxg-nav-logo"
:src="logoSrc"
mode="aspectFit"
:style="logoStyle"
/>
<text v-else class="yxg-nav-title">{{ title }}</text>
</view>
</view>
<view v-if="placeholder" :style="barStyle" />
</template>
<script setup>
import { YXG_MP_LOGO_URL } from '@/util/yxgConfig.js'
import { yxgBack } from '@/util/yxgNav.js'
const props = defineProps({
title: { type: String, default: '' },
logo: { type: Boolean, default: false },
showBack: { type: Boolean, default: true },
placeholder: { type: Boolean, default: true },
})
function readWindow() {
// #ifdef MP-WEIXIN
if (typeof wx !== 'undefined' && wx.getWindowInfo) return wx.getWindowInfo()
// #endif
return uni.getSystemInfoSync()
}
const win = readWindow()
const statusBarH = win.statusBarHeight || 0
const screenW = win.windowWidth || win.screenWidth || 375
let navBarH = 44
let menuH = 32
try {
const menu = uni.getMenuButtonBoundingClientRect()
if (menu?.height) {
navBarH = (menu.top - statusBarH) * 2 + menu.height
menuH = menu.height
}
} catch (e) {
/* keep default */
}
const headH = statusBarH + navBarH
const logoH = Math.min(menuH, 30)
const logoW = Math.min(Math.round((logoH * 598) / 130), Math.floor(screenW * 0.4))
const barStyle = `height:${headH}px;padding-top:${statusBarH}px;box-sizing:border-box;`
const rowStyle = `height:${navBarH}px;`
const logoStyle = `width:${logoW}px;height:${logoH}px;`
const logoSrc = YXG_MP_LOGO_URL
function onBack() {
yxgBack()
}
</script>
<style scoped>
.yxg-nav {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
background: #ffd1c7;
}
.yxg-nav-row {
display: flex;
align-items: center;
justify-content: center;
position: relative;
width: 100%;
}
.yxg-nav-back {
position: absolute;
left: 8px;
top: 0;
bottom: 0;
width: 44px;
display: flex;
align-items: center;
justify-content: center;
}
.yxg-nav-back-ico {
font-size: 28px;
line-height: 1;
color: #614d49;
font-weight: 300;
}
.yxg-nav-title {
font-size: 16px;
font-weight: 700;
color: #333;
max-width: 56%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.yxg-nav-logo {
display: block;
}
</style>
+28
View File
@@ -0,0 +1,28 @@
<template>
<view class="yxg-wrap">
<yxg-nav :title="title" :show-back="showBack" />
<view class="yxg-body">
<slot />
</view>
</view>
</template>
<script setup>
import YxgNav from './yxg-nav.vue'
defineProps({
title: { type: String, default: '' },
showBack: { type: Boolean, default: true },
})
</script>
<style scoped>
.yxg-wrap {
min-height: 100vh;
background: linear-gradient(180deg, #ffd1c7 0%, #ffe4d8 28%, #fff4ef 52%, #fff9f7 100%);
padding-bottom: 32px;
}
.yxg-body {
padding-bottom: 24px;
}
</style>
+70
View File
@@ -0,0 +1,70 @@
<template>
<view class="hti" :class="'hti-' + name" :style="boxStyle">
<text class="hti-txt">{{ mark }}</text>
</view>
</template>
<script setup>
const props = defineProps({
name: { type: String, default: 'mbti' },
size: { type: Number, default: 48 },
})
const MARK = {
mbti: 'I/E',
star: '★',
portrait: '解',
rhythm: '律',
synastry: '合',
astro: '盘',
companion: '节',
ask: '问',
cards: '卡',
reports: '报',
growth: '长',
relation: '配',
nine: '9',
eq: '情',
stress: '压',
sleep: '眠',
}
const mark = computed(() => MARK[props.name] || '·')
const boxStyle = computed(() => {
const px = Math.round(props.size * 1.15)
return `width:${px}px;height:${px}px;`
})
</script>
<style scoped>
.hti {
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.12);
}
.hti-txt {
color: #fff;
font-weight: 800;
font-size: 15px;
letter-spacing: 0.5px;
}
.hti-mbti { background: linear-gradient(145deg, #ffe9a0, #f0b020); }
.hti-star { background: linear-gradient(145deg, #b8deff, #2f7fe0); }
.hti-portrait { background: linear-gradient(145deg, #ffe8b0, #d4922a); }
.hti-rhythm { background: linear-gradient(145deg, #c8f5d4, #2fa866); }
.hti-synastry { background: linear-gradient(145deg, #ffd0e0, #e84880); }
.hti-astro { background: linear-gradient(145deg, #e4d4ff, #7c5ce0); }
.hti-companion { background: linear-gradient(145deg, #ffe9a0, #f0b020); }
.hti-ask { background: linear-gradient(145deg, #ffe0b8, #e87830); }
.hti-cards { background: linear-gradient(145deg, #c8f5f0, #2a9d8f); }
.hti-reports { background: linear-gradient(145deg, #e8f4ff, #2f7fe0); }
.hti-growth { background: linear-gradient(145deg, #ffe0b8, #e87830); }
.hti-relation { background: linear-gradient(145deg, #ffd0e0, #e84880); }
.hti-nine { background: linear-gradient(145deg, #e4d4ff, #7c5ce0); }
.hti-eq { background: linear-gradient(145deg, #c8f5f0, #2a9d8f); }
.hti-stress { background: linear-gradient(145deg, #ffc8b8, #e54d42); }
.hti-sleep { background: linear-gradient(145deg, #b8deff, #2f7fe0); }
</style>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name" : "yuxingu-miniprogram",
"appid" : "__UNI__E47533F",
"appid" : "__UNI__29F79D0",
"description" : "",
"versionName" : "1.0.0",
"versionCode" : "100",
+21 -7
View File
@@ -265,14 +265,28 @@
]
},
{
"root": "pages/webview-page",
"root": "pages/yxg",
"pages": [
{
"path": "webview-page",
"style": {
"navigationBarTitleText": ""
}
}
{ "path": "explore", "style": { "navigationBarTitleText": "探索", "navigationBarBackgroundColor": "#ffd1c7", "backgroundColor": "#ffd1c7" } },
{ "path": "explore-bank", "style": { "navigationBarTitleText": "题库", "navigationBarBackgroundColor": "#ffd1c7", "backgroundColor": "#ffd1c7" } },
{ "path": "explore-category", "style": { "navigationBarTitleText": "探索", "navigationBarBackgroundColor": "#ffd1c7", "backgroundColor": "#ffd1c7" } },
{ "path": "growth-plan", "style": { "navigationBarTitleText": "成长计划", "navigationBarBackgroundColor": "#ffd1c7", "backgroundColor": "#ffd1c7" } },
{ "path": "ask", "style": { "navigationBarTitleText": "AI问答", "navigationBarBackgroundColor": "#ffd1c7", "backgroundColor": "#ffd1c7" } },
{ "path": "companion", "style": { "navigationBarTitleText": "节气陪伴", "navigationBarBackgroundColor": "#ffd1c7", "backgroundColor": "#ffd1c7" } },
{ "path": "mine", "style": { "navigationBarTitleText": "我的魔方", "navigationBarBackgroundColor": "#ffd1c7", "backgroundColor": "#ffd1c7" } },
{ "path": "profile", "style": { "navigationBarTitleText": "个人档案", "navigationBarBackgroundColor": "#ffd1c7", "backgroundColor": "#ffd1c7" } },
{ "path": "portrait", "style": { "navigationBarTitleText": "愈心解码", "navigationBarBackgroundColor": "#ffd1c7", "backgroundColor": "#ffd1c7" } },
{ "path": "star", "style": { "navigationBarTitleText": "星座", "navigationBarBackgroundColor": "#ffd1c7", "backgroundColor": "#ffd1c7" } },
{ "path": "synastry", "style": { "navigationBarTitleText": "合盘", "navigationBarBackgroundColor": "#ffd1c7", "backgroundColor": "#ffd1c7" } },
{ "path": "synastry-invite", "style": { "navigationBarTitleText": "合盘邀请", "navigationBarBackgroundColor": "#ffd1c7", "backgroundColor": "#ffd1c7" } },
{ "path": "rhythm", "style": { "navigationBarTitleText": "身心节律", "navigationBarBackgroundColor": "#ffd1c7", "backgroundColor": "#ffd1c7" } },
{ "path": "cards", "style": { "navigationBarTitleText": "意象卡片", "navigationBarBackgroundColor": "#ffd1c7", "backgroundColor": "#ffd1c7" } },
{ "path": "relation", "style": { "navigationBarTitleText": "人格匹配", "navigationBarBackgroundColor": "#ffd1c7", "backgroundColor": "#ffd1c7" } },
{ "path": "membership", "style": { "navigationBarTitleText": "成长会员", "navigationBarBackgroundColor": "#ffd1c7", "backgroundColor": "#ffd1c7" } },
{ "path": "scale", "style": { "navigationBarTitleText": "测评", "navigationBarBackgroundColor": "#ffd1c7", "backgroundColor": "#ffd1c7" } },
{ "path": "share", "style": { "navigationBarTitleText": "分享", "navigationBarBackgroundColor": "#ffd1c7", "backgroundColor": "#ffd1c7" } },
{ "path": "reports", "style": { "navigationBarTitleText": "成长报告", "navigationBarBackgroundColor": "#ffd1c7", "backgroundColor": "#ffd1c7" } },
{ "path": "report", "style": { "navigationBarTitleText": "报告详情", "navigationBarBackgroundColor": "#ffd1c7", "backgroundColor": "#ffd1c7" } }
]
}
],
-13
View File
@@ -495,7 +495,6 @@
</text>
</view>
</view>
<web-view v-if="showWebView" :src="externalUrl"></web-view>
</view>
</template>
@@ -511,8 +510,6 @@
const consultList = ref([])
const newHealingList = ref([])
const newActivityList = ref([])
const showWebView = ref(false)
const externalUrl = ref()
const dotsStyles = ref({
backgroundColor: 'rgba(96, 98, 113, 1)',
border: '1px rgba(96, 98, 113, 1) solid',
@@ -596,16 +593,6 @@
})
}
const toTuTest=()=>{
const externalUrl = 'https://cps.jsbr.org.cn/login.html';
uni.navigateTo({
url: `/pages/webview-page/webview-page?url=${encodeURIComponent(externalUrl)}`
});
}
const toHealingDetail=(item)=>{
dohttp.navigateTo("/pages/healing-detail/healing-detail?id=" + item.id)
}
+35 -31
View File
@@ -154,25 +154,41 @@
}
}
const afterLogin = () => {
if (type.value == 1) {
uni.reLaunch({
url: "/pages/index/index"
})
} else if (type.value == 3) {
uni.reLaunch({
url: path.value
})
} else {
uni.navigateBack({
delta: 1
})
}
}
const toLogin = (code, encryptedData, iv) => {
const params = {
grantType: "mini_app",
account: code,
}
let others = {
encryptedData: encryptedData,
iv: iv,
code: code
}
params.others = JSON.stringify(others)
dohttp.post(urls.loginUrl, params).then((result) => {
userInfo.value.token = result.accessToken
uni.setStorageSync('token', result.accessToken)
dohttp.post(urls.loginUrl, { code, encryptedData, iv }).then((result) => {
const token = result.token || result.accessToken
userInfo.value.token = token
uni.setStorageSync('token', token)
uni.removeStorageSync('yxg_token')
if (result.user) {
uni.setStorageSync('userinfo', {
id: result.user.id,
avatarUrl: result.user.avatar_url,
nickName: result.user.nickname
})
}
loginPop.value.close()
getUserInfo()
}).catch((e) => {
uni.showToast({
title: (e && e.errMsg ? String(e.errMsg).replace('【request】', '') : '登录失败'),
icon: 'none'
})
})
}
const getUserInfo = () => {
@@ -182,23 +198,11 @@
avatarUrl: result.avatarUrl,
nickName: result.nickName
}
uni.setStorageSync('userinfo', userinfo)
if (type.value == 1) {
uni.reLaunch({
url: "/pages/index/index"
})
} else if (type.value == 3) {
uni.reLaunch({
url: path.value
})
} else {
uni.navigateBack({
delta: 1
})
}
afterLogin()
}).catch(() => {
afterLogin()
})
}
-13
View File
@@ -1,13 +0,0 @@
<template>
<web-view :src="url"></web-view>
</template>
<script setup>
const url = ref('')
onLoad((options) => {
if (options.url) {
url.value = decodeURIComponent(options.url)
}
})
</script>
+383 -107
View File
@@ -1,140 +1,416 @@
<template>
<!-- 顶栏在 web-view 外层页面级H5 只在下方 web-view -->
<view class="yxg-page">
<view class="yxg-nav-bar" :style="navBarStyle">
<view class="yxg-nav-row" :style="navRowStyle">
<image
class="yxg-nav-logo"
:src="YXG_MP_LOGO_URL"
mode="aspectFit"
:style="logoStyle"
/>
<view class="yxg-home">
<yxg-nav :logo="true" :show-back="false" />
<view class="atm a1" />
<view class="atm a2" />
<view class="atm a3" />
<view class="archive">
<view class="av-self" @click="yxgGo('/profile')">
<view class="av">
<image class="av-img" :src="avatarSrc" mode="aspectFill" />
</view>
<text class="av-name">{{ selfLabel }}</text>
</view>
<view class="av-add" @click="yxgGo('/profile', { add: '1' })">
<text class="plus-dot">+</text>
<text class="av-name">添加</text>
</view>
<view class="archive-tail" @click="yxgGo('/mine')">
<text class="chev">我的 </text>
</view>
</view>
<view class="self-card">
<view class="self-head">
<view class="who-btn" @click="yxgGo('/profile')">
<text class="who-name">{{ profileLabel }}</text>
<text class="caret"></text>
</view>
<text class="who-day">{{ dayLabel }}</text>
<text class="more" @click="yxgGo('/profile')">更多 </text>
</view>
<view v-if="tipsLoading" class="tips-loading">
<text>正在根据生日与此刻节律整理</text>
</view>
<view v-else class="self-body">
<view class="duo">
<view class="wear-panel">
<text class="wear-title">穿衣指数</text>
<view class="wear-ring">
<text class="ring-num">{{ tips.clothingIndex }}</text>
</view>
<text class="wear-text">{{ tips.clothing }}</text>
</view>
<view class="block color-block">
<text class="block-title">颜色搭配</text>
<view class="swatches">
<view v-for="c in tips.palette" :key="c.name" class="swatch">
<view class="chip" :style="{ background: c.hex }" />
<text class="chip-name">{{ c.name }}</text>
</view>
</view>
<text class="block-note">{{ tips.colorNote }}</text>
</view>
</view>
<view class="block wellness-block">
<text class="block-title">养生推荐</text>
<text class="wellness">{{ tips.wellness }}</text>
</view>
<text v-if="tips.needBirth" class="birth-hint">完善生日档案后会按你的生日与此刻节律个性化</text>
</view>
</view>
<view class="sheet">
<view class="tool-grid">
<view
v-for="t in tools"
:key="t.to + t.label"
class="tool-item"
@click="yxgGo(t.to)"
>
<yxg-tool-icon :name="t.icon" :size="44" />
<text class="label">{{ t.label }}</text>
<text v-if="t.badge" class="badge" :class="'b-' + (t.badgeTone || 'hot')">{{ t.badge }}</text>
</view>
</view>
<view class="promo-pair">
<view class="promo p-plaza" @click="yxgGo('/explore')">
<text class="p-title">探索广场</text>
<text class="p-sub">测评 · 工具 · 自我理解</text>
<text class="p-chip"></text>
</view>
<view class="promo p-vip" @click="yxgGo('/membership')">
<text class="p-title">成长会员</text>
<text class="p-sub">深度报告与全年陪伴</text>
<text class="p-chip soft"></text>
</view>
</view>
<text class="ai-note">部分内容由 AI 生成仅供参考</text>
<view v-if="feedsVisible" class="feed-sec">
<view class="sec-head">
<text class="title">今日推荐</text>
<text class="more" @click="yxgGo('/explore')">更多 </text>
</view>
<view class="feed-grid">
<view
v-for="f in feeds"
:key="f.to + f.title"
class="feed-card"
:class="f.tone"
@click="yxgGo(f.to)"
>
<text v-if="f.tag" class="feed-tag">{{ f.tag }}</text>
<view class="feed-cover">
<yxg-tool-icon :name="f.icon" :size="40" />
</view>
<view class="feed-body">
<text class="name">{{ f.title }}</text>
<text class="meta">{{ f.meta }}</text>
<view class="stat-row">
<text class="stat">{{ f.stat }}</text>
<text class="go"></text>
</view>
</view>
</view>
</view>
</view>
</view>
<web-view
class="yxg-webview"
:style="webviewStyle"
:src="h5Url"
@load="onWebLoad"
@error="onWebError"
/>
</view>
</template>
<script setup>
import { computed } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { YXG_H5_BASE, YXG_H5_VERSION, YXG_MP_LOGO_URL } from '@/util/yxgConfig.js'
import YxgNav from '@/components/yxg/yxg-nav.vue'
import YxgToolIcon from '@/components/yxg/yxg-tool-icon.vue'
import { yxgApi, assetURL } from '@/util/yxgApi.js'
import { setAccountNickname } from '@/util/yxgAuth.js'
import {
homeFeeds,
homeGridRow1,
homeGridRow2,
mapHomeBannersToFeeds,
mapHomeTools,
} from '@/util/yxgCatalog.js'
import { YXG_DEFAULT_AVATAR } from '@/util/yxgConfig.js'
import { yxgGo } from '@/util/yxgNav.js'
import { localFallbackTips, tipsFromApi } from '@/util/yxgTips.js'
function clearNavTitle() {
// #ifdef MP-WEIXIN
uni.setNavigationBarTitle({ title: '\u200b' })
// #endif
const profileLabel = ref('访客')
const avatarUrl = ref('')
const tips = ref(localFallbackTips())
const tipsLoading = ref(true)
const gridRow1 = ref([...homeGridRow1])
const gridRow2 = ref([...homeGridRow2])
const feeds = ref([...homeFeeds])
const feedsVisible = ref(true)
const tools = computed(() => [...gridRow1.value, ...gridRow2.value])
const selfLabel = computed(() => {
const n = (profileLabel.value || '').trim()
if (!n) return '访客'
return n.length > 4 ? `${n.slice(0, 4)}` : n
})
const avatarSrc = computed(() => assetURL(avatarUrl.value) || YXG_DEFAULT_AVATAR)
const dayLabel = computed(() => {
if (tipsLoading.value || !tips.value.asOf) return ''
return `今日 ${tips.value.asOf.slice(5)}`
})
function loadHome() {
yxgApi
.authMe()
.then((me) => {
if (me.nickname) {
profileLabel.value = me.nickname
setAccountNickname(me.nickname)
}
avatarUrl.value = me.avatar_url || ''
})
.catch(() => {
profileLabel.value = '访客'
avatarUrl.value = ''
})
yxgApi
.getHomeDailyTips()
.then((t) => {
tips.value = tipsFromApi(t)
})
.catch(() => {
tips.value = localFallbackTips()
})
.finally(() => {
tipsLoading.value = false
})
yxgApi
.getHomeTools()
.then((res) => {
const mapped = mapHomeTools(res.items || [])
if (mapped.row1.length || mapped.row2.length) {
gridRow1.value = mapped.row1
gridRow2.value = mapped.row2
}
})
.catch(() => {})
yxgApi
.getHomeBanners('home')
.then((res) => {
const mapped = mapHomeBannersToFeeds(res.items || [])
if (mapped.length) feeds.value = mapped
})
.catch(() => {})
yxgApi
.getHomeFeedSlots('home')
.then((res) => {
feedsVisible.value = (res.items || []).length > 0
})
.catch(() => {
feedsVisible.value = true
})
}
onShow(() => {
clearNavTitle()
loadHome()
})
clearNavTitle()
function readWindow() {
// #ifdef MP-WEIXIN
if (typeof wx !== 'undefined' && wx.getWindowInfo) {
return wx.getWindowInfo()
}
// #endif
return uni.getSystemInfoSync()
}
const win = readWindow()
const statusBarH = win.statusBarHeight || 0
const windowH = win.windowHeight || win.screenHeight || 667
const screenW = win.windowWidth || win.screenWidth || 375
let navBarH = 44
let menuH = 32
// #ifdef MP-WEIXIN
try {
const menu = uni.getMenuButtonBoundingClientRect()
if (menu?.height) {
navBarH = (menu.top - statusBarH) * 2 + menu.height
menuH = menu.height
}
} catch (e) {
console.warn('[yxg-magic] menuButton rect failed', e)
}
// #endif
const headH = statusBarH + navBarH
const webviewH = windowH - headH
const logoH = Math.min(menuH, 30)
const logoW = Math.min(Math.round((logoH * 598) / 130), Math.floor(screenW * 0.4))
const navBarStyle = `height:${headH}px;padding-top:${statusBarH}px;box-sizing:border-box;`
const navRowStyle = `height:${navBarH}px;`
const logoStyle = `width:${logoW}px;height:${logoH}px;`
const webviewStyle = `top:${headH}px;height:${webviewH}px;`
const h5Url = computed(
() =>
`${YXG_H5_BASE}?mp=1&nh=1&sbh=4&v=${YXG_H5_VERSION}#wechat_redirect`,
)
function onWebLoad() {
// web-view loaded
}
function onWebError(e) {
console.error('[yxg-magic] web-view error', e?.detail || e)
uni.showToast({
title: 'H5加载失败,请检查业务域名',
icon: 'none',
duration: 3000,
})
}
</script>
<style>
page {
background-color: #ffd1c7;
height: 100%;
}
</style>
<style scoped>
.yxg-page {
.yxg-home {
min-height: 100vh;
background: linear-gradient(180deg, #ffd1c7 0%, #ffe4d8 28%, #fff4ef 52%, #fff9f7 100%);
padding-bottom: 40px;
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background-color: #ffd1c7;
overflow-x: hidden;
}
.atm {
position: absolute;
pointer-events: none;
border-radius: 50%;
z-index: 0;
}
.a1 { width: 160px; height: 160px; top: 88px; right: -40px; background: rgba(255, 255, 255, 0.5); }
.a2 { width: 100px; height: 100px; top: 148px; left: -28px; background: rgba(255, 200, 180, 0.45); }
.a3 { width: 70px; height: 70px; top: 220px; right: 40px; background: rgba(255, 230, 210, 0.5); }
.yxg-nav-bar {
.archive {
margin: 12px 16px 0;
position: relative;
z-index: 2;
flex-shrink: 0;
width: 100%;
box-sizing: border-box;
background-color: #ffd1c7;
}
.yxg-nav-row {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
gap: 14px;
padding: 10px 12px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 6px 20px rgba(229, 77, 66, 0.08);
}
.yxg-nav-logo {
display: block;
.av-self, .av-add {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.av {
width: 40px;
height: 40px;
border-radius: 50%;
overflow: hidden;
background: linear-gradient(145deg, #ffe8e0, #ffd0c4);
border: 2px solid rgba(229, 77, 66, 0.35);
}
.av-img { width: 40px; height: 40px; }
.av-name {
font-size: 11px;
font-weight: 600;
color: #8a4a3a;
max-width: 3.2em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.plus-dot {
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(255, 236, 230, 0.95);
border: 1.5px dashed rgba(229, 77, 66, 0.35);
color: #e54d42;
font-size: 22px;
line-height: 38px;
text-align: center;
}
.archive-tail { margin-left: auto; padding: 8px 4px; }
.chev { font-size: 13px; color: #c4a090; }
.yxg-webview {
position: absolute;
left: 0;
width: 100%;
z-index: 1;
.self-card {
margin: 12px 16px 0;
position: relative;
z-index: 2;
padding: 14px;
background: #fff;
border-radius: 20px;
box-shadow: 0 8px 28px rgba(229, 77, 66, 0.12);
}
.self-head {
display: flex;
align-items: center;
margin-bottom: 12px;
}
.who-btn { display: flex; align-items: center; gap: 4px; min-width: 0; }
.who-name { font-size: 16px; font-weight: 700; color: #333; max-width: 7em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.caret { font-size: 10px; color: #bbb; }
.who-day { flex: 1; text-align: center; font-size: 11px; color: #bbb; }
.more { font-size: 12px; color: #bbb; }
.tips-loading { padding: 20px 8px; text-align: center; font-size: 12px; color: #bbb; }
.self-body { display: flex; flex-direction: column; gap: 10px; }
.duo { display: flex; gap: 10px; }
.wear-panel, .block {
flex: 1;
min-width: 0;
padding: 12px 10px;
border-radius: 16px;
}
.wear-panel { background: linear-gradient(160deg, #fff8f4 0%, #ffe8e0 55%, #fff5f0 100%); display: flex; flex-direction: column; align-items: center; gap: 8px; }
.wear-title, .block-title { font-size: 12px; font-weight: 700; color: #333; align-self: flex-start; }
.wear-ring {
width: 64px; height: 64px; border-radius: 50%;
border: 6px solid rgba(229, 77, 66, 0.12);
display: flex; align-items: center; justify-content: center;
}
.ring-num { font-size: 20px; font-weight: 700; color: #e54d42; }
.wear-text, .block-note { font-size: 11px; color: #7a6f6a; line-height: 1.45; text-align: center; }
.color-block { background: linear-gradient(160deg, #f7f9fc 0%, #eef4fa 100%); }
.wellness-block { background: linear-gradient(160deg, #f5faf6 0%, #eaf5ee 100%); }
.swatches { display: flex; justify-content: center; gap: 10px; margin: 8px 0; }
.swatch { display: flex; flex-direction: column; align-items: center; gap: 5px; }
.chip { width: 36px; height: 36px; border-radius: 11px; }
.chip-name { font-size: 10px; font-weight: 600; color: #999; }
.wellness { font-size: 13px; color: #333; font-weight: 500; margin-top: 8px; display: block; }
.birth-hint { font-size: 11px; color: #bbb; text-align: center; }
.sheet {
margin-top: 14px;
background: #fff;
border-radius: 22px 22px 0 0;
padding: 8px 0 28px;
box-shadow: 0 -4px 24px rgba(180, 100, 80, 0.06);
position: relative;
z-index: 3;
}
.tool-grid {
display: flex;
flex-wrap: wrap;
padding: 8px 8px 2px;
}
.tool-item {
width: 25%;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 8px 0 10px;
position: relative;
}
.label { font-size: 11px; color: #555; }
.badge {
position: absolute; top: 2px; right: 10px;
font-size: 9px; color: #fff; padding: 2px 5px; border-radius: 7px;
}
.b-hot { background: linear-gradient(135deg, #ff7a6e, #e54d42); }
.b-new { background: linear-gradient(135deg, #6eb6ff, #4a90e2); }
.promo-pair { display: flex; gap: 10px; padding: 14px 16px 0; }
.promo {
flex: 1; position: relative; border-radius: 16px; padding: 16px 14px; min-height: 96px;
display: flex; flex-direction: column; justify-content: center;
}
.p-plaza { background: linear-gradient(145deg, #ffe9e2, #ffc8ba); }
.p-vip { background: linear-gradient(145deg, #fff4e0, #ffd98a); }
.p-title { font-size: 15px; font-weight: 700; color: #333; }
.p-sub { font-size: 11px; color: rgba(0,0,0,.42); margin-top: 5px; }
.p-chip {
position: absolute; top: 10px; right: 10px;
font-size: 9px; font-weight: 700; color: #fff;
background: linear-gradient(135deg, #ff7a6e, #e54d42);
padding: 2px 6px; border-radius: 6px;
}
.p-chip.soft { background: linear-gradient(135deg, #6eb6ff, #4a90e2); }
.ai-note { display: block; margin-top: 10px; font-size: 10px; color: #ccc; text-align: center; }
.feed-sec { padding: 18px 16px 0; }
.sec-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 12px; }
.sec-head .title { font-size: 17px; font-weight: 700; color: #333; }
.feed-grid { display: flex; flex-wrap: wrap; gap: 10px; }
.feed-card {
width: calc(50% - 5px);
border-radius: 16px;
overflow: hidden;
background: #fff;
box-shadow: 0 2px 10px rgba(0,0,0,.04);
position: relative;
}
.feed-cover { height: 86px; display: flex; align-items: center; justify-content: center; }
.fc-a .feed-cover { background: linear-gradient(155deg, #ffd8d0, #ffb4a8); }
.fc-b .feed-cover { background: linear-gradient(155deg, #e8dff8, #cbb8f0); }
.fc-c .feed-cover { background: linear-gradient(155deg, #d8eaff, #b4d2f5); }
.fc-e .feed-cover { background: linear-gradient(155deg, #ffe9cc, #f5cc8a); }
.feed-body { padding: 10px 11px 12px; display: flex; flex-direction: column; gap: 4px; }
.name { font-size: 13px; font-weight: 700; color: #333; }
.meta { font-size: 11px; color: #999; line-height: 1.4; }
.stat-row { display: flex; justify-content: space-between; margin-top: 4px; }
.stat { font-size: 10px; color: #c4c4c4; }
.go { font-size: 12px; color: #e54d42; }
.feed-tag {
position: absolute; top: 8px; left: 8px; z-index: 1;
font-size: 10px; color: #fff; background: #e54d42;
padding: 2px 7px; border-radius: 7px;
}
</style>
+155
View File
@@ -0,0 +1,155 @@
<template>
<yxg-page title="AI问答">
<text v-if="bootLoading" class="hint">加载中</text>
<text v-else-if="bootError" class="err">{{ bootError }}</text>
<template v-else>
<view class="quota">
<text>今日剩余 {{ quota?.left ?? quota?.remaining ?? '—' }} </text>
<text class="link" @click="yxgGo('/membership')">加购 </text>
</view>
<scroll-view v-if="profiles.length > 1" class="chips" scroll-x>
<view
v-for="p in profiles"
:key="p.id"
class="chip"
:class="{ on: profileId === p.id }"
@click="profileId = p.id"
>
<text>{{ p.display_name || (p.relation === 'self' ? '我' : 'TA') }}</text>
</view>
</scroll-view>
<view v-if="!messages.length" class="scenes">
<view v-for="s in scenes" :key="s.key" class="scene" @click="useScene(s)">
<text>{{ s.label }}</text>
</view>
</view>
<view class="msgs">
<view v-for="m in messages" :key="m.id" class="msg" :class="m.role">
<text>{{ m.content }}</text>
</view>
</view>
<text v-if="sendError" class="err">{{ sendError }}</text>
<view class="composer">
<input class="inp" v-model="draft" placeholder="结合档案聊聊卡住的事…" confirm-type="send" @confirm="send" />
<view class="send" :class="{ off: sending || !draft.trim() }" @click="send"><text>发送</text></view>
</view>
</template>
</yxg-page>
</template>
<script setup>
import YxgPage from '@/components/yxg/yxg-page.vue'
import { yxgApi } from '@/util/yxgApi.js'
import { ensureAccount, pickableArchiveList } from '@/util/yxgAuth.js'
import { yxgGo } from '@/util/yxgNav.js'
const scenes = [
{ key: 'self', label: '我想更了解自己的性格与互动风格', prompt: '我想更了解自己的性格与互动风格。' },
{ key: 'relation', label: '和重要的人相处时该如何沟通?', prompt: '和重要的人相处时,我该如何更好地沟通?' },
{ key: 'emotion', label: '最近情绪有点乱,想梳理一下', prompt: '最近情绪有点乱,我想梳理一下。' },
{ key: 'career', label: '职业选择上可以注意什么?', prompt: '从我的特点看,职业选择上可以注意什么?' },
]
const bootLoading = ref(true)
const bootError = ref('')
const profiles = ref([])
const profileId = ref('')
const threadId = ref('')
const messages = ref([])
const draft = ref('')
const sending = ref(false)
const sendError = ref('')
const quota = ref(null)
async function boot() {
bootLoading.value = true
bootError.value = ''
if (!(await ensureAccount('/ask'))) {
bootLoading.value = false
return
}
try {
const [list, q] = await Promise.all([yxgApi.listProfiles(), yxgApi.getAskQuota()])
profiles.value = pickableArchiveList(list.items || [])
quota.value = q
const self = profiles.value.find((p) => p.relation === 'self')
profileId.value = self?.id || profiles.value[0]?.id || ''
} catch (e) {
bootError.value = e instanceof Error ? e.message : '加载失败'
} finally {
bootLoading.value = false
}
}
async function ensureThread() {
if (threadId.value) return threadId.value
if (!profileId.value) {
sendError.value = '请先完善档案'
yxgGo('/profile')
throw new Error('no profile')
}
const th = await yxgApi.createAskThread({ profile_id: profileId.value })
threadId.value = th.id || th.thread_id
return threadId.value
}
async function send() {
const text = draft.value.trim()
if (!text || sending.value) return
sending.value = true
sendError.value = ''
try {
const id = await ensureThread()
const out = await yxgApi.sendAskMessage(id, text)
draft.value = ''
if (out.user_message) messages.value.push(out.user_message)
if (out.assistant_message) messages.value.push(out.assistant_message)
if (out.quota) quota.value = out.quota
} catch (e) {
sendError.value = e instanceof Error ? e.message : '发送失败'
} finally {
sending.value = false
}
}
function useScene(s) {
draft.value = s.prompt
send()
}
onShow(boot)
</script>
<style>
page { background-color: #ffd1c7; }
</style>
<style scoped>
.hint, .err { display: block; padding: 16px; font-size: 13px; color: #999; }
.err { color: #e54d42; }
.quota {
margin: 8px 16px; padding: 10px 12px; background: #fff; border-radius: 12px;
display: flex; justify-content: space-between; font-size: 13px; color: #666;
}
.link { color: #e54d42; }
.chips { white-space: nowrap; padding: 0 16px 8px; }
.chip {
display: inline-block; margin-right: 8px; padding: 6px 12px; border-radius: 999px;
background: #fff; font-size: 13px; color: #666;
}
.chip.on { background: #ffe4e4; color: #e54d42; }
.scenes { padding: 0 16px; }
.scene {
background: #fff; border-radius: 14px; padding: 12px 14px; margin-bottom: 8px; font-size: 14px; color: #333;
}
.msgs { padding: 8px 16px 80px; }
.msg { padding: 10px 12px; border-radius: 14px; margin-bottom: 8px; font-size: 14px; line-height: 1.55; max-width: 88%; }
.msg.user, .msg.human { margin-left: auto; background: #e54d42; color: #fff; }
.msg.assistant, .msg.ai { background: #fff; color: #333; }
.composer {
position: fixed; left: 0; right: 0; bottom: 0; padding: 10px 12px 24px;
background: #fff9f7; display: flex; gap: 8px; align-items: center;
}
.inp { flex: 1; height: 40px; background: #fff; border-radius: 20px; padding: 0 14px; font-size: 14px; }
.send { padding: 0 14px; height: 40px; border-radius: 20px; background: #e54d42; color: #fff; display: flex; align-items: center; font-size: 14px; }
.send.off { opacity: 0.45; }
</style>
+104
View File
@@ -0,0 +1,104 @@
<template>
<yxg-page title="意象卡片">
<text v-if="loading" class="hint">加载中</text>
<text v-else-if="error" class="err">{{ error }}</text>
<template v-else>
<view class="quota"><text>今日剩余 {{ quota?.left ?? quota?.remaining ?? '—' }} </text></view>
<view class="scenes">
<view
v-for="s in scenes"
:key="s.key || s.code || s.id"
class="scene"
:class="{ on: scene === (s.key || s.code || s.id) }"
@click="scene = s.key || s.code || s.id"
>
<text>{{ s.title || s.name || s.label }}</text>
</view>
</view>
<view class="btn" :class="{ off: drawing }" @click="draw">
<text>{{ drawing ? '抽取中…' : '抽取一张卡片' }}</text>
</view>
<view v-if="drawn" class="card">
<image v-if="drawn.image_url" class="img" :src="assetURL(drawn.image_url)" mode="aspectFill" />
<text class="h1">{{ drawn.title || drawn.name || '意象卡片' }}</text>
<text class="body">{{ drawn.meaning || drawn.interpretation || drawn.text || '' }}</text>
</view>
</template>
</yxg-page>
</template>
<script setup>
import YxgPage from '@/components/yxg/yxg-page.vue'
import { assetURL, yxgApi } from '@/util/yxgApi.js'
import { ensureAccount, findSelfProfile } from '@/util/yxgAuth.js'
import { yxgGo } from '@/util/yxgNav.js'
const loading = ref(true)
const drawing = ref(false)
const error = ref('')
const scenes = ref([])
const scene = ref('')
const quota = ref(null)
const drawn = ref(null)
async function load() {
loading.value = true
error.value = ''
if (!(await ensureAccount('/cards'))) {
loading.value = false
return
}
try {
const [s, q] = await Promise.all([yxgApi.listImageCardScenes(), yxgApi.getImageCardQuota()])
scenes.value = s.items || []
quota.value = q
scene.value = scenes.value[0]?.key || scenes.value[0]?.code || scenes.value[0]?.id || ''
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function draw() {
if (drawing.value) return
drawing.value = true
error.value = ''
try {
const self = await findSelfProfile()
if (!self) {
yxgGo('/profile')
return
}
drawn.value = await yxgApi.drawImageCard({ profile_id: self.id, scene: scene.value })
quota.value = await yxgApi.getImageCardQuota().catch(() => quota.value)
} catch (e) {
error.value = e instanceof Error ? e.message : '抽取失败'
} finally {
drawing.value = false
}
}
onShow(load)
</script>
<style>
page { background-color: #ffd1c7; }
</style>
<style scoped>
.hint, .err { display: block; padding: 12px 16px; font-size: 13px; color: #999; }
.err { color: #e54d42; }
.quota { margin: 8px 16px; padding: 10px 12px; background: #fff; border-radius: 12px; font-size: 13px; color: #666; }
.scenes { display: flex; flex-wrap: wrap; gap: 8px; padding: 8px 16px; }
.scene { padding: 8px 12px; border-radius: 999px; background: #fff; font-size: 13px; color: #666; }
.scene.on { background: #ffe4e4; color: #e54d42; }
.btn {
margin: 8px 16px; height: 44px; border-radius: 999px; background: #e54d42; color: #fff;
display: flex; align-items: center; justify-content: center; font-weight: 700;
}
.btn.off { opacity: 0.5; }
.card { margin: 12px 16px; padding: 16px; background: #fff; border-radius: 18px; }
.img { width: 100%; height: 200px; border-radius: 12px; margin-bottom: 12px; }
.h1 { display: block; font-size: 18px; font-weight: 700; color: #333; }
.body { display: block; margin-top: 8px; font-size: 14px; color: #555; line-height: 1.65; }
</style>
+102
View File
@@ -0,0 +1,102 @@
<template>
<yxg-page title="节气陪伴">
<text v-if="loading" class="hint">加载中</text>
<text v-else-if="error" class="err">{{ error }}</text>
<view v-else class="card">
<text class="term">{{ today.name || today.solar_term || '今日节气' }}</text>
<text class="date">{{ today.date || today.as_of || '' }}</text>
<text class="body">{{ today.guidance || today.copy || today.description || '此刻宜慢一点,给身心留一点空间。' }}</text>
</view>
<view class="card">
<text class="h2">今日心情</text>
<view class="scores">
<view v-for="n in 5" :key="n" class="score" :class="{ on: mood === n }" @click="mood = n">
<text>{{ n }}</text>
</view>
</view>
<input class="inp" v-model="note" placeholder="想记一笔也可以(可选)" />
<view class="btn" :class="{ off: saving }" @click="save"><text>{{ saving ? '保存中…' : '记录心情' }}</text></view>
<text v-if="saved" class="ok">已记下今天的心情</text>
</view>
</yxg-page>
</template>
<script setup>
import YxgPage from '@/components/yxg/yxg-page.vue'
import { yxgApi } from '@/util/yxgApi.js'
import { ensureAccount } from '@/util/yxgAuth.js'
const loading = ref(true)
const error = ref('')
const today = ref({})
const mood = ref(0)
const note = ref('')
const saving = ref(false)
const saved = ref(false)
async function load() {
loading.value = true
error.value = ''
if (!(await ensureAccount('/companion'))) {
loading.value = false
return
}
try {
const [t, m] = await Promise.all([
yxgApi.getSolarTermsToday(),
yxgApi.getMoodToday().catch(() => ({ mood: null })),
])
today.value = t || {}
if (m?.mood) {
mood.value = m.mood.score || 0
note.value = m.mood.note || ''
saved.value = true
}
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function save() {
if (!mood.value || saving.value) return
saving.value = true
try {
await yxgApi.saveMood({ score: mood.value, note: note.value })
saved.value = true
} catch (e) {
uni.showToast({ title: e.message || '保存失败', icon: 'none' })
} finally {
saving.value = false
}
}
onShow(load)
</script>
<style>
page { background-color: #ffd1c7; }
</style>
<style scoped>
.hint, .err { display: block; padding: 12px 16px; font-size: 13px; color: #999; }
.err { color: #e54d42; }
.card { margin: 12px 16px; padding: 18px 16px; background: #fff; border-radius: 18px; }
.term { display: block; font-size: 22px; font-weight: 700; color: #333; }
.date { display: block; margin-top: 4px; font-size: 12px; color: #bbb; }
.body { display: block; margin-top: 12px; font-size: 14px; color: #555; line-height: 1.65; }
.h2 { display: block; font-size: 16px; font-weight: 700; color: #333; margin-bottom: 10px; }
.scores { display: flex; gap: 8px; margin-bottom: 12px; }
.score {
flex: 1; height: 40px; border-radius: 12px; background: #fdfaf8;
display: flex; align-items: center; justify-content: center; color: #666;
}
.score.on { background: #ffe4e4; color: #e54d42; font-weight: 700; }
.inp { width: 100%; height: 42px; background: #fdfaf8; border-radius: 12px; padding: 0 12px; box-sizing: border-box; }
.btn {
margin-top: 12px; height: 42px; border-radius: 999px; background: #e54d42; color: #fff;
display: flex; align-items: center; justify-content: center; font-weight: 700;
}
.btn.off { opacity: 0.5; }
.ok { display: block; margin-top: 8px; font-size: 12px; color: #2fa866; text-align: center; }
</style>
+85
View File
@@ -0,0 +1,85 @@
<template>
<yxg-page :title="cat?.title || '题库分类'">
<view class="head">
<yxg-tool-icon :name="heroIcon" :size="40" />
<view>
<text class="title">{{ cat?.title || '题库分类' }}</text>
<text v-if="cat" class="sub">{{ cat.description }}</text>
</view>
</view>
<text v-if="loading" class="hint">加载中</text>
<text v-else-if="error" class="err" @click="load">{{ error }} · 重试</text>
<view v-else class="list">
<view v-for="it in items" :key="it.slug" class="row" @click="yxgGo(it.path)">
<yxg-tool-icon :name="iconName(it.icon || cat?.icon || 'mbti')" :size="28" />
<view class="row-body">
<text class="row-t">{{ it.title }}</text>
<text class="row-d">{{ it.description }} · {{ it.question_count }} </text>
</view>
<text class="chev"></text>
</view>
</view>
<text class="disc">题库内容仅供自我探索参考不构成心理或医学诊断</text>
</yxg-page>
</template>
<script setup>
import YxgPage from '@/components/yxg/yxg-page.vue'
import YxgToolIcon from '@/components/yxg/yxg-tool-icon.vue'
import { yxgApi } from '@/util/yxgApi.js'
import { ensureAccount } from '@/util/yxgAuth.js'
import { resolveIcon } from '@/util/yxgCatalog.js'
import { yxgGo } from '@/util/yxgNav.js'
const category = ref('')
const cat = ref(null)
const items = ref([])
const loading = ref(true)
const error = ref('')
const heroIcon = computed(() => resolveIcon(cat.value?.icon || 'mbti'))
function iconName(raw) {
return resolveIcon(raw)
}
async function load() {
loading.value = true
error.value = ''
if (!(await ensureAccount(`/explore/bank/${category.value}`))) {
loading.value = false
return
}
try {
const res = await yxgApi.getScaleBankCategory(category.value)
cat.value = res.category
items.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
onLoad((q) => {
category.value = q.category || ''
load()
})
</script>
<style>
page { background-color: #ffd1c7; }
</style>
<style scoped>
.head { display: flex; align-items: center; gap: 12px; padding: 8px 16px; }
.title { display: block; font-size: 20px; font-weight: 700; color: #333; }
.sub { display: block; font-size: 12px; color: #999; margin-top: 2px; }
.list { margin: 8px 16px; background: #fff; border-radius: 16px; }
.row { display: flex; align-items: center; gap: 12px; padding: 14px; border-bottom: 1px solid #f5f5f5; }
.row:last-child { border-bottom: none; }
.row-body { flex: 1; min-width: 0; }
.row-t { display: block; font-size: 15px; font-weight: 650; color: #222; }
.row-d { display: block; font-size: 12px; color: #999; margin-top: 2px; }
.chev { color: #ccc; }
.hint, .err, .disc { display: block; padding: 16px; font-size: 13px; color: #999; }
.err { color: #e54d42; }
</style>
+77
View File
@@ -0,0 +1,77 @@
<template>
<yxg-page :title="cat?.title || '分类'">
<view class="head">
<yxg-tool-icon :name="heroIcon" :size="40" />
<view>
<text class="title">{{ cat?.title || '分类' }}</text>
<text v-if="cat" class="sub">{{ cat.description }}</text>
</view>
</view>
<text v-if="loading" class="hint">加载中</text>
<text v-else-if="error" class="err">{{ error }}</text>
<view v-else-if="cat" class="list">
<view v-for="it in cat.items" :key="it.key" class="row" @click="yxgGo(it.path)">
<yxg-tool-icon :name="toolIconFor(it.path || it.key)" :size="36" />
<view class="row-body">
<text class="row-t">{{ it.title }}</text>
<text class="row-d">{{ it.description }}</text>
</view>
<text class="chev"></text>
</view>
</view>
</yxg-page>
</template>
<script setup>
import YxgPage from '@/components/yxg/yxg-page.vue'
import YxgToolIcon from '@/components/yxg/yxg-tool-icon.vue'
import { yxgApi } from '@/util/yxgApi.js'
import { ensureAccount } from '@/util/yxgAuth.js'
import { toolIconFor } from '@/util/yxgCatalog.js'
import { yxgGo } from '@/util/yxgNav.js'
const category = ref('')
const cat = ref(null)
const loading = ref(true)
const error = ref('')
const heroIcon = computed(() => toolIconFor(cat.value?.key || category.value || 'explore'))
async function load() {
loading.value = true
error.value = ''
if (!(await ensureAccount(`/explore/${category.value}`))) {
loading.value = false
return
}
try {
cat.value = await yxgApi.getExploreCategory(category.value)
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
onLoad((q) => {
category.value = q.category || ''
load()
})
</script>
<style>
page { background-color: #ffd1c7; }
</style>
<style scoped>
.head { display: flex; align-items: center; gap: 12px; padding: 8px 16px; }
.title { display: block; font-size: 20px; font-weight: 700; color: #333; }
.sub { display: block; font-size: 12px; color: #999; margin-top: 2px; }
.list { margin: 8px 16px; background: #fff; border-radius: 16px; }
.row { display: flex; align-items: center; gap: 12px; padding: 14px; border-bottom: 1px solid #f5f5f5; }
.row:last-child { border-bottom: none; }
.row-body { flex: 1; min-width: 0; }
.row-t { display: block; font-size: 15px; font-weight: 650; color: #222; }
.row-d { display: block; font-size: 12px; color: #999; margin-top: 2px; }
.chev { color: #ccc; }
.hint, .err { display: block; padding: 16px; font-size: 13px; color: #999; }
.err { color: #e54d42; }
</style>
+120
View File
@@ -0,0 +1,120 @@
<template>
<yxg-page title="探索">
<view class="head">
<yxg-tool-icon name="mbti" :size="40" />
<view>
<text class="title">探索</text>
<text class="sub">题库测评 · 工具 · 自我理解</text>
</view>
</view>
<view class="sec">
<text class="sec-t">题库精选</text>
<text v-if="bankLoading" class="hint">加载中</text>
<text v-else-if="bankError" class="err" @click="loadBank">{{ bankError }} · 重试</text>
<view v-else class="grid">
<view v-for="t in featured" :key="t.slug" class="g-item" @click="yxgGo(t.path)">
<yxg-tool-icon :name="iconName(t.icon)" :size="40" />
<text class="g-label">{{ t.title }}</text>
</view>
</view>
</view>
<view class="sec">
<text class="sec-t">题库分类</text>
<view class="list">
<view v-for="c in categories" :key="c.key" class="row" @click="yxgGo(c.path)">
<yxg-tool-icon :name="iconName(c.icon)" :size="28" />
<view class="row-body">
<text class="row-t">{{ c.title }}</text>
<text class="row-d">{{ c.description }} · {{ c.count }} </text>
</view>
<text class="chev"></text>
</view>
</view>
</view>
<view class="sec">
<text class="sec-t">其它工具</text>
<view class="grid">
<view v-for="t in tools" :key="t.to" class="g-item" @click="yxgGo(t.to)">
<yxg-tool-icon :name="t.icon" :size="40" />
<text class="g-label">{{ t.label }}</text>
</view>
</view>
</view>
</yxg-page>
</template>
<script setup>
import YxgPage from '@/components/yxg/yxg-page.vue'
import YxgToolIcon from '@/components/yxg/yxg-tool-icon.vue'
import { yxgApi } from '@/util/yxgApi.js'
import { ensureAccount } from '@/util/yxgAuth.js'
import { resolveIcon } from '@/util/yxgCatalog.js'
import { yxgGo } from '@/util/yxgNav.js'
const tools = [
{ to: '/star', label: '星座', icon: 'star' },
{ to: '/portrait', label: '愈心解码', icon: 'portrait' },
{ to: '/rhythm', label: '身心节律', icon: 'rhythm' },
{ to: '/synastry', label: '合盘', icon: 'synastry' },
{ to: '/ask', label: 'AI问答', icon: 'ask' },
{ to: '/cards', label: '意象卡片', icon: 'cards' },
{ to: '/relation', label: '人格匹配', icon: 'relation' },
{ to: '/companion', label: '节气陪伴', icon: 'companion' },
]
const featured = ref([])
const categories = ref([])
const bankLoading = ref(true)
const bankError = ref('')
function iconName(raw) {
return resolveIcon(raw)
}
async function loadBank() {
bankLoading.value = true
bankError.value = ''
try {
const res = await yxgApi.getScaleBankCatalog()
featured.value = res.featured || []
categories.value = res.categories || []
} catch (e) {
bankError.value = e instanceof Error ? e.message : '加载失败'
} finally {
bankLoading.value = false
}
}
onShow(async () => {
if (!(await ensureAccount('/explore'))) {
bankLoading.value = false
return
}
await loadBank()
})
</script>
<style>
page { background-color: #ffd1c7; }
</style>
<style scoped>
.head { display: flex; align-items: center; gap: 12px; padding: 8px 16px 4px; }
.title { display: block; font-size: 22px; font-weight: 700; color: #333; }
.sub { display: block; margin-top: 2px; font-size: 12px; color: rgba(0,0,0,.38); }
.sec { padding: 0 16px; margin-top: 18px; }
.sec-t { display: block; font-size: 17px; font-weight: 700; color: #333; margin-bottom: 12px; }
.grid { display: flex; flex-wrap: wrap; }
.g-item { width: 25%; display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 8px 0; }
.g-label { font-size: 11px; color: #555; text-align: center; }
.list { background: #fafafa; border-radius: 16px; }
.row { display: flex; align-items: center; gap: 12px; padding: 14px 10px; border-bottom: 1px solid #f0ebe8; }
.row:last-child { border-bottom: none; }
.row-body { flex: 1; min-width: 0; }
.row-t { display: block; font-size: 15px; font-weight: 650; color: #222; }
.row-d { display: block; font-size: 12px; color: #999; margin-top: 2px; }
.chev { color: #ccc; }
.hint, .err { font-size: 13px; color: #999; }
.err { color: #e54d42; }
</style>
+110
View File
@@ -0,0 +1,110 @@
<template>
<yxg-page title="成长计划">
<view class="card">
<text class="lbl">新计划标题</text>
<input class="inp" v-model="title" maxlength="40" placeholder="例如:每晚 11 点前放下手机" />
<text class="lbl">焦点可选</text>
<input class="inp" v-model="focus" maxlength="40" placeholder="例如:保护睡眠与情绪" />
<view class="btn" :class="{ off: saving || !title.trim() }" @click="create"><text>创建计划</text></view>
<text v-if="err" class="err">{{ err }}</text>
</view>
<text v-if="loading" class="hint">加载中</text>
<view v-for="p in plans" :key="p.id" class="card">
<text class="h1">{{ p.title }}</text>
<text v-if="p.focus" class="meta">{{ p.focus }}</text>
<view class="btn ghost" @click="checkin(p.id)">
<text>{{ checking === p.id ? '记录中…' : '今日打卡' }}</text>
</view>
<view v-for="c in (checkins[p.id] || [])" :key="c.id" class="ck">
<text>{{ c.day }}{{ c.note ? ' · ' + c.note : '' }}</text>
</view>
</view>
<text v-if="!loading && !plans.length" class="hint">还没有计划先创建一个小目标吧</text>
</yxg-page>
</template>
<script setup>
import YxgPage from '@/components/yxg/yxg-page.vue'
import { yxgApi } from '@/util/yxgApi.js'
import { ensureAccount } from '@/util/yxgAuth.js'
const plans = ref([])
const checkins = ref({})
const title = ref('')
const focus = ref('')
const loading = ref(true)
const saving = ref(false)
const checking = ref('')
const err = ref('')
async function load() {
loading.value = true
if (!(await ensureAccount('/growth-plan'))) {
loading.value = false
return
}
try {
const res = await yxgApi.listGrowthPlans()
plans.value = res.items || []
const next = {}
for (const p of plans.value) {
const ck = await yxgApi.listGrowthCheckins(p.id)
next[p.id] = ck.items || []
}
checkins.value = next
} catch (e) {
err.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function create() {
if (saving.value || !title.value.trim()) return
saving.value = true
err.value = ''
try {
await yxgApi.createGrowthPlan({ title: title.value.trim(), focus: focus.value.trim() || undefined })
title.value = ''
focus.value = ''
await load()
} catch (e) {
err.value = e instanceof Error ? e.message : '创建失败'
} finally {
saving.value = false
}
}
async function checkin(id) {
if (checking.value) return
checking.value = id
try {
await yxgApi.createGrowthCheckin(id, {})
await load()
} catch (e) {
uni.showToast({ title: e.message || '打卡失败', icon: 'none' })
} finally {
checking.value = ''
}
}
onShow(load)
</script>
<style>
page { background-color: #ffd1c7; }
</style>
<style scoped>
.card { margin: 12px 16px; padding: 16px; background: #fff; border-radius: 16px; }
.lbl { display: block; font-size: 12px; color: #999; margin: 8px 0 6px; }
.inp { width: 100%; height: 42px; background: #fdfaf8; border-radius: 12px; padding: 0 12px; box-sizing: border-box; }
.h1 { display: block; font-size: 16px; font-weight: 700; color: #333; }
.meta, .hint, .err, .ck { display: block; margin-top: 6px; font-size: 13px; color: #666; }
.err { color: #e54d42; }
.btn {
margin-top: 12px; height: 42px; border-radius: 999px; background: #e54d42; color: #fff;
display: flex; align-items: center; justify-content: center; font-weight: 700;
}
.btn.ghost { background: #f7f2ef; color: #8a4a3a; }
.btn.off { opacity: 0.45; }
</style>
+106
View File
@@ -0,0 +1,106 @@
<template>
<yxg-page title="成长会员">
<text v-if="loading" class="hint">加载中</text>
<text v-else-if="error" class="err" @click="load">{{ error }} · 重试</text>
<template v-else>
<view v-if="me?.active" class="card on">
<text class="h1">会员有效 · {{ planLabel }}</text>
<text v-if="me.expires_at" class="meta">到期{{ formatDate(me.expires_at) }}</text>
<text class="meta">问答额度剩余{{ me.ask_quota_left ?? 0 }}</text>
</view>
<view v-else class="card">
<text class="lead">开通后可查看画像与关系理解的完整分析并获得更多 AI 问答次数</text>
</view>
<text class="sec">成长会员</text>
<view v-for="p in plans" :key="p.key" class="plan" :class="{ featured: p.featured }" @click="subscribe(p.key)">
<text v-if="p.tag" class="tag">{{ p.tag }}</text>
<text class="pl">{{ p.label }}</text>
<text class="pp">{{ p.price }}</text>
<text class="pd">{{ p.desc }}</text>
</view>
<text class="note">当前为模拟支付便于本地验收</text>
</template>
</yxg-page>
</template>
<script setup>
import YxgPage from '@/components/yxg/yxg-page.vue'
import { yxgApi } from '@/util/yxgApi.js'
import { ensureAccount } from '@/util/yxgAuth.js'
import { formatDate } from '@/util/yxgCatalog.js'
const plans = [
{ key: 'month', label: '月卡', price: '模拟开通', desc: '按月灵活体验', featured: false, tag: '' },
{ key: 'quarter', label: '季卡', price: '模拟开通', desc: '三个月持续成长', featured: true, tag: '推荐' },
{ key: 'year', label: '年卡', price: '模拟开通', desc: '全年陪伴与报告', featured: false, tag: '' },
]
const loading = ref(true)
const paying = ref(false)
const error = ref('')
const me = ref(null)
const planLabel = computed(() => {
const p = me.value?.plan
if (p === 'quarter') return '季卡'
if (p === 'year') return '年卡'
if (p === 'month') return '月卡'
return p || '成长会员'
})
async function load() {
loading.value = true
error.value = ''
if (!(await ensureAccount('/membership'))) {
loading.value = false
return
}
try {
me.value = await yxgApi.getMembership()
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function subscribe(plan) {
if (paying.value) return
paying.value = true
error.value = ''
try {
const { order_id } = await yxgApi.createOrder({ kind: 'membership', plan })
await yxgApi.payMock(order_id)
me.value = await yxgApi.getMembership()
uni.showToast({ title: '开通成功', icon: 'none' })
} catch (e) {
error.value = e instanceof Error ? e.message : '支付失败'
} finally {
paying.value = false
}
}
onShow(load)
</script>
<style>
page { background-color: #ffd1c7; }
</style>
<style scoped>
.hint, .err, .note { display: block; padding: 12px 16px; font-size: 13px; color: #999; }
.err { color: #e54d42; }
.card { margin: 12px 16px; padding: 16px; background: #fff; border-radius: 16px; }
.card.on { background: linear-gradient(145deg, #fff4e0, #ffd98a); }
.h1 { display: block; font-size: 17px; font-weight: 700; color: #8a5a18; }
.meta, .lead { display: block; margin-top: 6px; font-size: 13px; color: #666; }
.sec { display: block; margin: 16px 16px 8px; font-size: 16px; font-weight: 700; color: #333; }
.plan {
margin: 0 16px 10px; padding: 14px; background: #fff; border-radius: 16px; position: relative;
}
.plan.featured { box-shadow: 0 4px 14px rgba(200, 146, 58, 0.15); }
.tag {
position: absolute; top: 10px; right: 10px; font-size: 10px; color: #fff;
background: #e54d42; padding: 2px 6px; border-radius: 6px;
}
.pl { display: block; font-size: 16px; font-weight: 700; color: #333; }
.pp { display: block; margin-top: 4px; font-size: 13px; color: #e54d42; }
.pd { display: block; margin-top: 2px; font-size: 12px; color: #999; }
</style>
+160
View File
@@ -0,0 +1,160 @@
<template>
<yxg-page title="我的魔方">
<view class="hero">
<view class="av" @click="pickAvatar">
<image class="av-img" :src="avatarSrc" mode="aspectFill" />
</view>
<text class="name">{{ accountLabel }}</text>
<text class="meta">{{ loggedIn ? `${profileCount} 份档案` : '登录后同步你的探索' }}</text>
<view v-if="loggedIn" class="logout" @click="logout"><text>退出登录</text></view>
<view v-else class="login" @click="openWechatLogin"><text>登录</text></view>
</view>
<view v-if="loading" class="hint"><text>加载中</text></view>
<view v-else-if="error" class="err" @click="load"><text>{{ error }} · 点此重试</text></view>
<template v-else>
<view v-if="membership && membership.active" class="vip">
<text class="vip-t">成长会员 · {{ planLabel }}</text>
</view>
<view class="sec">
<text class="sec-t">档案与内容</text>
<view class="list">
<view v-for="it in archive" :key="it.to" class="row" @click="yxgGo(it.to)">
<yxg-tool-icon :name="it.icon" :size="28" />
<text class="row-t">{{ it.label }}</text>
<text class="chev"></text>
</view>
</view>
</view>
<view class="sec">
<text class="sec-t">成长与会员</text>
<view class="list">
<view v-for="it in growth" :key="it.to" class="row" @click="yxgGo(it.to)">
<yxg-tool-icon :name="it.icon" :size="28" />
<text class="row-t">{{ it.label }}</text>
<text class="chev"></text>
</view>
</view>
</view>
</template>
</yxg-page>
</template>
<script setup>
import YxgPage from '@/components/yxg/yxg-page.vue'
import YxgToolIcon from '@/components/yxg/yxg-tool-icon.vue'
import { yxgApi, assetURL, setYxgToken } from '@/util/yxgApi.js'
import { mineGroupArchive, mineGroupGrowth } from '@/util/yxgCatalog.js'
import { YXG_DEFAULT_AVATAR } from '@/util/yxgConfig.js'
import { openWechatLogin } from '@/util/yxgAuth.js'
import { yxgGo } from '@/util/yxgNav.js'
const loading = ref(true)
const error = ref('')
const me = ref(null)
const membership = ref(null)
const profileCount = ref(0)
const archive = mineGroupArchive
const growth = mineGroupGrowth
const loggedIn = computed(() => !!me.value)
const accountLabel = computed(() => me.value?.nickname || me.value?.phone || '未登录')
const avatarSrc = computed(() => assetURL(me.value?.avatar_url) || YXG_DEFAULT_AVATAR)
const planLabel = computed(() => {
const p = membership.value?.plan
if (p === 'quarter') return '季卡'
if (p === 'year') return '年卡'
if (p === 'month') return '月卡'
return '会员中'
})
async function load() {
loading.value = true
error.value = ''
try {
me.value = await yxgApi.authMe()
} catch {
me.value = null
membership.value = null
profileCount.value = 0
loading.value = false
return
}
try {
const [m, profiles] = await Promise.all([yxgApi.getMembership(), yxgApi.listProfiles()])
membership.value = m
profileCount.value = (profiles.items || []).length
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
async function logout() {
try { await yxgApi.authLogout() } catch { /* ignore */ }
setYxgToken('')
uni.removeStorageSync('userinfo')
me.value = null
membership.value = null
profileCount.value = 0
}
function pickAvatar() {
if (!loggedIn.value) {
openWechatLogin()
return
}
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
success: async (res) => {
const path = res.tempFilePaths[0]
try {
me.value = await yxgApi.authUploadAvatar(path)
} catch (e) {
uni.showToast({ title: e.message || '上传失败', icon: 'none' })
}
},
})
}
onShow(load)
</script>
<style>
page { background-color: #ffd1c7; }
</style>
<style scoped>
.hero {
margin: 8px 16px 0; padding: 20px 16px; background: #fff; border-radius: 20px;
display: flex; flex-direction: column; align-items: center; gap: 6px;
box-shadow: 0 8px 28px rgba(229, 77, 66, 0.1);
}
.av { width: 72px; height: 72px; border-radius: 50%; overflow: hidden; background: #ffe8e0; }
.av-img { width: 72px; height: 72px; }
.name { font-size: 18px; font-weight: 700; color: #333; }
.meta { font-size: 12px; color: #999; }
.logout, .login {
margin-top: 8px; padding: 6px 16px; border-radius: 999px; font-size: 13px;
}
.logout { color: #999; background: #f7f2ef; }
.login { color: #fff; background: #e54d42; }
.hint, .err { padding: 16px; font-size: 13px; color: #999; }
.err { color: #e54d42; }
.vip {
margin: 14px 16px 0; padding: 12px 14px; border-radius: 14px;
background: linear-gradient(145deg, #fff4e0, #ffd98a);
}
.vip-t { font-size: 14px; font-weight: 700; color: #8a5a18; }
.sec { margin: 18px 16px 0; }
.sec-t { display: block; font-size: 15px; font-weight: 700; color: #333; margin-bottom: 10px; }
.list { background: #fff; border-radius: 16px; overflow: hidden; }
.row {
display: flex; align-items: center; gap: 12px;
padding: 12px 14px; border-bottom: 1px solid #f5f5f5;
}
.row:last-child { border-bottom: none; }
.row-t { flex: 1; font-size: 15px; color: #333; }
.chev { color: #ccc; font-size: 16px; }
</style>
+128
View File
@@ -0,0 +1,128 @@
<template>
<yxg-page title="愈心解码">
<text v-if="loading" class="hint">生成中</text>
<text v-else-if="error" class="err">{{ error }}</text>
<view v-else-if="needBirth" class="card">
<text class="h1">一个生日读懂性格与身心节奏</text>
<text class="desc">填写公历生日生成你的愈心解码</text>
<view class="date-row">
<input class="inp" type="number" v-model="year" placeholder="年" />
<input class="inp" type="number" v-model="month" placeholder="月" />
<input class="inp" type="number" v-model="day" placeholder="日" />
</view>
<text v-if="formError" class="err">{{ formError }}</text>
<view class="btn" @click="start"><text>开始解码</text></view>
</view>
<view v-else-if="report" class="card">
<text class="h1">{{ headline }}</text>
<text class="lead">{{ oneLiner }}</text>
<view class="tags">
<text v-for="k in keywords" :key="k" class="tag">{{ k }}</text>
</view>
<text v-if="bodyText" class="body">{{ bodyText }}</text>
<view class="navs">
<view class="btn ghost" @click="needBirth = true"><text>重新生成</text></view>
<view class="btn" @click="yxgGo(`/reports/${report.id}`)"><text>完整报告</text></view>
</view>
</view>
</yxg-page>
</template>
<script setup>
import YxgPage from '@/components/yxg/yxg-page.vue'
import { yxgApi } from '@/util/yxgApi.js'
import { ensureAccount, ensureSelfProfile, loadSelfLatest } from '@/util/yxgAuth.js'
import { yxgGo } from '@/util/yxgNav.js'
const loading = ref(false)
const needBirth = ref(true)
const error = ref('')
const formError = ref('')
const report = ref(null)
const year = ref('')
const month = ref('')
const day = ref('')
const summary = computed(() => report.value?.summary || {})
const headline = computed(() => String(summary.value.headline || '愈心解码'))
const oneLiner = computed(() => String(summary.value.one_liner || ''))
const keywords = computed(() => (Array.isArray(summary.value.keywords) ? summary.value.keywords : []))
const bodyText = computed(() => String(report.value?.detail?.narrative || report.value?.detail?.body_text || summary.value.overview || ''))
async function generate(y, m, d) {
loading.value = true
error.value = ''
needBirth.value = false
try {
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const profile = await ensureSelfProfile({ birth_date: birth, display_name: '我' })
report.value = await yxgApi.createPortrait(profile.id)
} catch (e) {
error.value = e instanceof Error ? e.message : '生成失败'
needBirth.value = true
} finally {
loading.value = false
}
}
function start() {
const y = Number(year.value), m = Number(month.value), d = Number(day.value)
if (!y || !m || !d) {
formError.value = '请填写完整生日'
return
}
generate(y, m, d)
}
async function load() {
if (!(await ensureAccount('/portrait'))) return
loading.value = true
try {
const latest = await loadSelfLatest('portrait')
if (latest.report) {
report.value = latest.report
needBirth.value = false
} else {
needBirth.value = true
if (latest.profile?.birth_date) {
const [y, m, d] = String(latest.profile.birth_date).split('-')
year.value = y
month.value = m
day.value = d
}
}
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
onLoad((q) => {
if (q.y) year.value = q.y
if (q.m) month.value = q.m
if (q.d) day.value = q.d
load()
})
</script>
<style>
page { background-color: #ffd1c7; }
</style>
<style scoped>
.hint, .err { display: block; padding: 12px 16px; font-size: 13px; color: #999; }
.err { color: #e54d42; }
.card { margin: 12px 16px; padding: 18px 16px; background: #fff; border-radius: 18px; }
.h1 { display: block; font-size: 20px; font-weight: 700; color: #333; }
.desc, .lead, .body { display: block; margin-top: 10px; font-size: 14px; color: #555; line-height: 1.65; }
.date-row { display: flex; gap: 8px; margin: 14px 0; }
.inp { flex: 1; height: 42px; background: #fdfaf8; border-radius: 12px; padding: 0 10px; text-align: center; }
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.tag { padding: 3px 8px; border-radius: 8px; background: #ffe4e4; color: #e54d42; font-size: 11px; }
.navs { display: flex; gap: 10px; margin-top: 16px; }
.btn {
flex: 1; height: 42px; border-radius: 999px; background: #e54d42; color: #fff;
display: flex; align-items: center; justify-content: center; font-weight: 700;
}
.btn.ghost { background: #f7f2ef; color: #8a4a3a; }
</style>
+238
View File
@@ -0,0 +1,238 @@
<template>
<yxg-page title="个人档案">
<text v-if="loading" class="hint">加载中</text>
<text v-else-if="error" class="err" @click="load">{{ error }} · 重试</text>
<template v-else>
<view class="card">
<text class="card-t">自己</text>
<view v-if="selfProfile" class="who">
<text class="who-n">{{ selfProfile.display_name || '我' }}</text>
<text class="who-m">生日 {{ selfProfile.birth_date || '未填写' }}</text>
<text class="who-m">{{ selfProfile.birth_place || '出生地未填' }}</text>
<view class="btn ghost" @click="startEdit(selfProfile)"><text>编辑</text></view>
</view>
<view v-else class="empty">
<text>还没有自己的档案先补一个生日吧</text>
</view>
</view>
<view class="card">
<view class="row-head">
<text class="card-t">重要的人</text>
<text class="add" @click="showAdd = !showAdd">{{ showAdd ? '取消' : '+ 添加' }}</text>
</view>
<view v-if="showAdd" class="form">
<input class="inp" v-model="newName" placeholder="称呼,如伴侣 / 朋友" />
<view class="date-row">
<input class="inp sm" type="number" v-model="newY" placeholder="年" />
<input class="inp sm" type="number" v-model="newM" placeholder="月" />
<input class="inp sm" type="number" v-model="newD" placeholder="日" />
</view>
<input class="inp" v-model="newPlace" placeholder="出生地(可选)" />
<text v-if="addError" class="err">{{ addError }}</text>
<view class="btn" @click="addOther"><text>{{ adding ? '保存中…' : '保存' }}</text></view>
</view>
<view v-for="p in others" :key="p.id" class="who">
<text class="who-n">{{ p.display_name || 'TA' }}</text>
<text class="who-m">{{ p.birth_date }} · {{ p.birth_place || '出生地未填' }}</text>
<view class="acts">
<text class="link" @click="startEdit(p)">编辑</text>
<text class="link danger" @click="remove(p)">删除</text>
</view>
</view>
</view>
<view v-if="editing" class="card">
<text class="card-t">编辑档案</text>
<input class="inp" v-model="formName" placeholder="称呼" />
<view class="date-row">
<input class="inp sm" type="number" v-model="formY" placeholder="年" />
<input class="inp sm" type="number" v-model="formM" placeholder="月" />
<input class="inp sm" type="number" v-model="formD" placeholder="日" />
</view>
<input class="inp" v-model="formPlace" placeholder="出生地" />
<text v-if="formError" class="err">{{ formError }}</text>
<view class="acts">
<view class="btn" @click="saveEdit"><text>{{ saving ? '保存中…' : '保存' }}</text></view>
<view class="btn ghost" @click="editing = null"><text>取消</text></view>
</view>
</view>
</template>
</yxg-page>
</template>
<script setup>
import YxgPage from '@/components/yxg/yxg-page.vue'
import { yxgApi } from '@/util/yxgApi.js'
import { ensureAccount, ensureOtherProfile, ensureSelfProfile } from '@/util/yxgAuth.js'
const loading = ref(true)
const error = ref('')
const items = ref([])
const showAdd = ref(false)
const newName = ref('')
const newY = ref('')
const newM = ref('')
const newD = ref('')
const newPlace = ref('')
const addError = ref('')
const adding = ref(false)
const editing = ref(null)
const formName = ref('')
const formY = ref('')
const formM = ref('')
const formD = ref('')
const formPlace = ref('')
const formError = ref('')
const saving = ref(false)
const selfProfile = computed(() => items.value.find((p) => p.relation === 'self'))
const others = computed(() => items.value.filter((p) => p.relation !== 'self'))
function ymd(y, m, d) {
const Y = Number(y), M = Number(m), D = Number(d)
if (!Y || !M || !D || M < 1 || M > 12 || D < 1 || D > 31) return ''
return `${Y}-${String(M).padStart(2, '0')}-${String(D).padStart(2, '0')}`
}
async function load() {
loading.value = true
error.value = ''
if (!(await ensureAccount('/profile'))) {
loading.value = false
return
}
try {
const res = await yxgApi.listProfiles()
items.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
function startEdit(p) {
editing.value = p
formName.value = p.display_name || ''
const [y, m, d] = String(p.birth_date || '').split('-')
formY.value = y || ''
formM.value = m || ''
formD.value = d || ''
formPlace.value = p.birth_place || ''
formError.value = ''
}
async function saveEdit() {
if (!editing.value) return
const birth = ymd(formY.value, formM.value, formD.value)
if (!birth) {
formError.value = '请填写完整生日'
return
}
saving.value = true
formError.value = ''
try {
if (editing.value.relation === 'self') {
await ensureSelfProfile({
birth_date: birth,
display_name: formName.value,
birth_place: formPlace.value,
})
} else {
await yxgApi.updateProfile(editing.value.id, {
display_name: formName.value.trim() || 'TA',
birth_date: birth,
birth_place: formPlace.value,
})
}
editing.value = null
await load()
} catch (e) {
formError.value = e instanceof Error ? e.message : '保存失败'
} finally {
saving.value = false
}
}
async function addOther() {
const birth = ymd(newY.value, newM.value, newD.value)
if (!birth) {
addError.value = '请填写完整生日'
return
}
adding.value = true
addError.value = ''
try {
await ensureOtherProfile({
birth_date: birth,
display_name: newName.value || 'TA',
birth_place: newPlace.value,
})
showAdd.value = false
newName.value = newY.value = newM.value = newD.value = newPlace.value = ''
await load()
} catch (e) {
addError.value = e instanceof Error ? e.message : '添加失败'
} finally {
adding.value = false
}
}
function remove(p) {
uni.showModal({
title: '删除档案',
content: `确定删除「${p.display_name || 'TA'}」?`,
success: async (res) => {
if (!res.confirm) return
try {
await yxgApi.deleteProfile(p.id)
await load()
} catch (e) {
uni.showToast({ title: e.message || '删除失败', icon: 'none' })
}
},
})
}
onLoad((q) => {
if (q.add === '1') showAdd.value = true
load()
})
</script>
<style>
page { background-color: #ffd1c7; }
</style>
<style scoped>
.hint, .err { display: block; padding: 16px; font-size: 13px; color: #999; }
.err { color: #e54d42; }
.card {
margin: 12px 16px 0; padding: 16px; background: #fff; border-radius: 18px;
box-shadow: 0 2px 10px rgba(0,0,0,.04);
}
.card-t { display: block; font-size: 16px; font-weight: 700; color: #333; margin-bottom: 10px; }
.row-head { display: flex; justify-content: space-between; align-items: center; }
.add { font-size: 13px; color: #e54d42; }
.who { padding: 8px 0; border-bottom: 1px solid #f5f5f5; }
.who:last-child { border-bottom: none; }
.who-n { display: block; font-size: 15px; font-weight: 650; color: #222; }
.who-m { display: block; font-size: 12px; color: #999; margin-top: 2px; }
.empty { font-size: 13px; color: #999; }
.form { margin-top: 8px; }
.inp {
width: 100%; height: 42px; padding: 0 12px; box-sizing: border-box;
background: #fdfaf8; border-radius: 12px; font-size: 14px; margin-bottom: 8px;
}
.date-row { display: flex; gap: 8px; }
.inp.sm { flex: 1; }
.btn {
height: 40px; border-radius: 999px; background: #e54d42; color: #fff;
display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: 700;
margin-top: 6px;
}
.btn.ghost { background: #f7f2ef; color: #8a4a3a; }
.acts { display: flex; gap: 12px; margin-top: 6px; }
.link { font-size: 13px; color: #4a90e2; }
.link.danger { color: #e54d42; }
</style>
+119
View File
@@ -0,0 +1,119 @@
<template>
<yxg-page title="人格匹配">
<view v-if="!report" class="card">
<text class="h1">看见彼此的相处模式</text>
<text class="desc">填写 TA 的称呼与生日对照性格与默契</text>
<input class="inp" v-model="otherName" placeholder="TA 的称呼" />
<view class="rels">
<view v-for="o in relationOptions" :key="o.value" class="rel" :class="{ on: relationType === o.value }" @click="relationType = o.value">
<text>{{ o.label }}</text>
</view>
</view>
<view class="date-row">
<input class="inp sm" type="number" v-model="oy" placeholder="年" />
<input class="inp sm" type="number" v-model="om" placeholder="月" />
<input class="inp sm" type="number" v-model="od" placeholder="日" />
</view>
<text v-if="error" class="err">{{ error }}</text>
<view class="btn" :class="{ off: loading }" @click="run"><text>{{ loading ? '分析中…' : '开始匹配' }}</text></view>
</view>
<view v-else class="card">
<text class="h1">{{ fitLabel || '相处参考' }}</text>
<text class="lead">{{ diff }}</text>
<text class="meta">{{ meStyle }}</text>
<text class="meta">TA{{ otherStyle }}</text>
<text v-if="harmony != null" class="idx">默契 {{ harmony }}</text>
<view class="navs">
<view class="btn ghost" @click="report = null"><text>再测一次</text></view>
<view class="btn" @click="yxgGo(`/reports/${report.id}`)"><text>完整报告</text></view>
</view>
</view>
</yxg-page>
</template>
<script setup>
import YxgPage from '@/components/yxg/yxg-page.vue'
import { yxgApi } from '@/util/yxgApi.js'
import { ensureAccount, ensureOtherProfile, findSelfProfile } from '@/util/yxgAuth.js'
import { yxgGo } from '@/util/yxgNav.js'
const relationOptions = [
{ value: 'partner', label: '伴侣' },
{ value: 'friend', label: '朋友' },
{ value: 'family', label: '家人' },
{ value: 'other', label: '其他' },
]
const otherName = ref('TA')
const relationType = ref('partner')
const oy = ref('')
const om = ref('')
const od = ref('')
const loading = ref(false)
const error = ref('')
const report = ref(null)
const summary = computed(() => report.value?.summary || {})
const meStyle = computed(() => String(summary.value.me_style || ''))
const otherStyle = computed(() => String(summary.value.other_style || ''))
const diff = computed(() => String(summary.value.diff_one_liner || summary.value.one_liner || ''))
const fitLabel = computed(() => String(summary.value.fit_label || ''))
const harmony = computed(() => (typeof summary.value.harmony_index === 'number' ? summary.value.harmony_index : null))
async function run() {
const y = Number(oy.value), m = Number(om.value), d = Number(od.value)
if (!y || !m || !d) {
error.value = '请填写 TA 的完整生日'
return
}
loading.value = true
error.value = ''
if (!(await ensureAccount('/relation'))) {
loading.value = false
return
}
try {
const self = await findSelfProfile()
if (!self) {
error.value = '请先完善自己的生日档案'
yxgGo('/profile')
return
}
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const other = await ensureOtherProfile({
birth_date: birth,
display_name: otherName.value || 'TA',
relation_type: relationType.value,
})
const out = await yxgApi.createRelationInsight(self.id, other.id)
report.value = out.report || out
} catch (e) {
error.value = e instanceof Error ? e.message : '分析失败'
} finally {
loading.value = false
}
}
</script>
<style>
page { background-color: #ffd1c7; }
</style>
<style scoped>
.card { margin: 12px 16px; padding: 18px 16px; background: #fff; border-radius: 18px; }
.h1 { display: block; font-size: 20px; font-weight: 700; color: #333; }
.desc, .lead, .meta { display: block; margin-top: 8px; font-size: 14px; color: #555; line-height: 1.55; }
.inp { width: 100%; height: 42px; background: #fdfaf8; border-radius: 12px; padding: 0 12px; box-sizing: border-box; margin-top: 12px; }
.rels { display: flex; gap: 8px; margin-top: 12px; }
.rel { flex: 1; height: 36px; border-radius: 999px; background: #f7f2ef; display: flex; align-items: center; justify-content: center; font-size: 13px; color: #666; }
.rel.on { background: #ffe4e4; color: #e54d42; }
.date-row { display: flex; gap: 8px; margin-top: 12px; }
.inp.sm { flex: 1; margin-top: 0; text-align: center; }
.err { display: block; margin-top: 8px; color: #e54d42; font-size: 13px; }
.idx { display: block; margin-top: 10px; font-size: 16px; font-weight: 700; color: #e54d42; }
.navs { display: flex; gap: 10px; margin-top: 16px; }
.btn {
flex: 1; height: 42px; border-radius: 999px; background: #e54d42; color: #fff;
display: flex; align-items: center; justify-content: center; font-weight: 700; margin-top: 12px;
}
.btn.ghost { background: #f7f2ef; color: #8a4a3a; }
.btn.off { opacity: 0.5; }
</style>
+83
View File
@@ -0,0 +1,83 @@
<template>
<yxg-page :title="typeLabel(report?.type) || '报告详情'">
<text v-if="loading" class="hint">加载中</text>
<text v-else-if="error" class="err">{{ error }}</text>
<view v-else-if="report" class="card">
<yxg-tool-icon :name="toolIconFor(report.type)" :size="40" />
<text class="h1">{{ headlineOf(report) }}</text>
<text class="meta">{{ formatDate(report.created_at) }}</text>
<text v-if="oneLiner" class="lead">{{ oneLiner }}</text>
<view v-if="keywords.length" class="tags">
<text v-for="k in keywords" :key="k" class="tag">{{ k }}</text>
</view>
<text v-if="bodyText" class="body">{{ bodyText }}</text>
<rich-text v-else-if="html" class="html" :nodes="html" />
<view class="btn" @click="yxgGo('/share', { id: report.id })"><text>分享</text></view>
</view>
</yxg-page>
</template>
<script setup>
import YxgPage from '@/components/yxg/yxg-page.vue'
import YxgToolIcon from '@/components/yxg/yxg-tool-icon.vue'
import { yxgApi } from '@/util/yxgApi.js'
import { ensureAccount } from '@/util/yxgAuth.js'
import { formatDate, headlineOf, toolIconFor, typeLabel } from '@/util/yxgCatalog.js'
import { yxgGo } from '@/util/yxgNav.js'
const id = ref('')
const report = ref(null)
const loading = ref(true)
const error = ref('')
const summary = computed(() => report.value?.summary || {})
const detail = computed(() => report.value?.detail || {})
const oneLiner = computed(() => String(summary.value.one_liner || summary.value.overview || ''))
const keywords = computed(() => (Array.isArray(summary.value.keywords) ? summary.value.keywords : []))
const bodyText = computed(() => {
const d = detail.value || {}
return String(d.body_text || d.narrative || d.content || summary.value.body || '')
})
const html = computed(() => String(detail.value?.html || detail.value?.rich_html || ''))
async function load() {
loading.value = true
error.value = ''
if (!(await ensureAccount(`/reports/${id.value}`))) {
loading.value = false
return
}
try {
report.value = await yxgApi.getReport(id.value)
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
onLoad((q) => {
id.value = q.id || ''
load()
})
</script>
<style>
page { background-color: #ffd1c7; }
</style>
<style scoped>
.hint, .err { display: block; padding: 16px; font-size: 13px; color: #999; }
.err { color: #e54d42; }
.card { margin: 12px 16px; padding: 18px 16px; background: #fff; border-radius: 18px; }
.h1 { display: block; margin-top: 10px; font-size: 20px; font-weight: 700; color: #333; }
.meta { display: block; margin-top: 4px; font-size: 12px; color: #bbb; }
.lead { display: block; margin-top: 12px; font-size: 14px; color: #555; line-height: 1.6; }
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.tag { padding: 3px 8px; border-radius: 8px; background: #ffe4e4; color: #e54d42; font-size: 11px; }
.body { display: block; margin-top: 14px; font-size: 14px; color: #444; line-height: 1.7; white-space: pre-wrap; }
.html { margin-top: 14px; }
.btn {
margin-top: 16px; height: 42px; border-radius: 999px; background: #e54d42; color: #fff;
display: flex; align-items: center; justify-content: center; font-weight: 700;
}
</style>
+105
View File
@@ -0,0 +1,105 @@
<template>
<yxg-page title="成长报告">
<scroll-view class="chips" scroll-x>
<view v-for="f in filters" :key="f.key" class="chip" :class="{ on: filter === f.key }" @click="filter = f.key">
<text>{{ f.label }}</text>
</view>
</scroll-view>
<view class="vip" @click="yxgGo('/membership')">
<text class="vip-t">成长会员</text>
<text class="vip-s">解锁全部深度报告与节气陪伴 </text>
</view>
<text v-if="loading" class="hint">加载中</text>
<text v-else-if="error" class="err" @click="load">{{ error }} · 重试</text>
<view v-else-if="!filtered.length" class="empty">
<text>还没有{{ filter === 'all' ? '' : '该类' }}成长报告</text>
<view class="btn" @click="yxgGo('/explore')"><text>去探索</text></view>
</view>
<view v-else class="list">
<view v-for="r in filtered" :key="r.id" class="row" @click="yxgGo(`/reports/${r.id}`)">
<yxg-tool-icon :name="toolIconFor(r.type)" :size="36" />
<view class="body">
<text class="t">{{ typeLabel(r.type) }}</text>
<text class="d">{{ headlineOf(r) }}</text>
<text class="m">{{ formatDate(r.created_at) }}</text>
</view>
<text class="chev"></text>
</view>
</view>
</yxg-page>
</template>
<script setup>
import YxgPage from '@/components/yxg/yxg-page.vue'
import YxgToolIcon from '@/components/yxg/yxg-tool-icon.vue'
import { yxgApi } from '@/util/yxgApi.js'
import { ensureAccount } from '@/util/yxgAuth.js'
import { formatDate, headlineOf, toolIconFor, typeLabel } from '@/util/yxgCatalog.js'
import { yxgGo } from '@/util/yxgNav.js'
const filters = [
{ key: 'all', label: '全部' },
{ key: 'portrait', label: '解码' },
{ key: 'star', label: '星座' },
{ key: 'rhythm', label: '节律' },
{ key: 'synastry', label: '合盘' },
]
const filter = ref('all')
const items = ref([])
const loading = ref(true)
const error = ref('')
const filtered = computed(() =>
filter.value === 'all' ? items.value : items.value.filter((r) => r.type === filter.value),
)
async function load() {
loading.value = true
error.value = ''
if (!(await ensureAccount('/reports'))) {
loading.value = false
return
}
try {
const res = await yxgApi.listReports()
items.value = res.items || []
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
onShow(load)
</script>
<style>
page { background-color: #ffd1c7; }
</style>
<style scoped>
.chips { white-space: nowrap; padding: 8px 16px; }
.chip {
display: inline-block; margin-right: 8px; padding: 6px 12px; border-radius: 999px;
background: #fff; font-size: 13px; color: #666;
}
.chip.on { background: #e54d42; color: #fff; }
.vip {
margin: 0 16px 8px; padding: 12px 14px; border-radius: 14px;
background: linear-gradient(145deg, #fff4e0, #ffd98a);
}
.vip-t { display: block; font-weight: 700; color: #8a5a18; }
.vip-s { display: block; font-size: 12px; color: #a07838; margin-top: 2px; }
.list { margin: 0 16px; background: #fff; border-radius: 16px; }
.row { display: flex; align-items: center; gap: 12px; padding: 14px; border-bottom: 1px solid #f5f5f5; }
.row:last-child { border-bottom: none; }
.body { flex: 1; min-width: 0; }
.t { display: block; font-size: 15px; font-weight: 650; color: #222; }
.d { display: block; font-size: 12px; color: #666; margin-top: 2px; }
.m { display: block; font-size: 11px; color: #bbb; margin-top: 2px; }
.chev { color: #ccc; }
.hint, .err, .empty { display: block; padding: 16px; font-size: 13px; color: #999; text-align: center; }
.err { color: #e54d42; }
.btn {
margin: 12px auto 0; width: 140px; height: 40px; border-radius: 999px; background: #e54d42; color: #fff;
display: flex; align-items: center; justify-content: center; font-weight: 700;
}
</style>
+104
View File
@@ -0,0 +1,104 @@
<template>
<yxg-page title="身心节律">
<text v-if="loading" class="hint">加载中</text>
<text v-else-if="error" class="err">{{ error }}</text>
<view v-else-if="needBirth" class="card">
<text class="h1">看见身体与情绪的节奏</text>
<text class="desc">用生日生成今日身心节律建议</text>
<view class="date-row">
<input class="inp" type="number" v-model="year" placeholder="年" />
<input class="inp" type="number" v-model="month" placeholder="月" />
<input class="inp" type="number" v-model="day" placeholder="日" />
</view>
<view class="btn" @click="start"><text>查看节律</text></view>
</view>
<view v-else-if="report" class="card">
<text class="h1">{{ headline }}</text>
<text class="lead">{{ oneLiner }}</text>
<text v-if="bodyText" class="body">{{ bodyText }}</text>
<view class="navs">
<view class="btn ghost" @click="needBirth = true"><text>重新生成</text></view>
<view class="btn" @click="yxgGo(`/reports/${report.id}`)"><text>完整报告</text></view>
</view>
</view>
</yxg-page>
</template>
<script setup>
import YxgPage from '@/components/yxg/yxg-page.vue'
import { yxgApi } from '@/util/yxgApi.js'
import { ensureAccount, ensureSelfProfile, loadSelfLatest } from '@/util/yxgAuth.js'
import { yxgGo } from '@/util/yxgNav.js'
const loading = ref(false)
const needBirth = ref(true)
const error = ref('')
const report = ref(null)
const year = ref('')
const month = ref('')
const day = ref('')
const summary = computed(() => report.value?.summary || {})
const headline = computed(() => String(summary.value.headline || '身心节律'))
const oneLiner = computed(() => String(summary.value.one_liner || summary.value.overview || ''))
const bodyText = computed(() => String(report.value?.detail?.narrative || report.value?.detail?.body_text || ''))
async function start() {
const y = Number(year.value), m = Number(month.value), d = Number(day.value)
if (!y || !m || !d) {
error.value = '请填写完整生日'
return
}
loading.value = true
error.value = ''
try {
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const profile = await ensureSelfProfile({ birth_date: birth, display_name: '我' })
report.value = await yxgApi.createRhythm(profile.id)
needBirth.value = false
} catch (e) {
error.value = e instanceof Error ? e.message : '生成失败'
} finally {
loading.value = false
}
}
async function load() {
if (!(await ensureAccount('/rhythm'))) return
loading.value = true
try {
const latest = await loadSelfLatest('rhythm')
if (latest.report) {
report.value = latest.report
needBirth.value = false
} else if (latest.profile?.birth_date) {
const [y, m, d] = String(latest.profile.birth_date).split('-')
year.value = y; month.value = m; day.value = d
}
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
onShow(load)
</script>
<style>
page { background-color: #ffd1c7; }
</style>
<style scoped>
.hint, .err { display: block; padding: 12px 16px; font-size: 13px; color: #999; }
.err { color: #e54d42; }
.card { margin: 12px 16px; padding: 18px 16px; background: #fff; border-radius: 18px; }
.h1 { display: block; font-size: 20px; font-weight: 700; color: #333; }
.desc, .lead, .body { display: block; margin-top: 10px; font-size: 14px; color: #555; line-height: 1.65; }
.date-row { display: flex; gap: 8px; margin: 14px 0; }
.inp { flex: 1; height: 42px; background: #fdfaf8; border-radius: 12px; padding: 0 10px; text-align: center; }
.navs { display: flex; gap: 10px; margin-top: 16px; }
.btn {
flex: 1; height: 42px; border-radius: 999px; background: #e54d42; color: #fff;
display: flex; align-items: center; justify-content: center; font-weight: 700;
}
.btn.ghost { background: #f7f2ef; color: #8a4a3a; }
</style>
+207
View File
@@ -0,0 +1,207 @@
<template>
<yxg-page :title="title || '测评'">
<text v-if="loadingScale" class="hint">加载中</text>
<text v-else-if="loadError" class="err">{{ loadError }}</text>
<template v-else>
<view v-if="phase === 'intro'" class="card">
<text class="h1">{{ title }}</text>
<text class="desc">{{ description }}</text>
<text class="meta"> {{ estMinutes }} 分钟 · {{ questions.length }} </text>
<text v-if="locked" class="err">该测评需开通成长会员</text>
<view v-if="locked" class="btn" @click="yxgGo('/membership')"><text>去开通</text></view>
<view v-else class="btn" @click="startTest"><text>{{ draftRestored ? '继续作答' : '开始测评' }}</text></view>
</view>
<view v-else-if="phase === 'answering' && currentQuestion" class="card">
<text class="prog">{{ currentIdx + 1 }} / {{ questions.length }}</text>
<view class="bar"><view class="bar-in" :style="{ width: progressPct + '%' }" /></view>
<text class="prompt">{{ currentQuestion.body.prompt }}</text>
<view
v-for="opt in currentQuestion.body.options || []"
:key="opt.key"
class="opt"
:class="{ on: answers[currentQuestion.id] === opt.key }"
@click="pick(opt.key)"
>
<text>{{ opt.text }}</text>
</view>
<view class="navs">
<view v-if="currentIdx > 0" class="btn ghost" @click="currentIdx--"><text>上一题</text></view>
<view
class="btn"
:class="{ off: !answers[currentQuestion.id] || submitting }"
@click="onNext"
>
<text>{{ submitting ? '提交中…' : isLast ? '提交' : '下一题' }}</text>
</view>
</view>
<text v-if="submitError" class="err">{{ submitError }}</text>
</view>
<view v-else-if="phase === 'result'" class="card">
<text class="h1">{{ resultLabel }}</text>
<text class="desc">{{ shareLine || resultRaw?.summary || '' }}</text>
<text v-if="resultRaw?.overview" class="body">{{ resultRaw.overview }}</text>
<view class="navs">
<view class="btn ghost" @click="retake"><text>再测一次</text></view>
<view class="btn" @click="yxgGo('/reports')"><text>我的报告</text></view>
</view>
</view>
</template>
</yxg-page>
</template>
<script setup>
import YxgPage from '@/components/yxg/yxg-page.vue'
import { yxgApi } from '@/util/yxgApi.js'
import { ensureAccount, findSelfProfile } from '@/util/yxgAuth.js'
import { yxgGo } from '@/util/yxgNav.js'
const DRAFT_KEY = 'yxg_scale_draft_'
const slug = ref('')
const title = ref('')
const description = ref('')
const questions = ref([])
const answers = ref({})
const loadingScale = ref(true)
const submitting = ref(false)
const loadError = ref('')
const submitError = ref('')
const locked = ref(false)
const phase = ref('intro')
const resultLabel = ref('')
const shareLine = ref('')
const resultRaw = ref(null)
const draftRestored = ref(false)
const currentIdx = ref(0)
const currentQuestion = computed(() => questions.value[currentIdx.value] || null)
const isLast = computed(() => currentIdx.value >= questions.value.length - 1)
const progressPct = computed(() => {
if (!questions.value.length) return 0
return Math.round(((currentIdx.value + 1) / questions.value.length) * 100)
})
const estMinutes = computed(() => Math.max(1, Math.ceil(questions.value.length / 3)))
function pick(key) {
answers.value = { ...answers.value, [currentQuestion.value.id]: key }
uni.setStorageSync(DRAFT_KEY + slug.value, answers.value)
}
function startTest() {
if (locked.value) return
phase.value = 'answering'
if (!draftRestored.value) currentIdx.value = 0
}
function retake() {
uni.removeStorageSync(DRAFT_KEY + slug.value)
answers.value = {}
draftRestored.value = false
resultRaw.value = null
currentIdx.value = 0
phase.value = 'intro'
}
async function onNext() {
if (!answers.value[currentQuestion.value.id] || submitting.value) return
if (!isLast.value) {
currentIdx.value++
return
}
submitting.value = true
submitError.value = ''
try {
const self = await findSelfProfile()
if (!self) {
submitError.value = '请先完善自己的生日档案'
yxgGo('/profile')
return
}
const out = await yxgApi.submitScale(slug.value, self.id, answers.value)
uni.removeStorageSync(DRAFT_KEY + slug.value)
resultRaw.value = out.result || out
resultLabel.value = String(resultRaw.value.label || '探索结果')
shareLine.value = String(resultRaw.value.share_line || '')
phase.value = 'result'
} catch (e) {
submitError.value = e instanceof Error ? e.message : '提交失败'
} finally {
submitting.value = false
}
}
async function load() {
loadingScale.value = true
loadError.value = ''
if (!(await ensureAccount(`/scales/${slug.value}`))) {
loadingScale.value = false
return
}
try {
const d = await yxgApi.getScale(slug.value)
title.value = d.title
description.value = d.description
locked.value = !!d.locked
questions.value = (d.questions || []).map((q) => ({
id: q.id,
body: typeof q.body === 'string' ? JSON.parse(q.body) : q.body,
}))
if (locked.value) {
phase.value = 'intro'
return
}
try {
const latest = await yxgApi.getScaleResult(slug.value)
resultRaw.value = latest.result || latest
resultLabel.value = String(resultRaw.value.label || '探索结果')
shareLine.value = String(resultRaw.value.share_line || '')
phase.value = 'result'
return
} catch { /* no result */ }
const draft = uni.getStorageSync(DRAFT_KEY + slug.value)
if (draft && typeof draft === 'object') {
answers.value = draft
draftRestored.value = Object.keys(draft).length > 0
}
phase.value = 'intro'
} catch (e) {
loadError.value = e instanceof Error ? e.message : '加载失败'
} finally {
loadingScale.value = false
}
}
onLoad((q) => {
slug.value = q.slug || ''
load()
})
</script>
<style>
page { background-color: #ffd1c7; }
</style>
<style scoped>
.hint, .err { display: block; padding: 16px; font-size: 13px; color: #999; }
.err { color: #e54d42; }
.card {
margin: 12px 16px 0; padding: 18px 16px; background: #fff; border-radius: 18px;
}
.h1 { display: block; font-size: 20px; font-weight: 700; color: #333; }
.desc, .body, .meta { display: block; margin-top: 8px; font-size: 13px; color: #666; line-height: 1.6; }
.prog { font-size: 12px; color: #999; }
.bar { height: 6px; background: #f3ece8; border-radius: 99px; margin: 8px 0 14px; overflow: hidden; }
.bar-in { height: 100%; background: #e54d42; }
.prompt { display: block; font-size: 16px; font-weight: 650; color: #222; margin-bottom: 12px; line-height: 1.5; }
.opt {
padding: 12px; border-radius: 12px; background: #fdfaf8; margin-bottom: 8px; font-size: 14px; color: #333;
}
.opt.on { background: #ffe4e4; color: #e54d42; font-weight: 650; }
.navs { display: flex; gap: 10px; margin-top: 12px; }
.btn {
flex: 1; height: 42px; border-radius: 999px; background: #e54d42; color: #fff;
display: flex; align-items: center; justify-content: center; font-weight: 700;
}
.btn.ghost { background: #f7f2ef; color: #8a4a3a; }
.btn.off { opacity: 0.45; }
</style>
+77
View File
@@ -0,0 +1,77 @@
<template>
<yxg-page title="分享">
<view v-if="!report && !payload" class="card">
<text class="h1">分享内容无效</text>
<text class="desc">可以从首页重新开始</text>
<view class="btn" @click="yxgGo('/')"><text>回首页</text></view>
</view>
<view v-else class="card">
<yxg-tool-icon :name="icon" :size="48" />
<text class="h1">{{ title }}</text>
<text class="desc">{{ line }}</text>
<view class="btn" @click="shareNow"><text>转发给朋友</text></view>
<view class="btn ghost" @click="yxgGo(ctaTo)"><text>{{ ctaText }}</text></view>
<text class="disc">本内容为自我探索与生活方式参考不构成医疗建议亦非占卜预测</text>
</view>
</yxg-page>
</template>
<script setup>
import YxgPage from '@/components/yxg/yxg-page.vue'
import YxgToolIcon from '@/components/yxg/yxg-tool-icon.vue'
import { yxgApi } from '@/util/yxgApi.js'
import { headlineOf, toolIconFor } from '@/util/yxgCatalog.js'
import { yxgGo } from '@/util/yxgNav.js'
const report = ref(null)
const payload = ref(null)
const title = computed(() => payload.value?.title || headlineOf(report.value) || '愈心谷分享')
const line = computed(() => payload.value?.line || report.value?.summary?.one_liner || '来看看这份探索')
const icon = computed(() => toolIconFor(payload.value?.type || report.value?.type || 'portrait'))
const ctaTo = computed(() => {
const t = payload.value?.type || report.value?.type
if (t === 'relation') return '/relation'
if (t === 'star') return '/star'
if (t === 'synastry') return '/synastry'
return '/portrait'
})
const ctaText = computed(() => '我也去看看')
function shareNow() {
uni.showShareMenu({ withShareTicket: true, menus: ['shareAppMessage', 'shareTimeline'] })
uni.showToast({ title: '请点击右上角分享', icon: 'none' })
}
onShareAppMessage(() => ({
title: title.value,
path: '/pages/yxg-magic/index',
}))
onLoad(async (q) => {
if (q.id) {
try {
report.value = await yxgApi.getReport(q.id)
} catch {
report.value = null
}
}
if (q.title || q.line) {
payload.value = { title: q.title, line: q.line, type: q.type }
}
})
</script>
<style>
page { background-color: #ffd1c7; }
</style>
<style scoped>
.card { margin: 12px 16px; padding: 20px 16px; background: #fff; border-radius: 18px; }
.h1 { display: block; margin-top: 10px; font-size: 20px; font-weight: 700; color: #333; }
.desc { display: block; margin-top: 8px; font-size: 14px; color: #555; line-height: 1.6; }
.disc { display: block; margin-top: 14px; font-size: 11px; color: #bbb; }
.btn {
margin-top: 14px; height: 42px; border-radius: 999px; background: #e54d42; color: #fff;
display: flex; align-items: center; justify-content: center; font-weight: 700;
}
.btn.ghost { background: #f7f2ef; color: #8a4a3a; }
</style>
+104
View File
@@ -0,0 +1,104 @@
<template>
<yxg-page title="星座">
<text v-if="loading" class="hint">加载中</text>
<text v-else-if="error" class="err">{{ error }}</text>
<view v-else-if="needBirth" class="card">
<text class="h1">本命盘 · 相位 · 日周月运势</text>
<text class="desc">用生日生成星座排盘与性格解读</text>
<view class="date-row">
<input class="inp" type="number" v-model="year" placeholder="年" />
<input class="inp" type="number" v-model="month" placeholder="月" />
<input class="inp" type="number" v-model="day" placeholder="日" />
</view>
<view class="btn" @click="start"><text>查看星盘</text></view>
</view>
<view v-else-if="report" class="card">
<text class="h1">{{ headline }}</text>
<text class="lead">{{ oneLiner }}</text>
<text v-if="bodyText" class="body">{{ bodyText }}</text>
<view class="navs">
<view class="btn ghost" @click="needBirth = true"><text>重新生成</text></view>
<view class="btn" @click="yxgGo(`/reports/${report.id}`)"><text>完整报告</text></view>
</view>
</view>
</yxg-page>
</template>
<script setup>
import YxgPage from '@/components/yxg/yxg-page.vue'
import { yxgApi } from '@/util/yxgApi.js'
import { ensureAccount, ensureSelfProfile, loadSelfLatest } from '@/util/yxgAuth.js'
import { yxgGo } from '@/util/yxgNav.js'
const loading = ref(false)
const needBirth = ref(true)
const error = ref('')
const report = ref(null)
const year = ref('')
const month = ref('')
const day = ref('')
const summary = computed(() => report.value?.summary || {})
const headline = computed(() => String(summary.value.headline || summary.value.sign || '星座排盘'))
const oneLiner = computed(() => String(summary.value.one_liner || summary.value.overview || ''))
const bodyText = computed(() => String(report.value?.detail?.narrative || report.value?.detail?.body_text || ''))
async function start() {
const y = Number(year.value), m = Number(month.value), d = Number(day.value)
if (!y || !m || !d) {
error.value = '请填写完整生日'
return
}
loading.value = true
error.value = ''
try {
const birth = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const profile = await ensureSelfProfile({ birth_date: birth, display_name: '我' })
report.value = await yxgApi.createStar(profile.id)
needBirth.value = false
} catch (e) {
error.value = e instanceof Error ? e.message : '生成失败'
} finally {
loading.value = false
}
}
async function load() {
if (!(await ensureAccount('/star'))) return
loading.value = true
try {
const latest = await loadSelfLatest('star')
if (latest.report) {
report.value = latest.report
needBirth.value = false
} else if (latest.profile?.birth_date) {
const [y, m, d] = String(latest.profile.birth_date).split('-')
year.value = y; month.value = m; day.value = d
}
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
} finally {
loading.value = false
}
}
onShow(load)
</script>
<style>
page { background-color: #ffd1c7; }
</style>
<style scoped>
.hint, .err { display: block; padding: 12px 16px; font-size: 13px; color: #999; }
.err { color: #e54d42; }
.card { margin: 12px 16px; padding: 18px 16px; background: #fff; border-radius: 18px; }
.h1 { display: block; font-size: 20px; font-weight: 700; color: #333; }
.desc, .lead, .body { display: block; margin-top: 10px; font-size: 14px; color: #555; line-height: 1.65; }
.date-row { display: flex; gap: 8px; margin: 14px 0; }
.inp { flex: 1; height: 42px; background: #fdfaf8; border-radius: 12px; padding: 0 10px; text-align: center; }
.navs { display: flex; gap: 10px; margin-top: 16px; }
.btn {
flex: 1; height: 42px; border-radius: 999px; background: #e54d42; color: #fff;
display: flex; align-items: center; justify-content: center; font-weight: 700;
}
.btn.ghost { background: #f7f2ef; color: #8a4a3a; }
</style>
+110
View File
@@ -0,0 +1,110 @@
<template>
<yxg-page title="合盘邀请">
<text v-if="loading" class="hint">加载邀请</text>
<template v-else-if="meta">
<view class="card">
<text class="h1">{{ meta.host_name || '好友' }} 邀请你合盘</text>
<text class="desc">填写生日一起看见彼此的星盘互动与相处参考</text>
<view v-if="meta.already_accepted">
<text class="meta">该邀请已使用可直接去合盘页再测</text>
<view class="btn" @click="yxgGo('/synastry')"><text>回合盘页</text></view>
</view>
<view v-else>
<input class="inp" v-model="name" placeholder="你的称呼" />
<view class="date-row">
<input class="inp sm" type="number" v-model="ty" placeholder="年" />
<input class="inp sm" type="number" v-model="tm" placeholder="月" />
<input class="inp sm" type="number" v-model="td" placeholder="日" />
</view>
<text v-if="error" class="err">{{ error }}</text>
<view class="btn" :class="{ off: submitting }" @click="accept">
<text>{{ submitting ? '生成中…' : '接受并合盘' }}</text>
</view>
</view>
</view>
</template>
<view v-else class="card">
<text class="desc">{{ error || '邀请无效或已过期' }}</text>
<view class="btn" @click="yxgGo('/synastry')"><text>去合盘页</text></view>
</view>
</yxg-page>
</template>
<script setup>
import YxgPage from '@/components/yxg/yxg-page.vue'
import { yxgApi } from '@/util/yxgApi.js'
import { ensureAccount } from '@/util/yxgAuth.js'
import { yxgGo } from '@/util/yxgNav.js'
const token = ref('')
const loading = ref(true)
const submitting = ref(false)
const error = ref('')
const name = ref('我')
const ty = ref('')
const tm = ref('')
const td = ref('')
const meta = ref(null)
async function load() {
loading.value = true
if (!(await ensureAccount(`/synastry/invite/${token.value}`))) {
loading.value = false
return
}
try {
meta.value = await yxgApi.getSynastryInvite(token.value)
} catch (e) {
error.value = e instanceof Error ? e.message : '邀请无效'
} finally {
loading.value = false
}
}
async function accept() {
const y = Number(ty.value), m = Number(tm.value), d = Number(td.value)
if (!y || !m || !d) {
error.value = '请填写完整生日'
return
}
submitting.value = true
error.value = ''
try {
const report = await yxgApi.acceptSynastryInvite(token.value, {
display_name: name.value.trim() || '我',
birth_date: `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`,
})
const id = report.id || report.report?.id
if (id) yxgGo(`/reports/${id}`)
else yxgGo('/synastry')
} catch (e) {
error.value = e instanceof Error ? e.message : '接受失败'
} finally {
submitting.value = false
}
}
onLoad((q) => {
token.value = q.token || ''
load()
})
</script>
<style>
page { background-color: #ffd1c7; }
</style>
<style scoped>
.hint, .err { display: block; padding: 12px 16px; font-size: 13px; color: #999; }
.err { color: #e54d42; }
.card { margin: 12px 16px; padding: 18px 16px; background: #fff; border-radius: 18px; }
.h1 { display: block; font-size: 20px; font-weight: 700; color: #333; }
.desc, .meta { display: block; margin-top: 8px; font-size: 14px; color: #555; }
.inp { width: 100%; height: 42px; background: #fdfaf8; border-radius: 12px; padding: 0 12px; box-sizing: border-box; margin-top: 12px; }
.date-row { display: flex; gap: 8px; }
.inp.sm { flex: 1; text-align: center; }
.btn {
margin-top: 14px; height: 42px; border-radius: 999px; background: #e54d42; color: #fff;
display: flex; align-items: center; justify-content: center; font-weight: 700;
}
.btn.off { opacity: 0.5; }
</style>
+141
View File
@@ -0,0 +1,141 @@
<template>
<yxg-page title="合盘">
<view v-if="!report" class="card">
<text class="h1">恋爱 / 友情 / 婚姻指数</text>
<text class="desc">选择档案中的两个人或填写 TA 的生日生成合盘</text>
<picker :range="labels" :value="idxA" @change="idxA = Number($event.detail.value)">
<view class="pick"><text> / A{{ labels[idxA] || '请选择' }}</text></view>
</picker>
<picker :range="labels" :value="idxB" @change="idxB = Number($event.detail.value)">
<view class="pick"><text>TA / B{{ labels[idxB] || '请选择' }}</text></view>
</picker>
<text class="or">没有 TA 的档案填写生日快速添加</text>
<input class="inp" v-model="otherName" placeholder="TA 的称呼" />
<view class="date-row">
<input class="inp sm" type="number" v-model="oy" placeholder="年" />
<input class="inp sm" type="number" v-model="om" placeholder="月" />
<input class="inp sm" type="number" v-model="od" placeholder="日" />
</view>
<text v-if="error" class="err">{{ error }}</text>
<view class="btn" :class="{ off: loading }" @click="run"><text>{{ loading ? '生成中…' : '开始合盘' }}</text></view>
<view class="btn ghost" @click="invite"><text>{{ inviting ? '生成邀请…' : '生成邀请链接' }}</text></view>
</view>
<view v-else class="card">
<text class="h1">{{ headline }}</text>
<text class="lead">{{ oneLiner }}</text>
<view class="navs">
<view class="btn ghost" @click="report = null"><text>再测一次</text></view>
<view class="btn" @click="yxgGo(`/reports/${report.id}`)"><text>完整报告</text></view>
</view>
</view>
</yxg-page>
</template>
<script setup>
import YxgPage from '@/components/yxg/yxg-page.vue'
import { yxgApi } from '@/util/yxgApi.js'
import { ensureAccount, ensureOtherProfile, findSelfProfile, pickableArchiveList } from '@/util/yxgAuth.js'
import { yxgGo } from '@/util/yxgNav.js'
const items = ref([])
const idxA = ref(0)
const idxB = ref(0)
const otherName = ref('TA')
const oy = ref('')
const om = ref('')
const od = ref('')
const loading = ref(false)
const inviting = ref(false)
const error = ref('')
const report = ref(null)
const labels = computed(() =>
items.value.map((p) => p.display_name || (p.relation === 'self' ? '我' : 'TA')),
)
const summary = computed(() => report.value?.summary || {})
const headline = computed(() => String(summary.value.headline || '合盘'))
const oneLiner = computed(() => String(summary.value.one_liner || summary.value.overview || ''))
async function boot() {
if (!(await ensureAccount('/synastry'))) return
try {
const res = await yxgApi.listProfiles()
items.value = pickableArchiveList(res.items || [])
const selfIdx = items.value.findIndex((p) => p.relation === 'self')
idxA.value = selfIdx >= 0 ? selfIdx : 0
idxB.value = items.value.length > 1 ? (selfIdx === 0 ? 1 : 0) : 0
} catch (e) {
error.value = e instanceof Error ? e.message : '加载失败'
}
}
async function run() {
loading.value = true
error.value = ''
try {
let a = items.value[idxA.value]
let b = items.value[idxB.value]
const y = Number(oy.value), m = Number(om.value), d = Number(od.value)
if ((!b || a?.id === b?.id) && y && m && d) {
b = await ensureOtherProfile({
birth_date: `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`,
display_name: otherName.value || 'TA',
})
}
if (!a) a = await findSelfProfile()
if (!a || !b || a.id === b.id) {
error.value = '请选择两位不同的人,或填写 TA 的生日'
return
}
report.value = await yxgApi.createSynastry(a.id, b.id)
} catch (e) {
error.value = e instanceof Error ? e.message : '生成失败'
} finally {
loading.value = false
}
}
async function invite() {
inviting.value = true
try {
const self = await findSelfProfile()
if (!self) {
yxgGo('/profile')
return
}
const inv = await yxgApi.createSynastryInvite(self.id)
const token = inv.token || inv.id
uni.setClipboardData({
data: token,
success: () => uni.showToast({ title: '邀请码已复制', icon: 'none' }),
})
} catch (e) {
uni.showToast({ title: e.message || '生成失败', icon: 'none' })
} finally {
inviting.value = false
}
}
onShow(boot)
</script>
<style>
page { background-color: #ffd1c7; }
</style>
<style scoped>
.card { margin: 12px 16px; padding: 18px 16px; background: #fff; border-radius: 18px; }
.h1 { display: block; font-size: 20px; font-weight: 700; color: #333; }
.desc, .lead, .or { display: block; margin-top: 8px; font-size: 13px; color: #666; }
.pick { margin-top: 10px; padding: 12px; background: #fdfaf8; border-radius: 12px; font-size: 14px; }
.inp { width: 100%; height: 42px; background: #fdfaf8; border-radius: 12px; padding: 0 12px; box-sizing: border-box; margin-top: 10px; }
.date-row { display: flex; gap: 8px; }
.inp.sm { flex: 1; text-align: center; }
.err { display: block; margin-top: 8px; color: #e54d42; font-size: 13px; }
.navs { display: flex; gap: 10px; margin-top: 16px; }
.btn {
height: 42px; border-radius: 999px; background: #e54d42; color: #fff;
display: flex; align-items: center; justify-content: center; font-weight: 700; margin-top: 10px;
}
.btn.ghost { background: #f7f2ef; color: #8a4a3a; }
.btn.off { opacity: 0.5; }
</style>
+38 -42
View File
@@ -1,52 +1,48 @@
//统一设置API接口地址
// 统一打 Go /api/v1ECR-050 咨询域已原生,不再反代 Java)
let urls = {
// 七牛相关API
upload: `/api/v1/upload`,
newPageUrl: `/api/v1/psychic/news/page`,
newDetailUrl: `/api/v1/psychic/news/get`,
loginUrl: `/api/v1/auth/wechat`,
getBanner: `/api/v1/psychic/banner/all`,
psychicSave: `/api/v1/psychic/save`,
getPsychicListTest: `/api/v1/psychic/study/list`,
getSingleDetail: `/api/v1/psychic/study/getSingleDetail`,
userChoiceSave: `/api/v1/psychic/user-choice/save`,
getResult: `/api/v1/psychic/user-choice/getResult`,
myTest: `/api/v1/psychic/user-choice/my-test`,
businessScopeList: `/api/v1/psychic/doctor-info/business-scope-list`,
getDoctorInfo: `/api/v1/psychic/doctor-info/get`,
doctorInfopage: `/api/v1/psychic/doctor-info/page`,
focus: `/api/v1/psychic/doctor-info/focus`,
cancelFocus: `/api/v1/psychic/doctor-info/cancel-focus`,
appointTotalList: `/api/v1/psychic/appointment/remain-list`,
dateDetailList: `/api/v1/psychic/appointment/date-detail-list`,
getShowInfo: `/api/v1/psychic/doctor-info/get-show-info`,
createOrder: `/api/v1/psychic/pay/createOrder`,
refund: `/api/v1/psychic/pay/refund`,
upload: `/admin-api/infra/file/upload`,
newPageUrl: `/app-api/psychic/news/page`,
newDetailUrl: `/app-api/psychic/news/get`,
loginUrl: `/app-api/oauth/app/login`,
getBanner: `/app-api/psychic/banner/all`,
psychicSave: `/app-api/psychic/save`,
getPsychicListTest: `/app-api/psychic/study/list`,
getSingleDetail: `/app-api/psychic/study/getSingleDetail`,
userChoiceSave: `/app-api/psychic/user-choice/save`,
getResult: `/app-api/psychic/user-choice/getResult`,
myTest: `/app-api/psychic/user-choice/my-test`,
businessScopeList: `/app-api/psychic/doctor-info/business-scope-list`,
getDoctorInfo: `/app-api/psychic/doctor-info/get`,
doctorInfopage: `/app-api/psychic/doctor-info/page`,
focus: `/app-api/psychic/doctor-info/focus`,
cancelFocus: `/app-api/psychic/doctor-info/cancel-focus`,
appointTotalList: `/app-api/psychic/appointment/remain-list`,
dateDetailList: `/app-api/psychic/appointment/date-detail-list`,
getShowInfo: `/app-api/psychic/doctor-info/get-show-info`,
createOrder: `/app-api/psychic/pay/createOrder`,
refund: `/app-api/psychic/pay/refund`,
getSelfInfo: `/api/v1/psychic/platform-user/getSelfInfo`,
updateUserInfo: `/api/v1/psychic/platform-user/update`,
getSelfInfo: `/app-api/psychic/platform-user/getSelfInfo`,
updateUserInfo: `/app-api/psychic/platform-user/update`,
notReadNum: `/app-api/psychic/platform-user/not-read-num`,
focusToRead: `/app-api/psychic/platform-user/focus-to-read`,
orderToRead: `/app-api/psychic/platform-user/order-to-read`,
userFeedback: `/app-api/psychic/platform-user/user-feedback`,
feedbackFlag: `/app-api/psychic/platform-user/feedback-flag`,
notReadNum: `/api/v1/psychic/platform-user/not-read-num`,
focusToRead: `/api/v1/psychic/platform-user/focus-to-read`,
orderToRead: `/api/v1/psychic/platform-user/order-to-read`,
userFeedback: `/api/v1/psychic/platform-user/user-feedback`,
feedbackFlag: `/api/v1/psychic/platform-user/feedback-flag`,
focusAll: `/app-api/psychic/platform-user/focus-all`,
getByCode: `/app-api/psychic/procotol/getByCode`,
focusAll: `/api/v1/psychic/platform-user/focus-all`,
getByCode: `/api/v1/psychic/procotol/getByCode`,
createZxOrder: `/api/v1/psychic/order/create`,
// 订单
createZxOrder: `/app-api/psychic/order/create`,
orderList: `/app-api/psychic/order/order-list`,
orderDetail: `/app-api/psychic/order/order-detail`,
getPayParam: `/app-api/psychic/order/get-pay-param`,
cancelOrder: `/app-api/psychic/order/cancel`,
deleteOrder: `/app-api/psychic/order/delete-order`,
orderList: `/api/v1/psychic/order/order-list`,
orderDetail: `/api/v1/psychic/order/order-detail`,
getPayParam: `/api/v1/psychic/order/get-pay-param`,
cancelOrder: `/api/v1/psychic/order/cancel`,
deleteOrder: `/api/v1/psychic/order/delete-order`,
}
export {
urls
}
}
+52 -55
View File
@@ -2,13 +2,10 @@ import {
sysConsts
} from '../common/sysConsts.js';
import request from "./request";
// 全局配置的请求域名
// const baseUrl = 'http://192.168.100.19:8922'; //线上正式环境888
// const baseUrl = 'https://api-qa.scyuelai.com/'; //qa测试环境
// const baseUrl = 'https://api-zsh-dev.scyuelai.com/' //dev开发环境
// const baseUrl = 'https://local.scyuelai.com/'; //本地测试环境
const baseUrl = "https://miniapp.yuxingu.com.cn" //https://miniapp.yuxingu.com.cn
// const baseUrl = "http://8.137.99.227:8922"
import { YXG_API_BASE } from './yxgConfig.js'
const DEVICE_KEY = 'yxg_device_key'
const baseUrl = YXG_API_BASE
//可以new多个request来支持多个域名请求
@@ -69,11 +66,15 @@ $http.requestStart = function(options) {
}
}
//请求前加入token
let myToken = uni.getStorageSync('token')
let myToken = uni.getStorageSync('token') || uni.getStorageSync('yxg_token')
if (myToken) {
myToken = myToken.replace(/\"/g, "");
myToken = String(myToken).replace(/\"/g, "");
options.header['Authorization'] = 'Bearer ' + myToken;
}
const device = uni.getStorageSync(DEVICE_KEY)
if (device) {
options.header['X-Device-Key'] = device
}
return options; // return false 表示请求拦截,不会继续请求
}
@@ -97,19 +98,27 @@ $http.dataFactory = async function(res) {
// data: res.data,
// method: res.method,
// });
if (res.response.statusCode && res.response.statusCode == 200) {
let httpData = res.response.data;
if (typeof(httpData) == "string") {
httpData = JSON.parse(httpData);
const respHeader = res.response.header || res.response.headers || {}
const deviceOut = respHeader['X-Device-Key'] || respHeader['x-device-key']
if (deviceOut) {
uni.setStorageSync(DEVICE_KEY, deviceOut)
}
let httpData = res.response.data
if (typeof(httpData) == "string") {
try {
httpData = JSON.parse(httpData)
} catch (e) {
httpData = null
}
}
const errText = (httpData && (httpData.info || httpData.msg || httpData.message)) || ''
if (res.response.statusCode && res.response.statusCode == 200) {
/*********以下只是模板(及共参考),需要开发者根据各自的接口返回类型修改*********/
//判断数据是否请求成功
if (httpData.success || httpData.code == 0) {
if (httpData && (httpData.success || httpData.code == 0)) {
// 返回正确的结果(then接受数据)
return Promise.resolve(httpData.data);
} else if (httpData.code == "401" || httpData.code == "1001" || httpData.code == 1100) {
} else if (httpData.code == "401" || httpData.code == "1001" || httpData.code == 1100 || httpData.code == 40100 || httpData.code == 40112) {
// let content = '此时此刻需要您登录喔~';
// if (!uni.getStorageSync('loginPageAlive')) {
// await gotoLogin().then(()=>{
@@ -121,13 +130,13 @@ $http.dataFactory = async function(res) {
// 返回错误的结果(catch接受数据)
return Promise.reject({
statusCode: 0,
errMsg: "【request】" + (httpData.info || httpData.msg)
errMsg: "【request】" + errText
});
} else { //其他错误提示
if (res.isPrompt) {
setTimeout(() => {
uni.showToast({
title: httpData.info || httpData.msg,
title: errText || '请求失败',
icon: "none",
duration: 3000
});
@@ -136,16 +145,25 @@ $http.dataFactory = async function(res) {
// 返回错误的结果(catch接受数据)
return Promise.reject({
statusCode: 0,
errMsg: "【request】" + (httpData.info || httpData.msg)
errMsg: "【request】" + errText
});
}
} else if (res.response.statusCode && res.response.statusCode == 401) {
gotoLogin(res)
} else {
// 返回错误的结果(catch接受数据)
const text = errText || ("HTTP " + res.response.statusCode)
if (res.isPrompt) {
setTimeout(() => {
uni.showToast({
title: text,
icon: "none",
duration: 3000
});
}, 100)
}
return Promise.reject({
statusCode: res.response.statusCode,
errMsg: "【request】数据工厂验证不通过"
statusCode: 0,
errMsg: "【request】" + text
});
}
};
@@ -167,38 +185,17 @@ $http.requestError = function(e) {
//token过期,退出登录
function gotoLogin(res) {
uni.removeStorageSync("token")
uni.removeStorageSync("yxg_token")
uni.removeStorageSync("userinfo")
console.log('res', res)
// uni.navigateTo({
// "url": "/pages/login-pop/login-pop?type=1",
// "animationType": "pop-in"
// })
// return new Promise((resolve, rejict)=>{
// // uni.switchTab({
// // "url":"/pages/login-pop/login-pop",
// // success:(res)=>{
// // // uni.showToast({
// // // title: "登录已失效,请重新登录",
// // // icon: "none"
// // // });
// // uni.showModal({
// // title: '提示',
// // content: '登录已失效,是否重新登录',
// // success: function (res) {
// // if (res.confirm) {
// // uni.navigateTo({
// // "url":"/pageLogin/user-detail/user-detail"
// // })
// // }
// // }
// // });
// // }
// // })
// resolve()
// })
const pages = typeof getCurrentPages === 'function' ? getCurrentPages() : []
const cur = pages.length ? pages[pages.length - 1] : null
const route = (cur && (cur.route || cur.__route__)) || ''
if (String(route).indexOf('login-pop') !== -1) {
return
}
uni.navigateTo({
url: "/pages/login-pop/login-pop?type=2",
animationType: "pop-in"
})
}
export default $http;
+163
View File
@@ -0,0 +1,163 @@
import { YXG_API_BASE, YXG_ASSET_BASE } from './yxgConfig.js'
import $http from './requestConfig.js'
const TOKEN_KEY = 'token'
const LEGACY_TOKEN_KEY = 'yxg_token'
const DEVICE_KEY = 'yxg_device_key'
export function getYxgToken() {
return uni.getStorageSync(TOKEN_KEY) || uni.getStorageSync(LEGACY_TOKEN_KEY) || ''
}
export function setYxgToken(token) {
if (token) {
uni.setStorageSync(TOKEN_KEY, token)
uni.removeStorageSync(LEGACY_TOKEN_KEY)
} else {
uni.removeStorageSync(TOKEN_KEY)
uni.removeStorageSync(LEGACY_TOKEN_KEY)
uni.removeStorageSync('userinfo')
}
}
export function getDeviceKey() {
return uni.getStorageSync(DEVICE_KEY) || ''
}
export function setDeviceKey(key) {
if (key) uni.setStorageSync(DEVICE_KEY, key)
}
export function assetURL(path) {
const p = String(path || '').trim()
if (!p) return ''
if (/^https?:\/\//i.test(p)) return p
return YXG_ASSET_BASE + (p.startsWith('/') ? p : `/${p}`)
}
function joinURL(base, path) {
if (/^https?:\/\//i.test(path)) return path
return `${base.replace(/\/$/, '')}${path.startsWith('/') ? path : `/${path}`}`
}
function request(method, path, body, extra = {}) {
return $http.request({
method,
url: path,
data: body === undefined ? {} : body,
isPrompt: extra.isPrompt ?? false,
load: extra.load ?? false,
timeout: extra.timeout,
}).catch((e) => {
const raw = e instanceof Error ? e.message : (e && e.errMsg) || '请求失败'
throw new Error(String(raw).replace(/^【request】/, ''))
})
}
export const yxgApi = {
authRegister: (body) => request('POST', '/api/v1/auth/register', body),
authLogin: (body) => request('POST', '/api/v1/auth/login', body),
authLogout: () => request('POST', '/api/v1/auth/logout', {}),
authMe: () => request('GET', '/api/v1/auth/me'),
authUpdateMe: (body) => request('PATCH', '/api/v1/auth/me', body),
authUploadAvatar(filePath) {
return new Promise((resolve, reject) => {
const header = {}
const token = getYxgToken()
const device = getDeviceKey()
if (token) header.Authorization = `Bearer ${token}`
if (device) header['X-Device-Key'] = device
uni.uploadFile({
url: joinURL(YXG_API_BASE, '/api/v1/auth/me/avatar'),
filePath,
name: 'file',
header,
success(res) {
let data = res.data
if (typeof data === 'string') {
try {
data = JSON.parse(data)
} catch {
reject(new Error('上传失败'))
return
}
}
if (!data || data.code !== 0) {
reject(new Error(data?.message || '上传失败'))
return
}
resolve(data.data)
},
fail(err) {
reject(new Error(err.errMsg || '上传失败'))
},
})
})
},
listProfiles: () => request('GET', '/api/v1/profiles'),
createProfile: (body) => request('POST', '/api/v1/profiles', body),
updateProfile: (id, body) => request('PATCH', `/api/v1/profiles/${id}`, body),
deleteProfile: (id) => request('DELETE', `/api/v1/profiles/${id}`),
createPortrait: (profile_id) =>
request('POST', '/api/v1/reports/portrait', { profile_id }),
createStar: (profile_id) => request('POST', '/api/v1/reports/star', { profile_id }),
createSynastry: (profile_id_a, profile_id_b, as_of) =>
request('POST', '/api/v1/reports/synastry', {
profile_id_a,
profile_id_b,
...(as_of ? { as_of } : {}),
}),
getLatestReport: (profile_id, type, peer_profile_id) => {
let q = `profile_id=${encodeURIComponent(profile_id)}&type=${encodeURIComponent(type)}`
if (peer_profile_id) q += `&peer_profile_id=${encodeURIComponent(peer_profile_id)}`
return request('GET', `/api/v1/reports/latest?${q}`)
},
listSynastryNearby: (lat, lng, radius_km = 50) =>
request('GET', `/api/v1/synastry/nearby?lat=${lat}&lng=${lng}&radius_km=${radius_km}`),
createSynastryInvite: (profile_id) =>
request('POST', '/api/v1/synastry/invites', { profile_id }),
getSynastryInvite: (token) => request('GET', `/api/v1/synastry/invites/${token}`),
acceptSynastryInvite: (token, body) =>
request('POST', `/api/v1/synastry/invites/${token}/accept`, body),
createRhythm: (profile_id) => request('POST', '/api/v1/reports/rhythm', { profile_id }),
listImageCardScenes: () => request('GET', '/api/v1/image-cards/scenes'),
getImageCardQuota: () => request('GET', '/api/v1/image-cards/quota'),
drawImageCard: (body) => request('POST', '/api/v1/image-cards/draw', body),
getSolarTermsToday: () => request('GET', '/api/v1/solar-terms/today'),
saveMood: (body) => request('POST', '/api/v1/moods', body),
getMoodToday: () => request('GET', '/api/v1/moods/today'),
getMoodsRecent: () => request('GET', '/api/v1/moods/recent'),
getExploreCatalog: () => request('GET', '/api/v1/explore/catalog'),
getExploreCategory: (key) => request('GET', `/api/v1/explore/catalog/${key}`),
listGrowthPlans: () => request('GET', '/api/v1/growth/plans'),
createGrowthPlan: (body) => request('POST', '/api/v1/growth/plans', body),
createGrowthCheckin: (planId, body) =>
request('POST', `/api/v1/growth/plans/${planId}/checkin`, body || {}),
listGrowthCheckins: (planId) => request('GET', `/api/v1/growth/plans/${planId}/checkins`),
listReports: () => request('GET', '/api/v1/reports'),
getReport: (id) => request('GET', `/api/v1/reports/${id}`),
getMembership: () => request('GET', '/api/v1/membership/me'),
createOrder: (body) => request('POST', '/api/v1/orders', body),
payMock: (orderId) => request('POST', `/api/v1/orders/${orderId}/pay-mock`, {}),
createRelationInsight: (profile_a_id, profile_b_id) =>
request('POST', '/api/v1/relation/insight', { profile_a_id, profile_b_id }),
listScales: () => request('GET', '/api/v1/scales'),
getScaleBankCatalog: () => request('GET', '/api/v1/scale-bank/catalog'),
getScaleBankCategory: (key) => request('GET', `/api/v1/scale-bank/categories/${key}`),
getScale: (slug) => request('GET', `/api/v1/scales/${slug}`),
getScaleResult: (slug) => request('GET', `/api/v1/scales/${slug}/result`),
submitScale: (slug, profile_id, answers) =>
request('POST', `/api/v1/scales/${slug}/result`, { profile_id, answers }),
getAskQuota: () => request('GET', '/api/v1/ask/quota'),
createAskThread: (body) => request('POST', '/api/v1/ask/threads', body),
clearAskThread: (threadId) => request('DELETE', `/api/v1/ask/threads/${threadId}`),
listAskMessages: (threadId) => request('GET', `/api/v1/ask/threads/${threadId}/messages`),
sendAskMessage: (threadId, content) =>
request('POST', `/api/v1/ask/threads/${threadId}/messages`, { content }, { timeout: 60000 }),
getHomeTools: () => request('GET', '/api/v1/home/tools'),
getHomeBanners: (placement = 'home') =>
request('GET', `/api/v1/home/banners?placement=${encodeURIComponent(placement)}`),
getHomeFeedSlots: (placement = 'home') =>
request('GET', `/api/v1/home/feed-slots?placement=${encodeURIComponent(placement)}`),
getHomeDailyTips: () => request('GET', '/api/v1/home/daily-tips'),
}
+126
View File
@@ -0,0 +1,126 @@
import { yxgApi, getYxgToken, setYxgToken } from './yxgApi.js'
let cachedNick = ''
export function setAccountNickname(v) {
cachedNick = String(v || '').trim()
}
export function selfDisplayName(fallback = '我') {
return cachedNick || fallback
}
export async function loadAccountNickname(force = false) {
if (cachedNick && !force) return cachedNick
try {
const me = await yxgApi.authMe()
cachedNick = (me.nickname || '').trim()
return cachedNick
} catch {
return cachedNick
}
}
export function openWechatLogin() {
const pages = typeof getCurrentPages === 'function' ? getCurrentPages() : []
const cur = pages.length ? pages[pages.length - 1] : null
const route = (cur && (cur.route || cur.__route__)) || ''
if (String(route).indexOf('login-pop') !== -1) return
uni.navigateTo({ url: '/pages/login-pop/login-pop?type=2' })
}
export async function ensureAccount(redirect) {
if (!getYxgToken()) {
openWechatLogin()
return null
}
try {
return await yxgApi.authMe()
} catch {
setYxgToken('')
openWechatLogin()
return null
}
}
export async function findSelfProfile() {
const { items } = await yxgApi.listProfiles()
return (items || []).find((p) => p.relation === 'self') || null
}
export async function ensureSelfProfile(input) {
await loadAccountNickname()
const nick = selfDisplayName('我')
const nameIn = (input.display_name || '').trim()
const preferred = !nameIn || nameIn === '我' ? nick : nameIn
const existing = await findSelfProfile()
if (existing) {
const keep = existing.display_name?.trim()
const display = keep && keep !== '我' ? keep : preferred
return yxgApi.updateProfile(existing.id, {
display_name: display,
birth_date: input.birth_date,
birth_time: input.birth_time,
birth_place: input.birth_place,
})
}
try {
return await yxgApi.createProfile({
relation: 'self',
birth_date: input.birth_date,
display_name: preferred,
birth_time: input.birth_time,
birth_place: input.birth_place,
})
} catch (e) {
const msg = e instanceof Error ? e.message : ''
if (!/already exists|已有|409|self profile/.test(msg)) throw e
const again = await findSelfProfile()
if (!again) throw e
const keep = again.display_name?.trim()
return yxgApi.updateProfile(again.id, {
display_name: keep && keep !== '我' ? keep : preferred,
birth_date: input.birth_date,
birth_time: input.birth_time,
birth_place: input.birth_place,
})
}
}
export async function ensureOtherProfile(input) {
const name = (input.display_name || 'TA').trim() || 'TA'
const birth = String(input.birth_date || '').slice(0, 10)
const { items } = await yxgApi.listProfiles()
const hit = (items || []).find(
(p) =>
p.relation === 'other' &&
String(p.birth_date).slice(0, 10) === birth &&
(p.display_name || 'TA') === name,
)
if (hit) return hit
return yxgApi.createProfile({
relation: 'other',
birth_date: birth,
display_name: name,
relation_type: input.relation_type,
birth_place: input.birth_place,
})
}
export function pickableArchiveList(items) {
const list = items || []
const self = list.find((p) => p.relation === 'self')
const others = list.filter((p) => p.relation === 'other')
return self ? [self, ...others] : others
}
export async function loadSelfLatest(type) {
const profile = await findSelfProfile()
if (!profile) return { profile: null, report: null }
try {
const report = await yxgApi.getLatestReport(profile.id, type)
return { profile, report }
} catch {
return { profile, report: null }
}
}
+139
View File
@@ -0,0 +1,139 @@
export const ICON_NAMES = [
'mbti', 'star', 'portrait', 'rhythm', 'synastry', 'astro',
'companion', 'ask', 'cards', 'reports', 'growth', 'relation',
'nine', 'eq', 'stress', 'sleep',
]
export function resolveIcon(raw, fallback = 'mbti') {
return ICON_NAMES.includes(raw) ? raw : fallback
}
export function toolIconFor(pathOrKey) {
const s = String(pathOrKey || '').toLowerCase()
if (s.includes('mbti') || s.includes('人格类型') || s.includes('tests')) return 'mbti'
if (s.includes('synastry') || s.includes('合盘')) return 'synastry'
if (s.includes('portrait') || s.includes('decode') || s.includes('解码')) return 'portrait'
if (s.includes('star') || s.includes('星座') || s.includes('星盘')) return 'star'
if (s.includes('rhythm') || s.includes('节律')) return 'rhythm'
if (s.includes('companion') || s.includes('节气') || s.includes('mood')) return 'companion'
if (s.includes('ask') || s.includes('问答')) return 'ask'
if (s.includes('card') || s.includes('意象')) return 'cards'
if (s.includes('report') || s.includes('报告')) return 'reports'
if (s.includes('growth') || s.includes('计划') || s.includes('会员') || s.includes('member')) return 'growth'
if (s.includes('relation') || s.includes('匹配') || s.includes('love')) return 'relation'
if (s.includes('astro') || s.includes('星象')) return 'astro'
if (s.includes('explore') || s.includes('探索') || s.includes('分类')) return 'mbti'
return 'portrait'
}
export function typeLabel(type) {
const map = {
portrait: '愈心解码',
star: '星座排盘',
rhythm: '身心节律',
synastry: '合盘',
relation: '人格匹配',
scale: '测评',
}
return map[type] || '成长报告'
}
export function formatDate(iso) {
if (!iso) return ''
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return String(iso).slice(0, 10)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
export function headlineOf(report) {
const s = report?.summary || {}
return s.headline || s.one_liner || s.label || s.title || typeLabel(report?.type)
}
export const homeGridRow1 = [
{ to: '/scales/mbti-lite', icon: 'mbti', label: 'MBTI测试' },
{ to: '/star', icon: 'star', label: '星座' },
{ to: '/portrait', icon: 'portrait', label: '愈心解码', badge: '热', badgeTone: 'hot' },
{ to: '/rhythm', icon: 'rhythm', label: '身心节律' },
{ to: '/synastry', icon: 'synastry', label: '合盘', badge: '新', badgeTone: 'new' },
{ to: '/star', icon: 'astro', label: '星象性格' },
]
export const homeGridRow2 = [
{ to: '/companion', icon: 'companion', label: '节气陪伴' },
{ to: '/ask', icon: 'ask', label: 'AI问答' },
{ to: '/cards', icon: 'cards', label: '意象卡片' },
{ to: '/reports', icon: 'reports', label: '成长报告', badge: '新', badgeTone: 'new' },
{ to: '/growth-plan', icon: 'growth', label: '成长计划' },
{ to: '/relation', icon: 'relation', label: '人格匹配' },
]
export const homeFeeds = [
{ to: '/portrait', icon: 'portrait', title: '愈心解码', meta: '一个生日,读懂性格与身心节奏', stat: '核心入口', tone: 'fc-e', tag: '热' },
{ to: '/ask', icon: 'ask', title: 'AI 成长助手', meta: '结合档案聊聊卡住的事', stat: '随时可问', tone: 'fc-a', tag: 'AI' },
{ to: '/star', icon: 'star', title: '星座排盘', meta: '本命盘 · 相位 · 日周月运势', stat: '本周热门', tone: 'fc-b', tag: '新' },
{ to: '/synastry', icon: 'synastry', title: '合盘', meta: '恋爱 / 友情 / 婚姻指数', stat: '了解彼此', tone: 'fc-c' },
{ to: '/scales/mbti-lite', icon: 'mbti', title: 'MBTI测试', meta: '四维偏好探索,看见自己的能量与决策节奏', stat: '热门测评', tone: 'fc-a' },
{ to: '/membership', icon: 'growth', title: '成长会员', meta: '深度报告与全年节气陪伴', stat: '解锁更多', tone: 'fc-e' },
]
export const mineGroupArchive = [
{ to: '/profile', label: '个人档案', icon: 'portrait' },
{ to: '/reports', label: '我的成长报告', icon: 'reports' },
{ to: '/portrait', label: '愈心解码', icon: 'portrait' },
{ to: '/star', label: '星象性格', icon: 'star' },
{ to: '/rhythm', label: '身心节律', icon: 'rhythm' },
{ to: '/cards', label: '意象卡片', icon: 'cards' },
]
export const mineGroupGrowth = [
{ to: '/relation', label: '人格匹配', icon: 'relation' },
{ to: '/ask', label: 'AI 成长助手', icon: 'ask' },
{ to: '/explore', label: '探索测试', icon: 'mbti' },
{ to: '/growth-plan', label: '成长计划', icon: 'growth' },
{ to: '/membership', label: '成长会员', icon: 'growth' },
]
const FEED_TONES = ['fc-e', 'fc-a', 'fc-b', 'fc-c', 'fc-a', 'fc-e']
export function mapHomeBannersToFeeds(items) {
const sorted = [...(items || [])].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
const out = []
for (let i = 0; i < sorted.length; i++) {
const it = sorted[i]
const to = String(it.link_path || '').trim()
if (!to.startsWith('/') || to.includes('://')) continue
out.push({
to,
icon: 'portrait',
title: it.title,
meta: '运营推荐',
stat: '推荐',
tone: FEED_TONES[i % FEED_TONES.length],
tag: '荐',
})
}
return out
}
export function mapHomeTools(items) {
const toTool = (it) => {
if (!ICON_NAMES.includes(it.icon)) return null
const t = { to: it.path, icon: it.icon, label: it.label }
if (it.badge) t.badge = it.badge
if (it.badge_tone === 'hot' || it.badge_tone === 'new') t.badgeTone = it.badge_tone
return t
}
const sorted = [...(items || [])].sort(
(a, b) => a.row_index - b.row_index || a.sort_order - b.sort_order,
)
const row1 = []
const row2 = []
for (const it of sorted) {
const t = toTool(it)
if (!t) continue
if (it.row_index === 1) row1.push(t)
else if (it.row_index === 2) row2.push(t)
}
return { row1, row2 }
}
+7 -12
View File
@@ -1,16 +1,11 @@
/** 愈心魔方 H5digital-psychology user-h5 */
/** 愈心魔方 + 咨询统一后台(Go)。本地请指向 Go API(:8080),不要指向 Vite。 */
import { LOCAL_H5_HOST, USE_LOCAL_H5 } from './yxgConfig.local.js'
const PROD_BASE = 'https://h5.yuxingu.com.cn/psy/'
const PROD_VERSION = '202608283'
/** 愈心魔方顶栏 logo(小程序包内 static,不请求外网) */
export const YXG_MP_LOGO_URL = '/static/n-main/yxg-brand-logo.png'
const PROD_API = 'https://h5.yuxingu.com.cn/psy'
const isDev = import.meta.env.DEV
const useLocalH5 = isDev && USE_LOCAL_H5
const useLocal = isDev && USE_LOCAL_H5
export const YXG_H5_VERSION = useLocalH5 ? 'local' : PROD_VERSION
export const YXG_H5_BASE = useLocalH5 ? `http://${LOCAL_H5_HOST}/psy/` : PROD_BASE
/** H5 内嵌场景远程 logo(历史 cover 方案遗留,小程序顶栏用 YXG_MP_LOGO_URL */
export const YXG_H5_LOGO_URL = 'https://miniapp.yuxingu.com.cn/yxg-mp/logo.png'
export const YXG_H5_HOME_URL = `${YXG_H5_BASE}?mp=1&v=${YXG_H5_VERSION}`
export const YXG_API_BASE = useLocal ? `http://${LOCAL_H5_HOST}` : PROD_API
export const YXG_ASSET_BASE = useLocal ? `http://${LOCAL_H5_HOST}/psy` : PROD_API
export const YXG_MP_LOGO_URL = '/static/n-main/yxg-brand-logo.png'
export const YXG_DEFAULT_AVATAR = '/static/n-main/yxg-brand-logo.png'
+4 -4
View File
@@ -1,7 +1,7 @@
/**
* 本地小程序联调 H5
* USE_LOCAL_H5=false → 开发编译也访问远程 https://h5.yuxingu.com.cn/psy/(默认
* USE_LOCAL_H5=true → 访问本机 ViteLOCAL_H5_HOST 与 npm run dev:mp 打印一致
* 本地小程序联调统一 Go API
* USE_LOCAL_H5=false → https://h5.yuxingu.com.cn/psy(现网还没有 /api/v1/psychic,会 404
* USE_LOCAL_H5=true → 本机 GoLOCAL_H5_HOST 写成 host:8080(不要写 Vite :5173
*/
export const USE_LOCAL_H5 = false
export const LOCAL_H5_HOST = '192.168.100.16:5173'
export const LOCAL_H5_HOST = '127.0.0.1:8080'
+75
View File
@@ -0,0 +1,75 @@
const HOME = '/pages/yxg-magic/index'
const MAP = [
[/^\/explore\/bank\/([^/?#]+)/, (_, c) => `/pages/yxg/explore-bank?category=${encodeURIComponent(c)}`],
[/^\/explore\/([^/?#]+)/, (_, c) => `/pages/yxg/explore-category?category=${encodeURIComponent(c)}`],
[/^\/explore\/?$/, '/pages/yxg/explore'],
[/^\/growth-plan\/?$/, '/pages/yxg/growth-plan'],
[/^\/ask\/?$/, '/pages/yxg/ask'],
[/^\/companion\/?$/, '/pages/yxg/companion'],
[/^\/mine\/?$/, '/pages/yxg/mine'],
[/^\/login\/?$/, '/pages/login-pop/login-pop?type=2'],
[/^\/profile\/?$/, '/pages/yxg/profile'],
[/^\/portrait\/?$/, '/pages/yxg/portrait'],
[/^\/star\/?$/, '/pages/yxg/star'],
[/^\/synastry\/invite\/([^/?#]+)/, (_, t) => `/pages/yxg/synastry-invite?token=${encodeURIComponent(t)}`],
[/^\/synastry\/?$/, '/pages/yxg/synastry'],
[/^\/rhythm\/?$/, '/pages/yxg/rhythm'],
[/^\/cards\/?$/, '/pages/yxg/cards'],
[/^\/relation\/?$/, '/pages/yxg/relation'],
[/^\/membership\/?$/, '/pages/yxg/membership'],
[/^\/scales\/([^/?#]+)/, (_, s) => `/pages/yxg/scale?slug=${encodeURIComponent(s)}`],
[/^\/share\/?$/, '/pages/yxg/share'],
[/^\/reports\/([^/?#]+)/, (_, id) => `/pages/yxg/report?id=${encodeURIComponent(id)}`],
[/^\/reports\/?$/, '/pages/yxg/reports'],
[/^\/decode\/?$/, '/pages/yxg/portrait'],
[/^\/?$/, HOME],
]
function withQuery(url, query) {
if (!query) return url
const pairs = Object.keys(query)
.filter((k) => query[k] !== undefined && query[k] !== null && query[k] !== '')
.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(query[k])}`)
if (!pairs.length) return url
return url + (url.includes('?') ? '&' : '?') + pairs.join('&')
}
export function h5ToMp(path, query) {
const raw = String(path || '/')
const pathname = raw.split('?')[0]
for (const [re, dest] of MAP) {
const m = pathname.match(re)
if (!m) continue
const url = typeof dest === 'function' ? dest(...m) : dest
return withQuery(url, query)
}
return HOME
}
export function yxgGo(path, query) {
const url = h5ToMp(path, query)
if (url === HOME || url.startsWith(`${HOME}?`)) {
uni.switchTab({ url: HOME })
return
}
uni.navigateTo({ url })
}
export function yxgReplace(path, query) {
const url = h5ToMp(path, query)
if (url === HOME || url.startsWith(`${HOME}?`)) {
uni.switchTab({ url: HOME })
return
}
uni.redirectTo({ url })
}
export function yxgBack() {
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack()
return
}
uni.switchTab({ url: HOME })
}
+78
View File
@@ -0,0 +1,78 @@
export function localDayKey(d = new Date()) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
export function localShichenIndex(d = new Date()) {
return ((d.getHours() + 1) % 24) >> 1
}
function nextShichenEnd(d = new Date()) {
const shi = localShichenIndex(d)
const startHour = (shi * 2 + 23) % 24
const start = new Date(d)
start.setHours(startHour, 0, 0, 0)
if (start.getTime() > d.getTime()) start.setDate(start.getDate() - 1)
return new Date(start.getTime() + 2 * 3600 * 1000)
}
export function localFallbackTips() {
const n = new Date()
const day = Math.floor(Date.UTC(n.getFullYear(), n.getMonth(), n.getDate()) / 86400000)
const shi = localShichenIndex(n)
const seed = day * 13 + shi * 19
const palettes = [
[{ name: '米白', hex: '#F5F0E8' }, { name: '雾霾蓝', hex: '#A8C4D8' }],
[{ name: '燕麦色', hex: '#E8DCC8' }, { name: '浅杏', hex: '#F0C9A8' }],
[{ name: '浅灰', hex: '#D8D6D4' }, { name: '雾粉', hex: '#E8B8C4' }],
[{ name: '天青', hex: '#9BB8D4' }, { name: '陶土', hex: '#C4A484' }],
]
const clothing = [
'轻薄透气更舒服,外套可备一件薄衫应付温差。',
'今日宜层搭:薄内搭+透气外衫,方便随时增减。',
'选柔软面料贴身,活动一整天也不易紧绷。',
'宽松剪裁更自在,适合慢节奏出门与散步。',
]
const notes = ['清爽干净,不抢戏。', '今日偏清透,层次更轻。', '温柔耐看,适合日常。', '柔和提气色,不显沉。']
const well = [
'午后泡一杯温茶,给身心一点缓冲。',
'今日做三次深呼吸,拉长呼气更易放松。',
'今晚早点放下屏幕,让眼睛歇一会儿。',
'走路时把肩膀放松,呼吸会顺很多。',
]
const i = seed % palettes.length
return {
clothingIndex: 55 + (seed % 41),
clothing: clothing[i],
colorNote: notes[i],
palette: palettes[i],
wellness: well[i],
source: 'fallback',
needBirth: true,
asOf: localDayKey(n),
shichen: shi,
validUntil: nextShichenEnd(n).toISOString(),
}
}
export function tipsFromApi(t) {
return {
clothingIndex: t.clothing_index,
clothing: t.clothing,
colorNote: t.color_note,
palette: (t.palette || []).map((p) => ({ name: p.name, hex: p.hex })),
wellness: t.wellness,
needBirth: !!t.need_birth,
guaName: t.gua_name,
source: t.source,
asOf: t.as_of || localDayKey(),
shichen: t.shichen,
shichenName: t.shichen_name,
validUntil: t.valid_until,
}
}
export function ringOffset(index) {
const c = 2 * Math.PI * 30
const pct = Math.min(100, Math.max(0, Number(index) || 0)) / 100
return c * (1 - pct)
}