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:
@@ -1,156 +1,159 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 相册图片查看弹窗(源自旧项目 components/album-photo-viewer,新建复刻)
|
||||
* 双列瀑布流展示相册照片,支持大图预览
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { checkImageUrl } from '@/utils/url'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { checkImageUrl } from '@/utils/url'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
show: boolean
|
||||
albumName?: string
|
||||
photos?: IAlbumPhoto[]
|
||||
loading?: boolean
|
||||
}>(), {
|
||||
albumName: '',
|
||||
photos: () => [],
|
||||
loading: false,
|
||||
})
|
||||
const props = withDefaults(defineProps<{
|
||||
show : boolean
|
||||
albumName ?: string
|
||||
photos ?: IAlbumPhoto[]
|
||||
loading ?: boolean
|
||||
}>(), {
|
||||
albumName: '',
|
||||
photos: () => [],
|
||||
loading: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:show', show: boolean): void
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e : 'update:show', show : boolean) : void
|
||||
}>()
|
||||
|
||||
export interface IAlbumPhoto {
|
||||
name?: string
|
||||
url?: string
|
||||
title?: string
|
||||
takenDate?: string
|
||||
location?: string
|
||||
description?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
export interface IAlbumPhoto {
|
||||
name ?: string
|
||||
url ?: string
|
||||
title ?: string
|
||||
takenDate ?: string
|
||||
location ?: string
|
||||
description ?: string
|
||||
[key : string] : unknown
|
||||
}
|
||||
|
||||
const isShow = ref(false)
|
||||
const isShow = ref(false)
|
||||
|
||||
watch(() => props.show, (val) => {
|
||||
isShow.value = val
|
||||
})
|
||||
watch(() => props.show, (val) => {
|
||||
isShow.value = val
|
||||
})
|
||||
|
||||
/** 预处理图片路径(相对路径拼接 BASE_API) */
|
||||
const photoList = computed<IAlbumPhoto[]>(() =>
|
||||
(props.photos || []).map(photo => ({
|
||||
...photo,
|
||||
url: checkImageUrl(photo.url || ''),
|
||||
})),
|
||||
)
|
||||
/** 预处理图片路径(相对路径拼接 BASE_API) */
|
||||
const photoList = computed<IAlbumPhoto[]>(() =>
|
||||
(props.photos || []).map(photo => ({
|
||||
...photo,
|
||||
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() {
|
||||
isShow.value = false
|
||||
emit('update:show', false)
|
||||
}
|
||||
function handleClose() {
|
||||
isShow.value = false
|
||||
emit('update:show', false)
|
||||
}
|
||||
|
||||
/** 预览大图 */
|
||||
function handlePreview(url?: string) {
|
||||
const urls = photoList.value.map(photo => photo.url || '')
|
||||
if (urls.length === 0) {
|
||||
uni.showToast({ title: '相册暂无照片', icon: 'none' })
|
||||
return
|
||||
}
|
||||
uni.previewImage({
|
||||
current: url || urls[0],
|
||||
urls,
|
||||
})
|
||||
}
|
||||
/** 预览大图 */
|
||||
function handlePreview(url ?: string) {
|
||||
const urls = photoList.value.map(photo => photo.url || '')
|
||||
if (urls.length === 0) {
|
||||
uni.showToast({ title: '相册暂无照片', icon: 'none' })
|
||||
return
|
||||
}
|
||||
uni.previewImage({
|
||||
current: url || urls[0],
|
||||
urls,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<wd-popup v-model="isShow" position="center" custom-style="width:94vw;height:82vh;border-radius:12rpx;" @close="handleClose">
|
||||
<view class="album-photo-viewer h-full w-full flex flex-col overflow-hidden rounded-xl bg-white">
|
||||
<!-- 头部 -->
|
||||
<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="viewer-close h-14 w-14 flex shrink-0 items-center justify-center rounded-full bg-black/5" @click="handleClose">
|
||||
<wd-icon name="close" size="16px" color="#666" />
|
||||
</view>
|
||||
</view>
|
||||
<uh-glass-popup v-model="isShow" position="bottom" :z-index="100" custom-class="!border rounded-2xl"
|
||||
safe-area-inset-bottom @close="handleClose">
|
||||
<view class="box-border h-full w-full flex flex-col gap-y-3 p-4">
|
||||
<!-- 头部 -->
|
||||
<view class="w-full shrink-0 flex items-center justify-between">
|
||||
<view class="font-bold flex items-center gap-x-1">
|
||||
{{ albumName }}
|
||||
</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">
|
||||
<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="loading-text mt-7 text-[28rpx] text-[#56bbf9]">
|
||||
照片正在努力加载中啦~
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else-if="photoList.length === 0" class="viewer-empty box-border h-full flex items-center justify-center p-10">
|
||||
<wd-empty description="这个相册暂时还没有照片~" />
|
||||
</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">
|
||||
<image
|
||||
class="photo-image w-full"
|
||||
:src="photo.url"
|
||||
mode="widthFix"
|
||||
lazy-load
|
||||
@click="handlePreview(photo.url)"
|
||||
/>
|
||||
<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">
|
||||
{{ photo.title }}
|
||||
</view>
|
||||
<view v-if="photo.takenDate || photo.location" class="photo-meta mb-3 flex flex-wrap items-center">
|
||||
<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 v-if="photo.description" class="photo-desc text-[26rpx] text-[#666] leading-[1.6]">
|
||||
{{ photo.description }}
|
||||
</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">
|
||||
<image
|
||||
class="photo-image w-full"
|
||||
:src="photo.url"
|
||||
mode="widthFix"
|
||||
lazy-load
|
||||
@click="handlePreview(photo.url)"
|
||||
/>
|
||||
<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">
|
||||
{{ photo.title }}
|
||||
</view>
|
||||
<view v-if="photo.takenDate || photo.location" class="photo-meta mb-3 flex flex-wrap items-center">
|
||||
<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 v-if="photo.description" class="photo-desc text-[26rpx] text-[#666] leading-[1.6]">
|
||||
{{ photo.description }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<!-- 照片列表 -->
|
||||
<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 class="viewer-loading flex flex-col items-center">
|
||||
<view class="loading-text mt-7 text-[28rpx] text-[#56bbf9]">
|
||||
照片正在努力加载中啦~
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else-if="photoList.length === 0"
|
||||
class="viewer-empty box-border h-full flex items-center justify-center p-10">
|
||||
<wd-empty description="这个相册暂时还没有照片~" />
|
||||
</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">
|
||||
<image class="photo-image w-full" :src="photo.url" mode="widthFix" lazy-load
|
||||
@click="handlePreview(photo.url)" />
|
||||
<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">
|
||||
{{ photo.title }}
|
||||
</view>
|
||||
<view v-if="photo.takenDate || photo.location"
|
||||
class="photo-meta mb-3 flex flex-wrap items-center">
|
||||
<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 v-if="photo.description"
|
||||
class="photo-desc text-[26rpx] text-[#666] leading-[1.6]">
|
||||
{{ photo.description }}
|
||||
</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">
|
||||
<image class="photo-image w-full" :src="photo.url" mode="widthFix" lazy-load
|
||||
@click="handlePreview(photo.url)" />
|
||||
<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">
|
||||
{{ photo.title }}
|
||||
</view>
|
||||
<view v-if="photo.takenDate || photo.location"
|
||||
class="photo-meta mb-3 flex flex-wrap items-center">
|
||||
<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 v-if="photo.description"
|
||||
class="photo-desc text-[26rpx] text-[#666] leading-[1.6]">
|
||||
{{ photo.description }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 底部关闭 -->
|
||||
<view class="viewer-footer box-border shrink-0 border-t border-black/5 px-7 py-5">
|
||||
<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">
|
||||
<text class="footer-text text-[30rpx] text-white font-bold">关 闭</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</wd-popup>
|
||||
<!-- 底部关闭 -->
|
||||
<view class="w-full shrink-0">
|
||||
<uh-button custom-class="py-2 uh-global-card-glass rounded-xl !bg-love/90 text-white border"
|
||||
@click="handleClose">
|
||||
关闭
|
||||
</uh-button>
|
||||
</view>
|
||||
</view>
|
||||
</uh-glass-popup>
|
||||
</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>
|
||||
@@ -1,11 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
interface IProps {
|
||||
customClass : Array<string>;
|
||||
customClass?: string;
|
||||
}
|
||||
const props = defineProps({
|
||||
customClass: () => {
|
||||
return []
|
||||
},
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
customClass: ''
|
||||
})
|
||||
|
||||
function handleScrollTop() {
|
||||
@@ -19,13 +18,18 @@
|
||||
const visible = computed(() => {
|
||||
return !balckList.includes(currentPage.route)
|
||||
})
|
||||
|
||||
const _customClass = computed(() => {
|
||||
const colorClass = currentPage.route.includes('/love/')?'text-love':'text-primary'
|
||||
return `${props.customClass} ${colorClass}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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"
|
||||
:class="props.customClass" @click="handleScrollTop">
|
||||
<wd-icon name="arrow-up" size="20px" />
|
||||
<view class="uh-global-card-glass border h-11 w-11 flex items-center justify-center rounded-full"
|
||||
:class="_customClass" @click="handleScrollTop">
|
||||
<wd-icon name="arrow-up" size="42rpx" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
+234
-248
@@ -1,278 +1,264 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 恋爱相册页(源自旧项目 pagesA/love/album.vue,新建复刻)
|
||||
* 相册列表(两列网格)+ 加密相册密码解锁 + 图片查看弹窗
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import dayjs from 'dayjs'
|
||||
import { getLoveAlbumByName, getLoveAlbums } from '@/api/uni-halo'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
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'
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import dayjs from 'dayjs'
|
||||
import { getLoveAlbumByName, getLoveAlbums } from '@/api/uni-halo'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
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({
|
||||
style: {
|
||||
navigationBarTitleText: '恋爱相册',
|
||||
navigationStyle: 'custom',
|
||||
enablePullDownRefresh: true,
|
||||
},
|
||||
})
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '恋爱相册',
|
||||
navigationStyle: 'custom',
|
||||
enablePullDownRefresh: true,
|
||||
},
|
||||
})
|
||||
|
||||
const appConfigStore = useAppConfigStore()
|
||||
const loveConfig = computed(() => appConfigStore.configs.loveConfig)
|
||||
const appConfigStore = useAppConfigStore()
|
||||
const loveConfig = computed(() => appConfigStore.configs.loveConfig)
|
||||
|
||||
/** 已解锁相册本地缓存 key */
|
||||
const UNLOCKED_ALBUMS_CACHE_KEY = 'unlocked_albums'
|
||||
/** 解锁 token 有效期(后端默认 30 分钟) */
|
||||
const ALBUM_TOKEN_TTL_SECONDS = 30 * 60
|
||||
/** 已解锁相册本地缓存 key */
|
||||
const UNLOCKED_ALBUMS_CACHE_KEY = 'unlocked_albums'
|
||||
/** 解锁 token 有效期(后端默认 30 分钟) */
|
||||
const ALBUM_TOKEN_TTL_SECONDS = 30 * 60
|
||||
|
||||
/* ---------------- 展示层类型 ---------------- */
|
||||
/** 相册展示卡片(script 预处理后的干净展示数据) */
|
||||
interface ILoveAlbumCard {
|
||||
/** 相册 key(metadata.name,用于解锁/详情请求) */
|
||||
name: string
|
||||
displayName: string
|
||||
locked: boolean
|
||||
photoCount: number
|
||||
/** 封面图(已预处理 URL) */
|
||||
image: string
|
||||
/** 创建时间(格式化展示) */
|
||||
takeTime: string
|
||||
/** 相册照片(解锁后填充) */
|
||||
photos: ILovePhoto[]
|
||||
}
|
||||
/* ---------------- 展示层类型 ---------------- */
|
||||
/** 相册展示卡片(script 预处理后的干净展示数据) */
|
||||
interface ILoveAlbumCard {
|
||||
/** 相册 key(metadata.name,用于解锁/详情请求) */
|
||||
name : string
|
||||
displayName : string
|
||||
locked : boolean
|
||||
photoCount : number
|
||||
/** 封面图(已预处理 URL) */
|
||||
image : string
|
||||
/** 创建时间(格式化展示) */
|
||||
takeTime : string
|
||||
/** 相册照片(解锁后填充) */
|
||||
photos : ILovePhoto[]
|
||||
}
|
||||
|
||||
/** 相册卡片映射:字段取值 + 封面/时间预处理(模板不感知原始接口结构) */
|
||||
function mapAlbumCard(item: ILoveAlbum): ILoveAlbumCard {
|
||||
const creationTimestamp = item.metadata?.creationTimestamp
|
||||
return {
|
||||
name: item.name || item.metadata?.name || '',
|
||||
displayName: item.displayName || item.title || '',
|
||||
locked: !!item.locked,
|
||||
photoCount: Number(item.photoCount) || 0,
|
||||
image: checkImageUrl(item.cover || ''),
|
||||
takeTime: creationTimestamp ? dayjs(creationTimestamp).format('DD/MM/YYYY') : '',
|
||||
photos: item.photos || [],
|
||||
}
|
||||
}
|
||||
/** 相册卡片映射:字段取值 + 封面/时间预处理(模板不感知原始接口结构) */
|
||||
function mapAlbumCard(item : ILoveAlbum) : ILoveAlbumCard {
|
||||
const creationTimestamp = item.metadata?.creationTimestamp
|
||||
return {
|
||||
name: item.name || item.metadata?.name || '',
|
||||
displayName: item.displayName || item.title || '',
|
||||
locked: !!item.locked,
|
||||
photoCount: Number(item.photoCount) || 0,
|
||||
image: checkImageUrl(item.cover || ''),
|
||||
takeTime: creationTimestamp ? dayjs(creationTimestamp).format('DD/MM/YYYY') : '',
|
||||
photos: item.photos || [],
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
|
||||
const dataList = ref<ILoveAlbumCard[]>([])
|
||||
const unlockedAlbums = ref<Record<string, string>>({})
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
|
||||
const dataList = ref<ILoveAlbumCard[]>([])
|
||||
const unlockedAlbums = ref<Record<string, string>>({})
|
||||
|
||||
/** 密码解锁弹窗 */
|
||||
const showUnlockModal = ref(false)
|
||||
const currentUnlockAlbum = ref<ILoveAlbumCard | null>(null)
|
||||
/** 密码解锁弹窗 */
|
||||
const showUnlockModal = ref(false)
|
||||
const currentUnlockAlbum = ref<ILoveAlbumCard | null>(null)
|
||||
|
||||
/** 图片查看弹窗 */
|
||||
const showPhotoViewer = ref(false)
|
||||
const currentViewerAlbum = ref<ILoveAlbumCard | null>(null)
|
||||
const viewerLoading = ref(false)
|
||||
/** 图片查看弹窗 */
|
||||
const showPhotoViewer = ref(false)
|
||||
const currentViewerAlbum = ref<ILoveAlbumCard | null>(null)
|
||||
const viewerLoading = ref(false)
|
||||
|
||||
const unlockAlbumName = computed(() => currentUnlockAlbum.value?.displayName || '')
|
||||
const unlockAlbumKey = computed(() => currentUnlockAlbum.value?.name || '')
|
||||
const viewerAlbumName = computed(() => currentViewerAlbum.value?.displayName || '')
|
||||
const viewerPhotos = computed(() => currentViewerAlbum.value?.photos || [])
|
||||
const unlockAlbumName = computed(() => currentUnlockAlbum.value?.displayName || '')
|
||||
const unlockAlbumKey = computed(() => currentUnlockAlbum.value?.name || '')
|
||||
const viewerAlbumName = computed(() => currentViewerAlbum.value?.displayName || '')
|
||||
const viewerPhotos = computed(() => currentViewerAlbum.value?.photos || [])
|
||||
|
||||
/* ---------------- 缓存 ---------------- */
|
||||
function handleRestoreUnlockedAlbums() {
|
||||
try {
|
||||
const saved = getCache<Record<string, string>>(UNLOCKED_ALBUMS_CACHE_KEY)
|
||||
if (saved) {
|
||||
unlockedAlbums.value = saved
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.error('恢复解锁状态失败', e)
|
||||
}
|
||||
}
|
||||
/* ---------------- 缓存 ---------------- */
|
||||
function handleRestoreUnlockedAlbums() {
|
||||
try {
|
||||
const saved = getCache<Record<string, string>>(UNLOCKED_ALBUMS_CACHE_KEY)
|
||||
if (saved) {
|
||||
unlockedAlbums.value = saved
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.error('恢复解锁状态失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
function handleSaveUnlockedAlbums() {
|
||||
try {
|
||||
setCache(UNLOCKED_ALBUMS_CACHE_KEY, unlockedAlbums.value, ALBUM_TOKEN_TTL_SECONDS)
|
||||
}
|
||||
catch (e) {
|
||||
console.error('保存解锁状态失败', e)
|
||||
}
|
||||
}
|
||||
function handleSaveUnlockedAlbums() {
|
||||
try {
|
||||
setCache(UNLOCKED_ALBUMS_CACHE_KEY, unlockedAlbums.value, ALBUM_TOKEN_TTL_SECONDS)
|
||||
}
|
||||
catch (e) {
|
||||
console.error('保存解锁状态失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 数据加载 ---------------- */
|
||||
async function handleGetData() {
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Loading)
|
||||
try {
|
||||
const res = await getLoveAlbums({})
|
||||
const items = res.data?.items || []
|
||||
dataList.value = items.map(mapAlbumCard)
|
||||
updateLoadingStatus(dataList.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success)
|
||||
if (dataList.value.length > 0)
|
||||
handleLoadUnlockedAlbumPhotos()
|
||||
}
|
||||
catch (e) {
|
||||
console.error('获取相册失败', e)
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Error)
|
||||
}
|
||||
finally {
|
||||
setTimeout(() => {
|
||||
uni.stopPullDownRefresh()
|
||||
}, 200)
|
||||
}
|
||||
}
|
||||
/* ---------------- 数据加载 ---------------- */
|
||||
async function handleGetData() {
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Loading)
|
||||
try {
|
||||
const res = await getLoveAlbums({})
|
||||
const items = res.data?.items || []
|
||||
dataList.value = items.map(mapAlbumCard)
|
||||
updateLoadingStatus(dataList.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success)
|
||||
if (dataList.value.length > 0)
|
||||
handleLoadUnlockedAlbumPhotos()
|
||||
}
|
||||
catch (e) {
|
||||
console.error('获取相册失败', e)
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Error)
|
||||
}
|
||||
finally {
|
||||
setTimeout(() => {
|
||||
uni.stopPullDownRefresh()
|
||||
}, 200)
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载已解锁相册的照片 */
|
||||
async function handleLoadUnlockedAlbumPhotos() {
|
||||
for (const item of dataList.value) {
|
||||
const token = unlockedAlbums.value[item.name]
|
||||
if (item.locked && token) {
|
||||
try {
|
||||
const detail = await getLoveAlbumByName(item.name, { token })
|
||||
if (detail.locked) {
|
||||
delete unlockedAlbums.value[item.name]
|
||||
handleSaveUnlockedAlbums()
|
||||
}
|
||||
else if (detail.photos) {
|
||||
item.photos = detail.photos
|
||||
item.locked = false
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.error('加载相册照片失败', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/** 加载已解锁相册的照片 */
|
||||
async function handleLoadUnlockedAlbumPhotos() {
|
||||
for (const item of dataList.value) {
|
||||
const token = unlockedAlbums.value[item.name]
|
||||
if (item.locked && token) {
|
||||
try {
|
||||
const detail = await getLoveAlbumByName(item.name, { token })
|
||||
if (detail.locked) {
|
||||
delete unlockedAlbums.value[item.name]
|
||||
handleSaveUnlockedAlbums()
|
||||
}
|
||||
else if (detail.photos) {
|
||||
item.photos = detail.photos
|
||||
item.locked = false
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.error('加载相册照片失败', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 交互 ---------------- */
|
||||
function handleOnAlbumClick(item: ILoveAlbumCard) {
|
||||
if (item.locked && !unlockedAlbums.value[item.name]) {
|
||||
currentUnlockAlbum.value = item
|
||||
showUnlockModal.value = true
|
||||
return
|
||||
}
|
||||
handleOpenPhotoViewer(item)
|
||||
}
|
||||
/* ---------------- 交互 ---------------- */
|
||||
function handleOnAlbumClick(item : ILoveAlbumCard) {
|
||||
if (item.locked && !unlockedAlbums.value[item.name]) {
|
||||
currentUnlockAlbum.value = item
|
||||
showUnlockModal.value = true
|
||||
return
|
||||
}
|
||||
handleOpenPhotoViewer(item)
|
||||
}
|
||||
|
||||
async function handleOpenPhotoViewer(item: ILoveAlbumCard) {
|
||||
currentViewerAlbum.value = item
|
||||
showPhotoViewer.value = true
|
||||
if (item.photos.length > 0)
|
||||
return
|
||||
viewerLoading.value = true
|
||||
try {
|
||||
const token = unlockedAlbums.value[item.name] || ''
|
||||
const detail = await getLoveAlbumByName(item.name, { token })
|
||||
if (detail) {
|
||||
if (detail.locked) {
|
||||
delete unlockedAlbums.value[item.name]
|
||||
handleSaveUnlockedAlbums()
|
||||
}
|
||||
else if (detail.photos) {
|
||||
item.photos = detail.photos
|
||||
item.locked = false
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.error('获取相册照片失败', e)
|
||||
uni.showToast({ icon: 'none', title: '照片加载失败,请稍后重试' })
|
||||
}
|
||||
finally {
|
||||
viewerLoading.value = false
|
||||
}
|
||||
}
|
||||
async function handleOpenPhotoViewer(item : ILoveAlbumCard) {
|
||||
currentViewerAlbum.value = item
|
||||
showPhotoViewer.value = true
|
||||
if (item.photos.length > 0) { return }
|
||||
viewerLoading.value = true
|
||||
try {
|
||||
const token = unlockedAlbums.value[item.name] || ''
|
||||
const detail = await getLoveAlbumByName(item.name, { token })
|
||||
if (detail) {
|
||||
if (detail.locked) {
|
||||
delete unlockedAlbums.value[item.name]
|
||||
handleSaveUnlockedAlbums()
|
||||
}
|
||||
else if (detail.photos) {
|
||||
item.photos = detail.photos
|
||||
item.locked = false
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.error('获取相册照片失败', e)
|
||||
uni.showToast({ icon: 'none', title: '照片加载失败,请稍后重试' })
|
||||
}
|
||||
finally {
|
||||
viewerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleOnUnlockSuccess(data: { albumKey: string, token: string, photos: unknown[] }) {
|
||||
unlockedAlbums.value[data.albumKey] = data.token
|
||||
handleSaveUnlockedAlbums()
|
||||
function handleOnUnlockSuccess(data : { albumKey : string, token : string, photos : unknown[] }) {
|
||||
unlockedAlbums.value[data.albumKey] = data.token
|
||||
handleSaveUnlockedAlbums()
|
||||
|
||||
const albumIndex = dataList.value.findIndex(a => a.name === data.albumKey)
|
||||
if (albumIndex !== -1) {
|
||||
dataList.value[albumIndex].photos = data.photos as ILovePhoto[]
|
||||
dataList.value[albumIndex].locked = false
|
||||
}
|
||||
currentUnlockAlbum.value = null
|
||||
if (albumIndex !== -1) {
|
||||
handleOpenPhotoViewer(dataList.value[albumIndex])
|
||||
}
|
||||
}
|
||||
const albumIndex = dataList.value.findIndex(a => a.name === data.albumKey)
|
||||
if (albumIndex !== -1) {
|
||||
dataList.value[albumIndex].photos = data.photos as ILovePhoto[]
|
||||
dataList.value[albumIndex].locked = false
|
||||
}
|
||||
currentUnlockAlbum.value = null
|
||||
if (albumIndex !== -1) {
|
||||
handleOpenPhotoViewer(dataList.value[albumIndex])
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 生命周期 ---------------- */
|
||||
onLoad(() => {
|
||||
handleRestoreUnlockedAlbums()
|
||||
handleGetData()
|
||||
})
|
||||
function handleToTopPage(duration = 500) {
|
||||
uni.pageScrollTo({
|
||||
scrollTop: 0,
|
||||
duration,
|
||||
fail: (err) => {
|
||||
console.error('回顶失败', err)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
handleGetData()
|
||||
})
|
||||
/* ---------------- 生命周期 ---------------- */
|
||||
onLoad(() => {
|
||||
handleRestoreUnlockedAlbums()
|
||||
handleGetData()
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
handleGetData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<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%));">
|
||||
<!-- 自定义导航 -->
|
||||
<uh-navbar default-title="恋爱相册" title-color="text-gray-900" />
|
||||
<view class="app-page box-border min-h-screen w-screen flex flex-col pb-safe">
|
||||
<!-- 自定义导航 -->
|
||||
<uh-navbar default-title="恋爱相册" title-color="text-love" back-class="text-love" />
|
||||
|
||||
<!-- 加载/错误/空占位(状态机) -->
|
||||
<uh-data-loading
|
||||
v-if="loadingStatus !== DataLoadingStatusEnum.Success"
|
||||
:loading-status="loadingStatus"
|
||||
min-height="60vh"
|
||||
empty-text="相册暂时还没有数据~"
|
||||
@refresh="handleGetData"
|
||||
/>
|
||||
<!-- 加载/错误/空占位(状态机) -->
|
||||
<uh-data-loading v-if="loadingStatus !== DataLoadingStatusEnum.Success" :loading-status="loadingStatus"
|
||||
min-height="75vh" empty-text="相册暂时还没有数据~" @refresh="handleGetData" />
|
||||
|
||||
<!-- 相册列表(两列网格) -->
|
||||
<view v-else class="album-list box-border flex flex-wrap px-6">
|
||||
<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 class="album-cover-wrap relative h-[320rpx] w-full">
|
||||
<image class="album-cover h-full w-full" :src="item.image" mode="aspectFill" lazy-load />
|
||||
<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">
|
||||
<view class="lock-icon text-[64rpx]">
|
||||
🔒
|
||||
</view>
|
||||
<view class="lock-tip mt-3 text-[26rpx] text-white">
|
||||
已加密
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="album-info box-border p-5">
|
||||
<view class="album-name overflow-hidden text-ellipsis whitespace-nowrap text-[30rpx] text-[#333] font-bold">
|
||||
{{ item.displayName }}
|
||||
</view>
|
||||
<view class="album-count mt-1 text-[24rpx] text-[#999]">
|
||||
{{ item.photoCount }} 张照片
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 相册列表(两列网格) -->
|
||||
<view v-else class="box-border grid grid-cols-2 p-3 pt-2 gap-3">
|
||||
<view v-for="(item) in dataList" :key="item.name"
|
||||
class="uh-global-card-glass box-border overflow-hidden rounded-xl" @click="handleOnAlbumClick(item)">
|
||||
<view class="relative h-24 w-full">
|
||||
<image class="h-full w-full" :src="item.image" mode="aspectFill" lazy-load />
|
||||
<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">
|
||||
<wd-icon name="lock" size="52rpx" class="text-white" />
|
||||
<view class="text-xs text-white"> 已加密 </view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="album-info box-border p-3">
|
||||
<view
|
||||
class="album-name overflow-hidden text-ellipsis whitespace-nowrap text-[30rpx] text-[#333] font-bold">
|
||||
{{ item.displayName }}
|
||||
</view>
|
||||
<view class="album-count mt-1 text-xs text-gray-500">
|
||||
{{ item.photoCount }} 张照片
|
||||
</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
|
||||
v-if="currentUnlockAlbum"
|
||||
:show="showUnlockModal"
|
||||
: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>
|
||||
<!-- 相册图片查看弹窗 -->
|
||||
<uh-album-photo-viewer v-if="currentViewerAlbum" v-model="showPhotoViewer" :show="showPhotoViewer" :album-name="viewerAlbumName"
|
||||
:photos="viewerPhotos" :loading="viewerLoading" @update:show="showPhotoViewer = $event" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-page {
|
||||
/* 布局全部由 UnoCSS 原子类实现 */
|
||||
}
|
||||
.app-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%));
|
||||
}
|
||||
</style>
|
||||
+366
-191
@@ -1,212 +1,387 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
/**
|
||||
* 恋爱清单页(源自旧项目 pagesA/love/list.vue,新建复刻)
|
||||
* 恋爱清单卡片列表(未开始/进行中/已完成),展开查看详情与回忆图片
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { getLoveDailyItems } from '@/api/uni-halo'
|
||||
import { checkImageUrl } from '@/utils/url'
|
||||
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
|
||||
import type { ILoveDailyItem } from '@/api/types/uni-halo'
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { getLoveDailyItems } from '@/api/uni-halo'
|
||||
import { checkImageUrl } from '@/utils/url'
|
||||
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
|
||||
import type { ILoveDailyItem } from '@/api/types/uni-halo'
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '恋爱清单',
|
||||
navigationStyle: 'custom',
|
||||
enablePullDownRefresh: true,
|
||||
},
|
||||
})
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '恋爱清单',
|
||||
navigationStyle: 'custom',
|
||||
enablePullDownRefresh: true,
|
||||
},
|
||||
})
|
||||
|
||||
/* ---------------- 展示层类型 ---------------- */
|
||||
/** 清单展示卡片(script 预处理后的干净展示数据) */
|
||||
interface ILoveItemCard {
|
||||
/** 唯一 key(metadata.name,无则用索引) */
|
||||
name: string
|
||||
title: string
|
||||
content: string
|
||||
status: 'wait' | 'doing' | 'complete'
|
||||
planDate: string
|
||||
completeDate: string
|
||||
completeRemark: string
|
||||
/** 回忆图片(已预处理 URL) */
|
||||
images: string[]
|
||||
/** 是否展开详情 */
|
||||
open: boolean
|
||||
}
|
||||
/* ---------------- 展示层类型 ---------------- */
|
||||
/** 清单展示卡片(script 预处理后的干净展示数据) */
|
||||
interface ILoveItemCard {
|
||||
/** 唯一 key(metadata.name,无则用索引) */
|
||||
name : string
|
||||
title : string
|
||||
content : string
|
||||
status : 'wait' | 'doing' | 'complete'
|
||||
planDate : string
|
||||
completeDate : string
|
||||
completeRemark : string
|
||||
/** 回忆图片(已预处理 URL) */
|
||||
images : string[]
|
||||
/** 是否展开详情 */
|
||||
open : boolean
|
||||
}
|
||||
|
||||
/** 清单卡片映射:字段取值 + 图片路径预处理(模板不感知原始接口结构) */
|
||||
function mapItemCard(item: ILoveDailyItem, index: number): ILoveItemCard {
|
||||
const spec = item.spec || {}
|
||||
return {
|
||||
name: item.metadata?.name || `item-${index}`,
|
||||
title: spec.title || '',
|
||||
content: spec.content || '',
|
||||
status: spec.status || 'wait',
|
||||
planDate: spec.planDate || '',
|
||||
completeDate: spec.completeDate || '',
|
||||
completeRemark: spec.completeRemark || '',
|
||||
images: (spec.images || []).map(img => checkImageUrl(img || '')),
|
||||
open: false,
|
||||
}
|
||||
}
|
||||
/** 清单卡片映射:字段取值 + 图片路径预处理(模板不感知原始接口结构) */
|
||||
function mapItemCard(item : ILoveDailyItem, index : number) : ILoveItemCard {
|
||||
const spec = item.spec || {}
|
||||
return {
|
||||
name: item.metadata?.name || `item-${index}`,
|
||||
title: spec.title || '',
|
||||
content: spec.content || '',
|
||||
status: spec.status || 'wait',
|
||||
planDate: spec.planDate || '',
|
||||
completeDate: spec.completeDate || '',
|
||||
completeRemark: spec.completeRemark || '',
|
||||
images: (spec.images || []).map(img => checkImageUrl(img || '')),
|
||||
open: false,
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
|
||||
const list = ref<ILoveItemCard[]>([])
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
|
||||
const list = ref<ILoveItemCard[]>([])
|
||||
|
||||
/* ---------------- 数据加载 ---------------- */
|
||||
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)
|
||||
}
|
||||
}
|
||||
/* ---------------- 筛选与排序 ---------------- */
|
||||
interface IFilterOption {
|
||||
label : string
|
||||
value : string
|
||||
}
|
||||
|
||||
/* ---------------- 交互 ---------------- */
|
||||
function handleOnItemOpen(item: ILoveItemCard) {
|
||||
item.open = !item.open
|
||||
}
|
||||
interface IFilterItem {
|
||||
key : 'status' | 'sort'
|
||||
label : string
|
||||
options : IFilterOption[]
|
||||
}
|
||||
|
||||
/** 预览回忆图片(基于已预处理 URL) */
|
||||
function handlePreviewImages(images: string[], index: number) {
|
||||
if (images.length === 0)
|
||||
return
|
||||
uni.previewImage({ current: images[index], urls: images })
|
||||
}
|
||||
/** 筛选维度:状态筛选 + 排序(参考投票列表页顶部胶囊设计,各自独立状态) */
|
||||
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' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
function handleToTopPage(duration = 500) {
|
||||
uni.pageScrollTo({
|
||||
scrollTop: 0,
|
||||
duration,
|
||||
fail: (err) => {
|
||||
console.error('回顶失败', err)
|
||||
},
|
||||
})
|
||||
}
|
||||
/** 排序方向选项(顺序/倒序,排序弹层内选择) */
|
||||
const sortDirOptions : IFilterOption[] = [
|
||||
{ label: '顺序', value: 'asc' },
|
||||
{ label: '倒序', value: 'desc' },
|
||||
]
|
||||
|
||||
/* ---------------- 生命周期 ---------------- */
|
||||
onLoad(() => {
|
||||
handleGetList()
|
||||
})
|
||||
/** 各维度当前选中值(空串/首项 = 默认) */
|
||||
const filterValues = ref<Record<string, string>>({ status: '', sort: 'default', sortDir: 'asc' })
|
||||
|
||||
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>
|
||||
|
||||
<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%));">
|
||||
<!-- 自定义导航 -->
|
||||
<uh-navbar default-title="恋爱清单" title-color="text-gray-900" />
|
||||
<view class="uh-global-love-page box-border min-h-screen w-screen flex flex-col">
|
||||
<!-- 自定义导航 -->
|
||||
<uh-navbar default-title="恋爱清单" title-color="text-love" back-class="text-love"/>
|
||||
|
||||
<!-- 加载/错误/空占位(状态机) -->
|
||||
<uh-data-loading
|
||||
v-if="loadingStatus !== DataLoadingStatusEnum.Success"
|
||||
:loading-status="loadingStatus"
|
||||
min-height="60vh"
|
||||
empty-text="暂时还没有恋爱清单,快去制定你们的恋爱清单吧~"
|
||||
@refresh="handleGetList"
|
||||
/>
|
||||
<!-- 粘性筛选区:参考投票页顶部胶囊设计,每个维度独立状态 -->
|
||||
<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>
|
||||
|
||||
<!-- 清单列表 -->
|
||||
<view v-else class="list-wrap box-border flex-1 p-6 pb-[144rpx]">
|
||||
<view class="list-tip mb-7 w-full text-center text-[26rpx] text-[#999]">
|
||||
看看我们的恋爱清单都完成了哪些吧
|
||||
</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>
|
||||
<!-- 加载/错误/空占位(状态机) -->
|
||||
<uh-data-loading v-if="loadingStatus !== DataLoadingStatusEnum.Success" :loading-status="loadingStatus"
|
||||
min-height="60vh" empty-text="暂时还没有恋爱清单,快去制定你们的恋爱清单吧~" @refresh="handleGetList" />
|
||||
|
||||
<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>
|
||||
</view>
|
||||
<!-- 清单列表 -->
|
||||
<view v-else class="box-border flex flex-1 flex-col gap-y-3 p-3 pb-safe">
|
||||
<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>
|
||||
|
||||
<style scoped>
|
||||
.app-page {
|
||||
/* 布局全部由 UnoCSS 原子类实现 */
|
||||
}
|
||||
</style>
|
||||
|
||||
+275
-257
@@ -1,282 +1,300 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { getLoveStories } from '@/api/uni-halo'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
import { checkImageUrl } from '@/utils/url'
|
||||
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
|
||||
import type { ILoveStory } from '@/api/types/uni-halo'
|
||||
import { ref } from 'vue'
|
||||
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import dayjs from 'dayjs'
|
||||
import { getLoveStories } from '@/api/uni-halo'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
import { checkImageUrl } from '@/utils/url'
|
||||
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
|
||||
import type { ILoveStory } from '@/api/types/uni-halo'
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '恋爱故事',
|
||||
navigationStyle: 'custom',
|
||||
enablePullDownRefresh: true,
|
||||
},
|
||||
})
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '恋爱故事',
|
||||
navigationStyle: 'custom',
|
||||
enablePullDownRefresh: true,
|
||||
},
|
||||
})
|
||||
|
||||
const appConfigStore = useAppConfigStore()
|
||||
const appConfigStore = useAppConfigStore()
|
||||
|
||||
/* ---------------- 展示层类型 ---------------- */
|
||||
/** 时间轴故事卡片(script 预处理后的干净展示数据) */
|
||||
interface IStoryCard {
|
||||
/** 唯一 key(metadata.name,无则用索引) */
|
||||
key: string
|
||||
title: string
|
||||
date: string
|
||||
location: string
|
||||
/** 故事正文(HTML) */
|
||||
content: string
|
||||
/** 全部图片(已预处理 URL) */
|
||||
images: string[]
|
||||
/** 时间轴封面图(最多 3 张,已预处理 URL) */
|
||||
coverImages: string[]
|
||||
}
|
||||
/* ---------------- 展示层类型 ---------------- */
|
||||
/** 时间轴故事卡片(script 预处理后的干净展示数据) */
|
||||
interface IStoryCard {
|
||||
/** 唯一 key(metadata.name,无则用索引) */
|
||||
key : string
|
||||
title : string
|
||||
date : string
|
||||
/** 日期拆分:年(如"2023") */
|
||||
year : string
|
||||
/** 日期拆分:月(如"5") */
|
||||
month : string
|
||||
/** 日期拆分:日(如"20") */
|
||||
day : string
|
||||
/** 日期拆分:星期(如"周六") */
|
||||
weekend : string
|
||||
location : string
|
||||
/** 故事正文(HTML) */
|
||||
content : string
|
||||
/** 全部图片(已预处理 URL) */
|
||||
images : string[]
|
||||
/** 时间轴封面图(最多 3 张,已预处理 URL) */
|
||||
coverImages : string[]
|
||||
}
|
||||
|
||||
/** 空故事占位(弹窗未打开时) */
|
||||
const EMPTY_STORY: IStoryCard = {
|
||||
key: '',
|
||||
title: '',
|
||||
date: '',
|
||||
location: '',
|
||||
content: '',
|
||||
images: [],
|
||||
coverImages: [],
|
||||
}
|
||||
/** 空故事占位(弹窗未打开时) */
|
||||
const EMPTY_STORY : IStoryCard = {
|
||||
key: '',
|
||||
title: '',
|
||||
date: '',
|
||||
year: '',
|
||||
month: '',
|
||||
day: '',
|
||||
weekend: '',
|
||||
location: '',
|
||||
content: '',
|
||||
images: [],
|
||||
coverImages: [],
|
||||
}
|
||||
|
||||
/** 故事卡片映射:字段取值 + 图片路径预处理(模板不感知原始接口结构) */
|
||||
function mapStoryCard(story: ILoveStory, index: number): IStoryCard {
|
||||
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),
|
||||
}
|
||||
}
|
||||
/** 中文星期(dayjs day():0=周日) */
|
||||
const WEEKDAY_TEXT = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
|
||||
const stories = ref<IStoryCard[]>([])
|
||||
const showDetail = ref(false)
|
||||
const currentStory = ref<IStoryCard>(EMPTY_STORY)
|
||||
const storyImageIndex = ref(0)
|
||||
/** 从日期字符串拆分年月日星期(解析失败返回空) */
|
||||
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()],
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------- 数据加载 ---------------- */
|
||||
async function handleGetStories() {
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Loading)
|
||||
try {
|
||||
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 mapStoryCard(story : ILoveStory, index : number) : IStoryCard {
|
||||
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 || '',
|
||||
...splitStoryDate(spec.date || ''),
|
||||
location: spec.location || '',
|
||||
content: spec.content || '',
|
||||
images,
|
||||
coverImages: images.slice(0, 3),
|
||||
}
|
||||
}
|
||||
|
||||
function handleLoadFromLegacy() {
|
||||
const loveModuleConfig = appConfigStore.configs.loveConfig as { ourStory?: { content?: string } } | undefined
|
||||
if (loveModuleConfig?.ourStory?.content) {
|
||||
stories.value = [{
|
||||
key: 'legacy-story',
|
||||
title: '我们的故事',
|
||||
date: '',
|
||||
location: '',
|
||||
content: loveModuleConfig.ourStory.content,
|
||||
images: [],
|
||||
coverImages: [],
|
||||
}]
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Success)
|
||||
return
|
||||
}
|
||||
stories.value = []
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Empty)
|
||||
}
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
|
||||
const stories = ref<IStoryCard[]>([])
|
||||
const showDetail = ref(false)
|
||||
const currentStory = ref<IStoryCard>(EMPTY_STORY)
|
||||
const storyImageIndex = ref(0)
|
||||
|
||||
/* ---------------- 交互 ---------------- */
|
||||
function handleOnStoryClick(story: IStoryCard) {
|
||||
currentStory.value = story
|
||||
storyImageIndex.value = 0
|
||||
showDetail.value = true
|
||||
}
|
||||
/* ---------------- 数据加载 ---------------- */
|
||||
async function handleGetStories() {
|
||||
updateLoadingStatus(DataLoadingStatusEnum.Loading)
|
||||
try {
|
||||
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 } }) {
|
||||
storyImageIndex.value = e.detail.current
|
||||
}
|
||||
function handleLoadFromLegacy() {
|
||||
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) {
|
||||
if (story.images.length === 0)
|
||||
return
|
||||
uni.previewImage({ current: story.images[index], urls: story.images })
|
||||
}
|
||||
/* ---------------- 交互 ---------------- */
|
||||
function handleOnStoryClick(story : IStoryCard) {
|
||||
currentStory.value = story
|
||||
storyImageIndex.value = 0
|
||||
showDetail.value = true
|
||||
}
|
||||
|
||||
/** 预览弹窗内大图 */
|
||||
function handlePreviewImage(index: number) {
|
||||
const urls = currentStory.value.images
|
||||
if (urls.length > 0) {
|
||||
uni.previewImage({ current: urls[index], urls })
|
||||
}
|
||||
}
|
||||
function handleOnStoryImageChange(e : { detail : { current : number } }) {
|
||||
storyImageIndex.value = e.detail.current
|
||||
}
|
||||
|
||||
function handleToTopPage(duration = 500) {
|
||||
uni.pageScrollTo({
|
||||
scrollTop: 0,
|
||||
duration,
|
||||
fail: (err) => {
|
||||
console.error('回顶失败', err)
|
||||
},
|
||||
})
|
||||
}
|
||||
/** 预览时间轴封面图(基于已预处理 URL) */
|
||||
function handlePreviewStoryImages(story : IStoryCard, index : number) {
|
||||
if (story.images.length === 0)
|
||||
return
|
||||
uni.previewImage({ current: story.images[index], urls: story.images })
|
||||
}
|
||||
|
||||
/* ---------------- 生命周期 ---------------- */
|
||||
onLoad(() => {
|
||||
handleGetStories()
|
||||
})
|
||||
/** 预览弹窗内大图 */
|
||||
function handlePreviewImage(index : number) {
|
||||
const urls = currentStory.value.images
|
||||
if (urls.length > 0) {
|
||||
uni.previewImage({ current: urls[index], urls })
|
||||
}
|
||||
}
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
handleGetStories()
|
||||
})
|
||||
function handleToTopPage(duration = 500) {
|
||||
uni.pageScrollTo({
|
||||
scrollTop: 0,
|
||||
duration,
|
||||
fail: (err) => {
|
||||
console.error('回顶失败', err)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/* ---------------- 生命周期 ---------------- */
|
||||
onLoad(() => {
|
||||
handleGetStories()
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
handleGetStories()
|
||||
})
|
||||
</script>
|
||||
|
||||
<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);">
|
||||
<!-- 自定义导航 -->
|
||||
<uh-navbar default-title="恋爱故事" title-color="text-gray-900" />
|
||||
<view class="app-page box-border min-h-screen w-screen flex flex-col">
|
||||
<!-- 自定义导航 -->
|
||||
<uh-navbar default-title="恋爱故事" title-color="text-love" back-class="text-love" />
|
||||
|
||||
<!-- 加载/错误/空占位(状态机) -->
|
||||
<uh-data-loading
|
||||
v-if="loadingStatus !== DataLoadingStatusEnum.Success"
|
||||
:loading-status="loadingStatus"
|
||||
min-height="60vh"
|
||||
empty-text="还没有故事,等待你们来书写..."
|
||||
@refresh="handleGetStories"
|
||||
/>
|
||||
<!-- 加载/错误/空占位(状态机) -->
|
||||
<uh-data-loading v-if="loadingStatus !== DataLoadingStatusEnum.Success" :loading-status="loadingStatus"
|
||||
min-height="75vh" empty-text="还没有故事,敬请期待吧~" @refresh="handleGetStories" />
|
||||
|
||||
<!-- 时间轴 -->
|
||||
<view v-else class="content-wrap box-border flex-1 p-6 pb-[144rpx]">
|
||||
<view class="timeline relative pl-10">
|
||||
<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 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%);" />
|
||||
<view class="timeline-card rounded-xl bg-white p-6 shadow-sm">
|
||||
<view v-if="story.date" class="timeline-date mb-2 text-[24rpx] text-[#f88ca2]">
|
||||
{{ story.date }}
|
||||
</view>
|
||||
<view class="timeline-title text-[32rpx] text-[#333] font-bold">
|
||||
{{ story.title }}
|
||||
</view>
|
||||
<view v-if="story.location" class="timeline-location mt-2 flex items-center text-[24rpx] text-[#999]">
|
||||
<text class="location-text ml-1">{{ story.location }}</text>
|
||||
</view>
|
||||
<view v-if="story.coverImages.length" class="timeline-covers mt-4 flex flex-wrap gap-2">
|
||||
<view
|
||||
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
|
||||
v-if="story.images.length > 3"
|
||||
class="timeline-cover timeline-cover-more h-[180rpx] w-[calc((100%-16rpx)/3)] flex items-center justify-center bg-black/50"
|
||||
@click.stop="handleOnStoryClick(story)"
|
||||
>
|
||||
<text class="more-text text-[32rpx] text-white font-bold">
|
||||
+{{ story.images.length - 3 }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 时间轴 -->
|
||||
<view v-else class="box-border flex-1 p-3 pb-safe">
|
||||
<view class="relative box-border flex flex-col gap-y-3">
|
||||
<view v-for="(story, index) in stories" :key="story.key" class="relative flex"
|
||||
:class="index === stories.length - 1 ? '-last' : ''">
|
||||
<view class="shrink-0 box-border pr-2">
|
||||
<view class="flex flex-col items-center">
|
||||
<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="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 }}
|
||||
</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"
|
||||
@click="handleOnStoryClick(story)">详情</uh-button>
|
||||
</view>
|
||||
<view v-if="story.location" class="mt-2 flex items-center text-xs text-gray-600">
|
||||
<wd-icon name="location"></wd-icon> <text>{{ story.location }}</text>
|
||||
</view>
|
||||
<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"
|
||||
class="h-16 w-full 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 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 }}
|
||||
</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" />
|
||||
</view>
|
||||
|
||||
<!-- 故事详情弹窗 -->
|
||||
<wd-popup v-model="showDetail" position="center" custom-style="width:90vw;height:80vh;border-radius:12rpx;">
|
||||
<view class="story-detail 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-title text-[32rpx] text-[#333] font-bold">
|
||||
{{ currentStory.title }}
|
||||
</view>
|
||||
<view v-if="currentStory.date || currentStory.location" class="story-detail-meta mt-2 flex items-center text-[24rpx] text-[#999]">
|
||||
<text v-if="currentStory.date" class="story-detail-date">
|
||||
{{ currentStory.date }}
|
||||
</text>
|
||||
<text v-if="currentStory.location" class="story-detail-location ml-6">
|
||||
{{ currentStory.location }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 故事图片:多图 swiper 轮播 -->
|
||||
<view v-if="currentStory.images.length > 0" class="story-images shrink-0">
|
||||
<swiper
|
||||
v-if="currentStory.images.length > 1"
|
||||
class="story-images-swiper h-[360rpx] w-full"
|
||||
circular
|
||||
indicator-dots
|
||||
indicator-color="rgba(255,255,255,0.4)"
|
||||
indicator-active-color="#f88ca2"
|
||||
: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>
|
||||
<image v-else :src="currentStory.images[0]" mode="aspectFill" class="story-image story-image-single h-[360rpx] w-full" @click="handlePreviewImage(0)" />
|
||||
</view>
|
||||
<scroll-view scroll-y class="story-detail-content box-border min-h-0 flex-1 px-7 py-6">
|
||||
<view class="story-html text-[28rpx] text-[#333] leading-[1.8]" v-html="currentStory.content" />
|
||||
</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>
|
||||
<!-- 故事详情弹窗 -->
|
||||
<uh-glass-popup v-model="showDetail" :z-index="100" position="bottom" custom-class="rounded-xl">
|
||||
<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">
|
||||
{{ currentStory.title }}
|
||||
</view>
|
||||
<view v-if="currentStory.date || currentStory.location"
|
||||
class="mt-2 flex items-center text-xs text-gray-500">
|
||||
<text v-if="currentStory.date">
|
||||
<wd-icon name="time-line"></wd-icon> {{ currentStory.date }}
|
||||
</text>
|
||||
<text v-if="currentStory.location" class="ml-6">
|
||||
<wd-icon name="location"></wd-icon> {{ currentStory.location }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 故事图片:多图 swiper 轮播 -->
|
||||
<view v-if="currentStory.images.length > 0" class="story-images shrink-0">
|
||||
<swiper v-if="currentStory.images.length > 1" class="h-32 w-full rounded-lg overflow-hidden" circular
|
||||
indicator-dots indicator-color="rgba(255,255,255,0.4)" indicator-active-color="#f83856"
|
||||
: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="h-full w-full"
|
||||
@click="handlePreviewImage(imgIndex)" />
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
<image v-else :src="currentStory.images[0]" mode="aspectFill"
|
||||
class="h-32 w-full" @click="handlePreviewImage(0)" />
|
||||
</view>
|
||||
<scroll-view scroll-y :show-scrollbar="false" class="mt-4 box-border max-h-[50vh] flex-1">
|
||||
<view class="story-html text-sm text-gray-900 leading-7" v-html="currentStory.content" />
|
||||
</scroll-view>
|
||||
<view class="w-full mt-3">
|
||||
<uh-button custom-class="uh-global-card-glass border !bg-love/90 !py-2 text-white" @click="showDetail = false">关闭</uh-button>
|
||||
</view>
|
||||
</view>
|
||||
</uh-glass-popup>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.timeline {
|
||||
.timeline-item {
|
||||
&::before {
|
||||
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 scoped>
|
||||
.app-page {
|
||||
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);
|
||||
}
|
||||
</style>
|
||||
+15
-2
@@ -7,10 +7,10 @@
|
||||
:root,
|
||||
page {
|
||||
// 修改按主题色
|
||||
--wot-color-theme: #B9E424;
|
||||
--wot-color-theme: #b9e424;
|
||||
|
||||
// 修改按钮背景色
|
||||
--wot-button-primary-bg-color: #B9E424;
|
||||
--wot-button-primary-bg-color: #b9e424;
|
||||
}
|
||||
|
||||
.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%);
|
||||
}
|
||||
|
||||
.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
|
||||
由于uniapp中无法使用*选择器,使用魔法代替*,加上此规则可以简化border与divide的使用,并提升布局的兼容性
|
||||
|
||||
Reference in New Issue
Block a user