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,8 +1,4 @@
<script lang="ts" setup> <script lang="ts" setup>
/**
* 相册图片查看弹窗(源自旧项目 components/album-photo-viewer,新建复刻)
* 双列瀑布流展示相册照片,支持大图预览
*/
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { checkImageUrl } from '@/utils/url' import { checkImageUrl } from '@/utils/url'
@@ -71,18 +67,23 @@ function handlePreview(url?: string) {
</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"> <view class="w-full shrink-0 flex items-center justify-between">
<text class="viewer-title flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-[32rpx] text-[#333] font-bold">{{ albumName }}</text> <view class="font-bold flex items-center gap-x-1">
<view class="viewer-close h-14 w-14 flex shrink-0 items-center justify-center rounded-full bg-black/5" @click="handleClose"> {{ albumName }}
<wd-icon name="close" size="16px" color="#666" /> </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>
</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]">
@@ -90,29 +91,30 @@ function handlePreview(url?: string) {
</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"
class="viewer-empty box-border h-full flex items-center justify-center p-10">
<wd-empty description="这个相册暂时还没有照片~" /> <wd-empty description="这个相册暂时还没有照片~" />
</view> </view>
<view v-else class="photo-list box-border flex items-start p-5"> <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 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 v-for="photo in leftPhotos" :key="photo.name"
<image class="photo-card mb-6 box-border overflow-hidden rounded-xl bg-white shadow-sm">
class="photo-image w-full" <image class="photo-image w-full" :src="photo.url" mode="widthFix" lazy-load
:src="photo.url" @click="handlePreview(photo.url)" />
mode="widthFix"
lazy-load
@click="handlePreview(photo.url)"
/>
<view class="photo-info box-border px-6 py-5"> <view class="photo-info box-border px-6 py-5">
<view v-if="photo.title" class="photo-title mb-3 text-[30rpx] text-[#333] font-bold"> <view v-if="photo.title" class="photo-title mb-3 text-[30rpx] text-[#333] font-bold">
{{ photo.title }} {{ photo.title }}
</view> </view>
<view v-if="photo.takenDate || photo.location" class="photo-meta mb-3 flex flex-wrap items-center"> <view v-if="photo.takenDate || photo.location"
<text v-if="photo.takenDate" class="meta-item mr-8 text-[24rpx] text-[#999]">{{ photo.takenDate }}</text> class="photo-meta mb-3 flex flex-wrap items-center">
<text v-if="photo.location" class="meta-item mr-8 text-[24rpx] text-[#999]">{{ photo.location }}</text> <text v-if="photo.takenDate"
class="meta-item mr-8 text-[24rpx] text-[#999]">{{ photo.takenDate }}</text>
<text v-if="photo.location"
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"
class="photo-desc text-[26rpx] text-[#666] leading-[1.6]">
{{ photo.description }} {{ photo.description }}
</view> </view>
</view> </view>
@@ -120,23 +122,23 @@ function handlePreview(url?: string) {
</view> </view>
<!-- 右列 --> <!-- 右列 -->
<view class="photo-column box-border min-w-0 flex-1"> <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 v-for="photo in rightPhotos" :key="photo.name"
<image class="photo-card mb-6 box-border overflow-hidden rounded-xl bg-white shadow-sm">
class="photo-image w-full" <image class="photo-image w-full" :src="photo.url" mode="widthFix" lazy-load
:src="photo.url" @click="handlePreview(photo.url)" />
mode="widthFix"
lazy-load
@click="handlePreview(photo.url)"
/>
<view class="photo-info box-border px-6 py-5"> <view class="photo-info box-border px-6 py-5">
<view v-if="photo.title" class="photo-title mb-3 text-[30rpx] text-[#333] font-bold"> <view v-if="photo.title" class="photo-title mb-3 text-[30rpx] text-[#333] font-bold">
{{ photo.title }} {{ photo.title }}
</view> </view>
<view v-if="photo.takenDate || photo.location" class="photo-meta mb-3 flex flex-wrap items-center"> <view v-if="photo.takenDate || photo.location"
<text v-if="photo.takenDate" class="meta-item mr-8 text-[24rpx] text-[#999]">{{ photo.takenDate }}</text> class="photo-meta mb-3 flex flex-wrap items-center">
<text v-if="photo.location" class="meta-item mr-8 text-[24rpx] text-[#999]">{{ photo.location }}</text> <text v-if="photo.takenDate"
class="meta-item mr-8 text-[24rpx] text-[#999]">{{ photo.takenDate }}</text>
<text v-if="photo.location"
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"
class="photo-desc text-[26rpx] text-[#666] leading-[1.6]">
{{ photo.description }} {{ photo.description }}
</view> </view>
</view> </view>
@@ -146,11 +148,12 @@ function handlePreview(url?: string) {
</scroll-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">
关闭
</uh-button>
</view> </view>
</view> </view>
</view> </uh-glass-popup>
</wd-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>
+33 -47
View File
@@ -1,8 +1,4 @@
<script lang="ts" setup> <script lang="ts" setup>
/**
* 恋爱相册页(源自旧项目 pagesA/love/album.vue,新建复刻)
* 相册列表(两列网格)+ 加密相册密码解锁 + 图片查看弹窗
*/
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app' import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
import dayjs from 'dayjs' import dayjs from 'dayjs'
@@ -158,8 +154,7 @@ function handleOnAlbumClick(item: ILoveAlbumCard) {
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] || ''
@@ -199,6 +194,16 @@ function handleOnUnlockSuccess(data: { albumKey: string, token: string, photos:
} }
} }
function handleToTopPage(duration = 500) {
uni.pageScrollTo({
scrollTop: 0,
duration,
fail: (err) => {
console.error('回顶失败', err)
},
})
}
/* ---------------- 生命周期 ---------------- */ /* ---------------- 生命周期 ---------------- */
onLoad(() => { onLoad(() => {
handleRestoreUnlockedAlbums() handleRestoreUnlockedAlbums()
@@ -211,68 +216,49 @@ onPullDownRefresh(() => {
</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> <view class="album-info box-border p-3">
<view class="album-info box-border p-5"> <view
<view class="album-name overflow-hidden text-ellipsis whitespace-nowrap text-[30rpx] text-[#333] font-bold"> 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-[24rpx] text-[#999]"> <view class="album-count mt-1 text-xs text-gray-500">
{{ item.photoCount }} 张照片 {{ item.photoCount }} 张照片
</view> </view>
</view> </view>
</view> </view>
</view> </view>
<!-- 密码解锁弹窗 --> <!-- 密码解锁弹窗 -->
<uh-album-unlock-modal <uh-album-unlock-popup v-if="currentUnlockAlbum" v-model="showUnlockModal" :show="showUnlockModal" :album-name="unlockAlbumName"
v-if="currentUnlockAlbum" :album-key="unlockAlbumKey" @update:show="showUnlockModal = $event" @success="handleOnUnlockSuccess" />
:show="showUnlockModal"
:album-name="unlockAlbumName"
:album-key="unlockAlbumKey"
@update:show="showUnlockModal = $event"
@success="handleOnUnlockSuccess"
/>
<!-- 相册图片查看弹窗 --> <!-- 相册图片查看弹窗 -->
<uh-album-photo-viewer <uh-album-photo-viewer v-if="currentViewerAlbum" v-model="showPhotoViewer" :show="showPhotoViewer" :album-name="viewerAlbumName"
v-if="currentViewerAlbum" :photos="viewerPhotos" :loading="viewerLoading" @update:show="showPhotoViewer = $event" />
:show="showPhotoViewer"
:album-name="viewerAlbumName"
:photos="viewerPhotos"
:loading="viewerLoading"
@update:show="showPhotoViewer = $event"
/>
</view> </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>
+232 -57
View File
@@ -3,7 +3,7 @@
* 恋爱清单页(源自旧项目 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'
@@ -55,6 +55,112 @@ function mapItemCard(item: ILoveDailyItem, index: number): ILoveItemCard {
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus() const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
const list = ref<ILoveItemCard[]>([]) const list = ref<ILoveItemCard[]>([])
/* ---------------- 筛选与排序 ---------------- */
interface IFilterOption {
label : string
value : string
}
interface IFilterItem {
key : 'status' | 'sort'
label : string
options : IFilterOption[]
}
/** 筛选维度:状态筛选 + 排序(参考投票列表页顶部胶囊设计,各自独立状态) */
const filterConfig : IFilterItem[] = [
{
key: 'status',
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' },
],
},
]
/** 排序方向选项(顺序/倒序,排序弹层内选择) */
const sortDirOptions : IFilterOption[] = [
{ label: '顺序', value: 'asc' },
{ label: '倒序', value: 'desc' },
]
/** 各维度当前选中值(空串/首项 = 默认) */
const filterValues = ref<Record<string, string>>({ status: '', sort: 'default', sortDir: 'asc' })
/** 当前选中中文标签(用于胶囊展示当前状态) */
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() { async function handleGetList() {
updateLoadingStatus(DataLoadingStatusEnum.Loading) updateLoadingStatus(DataLoadingStatusEnum.Loading)
@@ -75,6 +181,32 @@ async function handleGetList() {
} }
} }
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) { function handleOnItemOpen(item : ILoveItemCard) {
item.open = !item.open item.open = !item.open
@@ -108,88 +240,105 @@ onPullDownRefresh(() => {
</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"/>
<!-- 粘性筛选区:参考投票页顶部胶囊设计,每个维度独立状态 -->
<wd-sticky>
<view class="box-border px-3 pb-1 pt-2">
<view class="box-border flex items-center justify-between gap-x-2">
<view v-for="f in filterConfig" :key="f.key"
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"
: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>
<!-- 加载/错误/空占位(状态机) --> <!-- 加载/错误/空占位(状态机) -->
<uh-data-loading <uh-data-loading v-if="loadingStatus !== DataLoadingStatusEnum.Success" :loading-status="loadingStatus"
v-if="loadingStatus !== DataLoadingStatusEnum.Success" min-height="60vh" empty-text="暂时还没有恋爱清单快去制定你们的恋爱清单吧~" @refresh="handleGetList" />
:loading-status="loadingStatus"
min-height="60vh"
empty-text="暂时还没有恋爱清单快去制定你们的恋爱清单吧~"
@refresh="handleGetList"
/>
<!-- 清单列表 --> <!-- 清单列表 -->
<view v-else class="list-wrap box-border flex-1 p-6 pb-[144rpx]"> <view v-else class="box-border flex flex-1 flex-col gap-y-3 p-3 pb-safe">
<view class="list-tip mb-7 w-full text-center text-[26rpx] text-[#999]"> <view
class="uh-global-card-glass uh-shadow-xs box-border w-full rounded-xl p-3 text-center text-xs text-love">
看看我们的恋爱清单都完成了哪些吧 看看我们的恋爱清单都完成了哪些吧
</view> </view>
<block v-for="item in list" :key="item.name"> <block v-for="(item, index) in showList" :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
<view class="head box-border w-full flex items-center" @click="handleOnItemOpen(item)"> class="uh-global-card-glass uh-shadow-xs box-border w-full flex flex-col items-center rounded-xl p-3">
<view class="status w-[100rpx] flex"> <view class="box-border w-full flex items-center gap-x-3" @click="handleOnItemOpen(item)">
<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
未开始 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>
<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 class="box-border flex-1">
进行中 <view class="text-md truncate text-love font-bold">
</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 }} {{ item.title }}
</view> </view>
<view v-if="item.content" class="title-desc mt-1 overflow-hidden text-ellipsis whitespace-nowrap text-[26rpx] text-[#555]"> <view class="mt-1 text-xs text-gray-500">
{{ item.content }} 完成状态{{ getCardStatusText(item) }}
</view> </view>
</view> </view>
<view class="actions w-[50rpx]"> <view
<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> 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> </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 v-if="item.planDate" class="desc mb-3 flex">
<view class="desc-label w-[140rpx] shrink-0 text-[#333]"> <view class="desc-label w-16 shrink-0 text-gray-500">
计划时间 计划时间
</view> </view>
<view class="desc-value w-0 flex-1 text-[#333] leading-[1.5]"> <view class="desc-value w-0 flex-1 text-gray-900 leading-4">
{{ item.planDate || '-' }} {{ item.planDate || '-' }}
</view> </view>
</view> </view>
<view v-if="item.completeDate" class="desc mb-3 flex"> <view v-if="item.completeDate" class="desc mb-3 flex">
<view class="desc-label w-[140rpx] shrink-0 text-[#333]"> <view class="desc-label w-16 shrink-0 text-gray-500">
完成时间 完成时间
</view> </view>
<view class="desc-value w-0 flex-1 text-[#333] leading-[1.5]"> <view class="desc-value w-0 flex-1 text-gray-900 leading-4">
{{ item.completeDate || '-' }} {{ item.completeDate || '-' }}
</view> </view>
</view> </view>
<view v-if="item.completeRemark" class="desc mb-3 flex"> <view v-if="item.completeRemark" class="desc mb-3 flex">
<view class="desc-label w-[140rpx] shrink-0 text-[#333]"> <view class="desc-label w-16 shrink-0 text-gray-500">
完成感想 完成感想
</view> </view>
<view class="desc-value w-0 flex-1 text-[#333] leading-[1.5]"> <view class="desc-value w-0 flex-1 text-gray-900 leading-4">
{{ item.completeRemark || '-' }} {{ item.completeRemark || '-' }}
</view> </view>
</view> </view>
<view v-if="item.images.length > 0" class="desc mb-3 flex"> <view v-if="item.images.length > 0" class="desc flex">
<view class="desc-label w-[140rpx] shrink-0 text-[#333]"> <view class="desc-label w-16 shrink-0 text-gray-500">
回忆图片 回忆图片
</view> </view>
<view class="desc-value w-0 flex-1 text-[#333] leading-[1.5]"> <view class="desc-value w-0 flex-1 text-gray-900 leading-4">
<view class="images flex flex-wrap"> <view class="grid grid-cols-3 gap-2">
<view <view v-for="(img, imgIndex) in item.images" :key="imgIndex"
v-for="(img, imgIndex) in item.images" class="h-16 w-full overflow-hidden rounded-lg"
:key="imgIndex" @click="handlePreviewImages(item.images, imgIndex)">
class="image mb-3 mr-3 h-[180rpx] w-[calc((100%-24rpx)/3)] overflow-hidden rounded-lg" <image class="h-full w-full" :src="img" mode="aspectFill" lazy-load />
@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>
@@ -197,16 +346,42 @@ onPullDownRefresh(() => {
</view> </view>
</view> </view>
</block> </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> </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="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>
</view> </view>
</template> </template>
</view>
<style scoped> </uh-glass-popup>
.app-page { </view>
/* 布局全部由 UnoCSS 原子类实现 */ </template>
}
</style>
+100 -82
View File
@@ -1,6 +1,7 @@
<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 dayjs from 'dayjs'
import { getLoveStories } from '@/api/uni-halo' import { getLoveStories } from '@/api/uni-halo'
import { useAppConfigStore } from '@/store/appConfig' import { useAppConfigStore } from '@/store/appConfig'
import { checkImageUrl } from '@/utils/url' import { checkImageUrl } from '@/utils/url'
@@ -24,6 +25,14 @@ interface IStoryCard {
key : string key : string
title : string title : string
date : string date : string
/** 日期拆分:年(如"2023") */
year : string
/** 日期拆分:月(如"5") */
month : string
/** 日期拆分:日(如"20") */
day : string
/** 日期拆分:星期(如"周六") */
weekend : string
location : string location : string
/** 故事正文(HTML) */ /** 故事正文(HTML) */
content : string content : string
@@ -38,13 +47,34 @@ const EMPTY_STORY: IStoryCard = {
key: '', key: '',
title: '', title: '',
date: '', date: '',
year: '',
month: '',
day: '',
weekend: '',
location: '', location: '',
content: '', content: '',
images: [], images: [],
coverImages: [], coverImages: [],
} }
/** 故事卡片映射:字段取值 + 图片路径预处理(模板不感知原始接口结构) */ /** 中文星期(dayjs day():0=周日) */
const WEEKDAY_TEXT = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
/** 从日期字符串拆分年月日星期(解析失败返回空) */
function splitStoryDate(dateStr : string) {
const d = dateStr ? dayjs(dateStr) : null
if (!d || !d.isValid()) {
return { year: '', month: '', day: '', weekend: '' }
}
return {
year: `${d.year()}`,
month: `${d.month() + 1}`,
day: `${d.date()}`,
weekend: WEEKDAY_TEXT[d.day()],
}
}
/** 故事卡片映射:字段取值 + 日期拆分 + 图片路径预处理(模板不感知原始接口结构) */
function mapStoryCard(story : ILoveStory, index : number) : IStoryCard { function mapStoryCard(story : ILoveStory, index : number) : IStoryCard {
const spec = story.spec || {} const spec = story.spec || {}
const images = (spec.images || []).map(img => checkImageUrl(img || '')) const images = (spec.images || []).map(img => checkImageUrl(img || ''))
@@ -52,6 +82,7 @@ function mapStoryCard(story: ILoveStory, index: number): IStoryCard {
key: story.metadata?.name || `story-${index}`, key: story.metadata?.name || `story-${index}`,
title: spec.title || '', title: spec.title || '',
date: spec.date || '', date: spec.date || '',
...splitStoryDate(spec.date || ''),
location: spec.location || '', location: spec.location || '',
content: spec.content || '', content: spec.content || '',
images, images,
@@ -101,6 +132,10 @@ function handleLoadFromLegacy() {
key: 'legacy-story', key: 'legacy-story',
title: '我们的故事', title: '我们的故事',
date: '', date: '',
year: '',
month: '',
day: '',
weekend: '',
location: '', location: '',
content: loveModuleConfig.ourStory.content, content: loveModuleConfig.ourStory.content,
images: [], images: [],
@@ -160,49 +195,52 @@ onPullDownRefresh(() => {
</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">
{{ story.day }} <text class="text-lg"></text>
</text>
<text class=" mt-2 text-sm text-love">{{ story.year }}/{{ story.month }}</text>
<text class=" mt-1 text-xs text-gray-500">{{ story.weekend }}</text>
</view> </view>
<view class="timeline-title text-[32rpx] text-[#333] font-bold"> </view>
<view class="relative overflow-hidden uh-global-card-glass box-border flex-1 rounded-xl p-3">
<text v-if="false"
class="absolute right-2 top-1 z-10 text-6xl text-love font-bold opacity-5">{{ story.day }}</text>
<view class="flex items-start justify-between gap-x-2">
<view class="text-md text-gray-900 font-bold truncate">
{{ story.title }} {{ story.title }}
</view> </view>
<view v-if="story.location" class="timeline-location mt-2 flex items-center text-[24rpx] text-[#999]"> <uh-button custom-class="uh-global-card-glass shadow-none !bg-love/90 border text-white !py-1 px-2 text-xs !rounded-md"
<text class="location-text ml-1">{{ story.location }}</text> @click="handleOnStoryClick(story)">详情</uh-button>
</view> </view>
<view v-if="story.coverImages.length" class="timeline-covers mt-4 flex flex-wrap gap-2"> <view v-if="story.location" class="mt-2 flex items-center text-xs text-gray-600">
<view <wd-icon name="location"></wd-icon> <text>{{ story.location }}</text>
v-for="(img, imgIndex) in story.coverImages"
:key="imgIndex"
class="timeline-cover h-[180rpx] w-[calc((100%-16rpx)/3)] overflow-hidden rounded-lg"
@click.stop="handlePreviewStoryImages(story, imgIndex)"
>
<image class="timeline-cover-img h-full w-full" :src="img" mode="aspectFill" lazy-load />
</view> </view>
<view <view v-if="story.coverImages.length" class="relative mt-3 grid grid-cols-3 gap-2">
v-if="story.images.length > 3" <view v-for="(img, imgIndex) in story.coverImages" :key="imgIndex"
class="timeline-cover timeline-cover-more h-[180rpx] w-[calc((100%-16rpx)/3)] flex items-center justify-center bg-black/50" class="h-16 w-full overflow-hidden rounded-lg"
@click.stop="handleOnStoryClick(story)" @click.stop="handlePreviewStoryImages(story, imgIndex)">
> <image class="timeline-cover-img h-full w-full" :src="img" mode="aspectFill"
<text class="more-text text-[32rpx] text-white font-bold"> lazy-load />
</view>
<view v-if="story.images.length > 3"
class="box-border px-1 py-0.5 rounded-lt-lg absolute bottom-0 right-0 flex items-center justify-center bg-black/30"
@click.stop="handleOnStoryClick(story)">
<text class="text-xs text-white">
+{{ story.images.length - 3 }} +{{ story.images.length - 3 }}
</text> </text>
</view> </view>
@@ -212,71 +250,51 @@ onPullDownRefresh(() => {
</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" />
</view>
<!-- 故事详情弹窗 --> <!-- 故事详情弹窗 -->
<wd-popup v-model="showDetail" position="center" custom-style="width:90vw;height:80vh;border-radius:12rpx;"> <uh-glass-popup v-model="showDetail" :z-index="100" position="bottom" custom-class="rounded-xl">
<view class="story-detail h-full w-full flex flex-col overflow-hidden rounded-xl bg-white"> <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 border-b border-black/5 px-7 py-6"> <view class="story-detail-header box-border shrink-0 mb-4">
<view class="story-detail-title text-[32rpx] text-[#333] font-bold"> <view class="story-detail-title text-lg text-gray-900 font-bold">
{{ currentStory.title }} {{ currentStory.title }}
</view> </view>
<view v-if="currentStory.date || currentStory.location" class="story-detail-meta mt-2 flex items-center text-[24rpx] text-[#999]"> <view v-if="currentStory.date || currentStory.location"
<text v-if="currentStory.date" class="story-detail-date"> class="mt-2 flex items-center text-xs text-gray-500">
{{ currentStory.date }} <text v-if="currentStory.date">
<wd-icon name="time-line"></wd-icon> {{ currentStory.date }}
</text> </text>
<text v-if="currentStory.location" class="story-detail-location ml-6"> <text v-if="currentStory.location" class="ml-6">
{{ currentStory.location }} <wd-icon name="location"></wd-icon> {{ currentStory.location }}
</text> </text>
</view> </view>
</view> </view>
<!-- 故事图片:多图 swiper 轮播 --> <!-- 故事图片:多图 swiper 轮播 -->
<view v-if="currentStory.images.length > 0" class="story-images shrink-0"> <view v-if="currentStory.images.length > 0" class="story-images shrink-0">
<swiper <swiper v-if="currentStory.images.length > 1" class="h-32 w-full rounded-lg overflow-hidden" circular
v-if="currentStory.images.length > 1" indicator-dots indicator-color="rgba(255,255,255,0.4)" indicator-active-color="#f83856"
class="story-images-swiper h-[360rpx] w-full" :current="storyImageIndex" @change="handleOnStoryImageChange">
circular <swiper-item v-for="(img, imgIndex) in currentStory.images" :key="imgIndex"
indicator-dots class="story-images-item h-full w-full">
indicator-color="rgba(255,255,255,0.4)" <image :src="img" mode="aspectFill" class="h-full w-full"
indicator-active-color="#f88ca2" @click="handlePreviewImage(imgIndex)" />
:current="storyImageIndex"
@change="handleOnStoryImageChange"
>
<swiper-item v-for="(img, imgIndex) in currentStory.images" :key="imgIndex" class="story-images-item h-full w-full">
<image :src="img" mode="aspectFill" class="story-image h-full w-full" @click="handlePreviewImage(imgIndex)" />
</swiper-item> </swiper-item>
</swiper> </swiper>
<image v-else :src="currentStory.images[0]" mode="aspectFill" class="story-image story-image-single h-[360rpx] w-full" @click="handlePreviewImage(0)" /> <image v-else :src="currentStory.images[0]" mode="aspectFill"
class="h-32 w-full" @click="handlePreviewImage(0)" />
</view> </view>
<scroll-view scroll-y class="story-detail-content box-border min-h-0 flex-1 px-7 py-6"> <scroll-view scroll-y :show-scrollbar="false" class="mt-4 box-border max-h-[50vh] flex-1">
<view class="story-html text-[28rpx] text-[#333] leading-[1.8]" v-html="currentStory.content" /> <view class="story-html text-sm text-gray-900 leading-7" v-html="currentStory.content" />
</scroll-view> </scroll-view>
<view class="story-detail-close box-border shrink-0 border-t border-black/5 px-7 py-5"> <view class="w-full mt-3">
<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> <uh-button custom-class="uh-global-card-glass border !bg-love/90 !py-2 text-white" @click="showDetail = false">关闭</uh-button>
</view> </view>
</view> </view>
</wd-popup> </uh-glass-popup>
</view> </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的使用,并提升布局的兼容性