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