feat: 小程序统一走 Go 后台,咨询与登录切到 /api/v1
去掉协会 WebView 和魔方账密页,微信登录与咨询接口共用同一 token 和拦截器。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user