diff --git a/docs/superpowers/plans/2026-07-19-favorites-real-api.md b/docs/superpowers/plans/2026-07-19-favorites-real-api.md new file mode 100644 index 0000000..11e2ca1 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-favorites-real-api.md @@ -0,0 +1,939 @@ +--- +author: AI Assistant +created_at: 2026-07-19 +purpose: 小程序收藏功能真实 API 化实施计划 +--- + +# 收藏功能真实 API 化实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 前端收藏功能从 localStorage 切换到真实 API。剧本收藏沿用现有后端 API,人生事件收藏新建独立表和 API。 + +**Architecture:** 剧本收藏后端已完整(`t_script_favorite` 表 + `ScriptFavoriteService`),前端 `ScriptLibraryView.vue` 未对接。人生事件收藏仿照剧本,新建 `t_event_favorite` 表 + `EventFavoriteService` + Controller,删除假接口 `/lifeEvent/favorite-placeholder`。前端所有 localStorage 收藏逻辑切换到 API。 + +**Tech Stack:** Spring Boot 2.7.18 + MyBatis-Plus 3.5.3.1 + UniApp Vue 3 + Pinia + Element Plus + rpx + +--- + +### Task 1: 后端人生事件收藏功能(Entity + Mapper + Service + Controller) + +**Files:** +- Create: `server/src/main/resources/db/migration/V20260719000002__create_event_favorite.sql` +- Create: `server/src/main/java/com/emotion/entity/EventFavorite.java` +- Create: `server/src/main/java/com/emotion/mapper/EventFavoriteMapper.java` +- Create: `server/src/main/java/com/emotion/dto/request/EventFavoriteToggleRequest.java` +- Create: `server/src/main/java/com/emotion/dto/response/EventFavoriteResponse.java` +- Create: `server/src/main/java/com/emotion/service/EventFavoriteService.java` +- Create: `server/src/main/java/com/emotion/service/impl/EventFavoriteServiceImpl.java` +- Modify: `server/src/main/java/com/emotion/controller/LifeEventController.java` + +- [ ] **Step 1: 创建数据库迁移 SQL** + +```sql +-- server/src/main/resources/db/migration/V20260719000002__create_event_favorite.sql +CREATE TABLE t_event_favorite ( + id VARCHAR(64) PRIMARY KEY, + user_id VARCHAR(64) NOT NULL COMMENT '用户ID', + event_id VARCHAR(64) NOT NULL COMMENT '事件ID', + create_by VARCHAR(64) COMMENT '创建人', + create_time DATETIME COMMENT '创建时间', + update_by VARCHAR(64) COMMENT '更新人', + update_time DATETIME COMMENT '更新时间', + is_deleted TINYINT(1) DEFAULT 0 COMMENT '是否删除:0-否 1-是', + remarks VARCHAR(500) COMMENT '备注', + INDEX idx_user_event (user_id, event_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='人生事件收藏关联表'; +``` + +- [ ] **Step 2: 创建 EventFavorite 实体类** + +```java +// server/src/main/java/com/emotion/entity/EventFavorite.java +package com.emotion.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.emotion.common.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +/** + * 人生事件收藏关联实体 + * + * @author AI Assistant + * @date 2026-07-19 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@TableName("t_event_favorite") +public class EventFavorite extends BaseEntity { + + @TableField("user_id") + private String userId; + + @TableField("event_id") + private String eventId; +} +``` + +- [ ] **Step 3: 创建 EventFavoriteMapper** + +```java +// server/src/main/java/com/emotion/mapper/EventFavoriteMapper.java +package com.emotion.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.emotion.entity.EventFavorite; +import org.apache.ibatis.annotations.Mapper; + +/** + * 人生事件收藏 Mapper + * + * @author AI Assistant + * @date 2026-07-19 + */ +@Mapper +public interface EventFavoriteMapper extends BaseMapper { +} +``` + +- [ ] **Step 4: 创建 EventFavoriteToggleRequest DTO** + +```java +// server/src/main/java/com/emotion/dto/request/EventFavoriteToggleRequest.java +package com.emotion.dto.request; + +import lombok.Data; +import javax.validation.constraints.NotBlank; + +/** + * 人生事件收藏切换请求 + * + * @author AI Assistant + * @date 2026-07-19 + */ +@Data +public class EventFavoriteToggleRequest { + + @NotBlank(message = "事件ID不能为空") + private String eventId; +} +``` + +- [ ] **Step 5: 创建 EventFavoriteResponse DTO** + +```java +// server/src/main/java/com/emotion/dto/response/EventFavoriteResponse.java +package com.emotion.dto.response; + +import lombok.Data; + +/** + * 人生事件收藏状态响应 + * + * @author AI Assistant + * @date 2026-07-19 + */ +@Data +public class EventFavoriteResponse { + + private String eventId; + + private Boolean isFavorited; + + private String favoriteTime; +} +``` + +- [ ] **Step 6: 创建 EventFavoriteService 接口** + +```java +// server/src/main/java/com/emotion/service/EventFavoriteService.java +package com.emotion.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.emotion.common.PageResult; +import com.emotion.dto.response.EventFavoriteResponse; +import com.emotion.dto.response.LifeEventResponse; +import com.emotion.entity.EventFavorite; + +import java.util.Set; + +/** + * 人生事件收藏服务接口 + * + * @author AI Assistant + * @date 2026-07-19 + */ +public interface EventFavoriteService extends IService { + + EventFavoriteResponse toggleFavorite(String eventId); + + PageResult getFavoritePage(long current, long size); + + Set getFavoritedEventIds(Set eventIds); + + boolean isFavorited(String eventId); + + long getFavoriteCount(); +} +``` + +- [ ] **Step 7: 创建 EventFavoriteServiceImpl 实现** + +```java +// server/src/main/java/com/emotion/service/impl/EventFavoriteServiceImpl.java +package com.emotion.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.emotion.common.PageResult; +import com.emotion.dto.response.EventFavoriteResponse; +import com.emotion.dto.response.LifeEventResponse; +import com.emotion.entity.EventFavorite; +import com.emotion.mapper.EventFavoriteMapper; +import com.emotion.service.EventFavoriteService; +import com.emotion.service.LifeEventService; +import com.emotion.util.UserContextHolder; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 人生事件收藏服务实现 + * + * @author AI Assistant + * @date 2026-07-19 + */ +@Slf4j +@Service +public class EventFavoriteServiceImpl extends ServiceImpl + implements EventFavoriteService { + + private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + @Autowired + private LifeEventService lifeEventService; + + @Override + @Transactional(rollbackFor = Exception.class) + public EventFavoriteResponse toggleFavorite(String eventId) { + String userId = UserContextHolder.getCurrentUserId(); + EventFavoriteResponse r = new EventFavoriteResponse(); + r.setEventId(eventId); + + if (!StringUtils.hasText(userId)) { + r.setIsFavorited(false); + return r; + } + + LambdaQueryWrapper w = new LambdaQueryWrapper<>(); + w.eq(EventFavorite::getUserId, userId) + .eq(EventFavorite::getEventId, eventId) + .eq(EventFavorite::getIsDeleted, 0); + + EventFavorite existing = this.getOne(w); + if (existing != null) { + this.removeById(existing.getId()); + r.setIsFavorited(false); + } else { + EventFavorite fav = new EventFavorite(); + fav.setUserId(userId); + fav.setEventId(eventId); + this.save(fav); + r.setIsFavorited(true); + if (fav.getCreateTime() != null) { + r.setFavoriteTime(fav.getCreateTime().format(FMT)); + } + } + return r; + } + + @Override + public PageResult getFavoritePage(long current, long size) { + String userId = UserContextHolder.getCurrentUserId(); + if (!StringUtils.hasText(userId)) { + return new PageResult<>(); + } + + LambdaQueryWrapper w = new LambdaQueryWrapper<>(); + w.eq(EventFavorite::getUserId, userId) + .eq(EventFavorite::getIsDeleted, 0) + .orderByDesc(EventFavorite::getCreateTime); + + Page page = new Page<>(current, size); + Page result = this.page(page, w); + + List records = result.getRecords().stream() + .map(EventFavorite::getEventId) + .map(lifeEventService::getEventById) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + PageResult pr = new PageResult<>(); + pr.setCurrent(result.getCurrent()); + pr.setSize(result.getSize()); + pr.setTotal(result.getTotal()); + pr.setPages(result.getPages()); + pr.setRecords(records); + return pr; + } + + @Override + public Set getFavoritedEventIds(Set eventIds) { + String userId = UserContextHolder.getCurrentUserId(); + if (!StringUtils.hasText(userId) || eventIds == null || eventIds.isEmpty()) { + return Collections.emptySet(); + } + + LambdaQueryWrapper w = new LambdaQueryWrapper<>(); + w.eq(EventFavorite::getUserId, userId) + .in(EventFavorite::getEventId, eventIds) + .eq(EventFavorite::getIsDeleted, 0); + + return this.list(w).stream() + .map(EventFavorite::getEventId) + .collect(Collectors.toSet()); + } + + @Override + public boolean isFavorited(String eventId) { + String userId = UserContextHolder.getCurrentUserId(); + if (!StringUtils.hasText(userId) || !StringUtils.hasText(eventId)) { + return false; + } + + LambdaQueryWrapper w = new LambdaQueryWrapper<>(); + w.eq(EventFavorite::getUserId, userId) + .eq(EventFavorite::getEventId, eventId) + .eq(EventFavorite::getIsDeleted, 0); + + return this.count(w) > 0; + } + + @Override + public long getFavoriteCount() { + String userId = UserContextHolder.getCurrentUserId(); + if (!StringUtils.hasText(userId)) { + return 0L; + } + + LambdaQueryWrapper w = new LambdaQueryWrapper<>(); + w.eq(EventFavorite::getUserId, userId) + .eq(EventFavorite::getIsDeleted, 0); + + return this.count(w); + } +} +``` + +- [ ] **Step 8: 修改 LifeEventController 添加收藏 API** + +在 `server/src/main/java/com/emotion/controller/LifeEventController.java` 中添加: + +1. 在类顶部添加注入: + +```java +@Autowired +private EventFavoriteService eventFavoriteService; +``` + +2. 添加 import: + +```java +import com.emotion.dto.request.EventFavoriteToggleRequest; +import com.emotion.dto.response.EventFavoriteResponse; +import com.emotion.service.EventFavoriteService; +``` + +3. 在类末尾(`readTags` 方法之前)添加收藏 API: + +```java +// ==================== 人生事件收藏 ==================== + +@Operation(summary = "切换收藏", description = "收藏或取消收藏指定事件。") +@PostMapping(value = "/favorite/toggle") +public Result toggleFavorite( + @Valid @RequestBody EventFavoriteToggleRequest request) { + return Result.success(eventFavoriteService.toggleFavorite(request.getEventId())); +} + +@Operation(summary = "分页查询收藏", description = "分页查询当前用户收藏的事件列表。") +@GetMapping(value = "/favorite/page") +public Result> getFavoritePage( + @RequestParam(defaultValue = "1") long current, + @RequestParam(defaultValue = "10") long size) { + return Result.success(eventFavoriteService.getFavoritePage(current, size)); +} + +@Operation(summary = "检查收藏状态", description = "检查指定事件是否已被当前用户收藏。") +@GetMapping(value = "/favorite/check") +public Result checkFavorite(@RequestParam String eventId) { + boolean favorited = eventFavoriteService.isFavorited(eventId); + EventFavoriteResponse r = new EventFavoriteResponse(); + r.setEventId(eventId); + r.setIsFavorited(favorited); + return Result.success(r); +} +``` + +4. **删除** `/lifeEvent/favorite-placeholder` 假接口(第 153-163 行): + +```java +// 删除以下方法 +@Operation(summary = "收藏占位接口", description = "返回收藏功能的占位响应。") +@PostMapping(value = "/favorite-placeholder") +public Result> favoritePlaceholder(@RequestBody Map request) { + // ... 整段删除 +} +``` + +- [ ] **Step 9: 提交** + +```bash +git add server/src/main/resources/db/migration/V20260719000002__create_event_favorite.sql +git add server/src/main/java/com/emotion/entity/EventFavorite.java +git add server/src/main/java/com/emotion/mapper/EventFavoriteMapper.java +git add server/src/main/java/com/emotion/dto/request/EventFavoriteToggleRequest.java +git add server/src/main/java/com/emotion/dto/response/EventFavoriteResponse.java +git add server/src/main/java/com/emotion/service/EventFavoriteService.java +git add server/src/main/java/com/emotion/service/impl/EventFavoriteServiceImpl.java +git add server/src/main/java/com/emotion/controller/LifeEventController.java +git commit -m "feat: 新增人生事件收藏功能(独立收藏表 + API)" +``` + +--- + +### Task 2: 后端编译 + 部署到服务器 + +**Files:** +- Build: `server/target/` +- Deploy: 服务器 + +- [ ] **Step 1: 本地编译验证** + +```bash +cd server +mvn clean install -DskipTests +``` + +Expected: BUILD SUCCESS,无编译错误。 + +- [ ] **Step 2: 部署到服务器** + +```bash +cd .. +python deploy.py backend +``` + +Expected: 部署成功,服务器自动重启。 + +- [ ] **Step 3: 验证部署** + +```bash +python deploy.py verify +``` + +Expected: 后端服务运行正常。 + +- [ ] **Step 4: 提交** + +```bash +git commit -m "chore: 后端人生事件收藏功能已部署" --allow-empty +``` + +--- + +### Task 3: 前端 ScriptLibraryView.vue 切换到剧本收藏 API + +**Files:** +- Modify: `mini-program/src/pages/main/ScriptLibraryView.vue` + +- [ ] **Step 1: 删除 localFavorites ref,改用内存状态** + +删除第 160 行: + +```javascript +// 删除这一行 +const localFavorites = ref(uni.getStorageSync('script_favorites') || {}) +``` + +替换为: + +```javascript +const favoriteStatus = ref({}) +``` + +- [ ] **Step 2: 修改 isFavorite 计算** + +修改第 245-247 行: + +```javascript +// 之前: +const isFavorite = (script) => { + return Boolean(script.isFavorite || script.favorite || localFavorites.value[String(script.id)]) +} + +// 之后: +const isFavorite = (script) => { + return Boolean(favoriteStatus.value[String(script.id)]) +} +``` + +- [ ] **Step 3: 修改 toggleFavorite,删除 localStorage 写** + +修改第 320-348 行: + +```javascript +// 之前: +const toggleFavorite = async (script) => { + const wasFav = isFavorite(script) + // 乐观更新:先改本地缓存 + const next = { ...localFavorites.value } + if (wasFav) { + delete next[String(script.id)] + } else { + next[String(script.id)] = true + } + localFavorites.value = next + uni.setStorageSync('script_favorites', next) + closeScriptMenu() + + try { + const result = await toggleFavoriteScript(script.id) + uni.showToast({ title: result.isFavorited ? '已收藏' : '已取消收藏', icon: 'success' }) + } catch (error) { + // 回滚乐观更新 + const restored = { ...localFavorites.value } + if (wasFav) { + restored[String(script.id)] = true + } else { + delete restored[String(script.id)] + } + localFavorites.value = restored + uni.setStorageSync('script_favorites', restored) + uni.showToast({ title: error?.message || '操作失败', icon: 'none' }) + } +} + +// 之后: +const toggleFavorite = async (script) => { + const wasFav = isFavorite(script) + // 乐观更新:先改内存状态 + const next = { ...favoriteStatus.value } + if (wasFav) { + delete next[String(script.id)] + } else { + next[String(script.id)] = true + } + favoriteStatus.value = next + closeScriptMenu() + + try { + const result = await toggleFavoriteScript(script.id) + uni.showToast({ title: result.isFavorited ? '已收藏' : '已取消收藏', icon: 'success' }) + } catch (error) { + // 回滚乐观更新 + const restored = { ...favoriteStatus.value } + if (wasFav) { + restored[String(script.id)] = true + } else { + delete restored[String(script.id)] + } + favoriteStatus.value = restored + uni.showToast({ title: error?.message || '操作失败', icon: 'none' }) + } +} +``` + +- [ ] **Step 4: 修改 confirmDeleteScript,删除 localStorage 清理** + +修改第 386-389 行: + +```javascript +// 之前: +const next = { ...localFavorites.value } +delete next[id] +localFavorites.value = next +uni.setStorageSync('script_favorites', next) + +// 之后: +const next = { ...favoriteStatus.value } +delete next[id] +favoriteStatus.value = next +``` + +- [ ] **Step 5: 列表加载后批量检查收藏状态** + +在 `visibleScripts` computed 后添加 watch 或 onMounted 钩子,批量调用 `checkFavoriteScript`: + +在文件顶部添加 import: + +```javascript +import { checkFavoriteScript } from '../../services/epicScript.js' +``` + +在 `visibleScripts` computed 后添加: + +```javascript +// 列表加载后批量检查收藏状态 +const loadFavoriteStatus = async () => { + const ids = scripts.value.map(s => String(s.id)).filter(id => !id.startsWith('demo-')) + if (!ids.length) return + const status = {} + await Promise.all(ids.map(async (id) => { + try { + status[id] = await checkFavoriteScript(id) + } catch { + status[id] = false + } + })) + favoriteStatus.value = status +} + +// 监听 scripts 变化时重新加载收藏状态 +watch(scripts, () => { + loadFavoriteStatus() +}, { immediate: true }) +``` + +确保 import watch: + +```javascript +import { computed, ref, watch } from 'vue' +``` + +- [ ] **Step 6: 提交** + +```bash +git add mini-program/src/pages/main/ScriptLibraryView.vue +git commit -m "refactor: 剧本收藏从 localStorage 切换到真实 API" +``` + +--- + +### Task 4: 前端 life-event 切换到人生事件收藏 API + +**Files:** +- Modify: `mini-program/src/services/lifeEvent.js` +- Modify: `mini-program/src/pages/life-event/detail.vue` +- Modify: `mini-program/src/stores/app.js` + +- [ ] **Step 1: 修改 lifeEvent.js 新增收藏 API 封装** + +在 `mini-program/src/services/lifeEvent.js` 中: + +1. **删除**旧的 `favoriteEvent` 方法(第 67-69 行): + +```javascript +// 删除: +export const favoriteEvent = async ({ id, favorite }) => { + return post('/lifeEvent/favorite-placeholder', { id, favorite }) +} +``` + +2. **新增**真实的收藏 API: + +```javascript +/** + * 切换人生事件收藏状态(收藏/取消收藏) + * @param {string} eventId 事件ID + * @returns {Promise<{success: boolean, isFavorited: boolean}>} + */ +export const toggleFavoriteEvent = async (eventId) => { + const res = await post('/lifeEvent/favorite/toggle', { eventId }) + return { + success: true, + isFavorited: res.data?.isFavorited ?? false, + favoriteTime: res.data?.favoriteTime ?? null + } +} + +/** + * 检查人生事件是否已收藏 + * @param {string} eventId 事件ID + * @returns {Promise} + */ +export const checkFavoriteEvent = async (eventId) => { + const res = await get('/lifeEvent/favorite/check', { eventId }) + return res.data?.isFavorited ?? false +} +``` + +3. 在 default export 中更新方法列表: + +```javascript +export default { + getEventList, + getEventPage, + getEventById, + createEvent, + updateEvent, + deleteEvent, + assistEventWriting, + chatAboutEvent, + shareEvent, + toggleFavoriteEvent, + checkFavoriteEvent, + transformToFrontendFormat, + transformListToFrontend +} +``` + +- [ ] **Step 2: 修改 stores/app.js 的 favoriteEvent action** + +在 `mini-program/src/stores/app.js` 中: + +1. **删除**旧的 `favoriteEvent` action(第 235-242 行): + +```javascript +// 删除: +const favoriteEvent = async ({ id, favorite }) => { + try { + const res = await lifeEventService.favoriteEvent({ id, favorite }) + return { success: true, data: res.data } + } catch (error) { + return { success: false, error: error.message } + } +} +``` + +2. **新增**新的 action: + +```javascript +const toggleEventFavorite = async (eventId) => { + try { + const result = await lifeEventService.toggleFavoriteEvent(eventId) + return { success: true, data: result } + } catch (error) { + return { success: false, error: error.message } + } +} + +const checkEventFavorite = async (eventId) => { + try { + const isFavorited = await lifeEventService.checkFavoriteEvent(eventId) + return { success: true, data: isFavorited } + } catch (error) { + return { success: false, error: error.message } + } +} +``` + +3. 在 return 对象中更新导出: + +```javascript +return { + // ... 其他方法 + toggleEventFavorite, + checkEventFavorite, + // 删除 favoriteEvent +} +``` + +- [ ] **Step 3: 修改 detail.vue 初始化时检查收藏状态** + +在 `mini-program/src/pages/life-event/detail.vue` 中: + +1. 修改 `onMounted`(第 137-149 行): + +```javascript +// 之前: +onMounted(async () => { + const info = uni.getWindowInfo() + safeAreaBottom.value = info.safeAreaInsets?.bottom || 0 + const pages = getCurrentPages() + eventId.value = pages[pages.length - 1]?.options?.id || '' + cachedEvent.value = uni.getStorageSync('current_life_event') || null + if (!store.events?.length) await store.fetchEvents() + isFavorite.value = Boolean(uni.getStorageSync(`event_favorite_${eventId.value}`)) + analytics.trackPageView(pagePath, { + life_event_id: eventId.value, + tag_count: Array.isArray(displayEvent.value.tags) ? displayEvent.value.tags.length : 0 + }) +}) + +// 之后: +onMounted(async () => { + const info = uni.getWindowInfo() + safeAreaBottom.value = info.safeAreaInsets?.bottom || 0 + const pages = getCurrentPages() + eventId.value = pages[pages.length - 1]?.options?.id || '' + cachedEvent.value = uni.getStorageSync('current_life_event') || null + if (!store.events?.length) await store.fetchEvents() + // 从 API 检查收藏状态 + const result = await store.checkEventFavorite(eventId.value) + if (result.success) { + isFavorite.value = result.data + } + analytics.trackPageView(pagePath, { + life_event_id: eventId.value, + tag_count: Array.isArray(displayEvent.value.tags) ? displayEvent.value.tags.length : 0 + }) +}) +``` + +- [ ] **Step 4: 修改 detail.vue 的 toggleFavorite,删除 localStorage 写** + +修改第 306-322 行: + +```javascript +// 之前: +const toggleFavorite = async () => { + if (!eventId.value) return + const next = !isFavorite.value + const result = await store.favoriteEvent({ id: eventId.value, favorite: next }) + if (!result.success) { + uni.showToast({ title: result.error || '收藏失败', icon: 'none' }) + return + } + isFavorite.value = next + if (next) uni.setStorageSync(`event_favorite_${eventId.value}`, '1') + else uni.removeStorageSync(`event_favorite_${eventId.value}`) + analytics.track('life_event_favorite', { + life_event_id: eventId.value, + favorite: next + }, { eventType: 'life_event', pagePath }) + uni.showToast({ title: next ? '已收藏' : '已取消收藏', icon: 'success' }) +} + +// 之后: +const toggleFavorite = async () => { + if (!eventId.value) return + const result = await store.toggleEventFavorite(eventId.value) + if (!result.success) { + uni.showToast({ title: result.error || '收藏失败', icon: 'none' }) + return + } + isFavorite.value = result.data?.isFavorited ?? false + analytics.track('life_event_favorite', { + life_event_id: eventId.value, + favorite: isFavorite.value + }, { eventType: 'life_event', pagePath }) + uni.showToast({ title: isFavorite.value ? '已收藏' : '已取消收藏', icon: 'success' }) +} +``` + +- [ ] **Step 5: 提交** + +```bash +git add mini-program/src/services/lifeEvent.js +git add mini-program/src/stores/app.js +git add mini-program/src/pages/life-event/detail.vue +git commit -m "refactor: 人生事件收藏从 localStorage 切换到真实 API" +``` + +--- + +### Task 5: H5 浏览器验证 + +**Files:** +- Test: `http://localhost:5180` (H5 开发服务器) + +- [ ] **Step 1: 确认 H5 开发服务器运行中** + +```bash +python dev-services.py status +``` + +Expected: `mini-program` 服务在端口 5180 上运行。如未运行,执行: + +```bash +python dev-services.py start mini-program +``` + +- [ ] **Step 2: 浏览器打开页面并登录** + +1. 打开 `http://localhost:5180` +2. 如未登录,使用手机号验证码 123456 登录 + +- [ ] **Step 3: 验证剧本收藏** + +1. 点击底部导航「爽文生成」 +2. 点击「剧本库」 +3. 点击任意剧本的收藏图标(心形) +4. 确认: + - ✅ Toast 提示「已收藏」 + - ✅ 心形图标变为实心 + - ✅ Network 面板中有 `POST /epicScript/favorite/toggle` 请求,返回 `isFavorited: true` +5. 刷新页面 +6. 确认收藏状态仍保持(心形图标仍为实心) +7. 再次点击收藏图标取消收藏 +8. 确认: + - ✅ Toast 提示「已取消收藏」 + - ✅ 心形图标变为空心 + - ✅ Network 面板中有 `POST /epicScript/favorite/toggle` 请求,返回 `isFavorited: false` + +- [ ] **Step 4: 验证人生事件收藏** + +1. 点击底部导航「人生轨迹」 +2. 点击任意事件进入详情页 +3. 点击收藏按钮 +4. 确认: + - ✅ Toast 提示「已收藏」 + - ✅ 收藏按钮状态变化 + - ✅ Network 面板中有 `POST /lifeEvent/favorite/toggle` 请求,返回 `isFavorited: true` +5. 返回列表页,再次进入同一事件详情 +6. 确认收藏状态仍保持 +7. 再次点击收藏按钮取消收藏 +8. 确认: + - ✅ Toast 提示「已取消收藏」 + - ✅ 收藏按钮状态恢复 + - ✅ Network 面板中有 `POST /lifeEvent/favorite/toggle` 请求,返回 `isFavorited: false` + +- [ ] **Step 5: 检查浏览器 Console 无报错** + +打开浏览器 DevTools → Console 面板,确认: +- ✅ 无红色 ERROR +- ✅ 无 localStorage 相关的 `script_favorites` 或 `event_favorite_` 读写 + +- [ ] **Step 6: 提交(如有微调)** + +```bash +git add mini-program/src/ +git commit -m "test: H5 浏览器验证收藏功能通过" +``` + +--- + +### Task 6: 通知用户验证 + +- [ ] **Step 1: 提示用户操作** + +告知用户: + +> 收藏功能已完成真实 API 化改造: +> +> **剧本收藏**: +> - 前端从 localStorage 切换到现有后端 API(`POST /epicScript/favorite/toggle`) +> - 收藏状态从服务器读取,不再依赖本地缓存 +> +> **人生事件收藏**: +> - 后端新建独立的 `t_event_favorite` 表 + API +> - 前端从 localStorage 切换到真实 API(`POST /lifeEvent/favorite/toggle`) +> - 删除了假接口 `/lifeEvent/favorite-placeholder` +> +> **验证方式**: +> 1. 本地 H5(`http://localhost:5180`)已验证通过 +> 2. 后端已部署到服务器 +> 3. 请在**微信开发者工具**中: +> - 打开 `unpackage/dist/dev/mp-weixin` 目录 +> - 点击「编译」重新编译 +> - 测试剧本收藏和人生事件收藏功能 +> - 确认收藏状态刷新后仍保持 +> +> 如有问题,截图反馈。 diff --git a/docs/superpowers/specs/2026-07-19-favorites-real-api-design.md b/docs/superpowers/specs/2026-07-19-favorites-real-api-design.md new file mode 100644 index 0000000..7b6d8c6 --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-favorites-real-api-design.md @@ -0,0 +1,270 @@ +--- +author: AI Assistant +created_at: 2026-07-19 +purpose: 小程序收藏功能真实 API 化,沿用现有独立收藏表架构 +--- + +# 小程序收藏功能真实 API 化设计(v2) + +## 概述 + +当前小程序的「收藏」功能(剧本收藏、人生事件收藏)完全基于前端 localStorage,未对接后端 API。 + +**重要发现**:剧本收藏后端 API 已经完整实现(独立的 `t_script_favorite` 表 + `ScriptFavoriteService` + Controller),但前端 `ScriptLibraryView.vue` 没有使用这些 API,还在用 localStorage。 + +本次修复: +1. **剧本收藏**:前端从 localStorage 切换到现有 API +2. **人生事件收藏**:仿照剧本收藏,新建独立的收藏表和 API,删除假接口 + +## 现状问题 + +### 剧本收藏 + +| 位置 | 问题 | +|---|---| +| `ScriptLibraryView.vue:159` | `localFavorites = ref(uni.getStorageSync('script_favorites') \|\| {})` | +| `ScriptLibraryView.vue:319-369` | `toggleFavorite` / `deleteScriptFromFavorites` 只写 localStorage | +| `ScriptLibraryView.vue:244` | `isFavorite` 从 `script.isFavorite \|\| localFavorites.value[id]` 读取 | +| `services/epicScript.js:206-232` | ✅ 已有 `toggleFavoriteScript` / `checkFavoriteScript` API 封装,但前端未调用 | + +**后端已实现**: +- `POST /epicScript/favorite/toggle` — 切换收藏 +- `GET /epicScript/favorite/check?scriptId=xxx` — 检查单个剧本收藏状态 +- `GET /epicScript/favorite/page` — 分页查询收藏列表 +- `ScriptFavoriteService.getFavoritedScriptIds(Set)` — 批量检查(Controller 未暴露) + +### 人生事件收藏 + +| 位置 | 问题 | +|---|---| +| `life-event/detail.vue:144` | `isFavorite` 从 `event_favorite_${id}` localStorage 读 | +| `life-event/detail.vue:315-316` | toggle 写/删 `event_favorite_${id}` localStorage | +| `services/lifeEvent.js:67-69` | `favoriteEvent` 调用 `/lifeEvent/favorite-placeholder` 假接口 | +| `LifeEventController.java:153-163` | `/lifeEvent/favorite-placeholder` 是空壳,返回 echo + `placeholder: true` | + +## 设计方案 + +### 存储方案(沿用现有架构) + +**剧本收藏**:沿用现有 `t_script_favorite` 独立表(无需改动)。 + +**人生事件收藏**:仿照剧本,新建独立的 `t_event_favorite` 表。 + +```sql +CREATE TABLE t_event_favorite ( + id VARCHAR(64) PRIMARY KEY, + user_id VARCHAR(64) NOT NULL COMMENT '用户ID', + event_id VARCHAR(64) NOT NULL COMMENT '事件ID', + create_by VARCHAR(64) COMMENT '创建人', + create_time DATETIME COMMENT '创建时间', + update_by VARCHAR(64) COMMENT '更新人', + update_time DATETIME COMMENT '更新时间', + is_deleted TINYINT(1) DEFAULT 0 COMMENT '是否删除:0-否 1-是', + remarks VARCHAR(500) COMMENT '备注', + INDEX idx_user_event (user_id, event_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='人生事件收藏关联表'; +``` + +**选择独立表的理由**: +1. 与剧本收藏架构保持一致,两套收藏机制对称 +2. 支持多用户场景(每个用户独立收藏) +3. 支持分页查询"我的收藏"列表 +4. 逻辑删除保留历史记录 + +### 后端改动 + +#### 1. 人生事件收藏(新增) + +**Entity**:`EventFavorite.java` + +```java +@Data +@EqualsAndHashCode(callSuper = true) +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@TableName("t_event_favorite") +public class EventFavorite extends BaseEntity { + @TableField("user_id") + private String userId; + + @TableField("event_id") + private String eventId; +} +``` + +**Mapper**:`EventFavoriteMapper.java` + +```java +@Mapper +public interface EventFavoriteMapper extends BaseMapper { +} +``` + +**Service**:`EventFavoriteService.java` + `EventFavoriteServiceImpl.java` + +```java +public interface EventFavoriteService extends IService { + EventFavoriteResponse toggleFavorite(String eventId); + PageResult getFavoritePage(long current, long size); + Set getFavoritedEventIds(Set eventIds); + boolean isFavorited(String eventId); + long getFavoriteCount(); +} +``` + +实现完全仿照 `ScriptFavoriteServiceImpl`。 + +**DTO**: +- `EventFavoriteToggleRequest.java`:`{ eventId: String }` +- `EventFavoriteResponse.java`:`{ eventId, isFavorited, favoriteTime }` + +**Controller**:`LifeEventController.java` 新增 + +```java +// ==================== 人生事件收藏 ==================== + +@PostMapping(value = "/favorite/toggle") +public Result toggleFavorite( + @Valid @RequestBody EventFavoriteToggleRequest request) { + return Result.success(eventFavoriteService.toggleFavorite(request.getEventId())); +} + +@GetMapping(value = "/favorite/page") +public Result> getFavoritePage( + @RequestParam(defaultValue = "1") long current, + @RequestParam(defaultValue = "10") long size) { + return Result.success(eventFavoriteService.getFavoritePage(current, size)); +} + +@GetMapping(value = "/favorite/check") +public Result checkFavorite(@RequestParam String eventId) { + boolean favorited = eventFavoriteService.isFavorited(eventId); + EventFavoriteResponse r = new EventFavoriteResponse(); + r.setEventId(eventId); + r.setIsFavorited(favorited); + return Result.success(r); +} +``` + +**删除**:`/lifeEvent/favorite-placeholder` 假接口 + +#### 2. 剧本收藏(无需改动) + +后端已完整,无需新增。 + +### 前端改动 + +#### 1. `ScriptLibraryView.vue` + +**删除**: +- `const localFavorites = ref(uni.getStorageSync('script_favorites') || {})` +- `toggleFavorite` / `deleteScriptFromFavorites` 的 localStorage 写/删逻辑 + +**修改**: +- `isFavorite` 计算改为:调用 `epicScriptService.checkFavoriteScript(script.id)` 或在列表加载后批量检查 +- `toggleFavorite` 改为调 `epicScriptService.toggleFavoriteScript(script.id)` + +**优化**:为避免 N+1 请求,可在后端新增 `GET /epicScript/favorite/ids` 接口,返回当前用户所有收藏的 scriptId 集合,前端列表加载后一次性拿到。但本次先用 `checkFavoriteScript` 逐个检查,后续优化。 + +#### 2. `services/lifeEvent.js` + +**修改**: +```js +// 之前:post('/lifeEvent/favorite-placeholder', { id, favorite }) +// 之后: +export const toggleFavoriteEvent = async (eventId) => { + const res = await post('/lifeEvent/favorite/toggle', { eventId }) + return { + success: true, + isFavorited: res.data?.isFavorited ?? false, + favoriteTime: res.data?.favoriteTime ?? null + } +} + +export const checkFavoriteEvent = async (eventId) => { + const res = await get('/lifeEvent/favorite/check', { eventId }) + return res.data?.isFavorited ?? false +} +``` + +**删除**:旧的 `favoriteEvent` 方法 + +#### 3. `life-event/detail.vue` + +**删除**: +- `const isFavorite = ref(Boolean(uni.getStorageSync('event_favorite_${id}')))` +- `toggleFavorite` 的 localStorage 写/删 + +**修改**: +- `isFavorite` 改为 `ref(false)`,页面加载时调用 `checkFavoriteEvent(id)` 初始化 +- `toggleFavorite` 改为调 `toggleFavoriteEvent(id)` + +## 数据流 + +``` +剧本列表页 ScriptLibraryView + └─ getScriptList() (GET /epicScript/listAll) + └─ 返回剧本列表 + └─ 对每个剧本调用 checkFavoriteScript(id) (GET /epicScript/favorite/check) + └─ 返回 isFavorited + └─ 前端渲染收藏状态 + +用户点剧本收藏 + └─ toggleFavoriteScript(id) (POST /epicScript/favorite/toggle) + └─ 后端 t_script_favorite 表 insert/delete + └─ 返回 isFavorited + └─ 前端更新 UI + +人生事件详情页 life-event/detail + └─ getEventById(id) (GET /lifeEvent/detail) + └─ 返回事件详情 + └─ checkFavoriteEvent(id) (GET /lifeEvent/favorite/check) + └─ 返回 isFavorited + └─ 前端渲染收藏状态 + +用户点事件收藏 + └─ toggleFavoriteEvent(id) (POST /lifeEvent/favorite/toggle) + └─ 后端 t_event_favorite 表 insert/delete + └─ 返回 isFavorited + └─ 前端更新 UI +``` + +## 不影响范围 + +- 列表渲染、筛选、排序逻辑不变,只换数据源(localStorage → API) +- 详情页展示逻辑不变(数据源变化在 sub-project B 清理) +- 其他页面的剧本/事件展示(如首页推荐)不受影响 + +## 数据库迁移 + +新增迁移 SQL 文件 `server/src/main/resources/db/migration/V20260719000002__create_event_favorite.sql`: + +```sql +CREATE TABLE t_event_favorite ( + id VARCHAR(64) PRIMARY KEY, + user_id VARCHAR(64) NOT NULL COMMENT '用户ID', + event_id VARCHAR(64) NOT NULL COMMENT '事件ID', + create_by VARCHAR(64) COMMENT '创建人', + create_time DATETIME COMMENT '创建时间', + update_by VARCHAR(64) COMMENT '更新人', + update_time DATETIME COMMENT '更新时间', + is_deleted TINYINT(1) DEFAULT 0 COMMENT '是否删除:0-否 1-是', + remarks VARCHAR(500) COMMENT '备注', + INDEX idx_user_event (user_id, event_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='人生事件收藏关联表'; +``` + +如果项目不用 Flyway,则手动执行或写入项目约定的 SQL 目录。 + +## 验收标准 + +- [ ] 剧本收藏:点击心形图标 → 调用 `POST /epicScript/favorite/toggle` → 后端字段更新 → 刷新页面后仍保持状态 +- [ ] 人生事件收藏:点击收藏 → 调用 `POST /lifeEvent/favorite/toggle` → 同上 +- [ ] 取消收藏:再次点击 → 同上 +- [ ] 列表页 `isFavorite` 来自 API,不再依赖 localStorage +- [ ] 详情页 `isFavorite` 来自 API,不再依赖 localStorage +- [ ] `/lifeEvent/favorite-placeholder` 假接口已删除 +- [ ] 浏览器 Console 无报错 +- [ ] 后端 `mvn clean install` 编译通过 +- [ ] 通过 `deploy.py backend` 部署到服务器后,在服务器环境验证 diff --git a/mini-program/src/pages/life-event/detail.vue b/mini-program/src/pages/life-event/detail.vue index 6c8287c..b8f1529 100644 --- a/mini-program/src/pages/life-event/detail.vue +++ b/mini-program/src/pages/life-event/detail.vue @@ -134,14 +134,41 @@ const isDescriptionCollapsed = ref(false) const scrollTop = ref(0) const currentScrollTop = ref(0) +// 从 localStorage 读取缓存的收藏状态 +const getCachedFavoriteStatus = (id) => { + const cached = uni.getStorageSync(`favorite_event_${id}`) + return cached === 'true' +} + +// 缓存收藏状态到 localStorage +const setCachedFavoriteStatus = (id, status) => { + uni.setStorageSync(`favorite_event_${id}`, status ? 'true' : 'false') +} + onMounted(async () => { const info = uni.getWindowInfo() safeAreaBottom.value = info.safeAreaInsets?.bottom || 0 const pages = getCurrentPages() eventId.value = pages[pages.length - 1]?.options?.id || '' + + // 先从缓存读取收藏状态,避免闪烁 + if (eventId.value) { + isFavorite.value = getCachedFavoriteStatus(eventId.value) + } + cachedEvent.value = uni.getStorageSync('current_life_event') || null if (!store.events?.length) await store.fetchEvents() - isFavorite.value = Boolean(uni.getStorageSync(`event_favorite_${eventId.value}`)) + + // 异步校验真实收藏状态并更新缓存 + if (eventId.value) { + const result = await store.checkEventFavorite(eventId.value) + if (result.success) { + const realStatus = result.data + isFavorite.value = realStatus + setCachedFavoriteStatus(eventId.value, realStatus) + } + } + analytics.trackPageView(pagePath, { life_event_id: eventId.value, tag_count: Array.isArray(displayEvent.value.tags) ? displayEvent.value.tags.length : 0 @@ -305,20 +332,19 @@ const editEvent = () => { const toggleFavorite = async () => { if (!eventId.value) return - const next = !isFavorite.value - const result = await store.favoriteEvent({ id: eventId.value, favorite: next }) + const result = await store.toggleEventFavorite(eventId.value) if (!result.success) { uni.showToast({ title: result.error || '收藏失败', icon: 'none' }) return } - isFavorite.value = next - if (next) uni.setStorageSync(`event_favorite_${eventId.value}`, '1') - else uni.removeStorageSync(`event_favorite_${eventId.value}`) + const newStatus = result.data?.isFavorited ?? false + isFavorite.value = newStatus + setCachedFavoriteStatus(eventId.value, newStatus) analytics.track('life_event_favorite', { life_event_id: eventId.value, - favorite: next + favorite: newStatus }, { eventType: 'life_event', pagePath }) - uni.showToast({ title: next ? '已收藏' : '已取消收藏', icon: 'success' }) + uni.showToast({ title: newStatus ? '已收藏' : '已取消收藏', icon: 'success' }) } const chatEvent = async () => { diff --git a/mini-program/src/pages/main/ScriptLibraryView.vue b/mini-program/src/pages/main/ScriptLibraryView.vue index 8970c74..bff4316 100644 --- a/mini-program/src/pages/main/ScriptLibraryView.vue +++ b/mini-program/src/pages/main/ScriptLibraryView.vue @@ -147,8 +147,9 @@