hookehuyr

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>
...@@ -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 - emit('collectChanged', { 189 + if (result.success) {
190 - id: props.id, 190 + emit('collectChanged', {
191 - title: props.title, 191 + id: props.id,
192 - excerpt: props.excerpt, 192 + title: props.title,
193 - coverUrl: props.coverUrl, 193 + excerpt: props.excerpt,
194 - date: props.date, 194 + coverUrl: props.coverUrl,
195 - collected: !props.collected // 新状态(取反) 195 + date: props.date,
196 - }); 196 + collected: result.newStatus // ← 使用 API 返回的新状态
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,18 +210,21 @@ const handleCollect = () => { ...@@ -210,18 +210,21 @@ const handleCollect = () => {
210 downloadUrl: props.downloadUrl 210 downloadUrl: props.downloadUrl
211 }) 211 })
212 212
213 - // 通知父组件收藏状态改变 213 + // API 调用成功后,通知父组件更新本地状态
214 - emit('collectChanged', { 214 + if (result.success) {
215 - id: props.id, 215 + emit('collectChanged', {
216 - title: props.title, 216 + id: props.id,
217 - fileName: props.fileName, 217 + title: props.title,
218 - fileSize: props.fileSize, 218 + fileName: props.fileName,
219 - learners: props.learners, 219 + fileSize: props.fileSize,
220 - readPeoplePercent: props.readPeoplePercent, 220 + learners: props.learners,
221 - collected: !props.collected, // 新状态(取反) 221 + readPeoplePercent: props.readPeoplePercent,
222 - extension: props.extension, 222 + collected: result.newStatus, // ← 使用 API 返回的新状态
223 - downloadUrl: props.downloadUrl 223 + extension: props.extension,
224 - }) 224 + downloadUrl: props.downloadUrl
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 45 + const newCollectStatus = !item.collected
44 - 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
......