hookehuyr

feat(相册): 实现相册功能接口对接及组件优化

- 新增相册相关API接口文件photo.js
- 修改UploadMedia页面实现相册保存功能
- 优化FamilyAlbum组件对接相册列表接口并添加加载状态
- 完善AlbumList页面实现相册删除功能
- 扩展useMediaPreview功能以支持相册数据类型
/*
* @Date: 2025-09-11 12:20:17
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-09-11 15:16:03
* @FilePath: /lls_program/src/api/photo.js
* @Description: 文件描述
*/
import { fn, fetch } from './fn';
const Api = {
PHOTO_LIST: '/srv/?a=media&t=list', // 获取相册列表
SAVE_PHOTO: '/srv/?a=media&t=add', // 保存相册
DELETE_PHOTO: '/srv/?a=media&t=del', // 删除相册
}
/**
* @description 获取相册列表
* @param {Object} params - 请求参数
* @param {number} params.page - 页码,从0开始
* @param {number} params.limit - 每页数量,默认10
* @returns {Object} response - 响应对象
* @returns {number} response.code - 响应状态码
* @returns {string} response.msg - 响应消息
* @returns {Object} response.data[] - 响应数据
* @returns {number} response.data.id - 图片ID
* @returns {string} response.data.media_type - 媒体文件类型 必需 IMAGE=图片, VIDEO=视频
* @returns {string} response.data.media_url - 媒体文件URL
* @returns {Boolean} response.data.is_my - 是否是我的相册 1=是, 0=否
* @returns {string} response.data.thumbnail - 缩略图URL
*/
export const getPhotoListAPI = (params = {}) => fn(fetch.post(Api.PHOTO_LIST, params));
/**
* @description 保存相册
* @param {Object} params - 请求参数
* @param {string} params.media_type - 媒体文件类型 必需 IMAGE=图片, VIDEO=视频
* @param {string} params.media_url - 媒体文件URL 必需
* @param {string} params.source_type - 上传来源 必需 CHECK_IN=打卡, COMPANION=陪伴
* @param {string} params.source_id - 上传来源ID 必需
* @param {string} params.qiniu_audit - 七牛云审核状态 必需
* @param {Object} data - 请求数据
* @returns {Promise} 返回保存结果
*/
export const savePhotoAPI = (data) => fn(fetch.post(Api.SAVE_PHOTO, data));
/**
* @description 删除相册
* @param {Array} ids - 要删除的图片/视频ID数组
* @returns {Promise} 返回删除结果
*/
export const deletePhotoAPI = (data) => fn(fetch.post(Api.DELETE_PHOTO, data));
......@@ -5,31 +5,54 @@
<view class="flex justify-between items-center mb-2">
<h2 class="font-medium text-lg">多彩瞬间</h2>
<view class="text-blue-500 flex items-center text-xs" @click="openAlbumList">
查看更多
进入相册
</view>
</view>
<p class="text-sm text-gray-500 mb-3">记录每一个家庭活动瞬间</p>
<view class="grid grid-cols-2 gap-3">
<!-- 加载状态 -->
<view v-if="loading" class="grid grid-cols-2 gap-3">
<view
v-for="n in 4"
:key="n"
class="rounded-lg h-32 bg-gray-200 animate-pulse"
></view>
</view>
<!-- 相册内容 -->
<view v-else-if="albumData.length > 0" class="grid grid-cols-2 gap-3">
<view
v-for="(item, index) in albumData"
:key="index"
:key="item.id || index"
class="rounded-lg overflow-hidden h-32 relative cursor-pointer"
@click="handleMediaClick(item, albumData)"
>
<image
:src="item.type === 'video' ? item.thumbnail : item.url"
:src="item.media_type === 'VIDEO' ? item.thumbnail : item.media_url"
alt="家庭活动照片"
class="w-full h-full object-cover rounded-lg"
/>
<!-- 视频标识 -->
<view
v-if="item.type === 'video'"
v-if="item.media_type === 'VIDEO'"
class="absolute top-2 left-2 px-2 py-1 bg-black bg-opacity-70 rounded text-white text-xs"
>
视频
</view>
<!-- 我的标识 -->
<!-- <view
v-if="item.is_my"
class="absolute top-2 right-2 px-2 py-1 bg-blue-500 bg-opacity-80 rounded text-white text-xs"
>
我的
</view> -->
</view>
</view>
<!-- 空状态 -->
<view v-else class="text-center py-8 text-gray-400">
<view class="text-sm">暂无相册内容</view>
<!-- <view class="text-xs mt-1">快去上传第一张照片吧~</view> -->
</view>
</view>
<!-- 图片预览 -->
......@@ -81,10 +104,11 @@
</template>
<script setup>
import { ref } from 'vue';
import Taro from '@tarojs/taro';
import { ref, onMounted } from 'vue';
import Taro, { useDidShow } from '@tarojs/taro';
import { Close } from '@nutui/icons-vue-taro';
import { useMediaPreview } from '@/composables/useMediaPreview';
import { getPhotoListAPI } from '@/api/photo';
// 使用媒体预览 composable
const {
......@@ -104,17 +128,29 @@ const {
} = useMediaPreview();
// 家庭相册数据
const albumData = ref([
{
type: 'image',
url: 'https://cdn.ipadbiz.cn/hager/0513-1_FsxMk28AGz6N_D1zZFFOl_EaRdss.png',
},
{
type: 'video',
url: 'https://vjs.zencdn.net/v/oceans.mp4',
thumbnail: 'https://cdn.ipadbiz.cn/hager/0513-1_FsxMk28AGz6N_D1zZFFOl_EaRdss.png',
const albumData = ref([]);
const loading = ref(false);
/**
* 获取家庭相册数据
*/
const fetchAlbumData = async () => {
try {
loading.value = true;
const response = await getPhotoListAPI({
page: 0,
limit: 4 // 首页只显示4张
});
if (response.code) {
albumData.value = response.data || [];
}
} catch (error) {
console.error('获取相册数据失败:', error);
} finally {
loading.value = false;
}
]);
};
/**
* 打开相册列表页面
......@@ -122,8 +158,17 @@ const albumData = ref([
const openAlbumList = () => {
Taro.navigateTo({ url: '/pages/AlbumList/index' });
};
// 组件挂载时获取数据
onMounted(() => {
fetchAlbumData();
});
useDidShow(() => {
fetchAlbumData();
});
</script>
<style lang="less" scoped>
// 组件样式
</style>
\ No newline at end of file
</style>
......
......@@ -19,21 +19,35 @@ export function useMediaPreview() {
/**
* 处理媒体项目点击事件
* @param {Object} item - 媒体项目
* @param {number} item.id - 媒体ID
* @param {string} item.media_type - 媒体类型 IMAGE=图片, VIDEO=视频
* @param {string} item.media_url - 媒体文件URL
* @param {string} item.thumbnail - 缩略图URL
* @param {boolean} item.is_my - 是否是我的相册
* @param {Array} mediaList - 完整的媒体列表
*/
const handleMediaClick = (item, mediaList = []) => {
if (item.type === 'image') {
if (item.media_type === 'IMAGE') {
// 图片预览
const imageItems = mediaList.filter(media => media.type === 'image');
previewImages.value = imageItems.map(img => ({ src: img.url }));
const imageItems = mediaList.filter(media => media.media_type === 'IMAGE');
previewImages.value = imageItems.map(img => ({
src: img.media_url,
id: img.id,
thumbnail: img.thumbnail,
is_my: img.is_my
}));
// 计算当前图片在图片列表中的索引
const imageIndex = imageItems.findIndex(img => img.url === item.url);
const imageIndex = imageItems.findIndex(img => img.media_url === item.media_url);
previewIndex.value = imageIndex >= 0 ? imageIndex : 0;
previewVisible.value = true;
} else if (item.type === 'video') {
} else if (item.media_type === 'VIDEO') {
// 视频播放
currentVideo.value = item;
currentVideo.value = {
...item,
url: item.media_url, // 兼容原有的url字段
type: 'video' // 兼容原有的type字段
};
videoId.value = Date.now(); // 生成新的视频ID
videoVisible.value = true;
}
......@@ -51,21 +65,42 @@ export function useMediaPreview() {
/**
* 预览多张图片
* @param {Array} images - 图片URL数组
* @param {Array} images - 图片数组,可以是URL字符串数组或媒体对象数组
* @param {number} index - 初始显示的图片索引
*/
const previewMultipleImages = (images, index = 0) => {
previewImages.value = images.map(url => ({ src: url }));
previewImages.value = images.map(item => {
if (typeof item === 'string') {
// 兼容URL字符串数组
return { src: item };
} else {
// 处理媒体对象数组
return {
src: item.media_url || item.url,
id: item.id,
thumbnail: item.thumbnail,
is_my: item.is_my
};
}
});
previewIndex.value = index;
previewVisible.value = true;
};
/**
* 播放视频
* @param {Object} video - 视频对象 {url, thumbnail, duration}
* @param {Object} video - 视频对象
* @param {string} video.media_url - 视频URL
* @param {string} video.thumbnail - 缩略图URL
* @param {number} video.id - 视频ID
* @param {boolean} video.is_my - 是否是我的视频
*/
const playVideo = (video) => {
currentVideo.value = video;
currentVideo.value = {
...video,
url: video.media_url || video.url, // 兼容新旧字段
type: 'video'
};
videoId.value = Date.now();
videoVisible.value = true;
};
......@@ -132,6 +167,59 @@ export function useMediaPreview() {
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
};
/**
* 判断媒体类型是否为图片
* @param {Object} media - 媒体对象
* @returns {boolean} 是否为图片
*/
const isImage = (media) => {
return media.media_type === 'IMAGE' || media.type === 'image';
};
/**
* 判断媒体类型是否为视频
* @param {Object} media - 媒体对象
* @returns {boolean} 是否为视频
*/
const isVideo = (media) => {
return media.media_type === 'VIDEO' || media.type === 'video';
};
/**
* 获取媒体URL
* @param {Object} media - 媒体对象
* @returns {string} 媒体URL
*/
const getMediaUrl = (media) => {
return media.media_url || media.url || '';
};
/**
* 获取缩略图URL
* @param {Object} media - 媒体对象
* @returns {string} 缩略图URL
*/
const getThumbnailUrl = (media) => {
return media.thumbnail || media.media_url || media.url || '';
};
/**
* 转换接口数据为预览格式
* @param {Array} mediaList - 接口返回的媒体列表
* @returns {Array} 转换后的媒体列表
*/
const transformMediaList = (mediaList = []) => {
return mediaList.map(item => ({
id: item.id,
type: item.media_type === 'IMAGE' ? 'image' : 'video',
media_type: item.media_type,
url: item.media_url,
media_url: item.media_url,
thumbnail: item.thumbnail,
is_my: item.is_my
}));
};
return {
// 状态
previewVisible,
......@@ -152,6 +240,13 @@ export function useMediaPreview() {
handleVideoPause,
handleFullscreenChange,
handleVideoError,
formatDuration
formatDuration,
// 辅助方法
isImage,
isVideo,
getMediaUrl,
getThumbnailUrl,
transformMediaList
};
}
......
......@@ -32,6 +32,23 @@
视频
</view>
<!-- 我的标识 -->
<view
v-if="item.is_my"
class="absolute top-2 right-2 px-2 py-1 bg-blue-500 bg-opacity-80 rounded text-white text-xs"
>
我的
</view>
<!-- 删除按钮 -->
<view
v-if="item.is_my"
@click.stop="handleDeleteClick(item)"
class="absolute bottom-2 right-2 px-2 py-1 bg-red-500 bg-opacity-80 rounded text-white text-xs"
>
删除
</view>
<!-- 视频时长 -->
<!-- <view
v-if="item.type === 'video'"
......@@ -94,14 +111,17 @@
@fullscreenchange="handleFullscreenChange"
/>
</view>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import Taro from '@tarojs/taro';
import { Left, Service, Photograph, Close } from '@nutui/icons-vue-taro';
import Taro, { useDidShow } from '@tarojs/taro';
import { Photograph, Close } from '@nutui/icons-vue-taro';
import { useMediaPreview } from '@/composables/useMediaPreview';
import { getPhotoListAPI, deletePhotoAPI } from '@/api/photo';
// 响应式数据
const albumList = ref([]);
......@@ -123,69 +143,50 @@ const {
handleVideoError,
} = useMediaPreview();
// 删除相关状态
const currentDeleteItem = ref(null);
/**
* 页面加载时设置标题和初始化数据
*/
onMounted(() => {
Taro.setNavigationBarTitle({ title: '家庭相册' });
initMockData();
fetchAlbumList();
});
useDidShow(() => {
fetchAlbumList();
});
/**
* 初始化模拟数据
* 获取相册列表数据
*/
const initMockData = () => {
albumList.value = [
{
type: 'image',
url: 'https://img.yzcdn.cn/vant/cat.jpeg',
createTime: '2024-01-15 10:30'
},
{
type: 'video',
url: 'https://vjs.zencdn.net/v/oceans.mp4',
thumbnail: 'https://img.yzcdn.cn/vant/apple-1.jpg',
// duration: 125, // 秒
createTime: '2024-01-14 16:20'
},
{
type: 'image',
url: 'https://img.yzcdn.cn/vant/apple-2.jpg',
createTime: '2024-01-13 14:15'
},
{
type: 'image',
url: 'https://img.yzcdn.cn/vant/apple-3.jpg',
createTime: '2024-01-12 09:45'
},
{
type: 'video',
url: 'https://vjs.zencdn.net/v/oceans.mp4',
thumbnail: 'https://img.yzcdn.cn/vant/apple-4.jpg',
createTime: '2024-01-11 18:30'
},
{
type: 'image',
url: 'https://img.yzcdn.cn/vant/tree.jpg',
createTime: '2024-01-10 12:00'
},
{
type: 'video',
url: 'https://vjs.zencdn.net/v/oceans.mp4',
thumbnail: 'https://img.yzcdn.cn/vant/leaf.jpg',
createTime: '2024-01-09 15:20'
},
{
type: 'image',
url: 'https://img.yzcdn.cn/vant/sand.jpg',
createTime: '2024-01-08 11:10'
},
{
type: 'image',
url: 'https://img.yzcdn.cn/vant/sand.jpg',
createTime: '2024-01-08 11:10'
},
];
const fetchAlbumList = async () => {
try {
Taro.showLoading({ title: '加载中...' });
const response = await getPhotoListAPI({ page: 0, limit: 50 });
if (response.code) {
// 转换接口数据格式为组件需要的格式
albumList.value = response.data.map(item => ({
id: item.id,
type: item.media_type === 'IMAGE' ? 'image' : 'video',
url: item.media_url,
thumbnail: item.thumbnail || item.media_url, // 使用thumbnail字段,如果没有则使用media_url
is_my: item.is_my,
media_type: item.media_type,
media_url: item.media_url
}));
}
} catch (error) {
console.error('获取相册列表失败:', error);
Taro.showToast({
title: '获取相册列表失败',
icon: 'none'
});
} finally {
Taro.hideLoading();
}
};
/**
......@@ -196,6 +197,69 @@ const initMockData = () => {
const handleItemClick = (item, index) => {
handleMediaClick(item, albumList.value);
};
/**
* 处理删除按钮点击事件
* @param {Object} item - 要删除的相册项目
*/
const handleDeleteClick = (item) => {
currentDeleteItem.value = item;
Taro.showModal({
title: '删除确认',
content: '确定要删除这张照片吗?删除后无法恢复。',
success: (res) => {
if (res.confirm) {
confirmDelete();
} else if (res.cancel) {
cancelDelete();
}
}
});
};
/**
* 确认删除相册项目
*/
const confirmDelete = async () => {
if (!currentDeleteItem.value) return;
try {
Taro.showLoading({ title: '删除中...' });
const response = await deletePhotoAPI({ ids: [currentDeleteItem.value.id] });
if (response.code) {
// 从列表中移除已删除的项目
const index = albumList.value.findIndex(item => item.id === currentDeleteItem.value.id);
if (index > -1) {
albumList.value.splice(index, 1);
}
Taro.showToast({
title: '删除成功',
icon: 'success'
});
} else {
throw new Error(response.msg || '删除失败');
}
} catch (error) {
console.error('删除相册项目失败:', error);
Taro.showToast({
title: '删除失败',
icon: 'none'
});
} finally {
Taro.hideLoading();
currentDeleteItem.value = null;
}
};
/**
* 取消删除
*/
const cancelDelete = () => {
currentDeleteItem.value = null;
};
</script>
<style scoped>
......
......@@ -144,8 +144,9 @@
<script setup>
import { ref, onMounted } from 'vue';
import Taro from '@tarojs/taro';
import { Left, Photograph, Close } from '@nutui/icons-vue-taro';
import BASE_URL, { THEME_COLORS } from '@/utils/config';
import { Photograph, Close } from '@nutui/icons-vue-taro';
import BASE_URL from '@/utils/config';
import { savePhotoAPI } from '@/api/photo';
//
const playIcon = 'https://cdn.ipadbiz.cn/lls_prog/icon/play.svg';
......@@ -433,58 +434,48 @@ const saveMedia = async () => {
});
try {
// TODO: 这里预留给后续的接口调用
// 调用后端接口保存媒体信息,传递 uploadedFile.value.serverUrl
// const result = await saveMediaToBackend({
// type: uploadedFile.value.type,
// url: uploadedFile.value.serverUrl,
// size: uploadedFile.value.size,
// name: uploadedFile.value.name,
// duration: uploadedFile.value.duration, // 仅视频有此字段
// qiniu_audit: uploadedFile.value.qiniu_audit,
// });
// 模拟接口调用
await new Promise(resolve => setTimeout(resolve, 1000));
Taro.hideLoading();
Taro.showToast({
title: '保存成功,获得积分奖励!',
icon: 'none',
duration: 2000
});
// 调用后端接口保存媒体信息
const saveData = {
media_type: uploadedFile.value.type === 'image' ? 'IMAGE' : 'VIDEO',
media_url: uploadedFile.value.serverUrl,
source_type: pageParams.value.from === 'checkin' ? 'CHECK_IN' : 'COMPANION',
source_id: pageParams.value.id || '0',
qiniu_audit: uploadedFile.value.qiniu_audit || ''
};
const result = await savePhotoAPI(saveData);
if (result.code) {
Taro.hideLoading();
Taro.showToast({
title: '保存成功,获得积分奖励!',
icon: 'none',
duration: 2000
});
// 根据来源进行不同的跳转处理
setTimeout(() => {
if (pageParams.value.from === 'checkin') {
// 如果是从打卡页面跳转过来的,带着参数跳转到海报打卡页面
Taro.redirectTo({
url: `/pages/PosterCheckin/index?id=${pageParams.value.id}`
});
} else {
// 其他情况返回上一页
Taro.navigateBack();
}
}, 2000);
// 根据来源进行不同的跳转处理
setTimeout(() => {
if (pageParams.value.from === 'checkin') {
// 如果是从打卡页面跳转过来的,带着参数跳转到海报打卡页面
Taro.redirectTo({
url: `/pages/PosterCheckin/index?id=${pageParams.value.id}`
});
} else {
// 其他情况返回上一页
Taro.navigateBack();
}
}, 2000);
} else {
throw new Error(result.msg || '保存失败');
}
} catch (error) {
console.error('保存失败:', error);
Taro.hideLoading();
Taro.showToast({
title: '保存失败,请重试',
title: error.message || '保存失败,请重试',
icon: 'error',
duration: 2000
});
}
};
/**
* 返回上一页
*/
const goBack = () => {
Taro.navigateBack();
};
</script>
<style scoped>
/* 自定义样式 */
</style>
......