1
0
mirror of https://github.com/ialley-workshop-open/uni-halo.git synced 2026-09-12 16:40:40 +08:00

refactor(love-module): 重构恋爱模块页面与组件

1. 统一主题色小写格式,新增恋爱页面渐变背景类
2. 替换旧版相册解锁弹窗为新版弹窗组件
3. 重构相册查看器样式与布局,适配新的玻璃态弹窗
4. 优化恋爱相册与故事页面的UI布局、样式与交互
5. 新增回顶按钮的样式适配,统一使用love主题色
This commit is contained in:
小莫唐尼
2026-09-08 22:08:23 +08:00
parent 3e787cbd0a
commit 201354dd14
8 changed files with 1235 additions and 1041 deletions
@@ -1,156 +1,159 @@
<script lang="ts" setup> <script lang="ts" setup>
/** import { computed, ref, watch } from 'vue'
* 相册图片查看弹窗(源自旧项目 components/album-photo-viewer,新建复刻) import { checkImageUrl } from '@/utils/url'
* 双列瀑布流展示相册照片,支持大图预览
*/
import { computed, ref, watch } from 'vue'
import { checkImageUrl } from '@/utils/url'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
show: boolean show : boolean
albumName?: string albumName ?: string
photos?: IAlbumPhoto[] photos ?: IAlbumPhoto[]
loading?: boolean loading ?: boolean
}>(), { }>(), {
albumName: '', albumName: '',
photos: () => [], photos: () => [],
loading: false, loading: false,
}) })
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'update:show', show: boolean): void (e : 'update:show', show : boolean) : void
}>() }>()
export interface IAlbumPhoto { export interface IAlbumPhoto {
name?: string name ?: string
url?: string url ?: string
title?: string title ?: string
takenDate?: string takenDate ?: string
location?: string location ?: string
description?: string description ?: string
[key: string]: unknown [key : string] : unknown
} }
const isShow = ref(false) const isShow = ref(false)
watch(() => props.show, (val) => { watch(() => props.show, (val) => {
isShow.value = val isShow.value = val
}) })
/** 预处理图片路径(相对路径拼接 BASE_API) */ /** 预处理图片路径(相对路径拼接 BASE_API) */
const photoList = computed<IAlbumPhoto[]>(() => const photoList = computed<IAlbumPhoto[]>(() =>
(props.photos || []).map(photo => ({ (props.photos || []).map(photo => ({
...photo, ...photo,
url: checkImageUrl(photo.url || ''), url: checkImageUrl(photo.url || ''),
})), })),
) )
/** 左列(偶数位照片,瀑布流错落) */ /** 左列(偶数位照片,瀑布流错落) */
const leftPhotos = computed(() => photoList.value.filter((_, index) => index % 2 === 0)) const leftPhotos = computed(() => photoList.value.filter((_, index) => index % 2 === 0))
/** 右列(奇数位照片) */ /** 右列(奇数位照片) */
const rightPhotos = computed(() => photoList.value.filter((_, index) => index % 2 === 1)) const rightPhotos = computed(() => photoList.value.filter((_, index) => index % 2 === 1))
function handleClose() { function handleClose() {
isShow.value = false isShow.value = false
emit('update:show', false) emit('update:show', false)
} }
/** 预览大图 */ /** 预览大图 */
function handlePreview(url?: string) { function handlePreview(url ?: string) {
const urls = photoList.value.map(photo => photo.url || '') const urls = photoList.value.map(photo => photo.url || '')
if (urls.length === 0) { if (urls.length === 0) {
uni.showToast({ title: '相册暂无照片', icon: 'none' }) uni.showToast({ title: '相册暂无照片', icon: 'none' })
return return
} }
uni.previewImage({ uni.previewImage({
current: url || urls[0], current: url || urls[0],
urls, urls,
}) })
} }
</script> </script>
<template> <template>
<wd-popup v-model="isShow" position="center" custom-style="width:94vw;height:82vh;border-radius:12rpx;" @close="handleClose"> <uh-glass-popup v-model="isShow" position="bottom" :z-index="100" custom-class="!border rounded-2xl"
<view class="album-photo-viewer h-full w-full flex flex-col overflow-hidden rounded-xl bg-white"> safe-area-inset-bottom @close="handleClose">
<!-- 头部 --> <view class="box-border h-full w-full flex flex-col gap-y-3 p-4">
<view class="viewer-header box-border flex shrink-0 items-center justify-between border-b border-black/5 px-7 py-6"> <!-- 头部 -->
<text class="viewer-title flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-[32rpx] text-[#333] font-bold">{{ albumName }}</text> <view class="w-full shrink-0 flex items-center justify-between">
<view class="viewer-close h-14 w-14 flex shrink-0 items-center justify-center rounded-full bg-black/5" @click="handleClose"> <view class="font-bold flex items-center gap-x-1">
<wd-icon name="close" size="16px" color="#666" /> {{ albumName }}
</view> </view>
</view> <view
class="w-6 h-6 uh-global-card-glass border uh-shadow-xs flex items-center justify-center rounded-lg"
@click="handleClose">
<wd-icon name="close" size="32rpx"></wd-icon>
</view>
</view>
<!-- 照片列表 --> <!-- 照片列表 -->
<scroll-view class="viewer-body box-border min-h-0 flex-1" scroll-y :show-scrollbar="false"> <scroll-view class="box-border max-h-[50vh] flex-1" scroll-y :show-scrollbar="false">
<view v-if="loading" class="viewer-empty box-border h-full flex items-center justify-center p-10"> <view v-if="loading" class="viewer-empty box-border h-full flex items-center justify-center p-10">
<view class="viewer-loading flex flex-col items-center"> <view class="viewer-loading flex flex-col items-center">
<view class="loading-text mt-7 text-[28rpx] text-[#56bbf9]"> <view class="loading-text mt-7 text-[28rpx] text-[#56bbf9]">
照片正在努力加载中啦~ 照片正在努力加载中啦~
</view> </view>
</view> </view>
</view> </view>
<view v-else-if="photoList.length === 0" class="viewer-empty box-border h-full flex items-center justify-center p-10"> <view v-else-if="photoList.length === 0"
<wd-empty description="这个相册暂时还没有照片~" /> class="viewer-empty box-border h-full flex items-center justify-center p-10">
</view> <wd-empty description="这个相册暂时还没有照片~" />
<view v-else class="photo-list box-border flex items-start p-5"> </view>
<!-- 左列 --> <view v-else class="photo-list box-border flex items-start p-5">
<view class="photo-column box-border min-w-0 flex-1 mr-[20rpx]"> <!-- 左列 -->
<view v-for="photo in leftPhotos" :key="photo.name" class="photo-card mb-6 box-border overflow-hidden rounded-xl bg-white shadow-sm"> <view class="photo-column box-border min-w-0 flex-1 mr-[20rpx]">
<image <view v-for="photo in leftPhotos" :key="photo.name"
class="photo-image w-full" class="photo-card mb-6 box-border overflow-hidden rounded-xl bg-white shadow-sm">
:src="photo.url" <image class="photo-image w-full" :src="photo.url" mode="widthFix" lazy-load
mode="widthFix" @click="handlePreview(photo.url)" />
lazy-load <view class="photo-info box-border px-6 py-5">
@click="handlePreview(photo.url)" <view v-if="photo.title" class="photo-title mb-3 text-[30rpx] text-[#333] font-bold">
/> {{ photo.title }}
<view class="photo-info box-border px-6 py-5"> </view>
<view v-if="photo.title" class="photo-title mb-3 text-[30rpx] text-[#333] font-bold"> <view v-if="photo.takenDate || photo.location"
{{ photo.title }} class="photo-meta mb-3 flex flex-wrap items-center">
</view> <text v-if="photo.takenDate"
<view v-if="photo.takenDate || photo.location" class="photo-meta mb-3 flex flex-wrap items-center"> class="meta-item mr-8 text-[24rpx] text-[#999]">{{ photo.takenDate }}</text>
<text v-if="photo.takenDate" class="meta-item mr-8 text-[24rpx] text-[#999]">{{ photo.takenDate }}</text> <text v-if="photo.location"
<text v-if="photo.location" class="meta-item mr-8 text-[24rpx] text-[#999]">{{ photo.location }}</text> class="meta-item mr-8 text-[24rpx] text-[#999]">{{ photo.location }}</text>
</view> </view>
<view v-if="photo.description" class="photo-desc text-[26rpx] text-[#666] leading-[1.6]"> <view v-if="photo.description"
{{ photo.description }} class="photo-desc text-[26rpx] text-[#666] leading-[1.6]">
</view> {{ photo.description }}
</view> </view>
</view> </view>
</view> </view>
<!-- 右列 --> </view>
<view class="photo-column box-border min-w-0 flex-1"> <!-- 右列 -->
<view v-for="photo in rightPhotos" :key="photo.name" class="photo-card mb-6 box-border overflow-hidden rounded-xl bg-white shadow-sm"> <view class="photo-column box-border min-w-0 flex-1">
<image <view v-for="photo in rightPhotos" :key="photo.name"
class="photo-image w-full" class="photo-card mb-6 box-border overflow-hidden rounded-xl bg-white shadow-sm">
:src="photo.url" <image class="photo-image w-full" :src="photo.url" mode="widthFix" lazy-load
mode="widthFix" @click="handlePreview(photo.url)" />
lazy-load <view class="photo-info box-border px-6 py-5">
@click="handlePreview(photo.url)" <view v-if="photo.title" class="photo-title mb-3 text-[30rpx] text-[#333] font-bold">
/> {{ photo.title }}
<view class="photo-info box-border px-6 py-5"> </view>
<view v-if="photo.title" class="photo-title mb-3 text-[30rpx] text-[#333] font-bold"> <view v-if="photo.takenDate || photo.location"
{{ photo.title }} class="photo-meta mb-3 flex flex-wrap items-center">
</view> <text v-if="photo.takenDate"
<view v-if="photo.takenDate || photo.location" class="photo-meta mb-3 flex flex-wrap items-center"> class="meta-item mr-8 text-[24rpx] text-[#999]">{{ photo.takenDate }}</text>
<text v-if="photo.takenDate" class="meta-item mr-8 text-[24rpx] text-[#999]">{{ photo.takenDate }}</text> <text v-if="photo.location"
<text v-if="photo.location" class="meta-item mr-8 text-[24rpx] text-[#999]">{{ photo.location }}</text> class="meta-item mr-8 text-[24rpx] text-[#999]">{{ photo.location }}</text>
</view> </view>
<view v-if="photo.description" class="photo-desc text-[26rpx] text-[#666] leading-[1.6]"> <view v-if="photo.description"
{{ photo.description }} class="photo-desc text-[26rpx] text-[#666] leading-[1.6]">
</view> {{ photo.description }}
</view> </view>
</view> </view>
</view> </view>
</view> </view>
</scroll-view> </view>
</scroll-view>
<!-- 底部关闭 --> <!-- 底部关闭 -->
<view class="viewer-footer box-border shrink-0 border-t border-black/5 px-7 py-5"> <view class="w-full shrink-0">
<view class="viewer-footer-btn h-20 flex items-center justify-center rounded-[40rpx]" style="background: linear-gradient(135deg, #f88ca2, #ff6b9d); box-shadow: 0 4rpx 24rpx rgb(248 140 162 / 35%);" @click="handleClose"> <uh-button custom-class="py-2 uh-global-card-glass rounded-xl !bg-love/90 text-white border"
<text class="footer-text text-[30rpx] text-white font-bold"> </text> @click="handleClose">
</view> 关闭
</view> </uh-button>
</view> </view>
</wd-popup> </view>
</uh-glass-popup>
</template> </template>
@@ -1,193 +0,0 @@
<script lang="ts" setup>
/**
* 相册密码解锁弹窗(源自旧项目 components/album-unlock-modal,新建复刻)
* 输入密码解锁加密相册;2026-09-03 接入插件防刷验证码:
* 首次提交若服务端要求验证码(403+附新码)则展示验证码行,携带后重试(验证码一次性)
*/
import { computed, ref, watch } from 'vue'
import { getPluginCaptcha, unlockAlbum } from '@/api/uni-halo'
import type { ICaptchaQuery, IPluginCaptcha } from '@/api/uni-halo'
const props = withDefaults(defineProps<{
show: boolean
albumName?: string
albumKey?: string
}>(), {
albumName: '',
albumKey: '',
})
const emit = defineEmits<{
(e: 'update:show', show: boolean): void
(e: 'success', data: { albumKey: string, token: string, photos: unknown[] }): void
}>()
const isShow = ref(false)
const password = ref('')
const loading = ref(false)
// 防刷验证码(服务端 403 附新码 / 主动刷新)
const captchaImage = ref('')
const captchaId = ref('')
const captchaCode = ref('')
const captchaLoading = ref(false)
const captchaSrc = computed(() => {
if (!captchaImage.value)
return ''
return captchaImage.value.startsWith('data:')
? captchaImage.value
: `data:image/png;base64,${captchaImage.value}`
})
function resetCaptcha() {
captchaImage.value = ''
captchaId.value = ''
captchaCode.value = ''
}
/** 用服务端返回的验证码(403 响应体附新码)填充展示 */
function applyCaptcha(captcha: IPluginCaptcha) {
captchaImage.value = captcha.imageBase64
captchaId.value = captcha.id
captchaCode.value = ''
}
/** 点击验证码图刷新 */
async function handleRefreshCaptcha() {
if (captchaLoading.value)
return
captchaLoading.value = true
try {
const res = await getPluginCaptcha()
if (res.data)
applyCaptcha(res.data)
}
catch (e) {
console.error('获取验证码失败', e)
}
finally {
captchaLoading.value = false
}
}
watch(() => props.show, (val) => {
isShow.value = val
if (val) {
password.value = ''
resetCaptcha()
}
})
function handleOnCancel() {
password.value = ''
resetCaptcha()
isShow.value = false
emit('update:show', false)
}
async function handleOnConfirm() {
if (!password.value.trim()) {
uni.showToast({ title: '请输入密码', icon: 'none' })
return
}
loading.value = true
try {
const captchaQuery: ICaptchaQuery | undefined = captchaImage.value
? { captchaId: captchaId.value, captchaCode: captchaCode.value }
: undefined
const res = await unlockAlbum(props.albumKey, password.value, captchaQuery)
if (res.data && (res.data as { token?: string }).token) {
password.value = ''
resetCaptcha()
isShow.value = false
emit('update:show', false)
emit('success', {
albumKey: props.albumKey,
token: (res.data as { token: string }).token,
photos: (res.data as { photos?: unknown[] }).photos || [],
})
uni.showToast({ title: '解锁成功', icon: 'success' })
}
}
catch (e) {
console.error('解锁失败', e)
const err = e as { code?: number, data?: { message?: string, captcha?: IPluginCaptcha } }
if (err.code === 403 && err.data?.captcha) {
// 需要/校验失败:服务端附新验证码(一次性,旧码已作废),展示并要求重试
applyCaptcha(err.data.captcha)
uni.showToast({ title: '请完成验证码后重新解锁', icon: 'none' })
}
else {
// 密码错误等业务失败:清空密码并复位验证码(一次性,需重新获取)
password.value = ''
resetCaptcha()
uni.showToast({ title: '密码错误,请重试', icon: 'none' })
}
}
finally {
loading.value = false
}
}
</script>
<template>
<wd-dialog
v-model="isShow"
title="相册密码"
:show-cancel="true"
confirm-text="解锁"
confirm-button-color="#f88ca2"
@cancel="handleOnCancel"
@confirm="handleOnConfirm"
>
<view class="unlock-modal-content py-5">
<view class="album-info flex flex-col items-center">
<view class="lock-icon mb-5 text-[80rpx]">
🔒
</view>
<view class="album-name mb-3 text-[32rpx] text-[#333] font-bold">
{{ albumName }}
</view>
<view class="tip-text text-[26rpx] text-[#999]">
此相册已加密请输入密码查看
</view>
</view>
<wd-input
v-model="password"
:password="true"
placeholder="请输入相册密码"
align="center"
clearable
class="password-input mt-9"
/>
<!-- 防刷验证码(首次提交 403 后展示;点击图片可刷新) -->
<view v-if="captchaSrc" class="captcha-box mt-5 flex items-center justify-center gap-4">
<image
:src="captchaSrc"
class="captcha-img h-[76rpx] w-[200rpx] rounded-lg"
mode="widthFix"
@click="handleRefreshCaptcha"
/>
<wd-input
v-model="captchaCode"
placeholder="验证码"
align="center"
clearable
class="captcha-input w-[240rpx]"
/>
</view>
<view v-if="captchaSrc" class="captcha-tip mt-2 text-center text-[22rpx] text-[#aaa]">
点击图片刷新验证码
</view>
</view>
</wd-dialog>
</template>
<style scoped>
.unlock-modal-content {
/* 布局全部由 UnoCSS 原子类实现 */
}
</style>
@@ -0,0 +1,188 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue'
import { getPluginCaptcha, unlockAlbum } from '@/api/uni-halo'
import type { ICaptchaQuery, IPluginCaptcha } from '@/api/uni-halo'
interface IProps {
show : boolean
}
const props = withDefaults(defineProps<{
show : boolean
albumName ?: string
albumKey ?: string
}>(), {
albumName: '',
albumKey: '',
})
const emit = defineEmits<{
(e : 'update:show', show : boolean) : void
(e : 'success', data : { albumKey : string, token : string, photos : unknown[] }) : void
}>()
const isShow = ref(false)
const password = ref('')
const loading = ref(false)
// 防刷验证码(服务端 403 附新码 / 主动刷新)
const captchaImage = ref('')
const captchaId = ref('')
const captchaCode = ref('')
const captchaLoading = ref(false)
const captchaSrc = computed(() => {
if (!captchaImage.value)
return ''
return captchaImage.value.startsWith('data:')
? captchaImage.value
: `data:image/png;base64,${captchaImage.value}`
})
function resetCaptcha() {
captchaImage.value = ''
captchaId.value = ''
captchaCode.value = ''
}
/** 用服务端返回的验证码(403 响应体附新码)填充展示 */
function applyCaptcha(captcha : IPluginCaptcha) {
captchaImage.value = captcha.imageBase64
captchaId.value = captcha.id
captchaCode.value = ''
}
/** 点击验证码图刷新 */
async function handleRefreshCaptcha() {
if (captchaLoading.value) { return }
captchaLoading.value = true
try {
const res = await getPluginCaptcha()
if (res.data) { applyCaptcha(res.data) }
}
catch (e) {
console.error('获取验证码失败', e)
}
finally {
captchaLoading.value = false
}
}
watch(() => props.show, (val) => {
isShow.value = val
if (val) {
password.value = ''
resetCaptcha()
}
})
/** 弹窗开关同步(遮罩/关闭按钮/取消):关闭时复位输入并通知父组件 */
function handleOnPopupClose(val : boolean) {
isShow.value = val
if (!val) {
password.value = ''
resetCaptcha()
}
emit('update:show', val)
}
function handleOnCancel() {
handleOnPopupClose(false)
}
async function handleOnConfirm() {
if (loading.value)
return
if (!password.value.trim()) {
uni.showToast({ title: '请输入密码', icon: 'none' })
return
}
loading.value = true
try {
const captchaQuery : ICaptchaQuery | undefined = captchaImage.value
? { captchaId: captchaId.value, captchaCode: captchaCode.value }
: undefined
const res = await unlockAlbum(props.albumKey, password.value, captchaQuery)
if (res.data && (res.data as { token ?: string }).token) {
password.value = ''
resetCaptcha()
isShow.value = false
emit('update:show', false)
emit('success', {
albumKey: props.albumKey,
token: (res.data as { token : string }).token,
photos: (res.data as { photos ?: unknown[] }).photos || [],
})
uni.showToast({ title: '解锁成功', icon: 'success' })
}
}
catch (e) {
console.error('解锁失败', e)
const err = e as { code ?: number, data ?: { message ?: string, captcha ?: IPluginCaptcha } }
if (err.code === 403 && err.data?.captcha) {
// 需要/校验失败:服务端附新验证码(一次性,旧码已作废),展示并要求重试
applyCaptcha(err.data.captcha)
uni.showToast({ title: '请完成验证码后重新解锁', icon: 'none' })
}
else {
// 密码错误等业务失败:清空密码并复位验证码(一次性,需重新获取)
password.value = ''
resetCaptcha()
uni.showToast({ title: '密码错误,请重试', icon: 'none' })
}
}
finally {
loading.value = false
}
}
</script>
<template>
<uh-glass-popup :model-value="isShow" position="bottom" :z-index="100" custom-class="!border rounded-2xl"
safe-area-inset-bottom @update:model-value="handleOnPopupClose">
<view class="box-border p-4 w-full">
<view class="w-full flex items-center justify-between">
<view class="font-bold flex items-center gap-x-1"> <wd-icon name="lock" size="42rpx"></wd-icon> 解锁相册
</view>
<view
class="w-6 h-6 uh-global-card-glass border uh-shadow-xs flex items-center justify-center rounded-lg"
@click="handleOnCancel">
<wd-icon name="close" size="32rpx"></wd-icon>
</view>
</view>
<view class="mt-6 flex flex-col items-center">
<view class="album-name mb-3 text-lg text-gray-900 font-bold">
{{ albumName }}
</view>
<view class="tip-text text-sm text-gray-600">
此相册已加密请输入密码查看
</view>
</view>
<input v-model="password" :password="true" placeholder="请输入相册密码"
class="box-border mt-6 h-10 px-3 rounded-xl text-sm uh-global-card-glass uh-shadow-xs border" />
<view v-if="captchaSrc" class="mt-5 flex items-center justify-center gap-4">
<input v-model="captchaCode" placeholder="验证码"
class="box-border flex-1 h-10 px-3 rounded-xl text-sm uh-global-card-glass uh-shadow-xs border" />
<image :src="captchaSrc" class="shrink-0 h-10 w-26 rounded-xl" mode="widthFix"
@click="handleRefreshCaptcha" />
</view>
<view v-if="captchaSrc" class="mt-4 text-center text-xs text-gray-500">
点击图片刷新验证码
</view>
<!-- 操作按钮:取消 + 解锁 -->
<view class="mt-6 box-border flex gap-4">
<uh-button custom-class="py-2 flex-1 uh-global-card-glass rounded-xl bg-white/90"
@click="handleOnCancel">
取消
</uh-button>
<uh-button custom-class="py-2 flex-1 uh-global-card-glass rounded-xl !bg-love/90 text-white border"
@click="handleOnConfirm">
{{ loading ? '解锁中...' : '解锁' }}
</uh-button>
</view>
</view>
</uh-glass-popup>
</template>
+12 -8
View File
@@ -1,11 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
interface IProps { interface IProps {
customClass : Array<string>; customClass?: string;
} }
const props = defineProps({
customClass: () => { const props = withDefaults(defineProps<IProps>(), {
return [] customClass: ''
},
}) })
function handleScrollTop() { function handleScrollTop() {
@@ -19,13 +18,18 @@
const visible = computed(() => { const visible = computed(() => {
return !balckList.includes(currentPage.route) return !balckList.includes(currentPage.route)
}) })
const _customClass = computed(() => {
const colorClass = currentPage.route.includes('/love/')?'text-love':'text-primary'
return `${props.customClass} ${colorClass}`
})
</script> </script>
<template> <template>
<view v-if="visible" class="fixed bottom-22 right-3 z-50 pb-safe"> <view v-if="visible" class="fixed bottom-22 right-3 z-50 pb-safe">
<view class="uh-global-card-glass border h-11 w-11 flex items-center justify-center rounded-full text-primary" <view class="uh-global-card-glass border h-11 w-11 flex items-center justify-center rounded-full"
:class="props.customClass" @click="handleScrollTop"> :class="_customClass" @click="handleScrollTop">
<wd-icon name="arrow-up" size="20px" /> <wd-icon name="arrow-up" size="42rpx" />
</view> </view>
</view> </view>
</template> </template>
+234 -248
View File
@@ -1,278 +1,264 @@
<script lang="ts" setup> <script lang="ts" setup>
/** import { computed, ref } from 'vue'
* 恋爱相册页(源自旧项目 pagesA/love/album.vue,新建复刻) import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
* 相册列表(两列网格)+ 加密相册密码解锁 + 图片查看弹窗 import dayjs from 'dayjs'
*/ import { getLoveAlbumByName, getLoveAlbums } from '@/api/uni-halo'
import { computed, ref } from 'vue' import { useAppConfigStore } from '@/store/appConfig'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app' import { checkImageUrl } from '@/utils/url'
import dayjs from 'dayjs' import { getCache, setCache } from '@/utils/storage'
import { getLoveAlbumByName, getLoveAlbums } from '@/api/uni-halo' import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
import { useAppConfigStore } from '@/store/appConfig' import type { ILoveAlbum, ILovePhoto } from '@/api/types/uni-halo'
import { checkImageUrl } from '@/utils/url'
import { getCache, setCache } from '@/utils/storage'
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
import type { ILoveAlbum, ILovePhoto } from '@/api/types/uni-halo'
definePage({ definePage({
style: { style: {
navigationBarTitleText: '恋爱相册', navigationBarTitleText: '恋爱相册',
navigationStyle: 'custom', navigationStyle: 'custom',
enablePullDownRefresh: true, enablePullDownRefresh: true,
}, },
}) })
const appConfigStore = useAppConfigStore() const appConfigStore = useAppConfigStore()
const loveConfig = computed(() => appConfigStore.configs.loveConfig) const loveConfig = computed(() => appConfigStore.configs.loveConfig)
/** 已解锁相册本地缓存 key */ /** 已解锁相册本地缓存 key */
const UNLOCKED_ALBUMS_CACHE_KEY = 'unlocked_albums' const UNLOCKED_ALBUMS_CACHE_KEY = 'unlocked_albums'
/** 解锁 token 有效期(后端默认 30 分钟) */ /** 解锁 token 有效期(后端默认 30 分钟) */
const ALBUM_TOKEN_TTL_SECONDS = 30 * 60 const ALBUM_TOKEN_TTL_SECONDS = 30 * 60
/* ---------------- 展示层类型 ---------------- */ /* ---------------- 展示层类型 ---------------- */
/** 相册展示卡片(script 预处理后的干净展示数据) */ /** 相册展示卡片(script 预处理后的干净展示数据) */
interface ILoveAlbumCard { interface ILoveAlbumCard {
/** 相册 key(metadata.name,用于解锁/详情请求) */ /** 相册 key(metadata.name,用于解锁/详情请求) */
name: string name : string
displayName: string displayName : string
locked: boolean locked : boolean
photoCount: number photoCount : number
/** 封面图(已预处理 URL) */ /** 封面图(已预处理 URL) */
image: string image : string
/** 创建时间(格式化展示) */ /** 创建时间(格式化展示) */
takeTime: string takeTime : string
/** 相册照片(解锁后填充) */ /** 相册照片(解锁后填充) */
photos: ILovePhoto[] photos : ILovePhoto[]
} }
/** 相册卡片映射:字段取值 + 封面/时间预处理(模板不感知原始接口结构) */ /** 相册卡片映射:字段取值 + 封面/时间预处理(模板不感知原始接口结构) */
function mapAlbumCard(item: ILoveAlbum): ILoveAlbumCard { function mapAlbumCard(item : ILoveAlbum) : ILoveAlbumCard {
const creationTimestamp = item.metadata?.creationTimestamp const creationTimestamp = item.metadata?.creationTimestamp
return { return {
name: item.name || item.metadata?.name || '', name: item.name || item.metadata?.name || '',
displayName: item.displayName || item.title || '', displayName: item.displayName || item.title || '',
locked: !!item.locked, locked: !!item.locked,
photoCount: Number(item.photoCount) || 0, photoCount: Number(item.photoCount) || 0,
image: checkImageUrl(item.cover || ''), image: checkImageUrl(item.cover || ''),
takeTime: creationTimestamp ? dayjs(creationTimestamp).format('DD/MM/YYYY') : '', takeTime: creationTimestamp ? dayjs(creationTimestamp).format('DD/MM/YYYY') : '',
photos: item.photos || [], photos: item.photos || [],
} }
} }
/* ---------------- 状态 ---------------- */ /* ---------------- 状态 ---------------- */
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus() const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
const dataList = ref<ILoveAlbumCard[]>([]) const dataList = ref<ILoveAlbumCard[]>([])
const unlockedAlbums = ref<Record<string, string>>({}) const unlockedAlbums = ref<Record<string, string>>({})
/** 密码解锁弹窗 */ /** 密码解锁弹窗 */
const showUnlockModal = ref(false) const showUnlockModal = ref(false)
const currentUnlockAlbum = ref<ILoveAlbumCard | null>(null) const currentUnlockAlbum = ref<ILoveAlbumCard | null>(null)
/** 图片查看弹窗 */ /** 图片查看弹窗 */
const showPhotoViewer = ref(false) const showPhotoViewer = ref(false)
const currentViewerAlbum = ref<ILoveAlbumCard | null>(null) const currentViewerAlbum = ref<ILoveAlbumCard | null>(null)
const viewerLoading = ref(false) const viewerLoading = ref(false)
const unlockAlbumName = computed(() => currentUnlockAlbum.value?.displayName || '') const unlockAlbumName = computed(() => currentUnlockAlbum.value?.displayName || '')
const unlockAlbumKey = computed(() => currentUnlockAlbum.value?.name || '') const unlockAlbumKey = computed(() => currentUnlockAlbum.value?.name || '')
const viewerAlbumName = computed(() => currentViewerAlbum.value?.displayName || '') const viewerAlbumName = computed(() => currentViewerAlbum.value?.displayName || '')
const viewerPhotos = computed(() => currentViewerAlbum.value?.photos || []) const viewerPhotos = computed(() => currentViewerAlbum.value?.photos || [])
/* ---------------- 缓存 ---------------- */ /* ---------------- 缓存 ---------------- */
function handleRestoreUnlockedAlbums() { function handleRestoreUnlockedAlbums() {
try { try {
const saved = getCache<Record<string, string>>(UNLOCKED_ALBUMS_CACHE_KEY) const saved = getCache<Record<string, string>>(UNLOCKED_ALBUMS_CACHE_KEY)
if (saved) { if (saved) {
unlockedAlbums.value = saved unlockedAlbums.value = saved
} }
} }
catch (e) { catch (e) {
console.error('恢复解锁状态失败', e) console.error('恢复解锁状态失败', e)
} }
} }
function handleSaveUnlockedAlbums() { function handleSaveUnlockedAlbums() {
try { try {
setCache(UNLOCKED_ALBUMS_CACHE_KEY, unlockedAlbums.value, ALBUM_TOKEN_TTL_SECONDS) setCache(UNLOCKED_ALBUMS_CACHE_KEY, unlockedAlbums.value, ALBUM_TOKEN_TTL_SECONDS)
} }
catch (e) { catch (e) {
console.error('保存解锁状态失败', e) console.error('保存解锁状态失败', e)
} }
} }
/* ---------------- 数据加载 ---------------- */ /* ---------------- 数据加载 ---------------- */
async function handleGetData() { async function handleGetData() {
updateLoadingStatus(DataLoadingStatusEnum.Loading) updateLoadingStatus(DataLoadingStatusEnum.Loading)
try { try {
const res = await getLoveAlbums({}) const res = await getLoveAlbums({})
const items = res.data?.items || [] const items = res.data?.items || []
dataList.value = items.map(mapAlbumCard) dataList.value = items.map(mapAlbumCard)
updateLoadingStatus(dataList.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success) updateLoadingStatus(dataList.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success)
if (dataList.value.length > 0) if (dataList.value.length > 0)
handleLoadUnlockedAlbumPhotos() handleLoadUnlockedAlbumPhotos()
} }
catch (e) { catch (e) {
console.error('获取相册失败', e) console.error('获取相册失败', e)
updateLoadingStatus(DataLoadingStatusEnum.Error) updateLoadingStatus(DataLoadingStatusEnum.Error)
} }
finally { finally {
setTimeout(() => { setTimeout(() => {
uni.stopPullDownRefresh() uni.stopPullDownRefresh()
}, 200) }, 200)
} }
} }
/** 加载已解锁相册的照片 */ /** 加载已解锁相册的照片 */
async function handleLoadUnlockedAlbumPhotos() { async function handleLoadUnlockedAlbumPhotos() {
for (const item of dataList.value) { for (const item of dataList.value) {
const token = unlockedAlbums.value[item.name] const token = unlockedAlbums.value[item.name]
if (item.locked && token) { if (item.locked && token) {
try { try {
const detail = await getLoveAlbumByName(item.name, { token }) const detail = await getLoveAlbumByName(item.name, { token })
if (detail.locked) { if (detail.locked) {
delete unlockedAlbums.value[item.name] delete unlockedAlbums.value[item.name]
handleSaveUnlockedAlbums() handleSaveUnlockedAlbums()
} }
else if (detail.photos) { else if (detail.photos) {
item.photos = detail.photos item.photos = detail.photos
item.locked = false item.locked = false
} }
} }
catch (e) { catch (e) {
console.error('加载相册照片失败', e) console.error('加载相册照片失败', e)
} }
} }
} }
} }
/* ---------------- 交互 ---------------- */ /* ---------------- 交互 ---------------- */
function handleOnAlbumClick(item: ILoveAlbumCard) { function handleOnAlbumClick(item : ILoveAlbumCard) {
if (item.locked && !unlockedAlbums.value[item.name]) { if (item.locked && !unlockedAlbums.value[item.name]) {
currentUnlockAlbum.value = item currentUnlockAlbum.value = item
showUnlockModal.value = true showUnlockModal.value = true
return return
} }
handleOpenPhotoViewer(item) handleOpenPhotoViewer(item)
} }
async function handleOpenPhotoViewer(item: ILoveAlbumCard) { async function handleOpenPhotoViewer(item : ILoveAlbumCard) {
currentViewerAlbum.value = item currentViewerAlbum.value = item
showPhotoViewer.value = true showPhotoViewer.value = true
if (item.photos.length > 0) if (item.photos.length > 0) { return }
return viewerLoading.value = true
viewerLoading.value = true try {
try { const token = unlockedAlbums.value[item.name] || ''
const token = unlockedAlbums.value[item.name] || '' const detail = await getLoveAlbumByName(item.name, { token })
const detail = await getLoveAlbumByName(item.name, { token }) if (detail) {
if (detail) { if (detail.locked) {
if (detail.locked) { delete unlockedAlbums.value[item.name]
delete unlockedAlbums.value[item.name] handleSaveUnlockedAlbums()
handleSaveUnlockedAlbums() }
} else if (detail.photos) {
else if (detail.photos) { item.photos = detail.photos
item.photos = detail.photos item.locked = false
item.locked = false }
} }
} }
} catch (e) {
catch (e) { console.error('获取相册照片失败', e)
console.error('获取相册照片失败', e) uni.showToast({ icon: 'none', title: '照片加载失败,请稍后重试' })
uni.showToast({ icon: 'none', title: '照片加载失败,请稍后重试' }) }
} finally {
finally { viewerLoading.value = false
viewerLoading.value = false }
} }
}
function handleOnUnlockSuccess(data: { albumKey: string, token: string, photos: unknown[] }) { function handleOnUnlockSuccess(data : { albumKey : string, token : string, photos : unknown[] }) {
unlockedAlbums.value[data.albumKey] = data.token unlockedAlbums.value[data.albumKey] = data.token
handleSaveUnlockedAlbums() handleSaveUnlockedAlbums()
const albumIndex = dataList.value.findIndex(a => a.name === data.albumKey) const albumIndex = dataList.value.findIndex(a => a.name === data.albumKey)
if (albumIndex !== -1) { if (albumIndex !== -1) {
dataList.value[albumIndex].photos = data.photos as ILovePhoto[] dataList.value[albumIndex].photos = data.photos as ILovePhoto[]
dataList.value[albumIndex].locked = false dataList.value[albumIndex].locked = false
} }
currentUnlockAlbum.value = null currentUnlockAlbum.value = null
if (albumIndex !== -1) { if (albumIndex !== -1) {
handleOpenPhotoViewer(dataList.value[albumIndex]) handleOpenPhotoViewer(dataList.value[albumIndex])
} }
} }
/* ---------------- 生命周期 ---------------- */ function handleToTopPage(duration = 500) {
onLoad(() => { uni.pageScrollTo({
handleRestoreUnlockedAlbums() scrollTop: 0,
handleGetData() duration,
}) fail: (err) => {
console.error('回顶失败', err)
},
})
}
onPullDownRefresh(() => { /* ---------------- 生命周期 ---------------- */
handleGetData() onLoad(() => {
}) handleRestoreUnlockedAlbums()
handleGetData()
})
onPullDownRefresh(() => {
handleGetData()
})
</script> </script>
<template> <template>
<view class="app-page box-border min-h-screen w-screen flex flex-col pb-[144rpx]" style="background: linear-gradient(-135deg, rgb(247 149 51 / 10%), rgb(243 112 85 / 10%) 15%, rgb(239 78 123 / 10%) 30%, rgb(161 102 171 / 10%) 44%, rgb(80 115 184 / 10%) 58%, rgb(16 152 173 / 10%) 72%, rgb(7 179 155 / 10%) 86%, rgb(109 186 130 / 10%));"> <view class="app-page box-border min-h-screen w-screen flex flex-col pb-safe">
<!-- 自定义导航 --> <!-- 自定义导航 -->
<uh-navbar default-title="恋爱相册" title-color="text-gray-900" /> <uh-navbar default-title="恋爱相册" title-color="text-love" back-class="text-love" />
<!-- 加载/错误/空占位(状态机) --> <!-- 加载/错误/空占位(状态机) -->
<uh-data-loading <uh-data-loading v-if="loadingStatus !== DataLoadingStatusEnum.Success" :loading-status="loadingStatus"
v-if="loadingStatus !== DataLoadingStatusEnum.Success" min-height="75vh" empty-text="相册暂时还没有数据~" @refresh="handleGetData" />
:loading-status="loadingStatus"
min-height="60vh"
empty-text="相册暂时还没有数据~"
@refresh="handleGetData"
/>
<!-- 相册列表(两列网格) --> <!-- 相册列表(两列网格) -->
<view v-else class="album-list box-border flex flex-wrap px-6"> <view v-else class="box-border grid grid-cols-2 p-3 pt-2 gap-3">
<view v-for="(item, index) in dataList" :key="item.name" class="album-card mb-6 box-border overflow-hidden rounded-xl bg-white shadow-sm" :class="index % 2 === 0 ? 'mr-6 w-[calc((100%-24rpx)/2)]' : 'w-[calc((100%-24rpx)/2)]'" @click="handleOnAlbumClick(item)"> <view v-for="(item) in dataList" :key="item.name"
<view class="album-cover-wrap relative h-[320rpx] w-full"> class="uh-global-card-glass box-border overflow-hidden rounded-xl" @click="handleOnAlbumClick(item)">
<image class="album-cover h-full w-full" :src="item.image" mode="aspectFill" lazy-load /> <view class="relative h-24 w-full">
<view v-if="item.locked && !unlockedAlbums[item.name]" class="album-lock-mask absolute left-0 top-0 h-full w-full flex flex-col items-center justify-center bg-black/45"> <image class="h-full w-full" :src="item.image" mode="aspectFill" lazy-load />
<view class="lock-icon text-[64rpx]"> <view v-if="item.locked && !unlockedAlbums[item.name]"
🔒 class="absolute left-0 top-0 h-full w-full flex flex-col items-center justify-center gap-1 bg-black/45">
</view> <wd-icon name="lock" size="52rpx" class="text-white" />
<view class="lock-tip mt-3 text-[26rpx] text-white"> <view class="text-xs text-white"> 已加密 </view>
已加密 </view>
</view> </view>
</view> <view class="album-info box-border p-3">
</view> <view
<view class="album-info box-border p-5"> class="album-name overflow-hidden text-ellipsis whitespace-nowrap text-[30rpx] text-[#333] font-bold">
<view class="album-name overflow-hidden text-ellipsis whitespace-nowrap text-[30rpx] text-[#333] font-bold"> {{ item.displayName }}
{{ item.displayName }} </view>
</view> <view class="album-count mt-1 text-xs text-gray-500">
<view class="album-count mt-1 text-[24rpx] text-[#999]"> {{ item.photoCount }} 张照片
{{ item.photoCount }} 张照片 </view>
</view> </view>
</view> </view>
</view> </view>
</view> <!-- 密码解锁弹窗 -->
<uh-album-unlock-popup v-if="currentUnlockAlbum" v-model="showUnlockModal" :show="showUnlockModal" :album-name="unlockAlbumName"
:album-key="unlockAlbumKey" @update:show="showUnlockModal = $event" @success="handleOnUnlockSuccess" />
<!-- 密码解锁弹窗 --> <!-- 相册图片查看弹窗 -->
<uh-album-unlock-modal <uh-album-photo-viewer v-if="currentViewerAlbum" v-model="showPhotoViewer" :show="showPhotoViewer" :album-name="viewerAlbumName"
v-if="currentUnlockAlbum" :photos="viewerPhotos" :loading="viewerLoading" @update:show="showPhotoViewer = $event" />
:show="showUnlockModal" </view>
:album-name="unlockAlbumName"
:album-key="unlockAlbumKey"
@update:show="showUnlockModal = $event"
@success="handleOnUnlockSuccess"
/>
<!-- 相册图片查看弹窗 -->
<uh-album-photo-viewer
v-if="currentViewerAlbum"
:show="showPhotoViewer"
:album-name="viewerAlbumName"
:photos="viewerPhotos"
:loading="viewerLoading"
@update:show="showPhotoViewer = $event"
/>
</view>
</template> </template>
<style scoped> <style scoped>
.app-page { .app-page {
/* 布局全部由 UnoCSS 原子类实现 */ background: linear-gradient(-135deg, rgb(247 149 51 / 10%), rgb(243 112 85 / 10%) 15%, rgb(239 78 123 / 10%) 30%, rgb(161 102 171 / 10%) 44%, rgb(80 115 184 / 10%) 58%, rgb(16 152 173 / 10%) 72%, rgb(7 179 155 / 10%) 86%, rgb(109 186 130 / 10%));
} }
</style> </style>
+366 -191
View File
@@ -1,212 +1,387 @@
<script lang="ts" setup> <script lang="ts" setup>
/** /**
* 恋爱清单页(源自旧项目 pagesA/love/list.vue,新建复刻) * 恋爱清单页(源自旧项目 pagesA/love/list.vue,新建复刻)
* 恋爱清单卡片列表(未开始/进行中/已完成),展开查看详情与回忆图片 * 恋爱清单卡片列表(未开始/进行中/已完成),展开查看详情与回忆图片
*/ */
import { ref } from 'vue' import { computed, ref } from 'vue'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app' import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { getLoveDailyItems } from '@/api/uni-halo' import { getLoveDailyItems } from '@/api/uni-halo'
import { checkImageUrl } from '@/utils/url' import { checkImageUrl } from '@/utils/url'
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus' import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
import type { ILoveDailyItem } from '@/api/types/uni-halo' import type { ILoveDailyItem } from '@/api/types/uni-halo'
definePage({ definePage({
style: { style: {
navigationBarTitleText: '恋爱清单', navigationBarTitleText: '恋爱清单',
navigationStyle: 'custom', navigationStyle: 'custom',
enablePullDownRefresh: true, enablePullDownRefresh: true,
}, },
}) })
/* ---------------- 展示层类型 ---------------- */ /* ---------------- 展示层类型 ---------------- */
/** 清单展示卡片(script 预处理后的干净展示数据) */ /** 清单展示卡片(script 预处理后的干净展示数据) */
interface ILoveItemCard { interface ILoveItemCard {
/** 唯一 key(metadata.name,无则用索引) */ /** 唯一 key(metadata.name,无则用索引) */
name: string name : string
title: string title : string
content: string content : string
status: 'wait' | 'doing' | 'complete' status : 'wait' | 'doing' | 'complete'
planDate: string planDate : string
completeDate: string completeDate : string
completeRemark: string completeRemark : string
/** 回忆图片(已预处理 URL) */ /** 回忆图片(已预处理 URL) */
images: string[] images : string[]
/** 是否展开详情 */ /** 是否展开详情 */
open: boolean open : boolean
} }
/** 清单卡片映射:字段取值 + 图片路径预处理(模板不感知原始接口结构) */ /** 清单卡片映射:字段取值 + 图片路径预处理(模板不感知原始接口结构) */
function mapItemCard(item: ILoveDailyItem, index: number): ILoveItemCard { function mapItemCard(item : ILoveDailyItem, index : number) : ILoveItemCard {
const spec = item.spec || {} const spec = item.spec || {}
return { return {
name: item.metadata?.name || `item-${index}`, name: item.metadata?.name || `item-${index}`,
title: spec.title || '', title: spec.title || '',
content: spec.content || '', content: spec.content || '',
status: spec.status || 'wait', status: spec.status || 'wait',
planDate: spec.planDate || '', planDate: spec.planDate || '',
completeDate: spec.completeDate || '', completeDate: spec.completeDate || '',
completeRemark: spec.completeRemark || '', completeRemark: spec.completeRemark || '',
images: (spec.images || []).map(img => checkImageUrl(img || '')), images: (spec.images || []).map(img => checkImageUrl(img || '')),
open: false, open: false,
} }
} }
/* ---------------- 状态 ---------------- */ /* ---------------- 状态 ---------------- */
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus() const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
const list = ref<ILoveItemCard[]>([]) const list = ref<ILoveItemCard[]>([])
/* ---------------- 数据加载 ---------------- */ /* ---------------- 筛选与排序 ---------------- */
async function handleGetList() { interface IFilterOption {
updateLoadingStatus(DataLoadingStatusEnum.Loading) label : string
try { value : string
const res = await getLoveDailyItems({}) }
const items = res.data?.items || []
list.value = items.map(mapItemCard)
updateLoadingStatus(list.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success)
}
catch (e) {
console.error('获取清单失败', e)
updateLoadingStatus(DataLoadingStatusEnum.Error)
}
finally {
setTimeout(() => {
uni.stopPullDownRefresh()
}, 200)
}
}
/* ---------------- 交互 ---------------- */ interface IFilterItem {
function handleOnItemOpen(item: ILoveItemCard) { key : 'status' | 'sort'
item.open = !item.open label : string
} options : IFilterOption[]
}
/** 预览回忆图片(基于已预处理 URL) */ /** 筛选维度:状态筛选 + 排序(参考投票列表页顶部胶囊设计,各自独立状态) */
function handlePreviewImages(images: string[], index: number) { const filterConfig : IFilterItem[] = [
if (images.length === 0) {
return key: 'status',
uni.previewImage({ current: images[index], urls: images }) label: '状态',
} options: [
{ label: '全部', value: '' },
{ label: '待完成', value: 'wait' },
{ label: '进行中', value: 'doing' },
{ label: '已完成', value: 'complete' },
],
},
{
key: 'sort',
label: '排序',
options: [
{ label: '默认排序', value: 'default' },
{ label: '按状态', value: 'status' },
{ label: '按计划时间', value: 'planDate' },
{ label: '按完成时间', value: 'completeDate' },
],
},
]
function handleToTopPage(duration = 500) { /** 排序方向选项(顺序/倒序,排序弹层内选择) */
uni.pageScrollTo({ const sortDirOptions : IFilterOption[] = [
scrollTop: 0, { label: '顺序', value: 'asc' },
duration, { label: '倒序', value: 'desc' },
fail: (err) => { ]
console.error('回顶失败', err)
},
})
}
/* ---------------- 生命周期 ---------------- */ /** 各维度当前选中值(空串/首项 = 默认) */
onLoad(() => { const filterValues = ref<Record<string, string>>({ status: '', sort: 'default', sortDir: 'asc' })
handleGetList()
})
onPullDownRefresh(() => { /** 当前选中中文标签(用于胶囊展示当前状态) */
handleGetList() const filterLabels = computed(() => {
}) const map : Record<string, string> = {}
for (const f of filterConfig) {
const cur = filterValues.value[f.key]
map[f.key] = f.options.find(o => o.value === cur)?.label || f.options[0].label
}
return map
})
/** 筛选弹层 */
const filterPopup = ref<{ show : boolean, item : IFilterItem | null }>({ show: false, item: null })
function handleOpenFilter(item : IFilterItem) {
filterPopup.value = { show: true, item }
}
function handleSelectFilter(option : IFilterOption) {
const item = filterPopup.value.item
if (!item)
return
if (item.key === 'sort' && option.value === 'default') {
// 选回默认排序:方向一并复位
filterValues.value.sort = 'default'
filterValues.value.sortDir = 'asc'
}
else {
filterValues.value[item.key] = option.value
}
filterPopup.value.show = false
}
function handleSelectSortDir(option : IFilterOption) {
filterValues.value.sortDir = option.value
}
/** 展示列表:状态筛选 + 排序(前端过滤,数据一次拉取) */
const showList = computed(() => {
const status = filterValues.value.status
const dir = filterValues.value.sortDir === 'desc' ? -1 : 1
const result = status
? list.value.filter(item => item.status === status)
: [...list.value]
switch (filterValues.value.sort) {
case 'status': {
const order : Record<string, number> = { wait: 0, doing: 1, complete: 2 }
result.sort((a, b) => (order[a.status] - order[b.status]) * dir)
break
}
case 'planDate':
result.sort((a, b) => (a.planDate || '').localeCompare(b.planDate || '') * dir)
break
case 'completeDate':
result.sort((a, b) => (a.completeDate || '').localeCompare(b.completeDate || '') * dir)
break
default:
break
}
return result
})
/* ---------------- 数据加载 ---------------- */
async function handleGetList() {
updateLoadingStatus(DataLoadingStatusEnum.Loading)
try {
const res = await getLoveDailyItems({})
const items = res.data?.items || []
list.value = items.map(mapItemCard)
updateLoadingStatus(list.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success)
}
catch (e) {
console.error('获取清单失败', e)
updateLoadingStatus(DataLoadingStatusEnum.Error)
}
finally {
setTimeout(() => {
uni.stopPullDownRefresh()
}, 200)
}
}
function getCardStatusClass(item : ILoveItemCard) {
switch (item.status) {
case 'wait':
return 'bg-yellow-300'
case 'doing':
return 'bg-love/30'
case 'complete':
return 'bg-blue-300'
default:
return 'bg-yellow-300'
}
}
function getCardStatusText(item : ILoveItemCard) {
switch (item.status) {
case 'wait':
return '待完成'
case 'doing':
return '进行中'
case 'complete':
return '已完成'
default:
return '待完成'
}
}
/* ---------------- 交互 ---------------- */
function handleOnItemOpen(item : ILoveItemCard) {
item.open = !item.open
}
/** 预览回忆图片(基于已预处理 URL) */
function handlePreviewImages(images : string[], index : number) {
if (images.length === 0)
return
uni.previewImage({ current: images[index], urls: images })
}
function handleToTopPage(duration = 500) {
uni.pageScrollTo({
scrollTop: 0,
duration,
fail: (err) => {
console.error('回顶失败', err)
},
})
}
/* ---------------- 生命周期 ---------------- */
onLoad(() => {
handleGetList()
})
onPullDownRefresh(() => {
handleGetList()
})
</script> </script>
<template> <template>
<view class="app-page box-border min-h-screen w-screen flex flex-col" style="background: linear-gradient(135deg, rgb(247 149 51 / 10%), rgb(243 112 85 / 10%) 15%, rgb(239 78 123 / 10%) 30%, rgb(161 102 171 / 10%) 44%, rgb(80 115 184 / 10%) 58%, rgb(16 152 173 / 10%) 72%, rgb(7 179 155 / 10%) 86%, rgb(109 186 130 / 10%));"> <view class="uh-global-love-page box-border min-h-screen w-screen flex flex-col">
<!-- 自定义导航 --> <!-- 自定义导航 -->
<uh-navbar default-title="恋爱清单" title-color="text-gray-900" /> <uh-navbar default-title="恋爱清单" title-color="text-love" back-class="text-love"/>
<!-- 加载/错误/空占位(状态机) --> <!-- 粘性筛选区:参考投票页顶部胶囊设计,每个维度独立状态 -->
<uh-data-loading <wd-sticky>
v-if="loadingStatus !== DataLoadingStatusEnum.Success" <view class="box-border px-3 pb-1 pt-2">
:loading-status="loadingStatus" <view class="box-border flex items-center justify-between gap-x-2">
min-height="60vh" <view v-for="f in filterConfig" :key="f.key"
empty-text="暂时还没有恋爱清单快去制定你们的恋爱清单吧~" class="uh-global-card-glass box-border flex flex-1 items-center justify-center gap-1 border rounded-full px-4 py-2 text-gray-500"
@refresh="handleGetList" :class="[filterValues[f.key] !== f.options[0].value ? 'bg-love/90 text-white font-bold' : 'bg-white/80 text-gray-600']"
/> @click="handleOpenFilter(f)">
<text class="truncate text-xs">{{ filterLabels[f.key] }}</text>
<template v-if="f.key!=='status'">
<wd-icon v-if="f.key === 'sort' && filterValues.sort !== 'default'"
:name="filterValues.sortDir === 'desc' ? 'arrow-down' : 'arrow-up'" size="26rpx" />
<wd-icon v-else name="arrow-down" size="26rpx" />
</template>
</view>
</view>
</view>
</wd-sticky>
<!-- 清单列表 --> <!-- 加载/错误/空占位(状态机) -->
<view v-else class="list-wrap box-border flex-1 p-6 pb-[144rpx]"> <uh-data-loading v-if="loadingStatus !== DataLoadingStatusEnum.Success" :loading-status="loadingStatus"
<view class="list-tip mb-7 w-full text-center text-[26rpx] text-[#999]"> min-height="60vh" empty-text="暂时还没有恋爱清单快去制定你们的恋爱清单吧~" @refresh="handleGetList" />
看看我们的恋爱清单都完成了哪些吧
</view>
<block v-for="item in list" :key="item.name">
<view class="card mb-6 box-border w-full flex flex-col items-center rounded-3xl bg-white p-6 shadow-sm">
<view class="head box-border w-full flex items-center" @click="handleOnItemOpen(item)">
<view class="status w-[100rpx] flex">
<view v-if="item.status === 'wait'" class="text h-[100rpx] w-[100rpx] rounded-full text-center text-[24rpx] leading-[100rpx]" style="background-color: #ffc6ba; color: #55423b;">
未开始
</view>
<view v-else-if="item.status === 'doing'" class="text doing h-[100rpx] w-[100rpx] rounded-full text-center text-[24rpx] leading-[100rpx]" style="background-color: #ffe9a8; color: #55423b;">
进行中
</view>
<view v-else class="text finish h-[100rpx] w-[100rpx] rounded-full text-center text-[24rpx] leading-[100rpx]" style="background-color: #bfe9ef; color: #55423b;">
已完成
</view>
</view>
<view class="title box-border w-0 flex-1 px-7">
<view class="title-name text-[30rpx] text-[#333] font-bold">
{{ item.title }}
</view>
<view v-if="item.content" class="title-desc mt-1 overflow-hidden text-ellipsis whitespace-nowrap text-[26rpx] text-[#555]">
{{ item.content }}
</view>
</view>
<view class="actions w-[50rpx]">
<text class="icon inline-block h-[45rpx] w-[45rpx] rounded-full bg-black/20 text-center text-[32rpx] font-bold leading-[45rpx]">{{ item.open ? '-' : '+' }}</text>
</view>
</view>
<view v-if="item.open" class="body mt-6 box-border w-full rounded-3xl bg-black/5 px-6 pb-3 pt-6 text-[26rpx]">
<view v-if="item.planDate" class="desc mb-3 flex">
<view class="desc-label w-[140rpx] shrink-0 text-[#333]">
计划时间
</view>
<view class="desc-value w-0 flex-1 text-[#333] leading-[1.5]">
{{ item.planDate || '-' }}
</view>
</view>
<view v-if="item.completeDate" class="desc mb-3 flex">
<view class="desc-label w-[140rpx] shrink-0 text-[#333]">
完成时间
</view>
<view class="desc-value w-0 flex-1 text-[#333] leading-[1.5]">
{{ item.completeDate || '-' }}
</view>
</view>
<view v-if="item.completeRemark" class="desc mb-3 flex">
<view class="desc-label w-[140rpx] shrink-0 text-[#333]">
完成感想
</view>
<view class="desc-value w-0 flex-1 text-[#333] leading-[1.5]">
{{ item.completeRemark || '-' }}
</view>
</view>
<view v-if="item.images.length > 0" class="desc mb-3 flex">
<view class="desc-label w-[140rpx] shrink-0 text-[#333]">
回忆图片
</view>
<view class="desc-value w-0 flex-1 text-[#333] leading-[1.5]">
<view class="images flex flex-wrap">
<view
v-for="(img, imgIndex) in item.images"
:key="imgIndex"
class="image mb-3 mr-3 h-[180rpx] w-[calc((100%-24rpx)/3)] overflow-hidden rounded-lg"
@click="handlePreviewImages(item.images, imgIndex)"
>
<image class="image-src h-full w-full" :src="img" mode="aspectFill" lazy-load />
</view>
</view>
</view>
</view>
</view>
</view>
</block>
</view>
<view class="to-top-btn fixed bottom-[160rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleToTopPage()"> <!-- 清单列表 -->
<wd-icon name="arrow-up" size="20px" color="#03a9f4" /> <view v-else class="box-border flex flex-1 flex-col gap-y-3 p-3 pb-safe">
</view> <view
</view> class="uh-global-card-glass uh-shadow-xs box-border w-full rounded-xl p-3 text-center text-xs text-love">
看看我们的恋爱清单都完成了哪些吧
</view>
<block v-for="(item, index) in showList" :key="item.name">
<view
class="uh-global-card-glass uh-shadow-xs box-border w-full flex flex-col items-center rounded-xl p-3">
<view class="box-border w-full flex items-center gap-x-3" @click="handleOnItemOpen(item)">
<view
class="uh-global-card-glass uh-shadow-xs text-md h-11 w-11 flex shrink-0 items-center justify-center rounded-full text-white font-bold"
:class="[getCardStatusClass(item)]">
{{ index + 1 }}
</view>
<view class="box-border flex-1">
<view class="text-md truncate text-love font-bold">
{{ item.title }}
</view>
<view class="mt-1 text-xs text-gray-500">
完成状态{{ getCardStatusText(item) }}
</view>
</view>
<view
class="h-6 w-6 flex shrink-0 items-center justify-center rounded-full text-love font-bold">
<wd-icon :name="item.open ? 'up' : 'down'" size="32rpx" />
</view>
</view>
<view v-if="item.open"
class="uh-global-card-glass mt-4 box-border w-full rounded-xl p-3 text-xs shadow-none">
<view v-if="item.content" class="desc mb-3 flex">
<view class="desc-label w-16 shrink-0 text-gray-500">
计划内容
</view>
<view class="desc-value w-0 flex-1 text-gray-900 leading-4">
{{ item.content || '-' }}
</view>
</view>
<view v-if="item.planDate" class="desc mb-3 flex">
<view class="desc-label w-16 shrink-0 text-gray-500">
计划时间
</view>
<view class="desc-value w-0 flex-1 text-gray-900 leading-4">
{{ item.planDate || '-' }}
</view>
</view>
<view v-if="item.completeDate" class="desc mb-3 flex">
<view class="desc-label w-16 shrink-0 text-gray-500">
完成时间
</view>
<view class="desc-value w-0 flex-1 text-gray-900 leading-4">
{{ item.completeDate || '-' }}
</view>
</view>
<view v-if="item.completeRemark" class="desc mb-3 flex">
<view class="desc-label w-16 shrink-0 text-gray-500">
完成感想
</view>
<view class="desc-value w-0 flex-1 text-gray-900 leading-4">
{{ item.completeRemark || '-' }}
</view>
</view>
<view v-if="item.images.length > 0" class="desc flex">
<view class="desc-label w-16 shrink-0 text-gray-500">
回忆图片
</view>
<view class="desc-value w-0 flex-1 text-gray-900 leading-4">
<view class="grid grid-cols-3 gap-2">
<view v-for="(img, imgIndex) in item.images" :key="imgIndex"
class="h-16 w-full overflow-hidden rounded-lg"
@click="handlePreviewImages(item.images, imgIndex)">
<image class="h-full w-full" :src="img" mode="aspectFill" lazy-load />
</view>
</view>
</view>
</view>
</view>
</view>
</block>
<view class="box-border py-10 text-center text-xs text-gray-500">
</view>
<uh-data-loading v-if="showList.length === 0" :loading-status="DataLoadingStatusEnum.Empty"
min-height="42vh" empty-text="该筛选条件下暂无清单~" :use-loading-button="false" />
</view>
<!-- 筛选弹层(状态/排序;排序附方向选择) -->
<uh-glass-popup v-model="filterPopup.show" :z-index="99" position="bottom" custom-class="rounded-2xl">
<view v-if="filterPopup.item" class="box-border p-4">
<view class="text-md mb-4 text-center text-gray-900 font-bold">
{{ filterPopup.item.label }}
</view>
<view class="flex flex-col gap-2">
<view v-for="opt in filterPopup.item.options" :key="opt.label"
class="uh-global-card-glass shadow-none box-border border rounded-xl px-5 py-2 text-center text-sm"
:class="filterValues[filterPopup.item.key] === opt.value ? 'bg-love/90 text-white font-bold' : 'text-gray-700'"
@click="handleSelectFilter(opt)">
{{ opt.label }}
</view>
</view>
<!-- 排序维度附加:方向选择(顺序/倒序) -->
<template v-if="filterPopup.item.key === 'sort'">
<view class="mb-2 mt-5 text-center text-xs text-gray-400">
排序方向
</view>
<view class="flex gap-2">
<view v-for="dir in sortDirOptions" :key="dir.value"
class="uh-global-card-glass shadow-none box-border flex-1 border rounded-xl px-5 py-2 text-center text-sm"
:class="filterValues.sortDir === dir.value ? 'bg-love/90 text-white font-bold' : 'text-gray-700'"
@click="handleSelectSortDir(dir)">
{{ dir.label }}
</view>
</view>
</template>
</view>
</uh-glass-popup>
</view>
</template> </template>
<style scoped>
.app-page {
/* 布局全部由 UnoCSS 原子类实现 */
}
</style>
+275 -257
View File
@@ -1,282 +1,300 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from 'vue' import { ref } from 'vue'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app' import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import { getLoveStories } from '@/api/uni-halo' import dayjs from 'dayjs'
import { useAppConfigStore } from '@/store/appConfig' import { getLoveStories } from '@/api/uni-halo'
import { checkImageUrl } from '@/utils/url' import { useAppConfigStore } from '@/store/appConfig'
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus' import { checkImageUrl } from '@/utils/url'
import type { ILoveStory } from '@/api/types/uni-halo' import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
import type { ILoveStory } from '@/api/types/uni-halo'
definePage({ definePage({
style: { style: {
navigationBarTitleText: '恋爱故事', navigationBarTitleText: '恋爱故事',
navigationStyle: 'custom', navigationStyle: 'custom',
enablePullDownRefresh: true, enablePullDownRefresh: true,
}, },
}) })
const appConfigStore = useAppConfigStore() const appConfigStore = useAppConfigStore()
/* ---------------- 展示层类型 ---------------- */ /* ---------------- 展示层类型 ---------------- */
/** 时间轴故事卡片(script 预处理后的干净展示数据) */ /** 时间轴故事卡片(script 预处理后的干净展示数据) */
interface IStoryCard { interface IStoryCard {
/** 唯一 key(metadata.name,无则用索引) */ /** 唯一 key(metadata.name,无则用索引) */
key: string key : string
title: string title : string
date: string date : string
location: string /** 日期拆分:年(如"2023") */
/** 故事正文(HTML) */ year : string
content: string /** 日期拆分:月(如"5") */
/** 全部图片(已预处理 URL) */ month : string
images: string[] /** 日期拆分:日(如"20") */
/** 时间轴封面图(最多 3 张,已预处理 URL) */ day : string
coverImages: string[] /** 日期拆分:星期(如"周六") */
} weekend : string
location : string
/** 故事正文(HTML) */
content : string
/** 全部图片(已预处理 URL) */
images : string[]
/** 时间轴封面图(最多 3 张,已预处理 URL) */
coverImages : string[]
}
/** 空故事占位(弹窗未打开时) */ /** 空故事占位(弹窗未打开时) */
const EMPTY_STORY: IStoryCard = { const EMPTY_STORY : IStoryCard = {
key: '', key: '',
title: '', title: '',
date: '', date: '',
location: '', year: '',
content: '', month: '',
images: [], day: '',
coverImages: [], weekend: '',
} location: '',
content: '',
images: [],
coverImages: [],
}
/** 故事卡片映射:字段取值 + 图片路径预处理(模板不感知原始接口结构) */ /** 中文星期(dayjs day():0=周日) */
function mapStoryCard(story: ILoveStory, index: number): IStoryCard { const WEEKDAY_TEXT = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
const spec = story.spec || {}
const images = (spec.images || []).map(img => checkImageUrl(img || ''))
return {
key: story.metadata?.name || `story-${index}`,
title: spec.title || '',
date: spec.date || '',
location: spec.location || '',
content: spec.content || '',
images,
coverImages: images.slice(0, 3),
}
}
/* ---------------- 状态 ---------------- */ /** 从日期字符串拆分年月日星期(解析失败返回空) */
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus() function splitStoryDate(dateStr : string) {
const stories = ref<IStoryCard[]>([]) const d = dateStr ? dayjs(dateStr) : null
const showDetail = ref(false) if (!d || !d.isValid()) {
const currentStory = ref<IStoryCard>(EMPTY_STORY) return { year: '', month: '', day: '', weekend: '' }
const storyImageIndex = ref(0) }
return {
year: `${d.year()}`,
month: `${d.month() + 1}`,
day: `${d.date()}`,
weekend: WEEKDAY_TEXT[d.day()],
}
}
/* ---------------- 数据加载 ---------------- */ /** 故事卡片映射:字段取值 + 日期拆分 + 图片路径预处理(模板不感知原始接口结构) */
async function handleGetStories() { function mapStoryCard(story : ILoveStory, index : number) : IStoryCard {
updateLoadingStatus(DataLoadingStatusEnum.Loading) const spec = story.spec || {}
try { const images = (spec.images || []).map(img => checkImageUrl(img || ''))
const res = await getLoveStories({}) return {
const items = res.data?.items || [] key: story.metadata?.name || `story-${index}`,
if (items.length > 0) { title: spec.title || '',
// 按 priority 排序(越大越靠前) date: spec.date || '',
const sorted = [...items].sort((a, b) => (b.spec?.priority || 0) - (a.spec?.priority || 0)) ...splitStoryDate(spec.date || ''),
stories.value = sorted.map(mapStoryCard) location: spec.location || '',
updateLoadingStatus(DataLoadingStatusEnum.Success) content: spec.content || '',
} images,
else { coverImages: images.slice(0, 3),
// 降级:从旧配置读取单条故事 }
handleLoadFromLegacy() }
}
}
catch (e) {
console.error('获取故事失败', e)
handleLoadFromLegacy()
}
finally {
setTimeout(() => {
uni.stopPullDownRefresh()
}, 200)
}
}
function handleLoadFromLegacy() { /* ---------------- 状态 ---------------- */
const loveModuleConfig = appConfigStore.configs.loveConfig as { ourStory?: { content?: string } } | undefined const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
if (loveModuleConfig?.ourStory?.content) { const stories = ref<IStoryCard[]>([])
stories.value = [{ const showDetail = ref(false)
key: 'legacy-story', const currentStory = ref<IStoryCard>(EMPTY_STORY)
title: '我们的故事', const storyImageIndex = ref(0)
date: '',
location: '',
content: loveModuleConfig.ourStory.content,
images: [],
coverImages: [],
}]
updateLoadingStatus(DataLoadingStatusEnum.Success)
return
}
stories.value = []
updateLoadingStatus(DataLoadingStatusEnum.Empty)
}
/* ---------------- 交互 ---------------- */ /* ---------------- 数据加载 ---------------- */
function handleOnStoryClick(story: IStoryCard) { async function handleGetStories() {
currentStory.value = story updateLoadingStatus(DataLoadingStatusEnum.Loading)
storyImageIndex.value = 0 try {
showDetail.value = true const res = await getLoveStories({})
} const items = res.data?.items || []
if (items.length > 0) {
// 按 priority 排序(越大越靠前)
const sorted = [...items].sort((a, b) => (b.spec?.priority || 0) - (a.spec?.priority || 0))
stories.value = sorted.map(mapStoryCard)
updateLoadingStatus(DataLoadingStatusEnum.Success)
}
else {
// 降级:从旧配置读取单条故事
handleLoadFromLegacy()
}
}
catch (e) {
console.error('获取故事失败', e)
handleLoadFromLegacy()
}
finally {
setTimeout(() => {
uni.stopPullDownRefresh()
}, 200)
}
}
function handleOnStoryImageChange(e: { detail: { current: number } }) { function handleLoadFromLegacy() {
storyImageIndex.value = e.detail.current const loveModuleConfig = appConfigStore.configs.loveConfig as { ourStory ?: { content ?: string } } | undefined
} if (loveModuleConfig?.ourStory?.content) {
stories.value = [{
key: 'legacy-story',
title: '我们的故事',
date: '',
year: '',
month: '',
day: '',
weekend: '',
location: '',
content: loveModuleConfig.ourStory.content,
images: [],
coverImages: [],
}]
updateLoadingStatus(DataLoadingStatusEnum.Success)
return
}
stories.value = []
updateLoadingStatus(DataLoadingStatusEnum.Empty)
}
/** 预览时间轴封面图(基于已预处理 URL) */ /* ---------------- 交互 ---------------- */
function handlePreviewStoryImages(story: IStoryCard, index: number) { function handleOnStoryClick(story : IStoryCard) {
if (story.images.length === 0) currentStory.value = story
return storyImageIndex.value = 0
uni.previewImage({ current: story.images[index], urls: story.images }) showDetail.value = true
} }
/** 预览弹窗内大图 */ function handleOnStoryImageChange(e : { detail : { current : number } }) {
function handlePreviewImage(index: number) { storyImageIndex.value = e.detail.current
const urls = currentStory.value.images }
if (urls.length > 0) {
uni.previewImage({ current: urls[index], urls })
}
}
function handleToTopPage(duration = 500) { /** 预览时间轴封面图(基于已预处理 URL) */
uni.pageScrollTo({ function handlePreviewStoryImages(story : IStoryCard, index : number) {
scrollTop: 0, if (story.images.length === 0)
duration, return
fail: (err) => { uni.previewImage({ current: story.images[index], urls: story.images })
console.error('回顶失败', err) }
},
})
}
/* ---------------- 生命周期 ---------------- */ /** 预览弹窗内大图 */
onLoad(() => { function handlePreviewImage(index : number) {
handleGetStories() const urls = currentStory.value.images
}) if (urls.length > 0) {
uni.previewImage({ current: urls[index], urls })
}
}
onPullDownRefresh(() => { function handleToTopPage(duration = 500) {
handleGetStories() uni.pageScrollTo({
}) scrollTop: 0,
duration,
fail: (err) => {
console.error('回顶失败', err)
},
})
}
/* ---------------- 生命周期 ---------------- */
onLoad(() => {
handleGetStories()
})
onPullDownRefresh(() => {
handleGetStories()
})
</script> </script>
<template> <template>
<view class="app-page box-border min-h-screen w-screen flex flex-col" style="background: linear-gradient(-45deg, rgb(247 149 51 / 10%), rgb(243 112 85 / 10%) 15%, rgb(239 78 123 / 10%) 30%, rgb(161 102 171 / 10%) 44%, rgb(80 115 184 / 10%) 58%, rgb(16 152 173 / 10%) 72%, rgb(7 179 155 / 10%) 86%, rgb(109 186 130 / 10%)); color: rgb(26 26 26);"> <view class="app-page box-border min-h-screen w-screen flex flex-col">
<!-- 自定义导航 --> <!-- 自定义导航 -->
<uh-navbar default-title="恋爱故事" title-color="text-gray-900" /> <uh-navbar default-title="恋爱故事" title-color="text-love" back-class="text-love" />
<!-- 加载/错误/空占位(状态机) --> <!-- 加载/错误/空占位(状态机) -->
<uh-data-loading <uh-data-loading v-if="loadingStatus !== DataLoadingStatusEnum.Success" :loading-status="loadingStatus"
v-if="loadingStatus !== DataLoadingStatusEnum.Success" min-height="75vh" empty-text="还没有故事敬请期待吧~" @refresh="handleGetStories" />
:loading-status="loadingStatus"
min-height="60vh"
empty-text="还没有故事等待你们来书写..."
@refresh="handleGetStories"
/>
<!-- 时间轴 --> <!-- 时间轴 -->
<view v-else class="content-wrap box-border flex-1 p-6 pb-[144rpx]"> <view v-else class="box-border flex-1 p-3 pb-safe">
<view class="timeline relative pl-10"> <view class="relative box-border flex flex-col gap-y-3">
<view v-for="(story, index) in stories" :key="story.key" class="timeline-item relative pb-10" :class="index === stories.length - 1 ? 'timeline-item-last' : ''" @click="handleOnStoryClick(story)"> <view v-for="(story, index) in stories" :key="story.key" class="relative flex"
<view class="timeline-dot absolute left-[-32rpx] top-4 z-2 h-5 w-5 rounded-full" style="background-color: #f88ca2; box-shadow: 0 0 0 6rpx rgb(248 140 162 / 20%);" /> :class="index === stories.length - 1 ? '-last' : ''">
<view class="timeline-card rounded-xl bg-white p-6 shadow-sm"> <view class="shrink-0 box-border pr-2">
<view v-if="story.date" class="timeline-date mb-2 text-[24rpx] text-[#f88ca2]"> <view class="flex flex-col items-center">
{{ story.date }} <text class="text-2xl text-love font-bold">
</view> {{ story.day }} <text class="text-lg"></text>
<view class="timeline-title text-[32rpx] text-[#333] font-bold"> </text>
{{ story.title }} <text class=" mt-2 text-sm text-love">{{ story.year }}/{{ story.month }}</text>
</view> <text class=" mt-1 text-xs text-gray-500">{{ story.weekend }}</text>
<view v-if="story.location" class="timeline-location mt-2 flex items-center text-[24rpx] text-[#999]"> </view>
<text class="location-text ml-1">{{ story.location }}</text> </view>
</view> <view class="relative overflow-hidden uh-global-card-glass box-border flex-1 rounded-xl p-3">
<view v-if="story.coverImages.length" class="timeline-covers mt-4 flex flex-wrap gap-2"> <text v-if="false"
<view class="absolute right-2 top-1 z-10 text-6xl text-love font-bold opacity-5">{{ story.day }}</text>
v-for="(img, imgIndex) in story.coverImages" <view class="flex items-start justify-between gap-x-2">
:key="imgIndex" <view class="text-md text-gray-900 font-bold truncate">
class="timeline-cover h-[180rpx] w-[calc((100%-16rpx)/3)] overflow-hidden rounded-lg" {{ story.title }}
@click.stop="handlePreviewStoryImages(story, imgIndex)" </view>
> <uh-button custom-class="uh-global-card-glass shadow-none !bg-love/90 border text-white !py-1 px-2 text-xs !rounded-md"
<image class="timeline-cover-img h-full w-full" :src="img" mode="aspectFill" lazy-load /> @click="handleOnStoryClick(story)">详情</uh-button>
</view> </view>
<view <view v-if="story.location" class="mt-2 flex items-center text-xs text-gray-600">
v-if="story.images.length > 3" <wd-icon name="location"></wd-icon> <text>{{ story.location }}</text>
class="timeline-cover timeline-cover-more h-[180rpx] w-[calc((100%-16rpx)/3)] flex items-center justify-center bg-black/50" </view>
@click.stop="handleOnStoryClick(story)" <view v-if="story.coverImages.length" class="relative mt-3 grid grid-cols-3 gap-2">
> <view v-for="(img, imgIndex) in story.coverImages" :key="imgIndex"
<text class="more-text text-[32rpx] text-white font-bold"> class="h-16 w-full overflow-hidden rounded-lg"
+{{ story.images.length - 3 }} @click.stop="handlePreviewStoryImages(story, imgIndex)">
</text> <image class="timeline-cover-img h-full w-full" :src="img" mode="aspectFill"
</view> lazy-load />
</view> </view>
</view> <view v-if="story.images.length > 3"
</view> class="box-border px-1 py-0.5 rounded-lt-lg absolute bottom-0 right-0 flex items-center justify-center bg-black/30"
</view> @click.stop="handleOnStoryClick(story)">
</view> <text class="text-xs text-white">
+{{ story.images.length - 3 }}
</text>
</view>
</view>
</view>
</view>
</view>
</view>
<view class="to-top-btn fixed bottom-[160rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full bg-white shadow-sm" @click="handleToTopPage()"> <!-- 故事详情弹窗 -->
<wd-icon name="arrow-up" size="20px" color="#03a9f4" /> <uh-glass-popup v-model="showDetail" :z-index="100" position="bottom" custom-class="rounded-xl">
</view> <view class="box-border p-3 h-full w-full flex flex-col overflow-hidden rounded-xl bg-white">
<view class="story-detail-header box-border shrink-0 mb-4">
<!-- 故事详情弹窗 --> <view class="story-detail-title text-lg text-gray-900 font-bold">
<wd-popup v-model="showDetail" position="center" custom-style="width:90vw;height:80vh;border-radius:12rpx;"> {{ currentStory.title }}
<view class="story-detail h-full w-full flex flex-col overflow-hidden rounded-xl bg-white"> </view>
<view class="story-detail-header box-border shrink-0 border-b border-black/5 px-7 py-6"> <view v-if="currentStory.date || currentStory.location"
<view class="story-detail-title text-[32rpx] text-[#333] font-bold"> class="mt-2 flex items-center text-xs text-gray-500">
{{ currentStory.title }} <text v-if="currentStory.date">
</view> <wd-icon name="time-line"></wd-icon> {{ currentStory.date }}
<view v-if="currentStory.date || currentStory.location" class="story-detail-meta mt-2 flex items-center text-[24rpx] text-[#999]"> </text>
<text v-if="currentStory.date" class="story-detail-date"> <text v-if="currentStory.location" class="ml-6">
{{ currentStory.date }} <wd-icon name="location"></wd-icon> {{ currentStory.location }}
</text> </text>
<text v-if="currentStory.location" class="story-detail-location ml-6"> </view>
{{ currentStory.location }} </view>
</text> <!-- 故事图片:多图 swiper 轮播 -->
</view> <view v-if="currentStory.images.length > 0" class="story-images shrink-0">
</view> <swiper v-if="currentStory.images.length > 1" class="h-32 w-full rounded-lg overflow-hidden" circular
<!-- 故事图片:多图 swiper 轮播 --> indicator-dots indicator-color="rgba(255,255,255,0.4)" indicator-active-color="#f83856"
<view v-if="currentStory.images.length > 0" class="story-images shrink-0"> :current="storyImageIndex" @change="handleOnStoryImageChange">
<swiper <swiper-item v-for="(img, imgIndex) in currentStory.images" :key="imgIndex"
v-if="currentStory.images.length > 1" class="story-images-item h-full w-full">
class="story-images-swiper h-[360rpx] w-full" <image :src="img" mode="aspectFill" class="h-full w-full"
circular @click="handlePreviewImage(imgIndex)" />
indicator-dots </swiper-item>
indicator-color="rgba(255,255,255,0.4)" </swiper>
indicator-active-color="#f88ca2" <image v-else :src="currentStory.images[0]" mode="aspectFill"
:current="storyImageIndex" class="h-32 w-full" @click="handlePreviewImage(0)" />
@change="handleOnStoryImageChange" </view>
> <scroll-view scroll-y :show-scrollbar="false" class="mt-4 box-border max-h-[50vh] flex-1">
<swiper-item v-for="(img, imgIndex) in currentStory.images" :key="imgIndex" class="story-images-item h-full w-full"> <view class="story-html text-sm text-gray-900 leading-7" v-html="currentStory.content" />
<image :src="img" mode="aspectFill" class="story-image h-full w-full" @click="handlePreviewImage(imgIndex)" /> </scroll-view>
</swiper-item> <view class="w-full mt-3">
</swiper> <uh-button custom-class="uh-global-card-glass border !bg-love/90 !py-2 text-white" @click="showDetail = false">关闭</uh-button>
<image v-else :src="currentStory.images[0]" mode="aspectFill" class="story-image story-image-single h-[360rpx] w-full" @click="handlePreviewImage(0)" /> </view>
</view> </view>
<scroll-view scroll-y class="story-detail-content box-border min-h-0 flex-1 px-7 py-6"> </uh-glass-popup>
<view class="story-html text-[28rpx] text-[#333] leading-[1.8]" v-html="currentStory.content" /> </view>
</scroll-view>
<view class="story-detail-close box-border shrink-0 border-t border-black/5 px-7 py-5">
<text class="close-text block h-20 rounded-[40rpx] text-center text-[30rpx] text-white font-bold" style="background: linear-gradient(135deg, #f88ca2, #ff6b9d); line-height: 80rpx;" @click="showDetail = false">关闭</text>
</view>
</view>
</wd-popup>
</view>
</template> </template>
<style scoped lang="scss"> <style scoped>
.timeline { .app-page {
.timeline-item { background: linear-gradient(-45deg, rgb(247 149 51 / 10%), rgb(243 112 85 / 10%) 15%, rgb(239 78 123 / 10%) 30%, rgb(161 102 171 / 10%) 44%, rgb(80 115 184 / 10%) 58%, rgb(16 152 173 / 10%) 72%, rgb(7 179 155 / 10%) 86%, rgb(109 186 130 / 10%));
&::before { color: rgb(26 26 26);
content: ''; }
position: absolute;
left: -20rpx;
top: 12rpx;
bottom: 0;
width: 2rpx;
background-color: rgb(248 140 162 / 40%);
}
&.timeline-item-last::before {
display: none;
}
}
}
</style> </style>
+15 -2
View File
@@ -7,10 +7,10 @@
:root, :root,
page { page {
// 修改按主题色 // 修改按主题色
--wot-color-theme: #B9E424; --wot-color-theme: #b9e424;
// 修改按钮背景色 // 修改按钮背景色
--wot-button-primary-bg-color: #B9E424; --wot-button-primary-bg-color: #b9e424;
} }
.uh-global-page { .uh-global-page {
@@ -34,6 +34,19 @@ page {
box-shadow: inset 0 1rpx 0 rgb(255 255 255 / 75%), 0 8rpx 32rpx rgb(90 105 200 / 7%); box-shadow: inset 0 1rpx 0 rgb(255 255 255 / 75%), 0 8rpx 32rpx rgb(90 105 200 / 7%);
} }
.uh-global-love-page {
background: linear-gradient(
135deg,
rgb(247 149 51 / 10%),
rgb(243 112 85 / 10%) 15%,
rgb(239 78 123 / 10%) 30%,
rgb(161 102 171 / 10%) 44%,
rgb(80 115 184 / 10%) 58%,
rgb(16 152 173 / 10%) 72%,
rgb(7 179 155 / 10%) 86%,
rgb(109 186 130 / 10%)
);
}
/* /*
border-t-1 border-t-1
由于uniapp中无法使用*选择器,使用魔法代替*,加上此规则可以简化border与divide的使用,并提升布局的兼容性 由于uniapp中无法使用*选择器,使用魔法代替*,加上此规则可以简化border与divide的使用,并提升布局的兼容性