refactor(collect): 收藏功能改为 API 驱动模式
- 移除乐观更新逻辑,改为 API 成功后更新 UI
- useCollectOperation 返回 { success, newStatus } 对象
- ArticleCard/MaterialCard 使用 async/await 等待 API 响应
- ListItemActions 添加 @tap.stop 防止事件冒泡
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Showing
4 changed files
with
43 additions
and
35 deletions
| ... | @@ -118,7 +118,7 @@ const props = defineProps({ | ... | @@ -118,7 +118,7 @@ const props = defineProps({ |
| 118 | /** 是否显示封面图 */ | 118 | /** 是否显示封面图 */ |
| 119 | showCover: { | 119 | showCover: { |
| 120 | type: Boolean, | 120 | type: Boolean, |
| 121 | - default: true | 121 | + default: false |
| 122 | } | 122 | } |
| 123 | }); | 123 | }); |
| 124 | 124 | ||
| ... | @@ -172,11 +172,11 @@ const handleView = () => { | ... | @@ -172,11 +172,11 @@ const handleView = () => { |
| 172 | /** | 172 | /** |
| 173 | * 处理收藏点击 | 173 | * 处理收藏点击 |
| 174 | * | 174 | * |
| 175 | - * @description 调用收藏操作并通知父组件 | 175 | + * @description 调用收藏操作,成功后通知父组件更新状态 |
| 176 | */ | 176 | */ |
| 177 | -const handleCollect = () => { | 177 | +const handleCollect = async () => { |
| 178 | - // 调用收藏操作 | 178 | + // 调用收藏操作 API |
| 179 | - toggleCollect({ | 179 | + const result = await toggleCollect({ |
| 180 | id: props.id, | 180 | id: props.id, |
| 181 | title: props.title, | 181 | title: props.title, |
| 182 | excerpt: props.excerpt, | 182 | excerpt: props.excerpt, |
| ... | @@ -185,15 +185,18 @@ const handleCollect = () => { | ... | @@ -185,15 +185,18 @@ const handleCollect = () => { |
| 185 | collected: props.collected | 185 | collected: props.collected |
| 186 | }); | 186 | }); |
| 187 | 187 | ||
| 188 | - // 通知父组件收藏状态改变 | 188 | + // API 调用成功后,通知父组件更新本地状态 |
| 189 | + if (result.success) { | ||
| 189 | emit('collectChanged', { | 190 | emit('collectChanged', { |
| 190 | id: props.id, | 191 | id: props.id, |
| 191 | title: props.title, | 192 | title: props.title, |
| 192 | excerpt: props.excerpt, | 193 | excerpt: props.excerpt, |
| 193 | coverUrl: props.coverUrl, | 194 | coverUrl: props.coverUrl, |
| 194 | date: props.date, | 195 | date: props.date, |
| 195 | - collected: !props.collected // 新状态(取反) | 196 | + collected: result.newStatus // ← 使用 API 返回的新状态 |
| 196 | }); | 197 | }); |
| 198 | + } | ||
| 199 | + // 如果 API 失败,不发出事件,UI 保持原状态 | ||
| 197 | }; | 200 | }; |
| 198 | </script> | 201 | </script> |
| 199 | 202 | ... | ... |
| ... | @@ -194,11 +194,11 @@ const handleView = () => { | ... | @@ -194,11 +194,11 @@ const handleView = () => { |
| 194 | /** | 194 | /** |
| 195 | * 处理收藏点击 | 195 | * 处理收藏点击 |
| 196 | * | 196 | * |
| 197 | - * @description 内部处理收藏逻辑,调用 useCollectOperation 并通知父组件 | 197 | + * @description 调用收藏操作,成功后通知父组件更新状态 |
| 198 | */ | 198 | */ |
| 199 | -const handleCollect = () => { | 199 | +const handleCollect = async () => { |
| 200 | - // 调用收藏操作 | 200 | + // 调用收藏操作 API |
| 201 | - toggleCollect({ | 201 | + const result = await toggleCollect({ |
| 202 | id: props.id, | 202 | id: props.id, |
| 203 | title: props.title, | 203 | title: props.title, |
| 204 | fileName: props.fileName, | 204 | fileName: props.fileName, |
| ... | @@ -210,7 +210,8 @@ const handleCollect = () => { | ... | @@ -210,7 +210,8 @@ const handleCollect = () => { |
| 210 | downloadUrl: props.downloadUrl | 210 | downloadUrl: props.downloadUrl |
| 211 | }) | 211 | }) |
| 212 | 212 | ||
| 213 | - // 通知父组件收藏状态改变 | 213 | + // API 调用成功后,通知父组件更新本地状态 |
| 214 | + if (result.success) { | ||
| 214 | emit('collectChanged', { | 215 | emit('collectChanged', { |
| 215 | id: props.id, | 216 | id: props.id, |
| 216 | title: props.title, | 217 | title: props.title, |
| ... | @@ -218,10 +219,12 @@ const handleCollect = () => { | ... | @@ -218,10 +219,12 @@ const handleCollect = () => { |
| 218 | fileSize: props.fileSize, | 219 | fileSize: props.fileSize, |
| 219 | learners: props.learners, | 220 | learners: props.learners, |
| 220 | readPeoplePercent: props.readPeoplePercent, | 221 | readPeoplePercent: props.readPeoplePercent, |
| 221 | - collected: !props.collected, // 新状态(取反) | 222 | + collected: result.newStatus, // ← 使用 API 返回的新状态 |
| 222 | extension: props.extension, | 223 | extension: props.extension, |
| 223 | downloadUrl: props.downloadUrl | 224 | downloadUrl: props.downloadUrl |
| 224 | }) | 225 | }) |
| 226 | + } | ||
| 227 | + // 如果 API 失败,不发出事件,UI 保持原状态 | ||
| 225 | }; | 228 | }; |
| 226 | </script> | 229 | </script> |
| 227 | 230 | ... | ... |
| ... | @@ -17,19 +17,19 @@ | ... | @@ -17,19 +17,19 @@ |
| 17 | <template> | 17 | <template> |
| 18 | <view class="flex justify-end gap-[24rpx]"> | 18 | <view class="flex justify-end gap-[24rpx]"> |
| 19 | <!-- 查看按钮 --> | 19 | <!-- 查看按钮 --> |
| 20 | - <view v-if="viewable" class="flex items-center text-blue-600" @tap="handleView"> | 20 | + <view v-if="viewable" class="flex items-center text-blue-600" @tap.stop="handleView"> |
| 21 | <IconFont name="eye" size="14" class="mr-[8rpx]" /> | 21 | <IconFont name="eye" size="14" class="mr-[8rpx]" /> |
| 22 | <text class="text-[24rpx]">查看</text> | 22 | <text class="text-[24rpx]">查看</text> |
| 23 | </view> | 23 | </view> |
| 24 | 24 | ||
| 25 | <!-- 收藏按钮 --> | 25 | <!-- 收藏按钮 --> |
| 26 | - <view v-if="collectable" class="flex items-center" :class="isCollected ? 'text-red-500' : 'text-gray-400'" @tap="handleCollect"> | 26 | + <view v-if="collectable" class="flex items-center" :class="isCollected ? 'text-red-500' : 'text-gray-400'" @tap.stop="handleCollect"> |
| 27 | <IconFont :name="isCollected ? 'heart-fill' : 'heart'" size="14" class="mr-[8rpx]" /> | 27 | <IconFont :name="isCollected ? 'heart-fill' : 'heart'" size="14" class="mr-[8rpx]" /> |
| 28 | <text class="text-[24rpx]">{{ isCollected ? '已收藏' : '收藏' }}</text> | 28 | <text class="text-[24rpx]">{{ isCollected ? '已收藏' : '收藏' }}</text> |
| 29 | </view> | 29 | </view> |
| 30 | 30 | ||
| 31 | <!-- 删除按钮 --> | 31 | <!-- 删除按钮 --> |
| 32 | - <view v-if="deletable" class="flex items-center text-red-500" @tap="handleDelete"> | 32 | + <view v-if="deletable" class="flex items-center text-red-500" @tap.stop="handleDelete"> |
| 33 | <IconFont name="del" size="14" class="mr-[8rpx]" /> | 33 | <IconFont name="del" size="14" class="mr-[8rpx]" /> |
| 34 | <text class="text-[24rpx]">删除</text> | 34 | <text class="text-[24rpx]">删除</text> |
| 35 | </view> | 35 | </view> | ... | ... |
| 1 | /** | 1 | /** |
| 2 | * 收藏操作 Composable | 2 | * 收藏操作 Composable |
| 3 | * | 3 | * |
| 4 | - * @description 统一的收藏/取消收藏逻辑,支持乐观更新和错误回滚 | 4 | + * @description 统一的收藏/取消收藏逻辑 |
| 5 | * @author Claude Code | 5 | * @author Claude Code |
| 6 | * @created 2026-02-05 | 6 | * @created 2026-02-05 |
| 7 | + * @updated 2026-02-27 - 改为事件驱动模式,移除乐观更新 | ||
| 7 | */ | 8 | */ |
| 8 | 9 | ||
| 9 | import { addAPI, delAPI } from '../api/favorite.js' | 10 | import { addAPI, delAPI } from '../api/favorite.js' |
| ... | @@ -13,7 +14,7 @@ import eventBus, { Events } from '@/utils/eventBus' | ... | @@ -13,7 +14,7 @@ import eventBus, { Events } from '@/utils/eventBus' |
| 13 | /** | 14 | /** |
| 14 | * 使用收藏操作 | 15 | * 使用收藏操作 |
| 15 | * | 16 | * |
| 16 | - * @description 提供统一的收藏/取消收藏功能,包含乐观更新和错误处理 | 17 | + * @description 提供统一的收藏/取消收藏功能 |
| 17 | * @param {Object} options - 配置选项 | 18 | * @param {Object} options - 配置选项 |
| 18 | * @param {Function} [options.onSuccess] - 成功回调 | 19 | * @param {Function} [options.onSuccess] - 成功回调 |
| 19 | * @param {Function} [options.onError] - 错误回调 | 20 | * @param {Function} [options.onError] - 错误回调 |
| ... | @@ -22,10 +23,11 @@ import eventBus, { Events } from '@/utils/eventBus' | ... | @@ -22,10 +23,11 @@ import eventBus, { Events } from '@/utils/eventBus' |
| 22 | * @example | 23 | * @example |
| 23 | * const { toggleCollect } = useCollectOperation() | 24 | * const { toggleCollect } = useCollectOperation() |
| 24 | * | 25 | * |
| 25 | - * // 在组件中使用 | 26 | + * const result = await toggleCollect({ id: 123, collected: false }) |
| 26 | - * const item = ref({ id: 123, collected: false }) | 27 | + * if (result.success) { |
| 27 | - * | 28 | + * // 更新本地状态 |
| 28 | - * await toggleCollect(item, '收藏成功', '已取消收藏') | 29 | + * item.collected = result.newStatus |
| 30 | + * } | ||
| 29 | */ | 31 | */ |
| 30 | export function useCollectOperation(options = {}) { | 32 | export function useCollectOperation(options = {}) { |
| 31 | const { onSuccess, onError } = options | 33 | const { onSuccess, onError } = options |
| ... | @@ -34,20 +36,19 @@ export function useCollectOperation(options = {}) { | ... | @@ -34,20 +36,19 @@ export function useCollectOperation(options = {}) { |
| 34 | * 切换收藏状态 | 36 | * 切换收藏状态 |
| 35 | * @description 统一的收藏/取消收藏操作 | 37 | * @description 统一的收藏/取消收藏操作 |
| 36 | * @param {Object} item - 资料项(必须包含 collected 和 id/meta_id 字段) | 38 | * @param {Object} item - 资料项(必须包含 collected 和 id/meta_id 字段) |
| 37 | - * @param {string} successMsg - 成功提示文案 | 39 | + * @param {string} successMsg - 成功提示文案(可选) |
| 38 | * @param {string} [errorMsg='操作失败'] - 失败提示文案 | 40 | * @param {string} [errorMsg='操作失败'] - 失败提示文案 |
| 39 | - * @returns {Promise<boolean>} 操作是否成功 | 41 | + * @returns {Promise<Object>} 操作结果 { success: boolean, newStatus: boolean } |
| 40 | */ | 42 | */ |
| 41 | const toggleCollect = async (item, successMsg, errorMsg = '操作失败') => { | 43 | const toggleCollect = async (item, successMsg, errorMsg = '操作失败') => { |
| 42 | - try { | 44 | + // 计算目标状态(注意:这里不修改原对象) |
| 43 | - // 乐观更新 UI | ||
| 44 | const newCollectStatus = !item.collected | 45 | const newCollectStatus = !item.collected |
| 45 | - item.collected = newCollectStatus | ||
| 46 | 46 | ||
| 47 | // 获取 meta_id(优先使用 meta_id,其次使用 id) | 47 | // 获取 meta_id(优先使用 meta_id,其次使用 id) |
| 48 | const metaId = item.meta_id || item.id | 48 | const metaId = item.meta_id || item.id |
| 49 | 49 | ||
| 50 | - // 调用 API | 50 | + try { |
| 51 | + // 调用 API(根据当前状态决定是添加还是删除) | ||
| 51 | const res = newCollectStatus | 52 | const res = newCollectStatus |
| 52 | ? await addAPI({ meta_id: metaId }) // 添加收藏 | 53 | ? await addAPI({ meta_id: metaId }) // 添加收藏 |
| 53 | : await delAPI({ meta_id: metaId }) // 取消收藏 | 54 | : await delAPI({ meta_id: metaId }) // 取消收藏 |
| ... | @@ -70,10 +71,10 @@ export function useCollectOperation(options = {}) { | ... | @@ -70,10 +71,10 @@ export function useCollectOperation(options = {}) { |
| 70 | // 调用成功回调 | 71 | // 调用成功回调 |
| 71 | onSuccess?.(item, newCollectStatus) | 72 | onSuccess?.(item, newCollectStatus) |
| 72 | 73 | ||
| 73 | - return true | 74 | + // 返回成功结果和新状态,由调用方决定如何更新 UI |
| 75 | + return { success: true, newStatus: newCollectStatus } | ||
| 74 | } else { | 76 | } else { |
| 75 | - // API 调用失败,回滚 UI 状态 | 77 | + // API 调用失败 |
| 76 | - item.collected = !newCollectStatus | ||
| 77 | Taro.showToast({ | 78 | Taro.showToast({ |
| 78 | title: res.msg || errorMsg, | 79 | title: res.msg || errorMsg, |
| 79 | icon: 'none', | 80 | icon: 'none', |
| ... | @@ -83,11 +84,11 @@ export function useCollectOperation(options = {}) { | ... | @@ -83,11 +84,11 @@ export function useCollectOperation(options = {}) { |
| 83 | // 调用错误回调 | 84 | // 调用错误回调 |
| 84 | onError?.(item, res.msg) | 85 | onError?.(item, res.msg) |
| 85 | 86 | ||
| 86 | - return false | 87 | + // 返回失败结果,状态不变 |
| 88 | + return { success: false, newStatus: item.collected } | ||
| 87 | } | 89 | } |
| 88 | } catch (err) { | 90 | } catch (err) { |
| 89 | - // 发生错误,回滚 UI 状态 | 91 | + // 发生错误 |
| 90 | - item.collected = !item.collected | ||
| 91 | console.error('[useCollectOperation] 收藏操作失败:', err) | 92 | console.error('[useCollectOperation] 收藏操作失败:', err) |
| 92 | Taro.showToast({ | 93 | Taro.showToast({ |
| 93 | title: '网络错误,请重试', | 94 | title: '网络错误,请重试', |
| ... | @@ -98,7 +99,8 @@ export function useCollectOperation(options = {}) { | ... | @@ -98,7 +99,8 @@ export function useCollectOperation(options = {}) { |
| 98 | // 调用错误回调 | 99 | // 调用错误回调 |
| 99 | onError?.(item, err.message) | 100 | onError?.(item, err.message) |
| 100 | 101 | ||
| 101 | - return false | 102 | + // 返回失败结果,状态不变 |
| 103 | + return { success: false, newStatus: item.collected } | ||
| 102 | } | 104 | } |
| 103 | } | 105 | } |
| 104 | 106 | ... | ... |
-
Please register or login to post a comment