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