Files
what-to-eat/apps/web/app/pages/tabs/my/index.vue
T
toor 7408591fac feat: 落地 Warm Kitchen Ledger 原型,宽屏左导航 + 双栏布局
新增 ledger.css 设计令牌与桌面端布局,AppRail 左导航、PageHead 桌面页头、ResultsColumn 常驻结果栏;做菜/菜谱/发现/我的/设置/帮助/关于各页做桌面窄屏双形态适配,窄屏仍走原有底部标签栏与弹层交互。
2026-10-22 03:30:56 +08:00

379 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts" setup>
import dayjs from 'dayjs'
import { customRecipes } from '~/composables/store/customRecipe'
import { favoriteEntries } from '~/composables/store/favorite'
import { recipeHistories } from '~/composables/store/history'
import { links } from '~/constants'
definePageMeta({
alias: ['/my'],
})
const {
account,
configurationMessage,
errorMessage,
isAuthenticated,
signIn,
signOut,
status,
} = useYunlefunAuth()
const authBusy = computed(() => status.value === 'checking' || status.value === 'signing-in')
const {
errorMessage: syncErrorMessage,
lastSyncedAt,
status: syncStatus,
syncNow,
} = useUserDataSync()
const syncText = computed(() => {
switch (syncStatus.value) {
case 'syncing':
return '正在同步…'
case 'synced':
return `已同步 ${dayjs(lastSyncedAt.value).format('HH:mm')}`
case 'error':
return `${syncErrorMessage.value},点击重试`
default:
return '等待同步'
}
})
const rStore = useRecipeStore()
const favCount = computed(() => favoriteEntries.value.length)
const historyCount = computed(() => recipeHistories.value.length)
const mineCount = computed(() => customRecipes.value.length)
const syncedText = computed(() => lastSyncedAt.value ? dayjs(lastSyncedAt.value).format('MM-DD HH:mm') : '—')
const pantryText = computed(() => rStore.selectedPantry.join('、') || '无')
const avoidText = computed(() => rStore.selectedAvoid.join('、') || '无')
const spicyText = computed(() => rStore.noSpicy ? '开' : '关')
function handleSyncClick(): void {
if (syncStatus.value === 'error')
syncNow()
}
async function handleSignIn(): Promise<void> {
await signIn()
}
async function handleSignOut(): Promise<void> {
await signOut()
}
</script>
<template>
<ion-page>
<ion-header :translucent="true">
<ion-toolbar>
<ion-title>我的</ion-title>
</ion-toolbar>
</ion-header>
<ion-content :fullscreen="true">
<ion-header collapse="condense">
<ion-toolbar>
<ion-title size="large">
我的
</ion-title>
</ion-toolbar>
</ion-header>
<div class="ledger-desktop-block">
<PageHead title="我的" subtitle="收藏、历史和自己记的菜谱都在这儿">
<button
v-if="!isAuthenticated"
type="button"
class="ledger-btn is-ghost is-sm"
:disabled="authBusy || Boolean(configurationMessage)"
@click="handleSignIn"
>
<ion-icon :icon="ioniconsLogInOutline" aria-hidden="true" />
<span>{{ authBusy ? '检查中…' : '登录' }}</span>
</button>
</PageHead>
<section class="ledger-section">
<div class="ledger-account">
<span class="avatar">
<img v-if="account?.avatarUrl" :alt="account.displayName" :src="account.avatarUrl">
<ion-icon v-else :icon="ioniconsPersonCircle" aria-hidden="true" />
</span>
<div class="ledger-account-info">
<b>{{ account?.displayName ?? '未登录' }}</b>
<small>{{ isAuthenticated ? '云乐坊账号' : '登录后可以同步收藏和历史' }}</small>
</div>
<button
type="button"
class="ledger-btn is-ghost is-sm"
:disabled="!isAuthenticated || syncStatus === 'syncing'"
@click="handleSyncClick"
>
<ion-icon :icon="ioniconsCloudDoneOutline" aria-hidden="true" />
<span>{{ syncStatus === 'syncing' ? '同步中' : '同步' }}</span>
</button>
</div>
</section>
<section class="ledger-section">
<div class="ledger-stat-grid">
<div class="ledger-stat">
<span>收藏</span>
<b>{{ favCount }}</b>
</div>
<div class="ledger-stat">
<span>浏览历史</span>
<b>{{ historyCount }}</b>
</div>
<div class="ledger-stat">
<span>我的菜谱</span>
<b>{{ mineCount }}</b>
</div>
<div class="ledger-stat">
<span>上次同步</span>
<b>{{ syncedText }}</b>
</div>
</div>
</section>
<div class="ledger-menu-grid">
<section class="ledger-section">
<div class="ledger-section-head">
<h2>我的菜谱</h2>
</div>
<div class="ledger-menu">
<NuxtLink to="/recipes/favorites">
<span class="ico"><ion-icon :icon="ioniconsStarOutline" aria-hidden="true" /></span>
<span class="label">收藏的菜谱</span>
<span class="value">{{ favCount }}</span>
<span class="chev"><ion-icon :icon="ioniconsChevronForwardOutline" aria-hidden="true" /></span>
</NuxtLink>
<NuxtLink to="/recipes/history">
<span class="ico"><ion-icon :icon="ioniconsTimeOutline" aria-hidden="true" /></span>
<span class="label">浏览历史</span>
<span class="value">{{ historyCount }}</span>
<span class="chev"><ion-icon :icon="ioniconsChevronForwardOutline" aria-hidden="true" /></span>
</NuxtLink>
<NuxtLink to="/recipes/mine">
<span class="ico"><ion-icon :icon="ioniconsBookOutline" aria-hidden="true" /></span>
<span class="label">自己记的菜谱</span>
<span class="value">{{ mineCount }}</span>
<span class="chev"><ion-icon :icon="ioniconsChevronForwardOutline" aria-hidden="true" /></span>
</NuxtLink>
</div>
</section>
<section class="ledger-section">
<div class="ledger-section-head">
<h2>偏好</h2>
</div>
<div class="ledger-menu">
<NuxtLink to="/home">
<span class="ico"><ion-icon :icon="ioniconsRestaurantOutline" aria-hidden="true" /></span>
<span class="label">常备调料</span>
<span class="value">{{ pantryText }}</span>
<span class="chev"><ion-icon :icon="ioniconsChevronForwardOutline" aria-hidden="true" /></span>
</NuxtLink>
<NuxtLink to="/home">
<span class="ico"><ion-icon :icon="ioniconsAlertCircleOutline" aria-hidden="true" /></span>
<span class="label">忌口</span>
<span class="value">{{ avoidText }}</span>
<span class="chev"><ion-icon :icon="ioniconsChevronForwardOutline" aria-hidden="true" /></span>
</NuxtLink>
<NuxtLink to="/home">
<span class="ico"><ion-icon :icon="ioniconsFlameOutline" aria-hidden="true" /></span>
<span class="label">不吃辣</span>
<span class="value">{{ spicyText }}</span>
<span class="chev"><ion-icon :icon="ioniconsChevronForwardOutline" aria-hidden="true" /></span>
</NuxtLink>
</div>
</section>
<section class="ledger-section">
<div class="ledger-section-head">
<h2>应用</h2>
</div>
<div class="ledger-menu">
<NuxtLink to="/settings">
<span class="ico"><ion-icon :icon="ioniconsSettingsOutline" aria-hidden="true" /></span>
<span class="label">设置</span>
<span class="chev"><ion-icon :icon="ioniconsChevronForwardOutline" aria-hidden="true" /></span>
</NuxtLink>
<NuxtLink to="/help">
<span class="ico"><ion-icon :icon="ioniconsHelpCircleOutline" aria-hidden="true" /></span>
<span class="label">帮助</span>
<span class="chev"><ion-icon :icon="ioniconsChevronForwardOutline" aria-hidden="true" /></span>
</NuxtLink>
<NuxtLink to="/about">
<span class="ico"><ion-icon :icon="ioniconsInformationCircleOutline" aria-hidden="true" /></span>
<span class="label">关于</span>
<span class="chev"><ion-icon :icon="ioniconsChevronForwardOutline" aria-hidden="true" /></span>
</NuxtLink>
<a :href="links.githubIssue" target="_blank" rel="noopener">
<span class="ico"><ion-icon :icon="ioniconsChatbubbleEllipsesOutline" aria-hidden="true" /></span>
<span class="label">反馈问题</span>
<span class="chev"><ion-icon :icon="ioniconsChevronForwardOutline" aria-hidden="true" /></span>
</a>
</div>
</section>
</div>
<section v-if="isAuthenticated" class="ledger-section">
<button type="button" class="ledger-btn is-ghost" @click="handleSignOut">
退出登录
</button>
</section>
</div>
<div class="ledger-mobile-only">
<ion-list :inset="true">
<ion-item v-if="isAuthenticated && account" class="my-account" lines="full">
<ion-avatar v-if="account.avatarUrl" slot="start">
<img :alt="account.displayName" :src="account.avatarUrl">
</ion-avatar>
<ion-icon v-else slot="start" class="my-account-placeholder" :icon="ioniconsPersonCircle" />
<ion-label>
<h2>{{ account.displayName }}</h2>
<p>云乐坊账号</p>
</ion-label>
</ion-item>
<ion-item
v-if="isAuthenticated"
:button="syncStatus === 'error'"
:detail="false"
@click="handleSyncClick"
>
<span v-if="syncStatus === 'syncing'" slot="start" class="my-sync-spinner">
<ion-spinner name="crescent" />
</span>
<CookListIcon
v-else
slot="start"
:icon="syncStatus === 'error' ? ioniconsCloudOfflineOutline : ioniconsCloudDoneOutline"
:color="syncStatus === 'error' ? '#ff3b30' : '#34c759'"
/>
<ion-label>
<h2>收藏和历史云同步</h2>
<p>{{ syncText }}</p>
</ion-label>
</ion-item>
<ion-item
v-if="!isAuthenticated"
:button="true"
:disabled="authBusy || Boolean(configurationMessage)"
@click="handleSignIn"
>
<span v-if="authBusy" slot="start" class="my-sync-spinner">
<ion-spinner name="crescent" />
</span>
<CookListIcon v-else slot="start" :icon="ioniconsLogInOutline" color="#007aff" />
<ion-label>
<h2>{{ authBusy ? '正在检查登录状态' : '使用云乐坊账号登录' }}</h2>
<p>安全授权,无需在 Cook 输入密码</p>
</ion-label>
</ion-item>
<ion-item v-if="configurationMessage || errorMessage" lines="none">
<ion-icon slot="start" color="danger" :icon="ioniconsAlertCircleOutline" />
<ion-label class="ion-text-wrap" color="danger">
{{ configurationMessage || errorMessage }}
</ion-label>
</ion-item>
</ion-list>
<ion-list :inset="true">
<ion-item router-link="/recipes/history">
<CookListIcon slot="start" :icon="ioniconsTimeOutline" color="#5856d6" />
<ion-label>历史记录</ion-label>
</ion-item>
<ion-item router-link="/recipes/favorites">
<CookListIcon slot="start" :icon="ioniconsStarOutline" color="#ff9500" />
<ion-label>我的收藏</ion-label>
</ion-item>
<ion-item router-link="/recipes/mine">
<CookListIcon slot="start" :icon="ioniconsBookOutline" color="#a2845e" />
<ion-label>我的菜谱</ion-label>
<ion-note v-if="customRecipes.length" slot="end">
{{ customRecipes.length }}
</ion-note>
</ion-item>
</ion-list>
<ion-list :inset="true">
<ion-item :href="links.githubIssue" target="_blank" :detail-icon="ioniconsOpenOutline">
<CookListIcon slot="start" :icon="ioniconsChatbubbleEllipsesOutline" color="#34c759" />
<ion-label>问题反馈</ion-label>
</ion-item>
<ion-item :href="links.githubDiscussions" target="_blank" :detail-icon="ioniconsOpenOutline">
<CookListIcon slot="start" :icon="ioniconsChatbubblesOutline" color="#30b0c7" />
<ion-label>参与讨论</ion-label>
</ion-item>
<ion-item :href="links.contribute" target="_blank" :detail-icon="ioniconsOpenOutline">
<CookListIcon slot="start" :icon="ioniconsMailOutline" color="#ff2d55" />
<ion-label>菜谱投稿</ion-label>
</ion-item>
</ion-list>
<ion-list :inset="true">
<ion-item router-link="/settings">
<CookListIcon slot="start" :icon="ioniconsSettingsOutline" color="#8e8e93" />
<ion-label>设置</ion-label>
</ion-item>
</ion-list>
<ion-list :inset="true">
<ion-item router-link="/changelog">
<CookListIcon slot="start" :icon="ioniconsDocumentTextOutline" color="#af52de" />
<ion-label>更新日志</ion-label>
</ion-item>
<ion-item router-link="/help">
<CookListIcon slot="start" :icon="ioniconsHelpCircleOutline" color="#007aff" />
<ion-label>帮助</ion-label>
</ion-item>
<ion-item router-link="/about">
<CookListIcon slot="start" :icon="ioniconsInformationCircleOutline" color="#5ac8fa" />
<ion-label>关于</ion-label>
</ion-item>
</ion-list>
<ion-list v-if="isAuthenticated" :inset="true">
<ion-item :button="true" :detail="false" :disabled="authBusy" @click="handleSignOut">
<ion-label class="ion-text-center" color="danger">
退出登录
</ion-label>
</ion-item>
</ion-list>
</div>
</ion-content>
</ion-page>
</template>
<style scoped>
.my-account {
--padding-top: 6px;
--padding-bottom: 6px;
}
.my-account ion-avatar {
width: 56px;
height: 56px;
margin-inline-end: 14px;
}
.my-account-placeholder {
font-size: 56px;
margin-inline-end: 14px;
color: var(--ion-color-medium);
}
.my-account h2 {
font-size: 20px;
font-weight: 600;
}
.my-sync-spinner {
display: inline-flex;
justify-content: center;
width: 29px;
margin-inline-end: 14px;
}
</style>