From 74a2c15b6bf6d06131c3a41d737f01a2401e558c Mon Sep 17 00:00:00 2001 From: Peanut Date: Mon, 15 Jun 2026 10:17:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E4=BA=BA=E7=94=9F?= =?UTF-8?q?=E8=BD=A8=E8=BF=B9=E8=AF=A6=E6=83=85=E9=A1=B5=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=8F=8A=E5=BE=AE=E4=BF=A1=E5=B0=8F=E7=A8=8B=E5=BA=8F=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ses_152f89055ffej3OExy0HNx26un.json | 10 + .../ses_15f343272ffe65miVsUT3gxmR0.json | 10 + .../ses_17cc33154ffe9MqmbYyg3bghu8.json | 10 + .../java/com/emotion/config/WebMvcConfig.java | 1 + .../config/WechatMiniProgramProperties.java | 17 + .../emotion/controller/AuthController.java | 10 +- .../dto/request/WechatLoginRequest.java | 18 + .../dto/wechat/WechatCodeSessionResponse.java | 24 + .../interceptor/JwtAuthInterceptor.java | 1 + .../WechatApiLoggingInterceptor.java | 15 +- .../java/com/emotion/service/AuthService.java | 5 +- .../com/emotion/service/TtsTaskService.java | 2 + .../java/com/emotion/service/UserService.java | 4 +- .../service/WechatMiniProgramService.java | 8 + .../emotion/service/impl/AuthServiceImpl.java | 53 +- .../service/impl/EpicScriptServiceImpl.java | 18 + .../service/impl/HttpTtsEngineClient.java | 44 +- .../service/impl/TtsTaskServiceImpl.java | 53 +- .../emotion/service/impl/UserServiceImpl.java | 14 +- .../impl/WechatMiniProgramServiceImpl.java | 72 +- .../src/main/resources/application-prod.yml | 2 + .../src/main/resources/application.yml | 8 + .../WechatApiLoggingInterceptorTest.java | 15 + .../WechatMiniProgramServiceImplTest.java | 199 ++--- .../plans/2026-06-03-login-route-fix.md | 167 ++++ .../2026-06-03-mine-view-dynamic-data.md | 241 ++++++ mini-program/src/composables/useTtsPlayer.js | 71 +- mini-program/src/pages/life-event/detail.vue | 223 ++---- mini-program/src/pages/main/MineView.vue | 7 +- .../src/pages/main/ScriptDetailView.vue | 3 + .../src/pages/main/ScriptLibraryView.vue | 274 ++++++- mini-program/src/pages/main/ScriptView.vue | 738 +++++++++++++++--- mini-program/src/pages/onboarding/index.vue | 20 +- mini-program/src/services/auth.js | 17 + mini-program/src/services/epicScript.js | 17 +- mini-program/src/services/userProfile.js | 36 +- mini-program/src/stores/app.js | 63 +- 37 files changed, 1974 insertions(+), 516 deletions(-) create mode 100644 .omo/run-continuation/ses_152f89055ffej3OExy0HNx26un.json create mode 100644 .omo/run-continuation/ses_15f343272ffe65miVsUT3gxmR0.json create mode 100644 .omo/run-continuation/ses_17cc33154ffe9MqmbYyg3bghu8.json create mode 100644 backend-single/src/main/java/com/emotion/config/WechatMiniProgramProperties.java create mode 100644 backend-single/src/main/java/com/emotion/dto/request/WechatLoginRequest.java create mode 100644 backend-single/src/main/java/com/emotion/dto/wechat/WechatCodeSessionResponse.java create mode 100644 backend-single/src/main/java/com/emotion/service/WechatMiniProgramService.java create mode 100644 docs/superpowers/plans/2026-06-03-login-route-fix.md create mode 100644 docs/superpowers/plans/2026-06-03-mine-view-dynamic-data.md diff --git a/.omo/run-continuation/ses_152f89055ffej3OExy0HNx26un.json b/.omo/run-continuation/ses_152f89055ffej3OExy0HNx26un.json new file mode 100644 index 0000000..883c81c --- /dev/null +++ b/.omo/run-continuation/ses_152f89055ffej3OExy0HNx26un.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_152f89055ffej3OExy0HNx26un", + "updatedAt": "2026-06-10T11:59:35.517Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-06-10T11:59:35.517Z" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_15f343272ffe65miVsUT3gxmR0.json b/.omo/run-continuation/ses_15f343272ffe65miVsUT3gxmR0.json new file mode 100644 index 0000000..fa8f9fb --- /dev/null +++ b/.omo/run-continuation/ses_15f343272ffe65miVsUT3gxmR0.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_15f343272ffe65miVsUT3gxmR0", + "updatedAt": "2026-06-07T06:37:08.712Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-06-07T06:37:08.712Z" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_17cc33154ffe9MqmbYyg3bghu8.json b/.omo/run-continuation/ses_17cc33154ffe9MqmbYyg3bghu8.json new file mode 100644 index 0000000..9b14194 --- /dev/null +++ b/.omo/run-continuation/ses_17cc33154ffe9MqmbYyg3bghu8.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_17cc33154ffe9MqmbYyg3bghu8", + "updatedAt": "2026-06-02T23:41:53.619Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-06-02T23:41:53.619Z" + } + } +} \ No newline at end of file diff --git a/backend-single/src/main/java/com/emotion/config/WebMvcConfig.java b/backend-single/src/main/java/com/emotion/config/WebMvcConfig.java index 787201d..04a5b6b 100644 --- a/backend-single/src/main/java/com/emotion/config/WebMvcConfig.java +++ b/backend-single/src/main/java/com/emotion/config/WebMvcConfig.java @@ -59,6 +59,7 @@ public class WebMvcConfig implements WebMvcConfigurer { "/swagger-resources/**", // Swagger resources sub-paths "/actuator/**", // Actuator endpoints "/admin/**", // 排除管理员路径,由管理员拦截器处理 + "/auth/wechat/login", // WeChat mini program login endpoint "/error" // Spring Boot error page ) .order(2); // 优先级2 diff --git a/backend-single/src/main/java/com/emotion/config/WechatMiniProgramProperties.java b/backend-single/src/main/java/com/emotion/config/WechatMiniProgramProperties.java new file mode 100644 index 0000000..6413cd4 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/config/WechatMiniProgramProperties.java @@ -0,0 +1,17 @@ +package com.emotion.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Data +@Configuration +@ConfigurationProperties(prefix = "emotion.wechat.mini-program") +public class WechatMiniProgramProperties { + + private String appId; + + private String appSecret; + + private String baseUrl = "https://api.weixin.qq.com"; +} diff --git a/backend-single/src/main/java/com/emotion/controller/AuthController.java b/backend-single/src/main/java/com/emotion/controller/AuthController.java index 7d78af1..e59b8fa 100644 --- a/backend-single/src/main/java/com/emotion/controller/AuthController.java +++ b/backend-single/src/main/java/com/emotion/controller/AuthController.java @@ -5,6 +5,7 @@ import com.emotion.dto.request.LoginRequest; import com.emotion.dto.request.RegisterRequest; import com.emotion.dto.request.RefreshTokenRequest; import com.emotion.dto.request.ResetPasswordRequest; +import com.emotion.dto.request.WechatLoginRequest; import com.emotion.dto.response.ResetPasswordResponse; import com.emotion.dto.response.AuthResponse; @@ -53,6 +54,13 @@ public class AuthController { /** * 用户注册(简化版:仅需手机号、密码和短信验证码) */ + @PostMapping("/wechat/login") + @Operation(summary = "微信小程序登录", description = "使用微信小程序登录 code 换取 openid 并登录") + public Result wechatLogin(@Valid @RequestBody WechatLoginRequest request) { + AuthResponse response = authService.wechatLogin(request); + return Result.success("登录成功", response); + } + @PostMapping(value = "/register") @Operation(summary = "用户注册", description = "使用手机号、密码和短信验证码进行注册") public Result register(@Valid @RequestBody RegisterRequest request) { @@ -177,4 +185,4 @@ public class AuthController { boolean exists = authService.existsByPhone(phone); return Result.success(exists); } -} \ No newline at end of file +} diff --git a/backend-single/src/main/java/com/emotion/dto/request/WechatLoginRequest.java b/backend-single/src/main/java/com/emotion/dto/request/WechatLoginRequest.java new file mode 100644 index 0000000..5170601 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/request/WechatLoginRequest.java @@ -0,0 +1,18 @@ +package com.emotion.dto.request; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.validation.constraints.NotBlank; + +@Data +@EqualsAndHashCode(callSuper = true) +public class WechatLoginRequest extends BaseRequest { + + @NotBlank(message = "Wechat login code is required") + private String code; + + private String nickname; + + private String avatar; +} diff --git a/backend-single/src/main/java/com/emotion/dto/wechat/WechatCodeSessionResponse.java b/backend-single/src/main/java/com/emotion/dto/wechat/WechatCodeSessionResponse.java new file mode 100644 index 0000000..1d839fa --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/wechat/WechatCodeSessionResponse.java @@ -0,0 +1,24 @@ +package com.emotion.dto.wechat; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import org.springframework.util.StringUtils; + +@Data +public class WechatCodeSessionResponse { + + private String openid; + + @JsonProperty("session_key") + private String sessionKey; + + private String unionid; + + private Integer errcode; + + private String errmsg; + + public boolean isSuccess() { + return (errcode == null || errcode == 0) && StringUtils.hasText(openid); + } +} diff --git a/backend-single/src/main/java/com/emotion/interceptor/JwtAuthInterceptor.java b/backend-single/src/main/java/com/emotion/interceptor/JwtAuthInterceptor.java index fc69f87..286f7c6 100644 --- a/backend-single/src/main/java/com/emotion/interceptor/JwtAuthInterceptor.java +++ b/backend-single/src/main/java/com/emotion/interceptor/JwtAuthInterceptor.java @@ -98,6 +98,7 @@ public class JwtAuthInterceptor implements HandlerInterceptor { // 公开接口列表 String[] publicEndpoints = { "/api/auth/login", + "/api/auth/wechat/login", "/api/auth/register", "/api/auth/captcha", "/api/auth/refresh-token", diff --git a/backend-single/src/main/java/com/emotion/interceptor/WechatApiLoggingInterceptor.java b/backend-single/src/main/java/com/emotion/interceptor/WechatApiLoggingInterceptor.java index f268649..da248ef 100644 --- a/backend-single/src/main/java/com/emotion/interceptor/WechatApiLoggingInterceptor.java +++ b/backend-single/src/main/java/com/emotion/interceptor/WechatApiLoggingInterceptor.java @@ -7,6 +7,7 @@ import org.springframework.http.client.ClientHttpRequestInterceptor; import org.springframework.http.client.ClientHttpResponse; import java.io.IOException; +import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.regex.Pattern; @@ -34,13 +35,14 @@ public class WechatApiLoggingInterceptor implements ClientHttpRequestInterceptor ClientHttpRequestExecution execution) throws IOException { long start = System.currentTimeMillis(); - log.info("[WeChatAPI] ---> {} {}", request.getMethod(), request.getURI()); + String safeUri = maskSensitiveQuery(request.getURI()); + log.info("[WeChatAPI] ---> {} {}", request.getMethod(), safeUri); ClientHttpResponse response = execution.execute(request, body); long duration = System.currentTimeMillis() - start; log.info("[WeChatAPI] <--- {} {} ({}ms) content-type={}", - response.getStatusCode(), request.getURI(), duration, + response.getStatusCode(), safeUri, duration, response.getHeaders().getContentType()); // body 读取(已由 BufferingClientHttpRequestFactory 缓存,可重复读) @@ -66,4 +68,13 @@ public class WechatApiLoggingInterceptor implements ClientHttpRequestInterceptor } return SENSITIVE_FIELDS.matcher(body).replaceAll("\"$1\":\"***\""); } + + private String maskSensitiveQuery(URI uri) { + if (uri == null) { + return ""; + } + String value = uri.toString(); + return value + .replaceAll("(?i)([?&](?:secret|appsecret|js_code|access_token)=)[^&]*", "$1***"); + } } diff --git a/backend-single/src/main/java/com/emotion/service/AuthService.java b/backend-single/src/main/java/com/emotion/service/AuthService.java index 0d094e7..77b36a9 100644 --- a/backend-single/src/main/java/com/emotion/service/AuthService.java +++ b/backend-single/src/main/java/com/emotion/service/AuthService.java @@ -3,6 +3,7 @@ package com.emotion.service; import com.emotion.dto.request.LoginRequest; import com.emotion.dto.request.RegisterRequest; import com.emotion.dto.request.ResetPasswordRequest; +import com.emotion.dto.request.WechatLoginRequest; import com.emotion.dto.response.ResetPasswordResponse; import com.emotion.dto.response.AuthResponse; @@ -28,6 +29,8 @@ public interface AuthService { */ AuthResponse login(LoginRequest request); + AuthResponse wechatLogin(WechatLoginRequest request); + /** * 用户注册 * @@ -165,4 +168,4 @@ public interface AuthService { * @return 是否验证成功 */ boolean validateSmsCode(String phone, String code); -} \ No newline at end of file +} diff --git a/backend-single/src/main/java/com/emotion/service/TtsTaskService.java b/backend-single/src/main/java/com/emotion/service/TtsTaskService.java index a218d87..1e686b9 100644 --- a/backend-single/src/main/java/com/emotion/service/TtsTaskService.java +++ b/backend-single/src/main/java/com/emotion/service/TtsTaskService.java @@ -9,6 +9,8 @@ public interface TtsTaskService extends IService { TtsTaskResponse createOrReuse(TtsTaskCreateRequest request); + void prewarmEpicScript(String userId, String sourceId); + TtsTaskResponse getTask(String id); TtsTaskResponse getBySource(String sourceType, String sourceId, String voice, diff --git a/backend-single/src/main/java/com/emotion/service/UserService.java b/backend-single/src/main/java/com/emotion/service/UserService.java index f37264b..83e428b 100644 --- a/backend-single/src/main/java/com/emotion/service/UserService.java +++ b/backend-single/src/main/java/com/emotion/service/UserService.java @@ -69,6 +69,8 @@ public interface UserService extends IService { */ User getByPhone(String phone); + User getByThirdParty(String thirdPartyType, String thirdPartyId); + /** * 创建用户 * @@ -88,4 +90,4 @@ public interface UserService extends IService { * @param lastActiveTime 最后活跃时间 */ void updateLastActiveTime(String userId, LocalDateTime lastActiveTime); -} \ No newline at end of file +} diff --git a/backend-single/src/main/java/com/emotion/service/WechatMiniProgramService.java b/backend-single/src/main/java/com/emotion/service/WechatMiniProgramService.java new file mode 100644 index 0000000..659d57c --- /dev/null +++ b/backend-single/src/main/java/com/emotion/service/WechatMiniProgramService.java @@ -0,0 +1,8 @@ +package com.emotion.service; + +import com.emotion.dto.wechat.WechatCodeSessionResponse; + +public interface WechatMiniProgramService { + + WechatCodeSessionResponse code2Session(String code); +} diff --git a/backend-single/src/main/java/com/emotion/service/impl/AuthServiceImpl.java b/backend-single/src/main/java/com/emotion/service/impl/AuthServiceImpl.java index 4a1641c..bc0bef3 100644 --- a/backend-single/src/main/java/com/emotion/service/impl/AuthServiceImpl.java +++ b/backend-single/src/main/java/com/emotion/service/impl/AuthServiceImpl.java @@ -3,11 +3,13 @@ package com.emotion.service.impl; import com.emotion.dto.request.LoginRequest; import com.emotion.dto.request.RegisterRequest; import com.emotion.dto.request.ResetPasswordRequest; +import com.emotion.dto.request.WechatLoginRequest; import com.emotion.dto.response.AuthResponse; import com.emotion.dto.response.CaptchaResponse; import com.emotion.dto.response.ResetPasswordResponse; import com.emotion.dto.response.SmsCodeResponse; import com.emotion.dto.response.UserInfoResponse; +import com.emotion.dto.wechat.WechatCodeSessionResponse; import com.emotion.entity.User; import com.emotion.exception.AuthException; import com.emotion.exception.BusinessException; @@ -15,6 +17,7 @@ import com.emotion.exception.CaptchaException; import com.emotion.exception.TokenException; import com.emotion.service.AuthService; import com.emotion.service.UserService; +import com.emotion.service.WechatMiniProgramService; import com.emotion.util.JwtUtil; import com.emotion.util.TokenUtil; import lombok.extern.slf4j.Slf4j; @@ -51,6 +54,9 @@ public class AuthServiceImpl implements AuthService { @Autowired private UserService userService; + @Autowired + private WechatMiniProgramService wechatMiniProgramService; + @Autowired private RedisTemplate redisTemplate; @@ -72,6 +78,7 @@ public class AuthServiceImpl implements AuthService { private static final int TOKEN_EXPIRE_HOURS = 24; private static final int REFRESH_TOKEN_EXPIRE_DAYS = 7; private static final String DEFAULT_SMS_CODE = "123456"; + private static final String WECHAT_MP_TYPE = "wechat-mp"; private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); @Override @@ -114,6 +121,32 @@ public class AuthServiceImpl implements AuthService { return response; } + @Override + public AuthResponse wechatLogin(WechatLoginRequest request) { + WechatCodeSessionResponse session = wechatMiniProgramService.code2Session(request.getCode()); + String openid = session.getOpenid(); + + User user = userService.getByThirdParty(WECHAT_MP_TYPE, openid); + if (user == null) { + user = createWechatUser(openid, request); + log.info("Wechat mini program user created: userId={}", user.getId()); + } else if (user.getStatus() != 1) { + throw new AuthException("账号已被禁用"); + } + + String accessToken = generateAccessToken(user); + String refreshToken = generateRefreshToken(user); + userService.updateLastActiveTime(user.getId(), LocalDateTime.now()); + + AuthResponse response = new AuthResponse(); + response.setAccessToken(accessToken); + response.setRefreshToken(refreshToken); + response.setExpiresIn((long) TOKEN_EXPIRE_HOURS * 3600); + response.setUserInfo(convertToUserInfoResponse(user)); + response.setLoginTime(LocalDateTime.now().format(DATE_TIME_FORMATTER)); + return response; + } + @Override public AuthResponse register(RegisterRequest request) { // 验证短信验证码 @@ -626,6 +659,24 @@ public class AuthServiceImpl implements AuthService { * * @return 随机密码 */ + private User createWechatUser(String openid, WechatLoginRequest request) { + String username = generateRandomUsername(); + User user = new User(); + user.setAccount("wx_" + openid); + user.setUsername(username); + user.setNickname(StringUtils.hasText(request.getNickname()) ? request.getNickname() : username); + user.setAvatar(StringUtils.hasText(request.getAvatar()) ? request.getAvatar() : null); + user.setPassword(passwordEncoder.encode(UUID.randomUUID().toString())); + user.setMemberLevel("free"); + user.setStatus(1); + user.setIsVerified(1); + user.setLastActiveTime(LocalDateTime.now()); + user.setThirdPartyType(WECHAT_MP_TYPE); + user.setThirdPartyId(openid); + userService.save(user); + return user; + } + private String generateRandomPassword() { String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; Random random = new Random(); @@ -638,4 +689,4 @@ public class AuthServiceImpl implements AuthService { return sb.toString(); } -} \ No newline at end of file +} diff --git a/backend-single/src/main/java/com/emotion/service/impl/EpicScriptServiceImpl.java b/backend-single/src/main/java/com/emotion/service/impl/EpicScriptServiceImpl.java index 547e0ee..4a97253 100644 --- a/backend-single/src/main/java/com/emotion/service/impl/EpicScriptServiceImpl.java +++ b/backend-single/src/main/java/com/emotion/service/impl/EpicScriptServiceImpl.java @@ -20,6 +20,7 @@ import com.emotion.service.AiRuntimeService; import com.emotion.service.EpicScriptService; import com.emotion.service.LifePathService; import com.emotion.service.ScriptContextService; +import com.emotion.service.TtsTaskService; import com.emotion.util.UserContextHolder; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.BeanUtils; @@ -75,6 +76,9 @@ public class EpicScriptServiceImpl extends ServiceImpl getPageByCurrentUser(EpicScriptPageRequest request) { String currentUserId = UserContextHolder.getCurrentUserId(); @@ -195,6 +199,7 @@ public class EpicScriptServiceImpl extends ServiceImpl 0L; + } } diff --git a/backend-single/src/main/java/com/emotion/service/impl/TtsTaskServiceImpl.java b/backend-single/src/main/java/com/emotion/service/impl/TtsTaskServiceImpl.java index 5585d8c..5e36b78 100644 --- a/backend-single/src/main/java/com/emotion/service/impl/TtsTaskServiceImpl.java +++ b/backend-single/src/main/java/com/emotion/service/impl/TtsTaskServiceImpl.java @@ -18,6 +18,7 @@ import org.springframework.stereotype.Service; import org.springframework.util.DigestUtils; import org.springframework.util.StringUtils; +import java.io.File; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; @@ -83,6 +84,25 @@ public class TtsTaskServiceImpl extends ServiceImpl impl } String userId = currentUserId(); + return createOrReuseForUser(userId, request); + } + + @Override + public void prewarmEpicScript(String userId, String sourceId) { + if (!enabled || !StringUtils.hasText(userId) || !StringUtils.hasText(sourceId)) { + return; + } + try { + TtsTaskCreateRequest request = new TtsTaskCreateRequest(); + request.setSourceType(SOURCE_TYPE_EPIC_SCRIPT); + request.setSourceId(sourceId); + createOrReuseForUser(userId, request); + } catch (Exception e) { + log.warn("TTS prewarm skipped, userId={}, sourceId={}, error={}", userId, sourceId, e.getMessage()); + } + } + + private TtsTaskResponse createOrReuseForUser(String userId, TtsTaskCreateRequest request) { String sourceType = normalizeSourceType(request.getSourceType()); String sourceId = request.getSourceId().trim(); String voice = resolveVoice(request.getVoice()); @@ -98,8 +118,13 @@ public class TtsTaskServiceImpl extends ServiceImpl impl String hash = DigestUtils.md5DigestAsHex((voice + "\n" + options.cacheKey() + "\n" + cleaned).getBytes(StandardCharsets.UTF_8)); TtsTask owned = findOwnedTask(userId, sourceType, sourceId, voice, hash); if (owned != null) { - incrementRequestCount(owned); - return toResponse(owned); + if (STATUS_FAILED.equals(owned.getStatus()) && !recoverGeneratedAudio(owned)) { + log.info("Retrying failed TTS task, oldTaskId={}, sourceType={}, sourceId={}", + owned.getId(), sourceType, sourceId); + } else { + incrementRequestCount(owned); + return toResponse(owned); + } } TtsTask cachedSuccess = findSuccessfulCache(voice, hash); @@ -128,6 +153,9 @@ public class TtsTaskServiceImpl extends ServiceImpl impl if (task == null || !userId.equals(task.getUserId())) { return null; } + if (STATUS_FAILED.equals(task.getStatus())) { + recoverGeneratedAudio(task); + } return toResponse(task); } @@ -144,9 +172,27 @@ public class TtsTaskServiceImpl extends ServiceImpl impl } String hash = DigestUtils.md5DigestAsHex((normalizedVoice + "\n" + options.cacheKey() + "\n" + cleaned).getBytes(StandardCharsets.UTF_8)); TtsTask task = findOwnedTask(userId, normalizedSourceType, sourceId, normalizedVoice, hash); + if (task != null && STATUS_FAILED.equals(task.getStatus())) { + recoverGeneratedAudio(task); + } return task == null ? null : toResponse(task); } + private boolean recoverGeneratedAudio(TtsTask task) { + if (task == null || !StringUtils.hasText(task.getAudioPath())) { + return false; + } + File file = new File(task.getAudioPath()); + if (!file.isFile() || file.length() <= 0L) { + return false; + } + task.setStatus(STATUS_SUCCESS); + task.setErrorMessage(null); + updateById(task); + log.info("Recovered generated TTS audio, taskId={}, audioPath={}", task.getId(), task.getAudioPath()); + return true; + } + private void process(String taskId, String text, String voice, String outputPath, TtsEngineClient.SynthesisOptions options) { try { TtsTask task = getById(taskId); @@ -178,6 +224,9 @@ public class TtsTaskServiceImpl extends ServiceImpl impl log.warn("TTS task processing failed, taskId={}", taskId, e); TtsTask task = getById(taskId); if (task != null) { + if (recoverGeneratedAudio(task)) { + return; + } task.setStatus(STATUS_FAILED); task.setErrorMessage(limitError(e.getMessage())); updateById(task); diff --git a/backend-single/src/main/java/com/emotion/service/impl/UserServiceImpl.java b/backend-single/src/main/java/com/emotion/service/impl/UserServiceImpl.java index 0df0cac..6d0a456 100644 --- a/backend-single/src/main/java/com/emotion/service/impl/UserServiceImpl.java +++ b/backend-single/src/main/java/com/emotion/service/impl/UserServiceImpl.java @@ -278,6 +278,18 @@ public class UserServiceImpl extends ServiceImpl implements Us return this.getOne(wrapper); } + @Override + public User getByThirdParty(String thirdPartyType, String thirdPartyId) { + if (!StringUtils.hasText(thirdPartyType) || !StringUtils.hasText(thirdPartyId)) { + return null; + } + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(User::getThirdPartyType, thirdPartyType) + .eq(User::getThirdPartyId, thirdPartyId) + .eq(User::getIsDeleted, 0); + return this.getOne(wrapper); + } + @Override public User createUser(String account, String username, String password, String email, String phone) { User user = new User(); @@ -326,4 +338,4 @@ public class UserServiceImpl extends ServiceImpl implements Us } return response; } -} \ No newline at end of file +} diff --git a/backend-single/src/main/java/com/emotion/service/impl/WechatMiniProgramServiceImpl.java b/backend-single/src/main/java/com/emotion/service/impl/WechatMiniProgramServiceImpl.java index ba10a90..f232d92 100644 --- a/backend-single/src/main/java/com/emotion/service/impl/WechatMiniProgramServiceImpl.java +++ b/backend-single/src/main/java/com/emotion/service/impl/WechatMiniProgramServiceImpl.java @@ -17,9 +17,6 @@ import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; -import java.util.HashMap; -import java.util.Map; - @Slf4j @Service @RequiredArgsConstructor @@ -43,56 +40,37 @@ public class WechatMiniProgramServiceImpl implements WechatMiniProgramService { try { responseEntity = restTemplate.getForEntity(url, String.class); } catch (ResourceAccessException e) { - // 网络层异常(连接超时、连接拒绝、DNS 失败) - log.error("[WeChatAPI] 网络异常,jsCode={}", code, e); - throw new WechatApiException(502, "微信API网络异常: " + e.getMessage(), null); + log.error("[WeChatAPI] network error, jsCode={}", code, e); + throw new WechatApiException(502, "Wechat API network error: " + e.getMessage(), null); } catch (RestClientException e) { - // 其他 RestTemplate 异常(如 SSL、URI 解析) - log.error("[WeChatAPI] 客户端异常,jsCode={}", code, e); - throw new WechatApiException(502, "微信API客户端异常: " + e.getMessage(), null); + log.error("[WeChatAPI] client error, jsCode={}", code, e); + throw new WechatApiException(502, "Wechat API client error: " + e.getMessage(), null); } - // Step 2: HTTP 状态码检查(4xx/5xx 不会抛异常,因为 WechatResponseErrorHandler 处理了) + String responseBody = responseEntity.getBody(); if (!responseEntity.getStatusCode().is2xxSuccessful()) { - log.error("[WeChatAPI] HTTP 非 2xx 响应,status={}", responseEntity.getStatusCode()); + log.error("[WeChatAPI] non-2xx response, status={}, body={}", + responseEntity.getStatusCode(), responseBody); throw new WechatApiException(502, - "微信API返回非2xx状态: " + responseEntity.getStatusCode(), responseEntity.getBody()); + "Wechat API returned non-2xx status: " + responseEntity.getStatusCode(), responseBody); } - // Step 3: Content-Type 检查(防御 WAF 返回 text/plain 拦截页) - MediaType contentType = responseEntity.getHeaders().getContentType(); - if (contentType == null || !MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) { - log.error("[WeChatAPI] Content-Type 不是 JSON,contentType={}, body={}", - contentType, responseEntity.getBody()); - throw new WechatApiException(502, - "微信API返回非JSON Content-Type: " + contentType, responseEntity.getBody()); - } + JSONObject result = parseWechatJsonBody(responseEntity); - // Step 4: JSON 解析 - JSONObject result; - try { - result = JSONObject.parseObject(responseEntity.getBody()); - } catch (Exception e) { - log.error("[WeChatAPI] JSON 解析失败,body={}", responseEntity.getBody(), e); - throw new WechatApiException(500, "微信API响应JSON解析失败: " + e.getMessage(), responseEntity.getBody()); - } - - // Step 5: 业务错误码检查 int errcode = result.getIntValue("errcode"); if (errcode != 0) { String errmsg = result.getString("errmsg"); - log.error("[WeChatAPI] 业务错误,errcode={}, errmsg={}", errcode, errmsg); + log.error("[WeChatAPI] business error, errcode={}, errmsg={}", errcode, errmsg); throw new WechatApiException(400, - "微信API业务错误,errcode=" + errcode + ", errmsg=" + errmsg, responseEntity.getBody()); + "Wechat API business error: errcode=" + errcode + ", errmsg=" + errmsg, responseBody); } - // 成功:提取 openid 和 session_key String openid = result.getString("openid"); String sessionKey = result.getString("session_key"); - if (openid == null || sessionKey == null) { - log.error("[WeChatAPI] 响应缺少必要字段 (openid 或 session_key)"); + if (!StringUtils.hasText(openid) || !StringUtils.hasText(sessionKey)) { + log.error("[WeChatAPI] response missing openid or session_key, body={}", responseBody); throw new WechatApiException(502, - "微信API响应缺少 openid 或 session_key", responseEntity.getBody()); + "Wechat API response missing openid or session_key", responseBody); } WechatCodeSessionResponse response = new WechatCodeSessionResponse(); @@ -102,6 +80,28 @@ public class WechatMiniProgramServiceImpl implements WechatMiniProgramService { return response; } + private JSONObject parseWechatJsonBody(ResponseEntity responseEntity) { + String responseBody = responseEntity.getBody(); + MediaType contentType = responseEntity.getHeaders().getContentType(); + boolean jsonContentType = contentType != null && MediaType.APPLICATION_JSON.isCompatibleWith(contentType); + boolean jsonLikeBody = StringUtils.hasText(responseBody) + && (responseBody.trim().startsWith("{") || responseBody.trim().startsWith("[")); + + if (!jsonContentType && !jsonLikeBody) { + log.error("[WeChatAPI] non-JSON response, contentType={}, body={}", contentType, responseBody); + throw new WechatApiException(502, + "Wechat API returned a non-JSON response: " + contentType, responseBody); + } + + try { + return JSONObject.parseObject(responseBody); + } catch (Exception e) { + log.error("[WeChatAPI] JSON parse failed, contentType={}, body={}", contentType, responseBody, e); + throw new WechatApiException(jsonContentType ? 500 : 502, + "Wechat API response JSON parse failed: " + e.getMessage(), responseBody); + } + } + private void validateConfigured() { if (!StringUtils.hasText(properties.getAppId()) || !StringUtils.hasText(properties.getAppSecret())) { throw new BusinessException("微信小程序登录未配置"); diff --git a/backend-single/src/main/resources/application-prod.yml b/backend-single/src/main/resources/application-prod.yml index 672b00a..c1ff616 100644 --- a/backend-single/src/main/resources/application-prod.yml +++ b/backend-single/src/main/resources/application-prod.yml @@ -65,6 +65,8 @@ emotion: tts: enabled: true engine-url: http://127.0.0.1:19110 + engine-connect-timeout-ms: 5000 + engine-read-timeout-ms: 240000 output-dir: /data/uploads/emotion-museum/tts public-url-prefix: /tts/audio max-text-length: 5000 diff --git a/backend-single/src/main/resources/application.yml b/backend-single/src/main/resources/application.yml index 9fce6b9..1414dcd 100644 --- a/backend-single/src/main/resources/application.yml +++ b/backend-single/src/main/resources/application.yml @@ -73,6 +73,12 @@ emotion: header: Authorization prefix: "Bearer " + wechat: + mini-program: + app-id: ${WECHAT_MINI_PROGRAM_APP_ID:wxaf2eaba72d28f0e4} + app-secret: ${WECHAT_MINI_PROGRAM_APP_SECRET:4f087bd363a4147ef543d634f9f6950d} + base-url: https://api.weixin.qq.com + # Coze API配置 - 所有环境统一 coze: api: @@ -102,6 +108,8 @@ emotion: tts: enabled: true engine-url: http://127.0.0.1:19110 + engine-connect-timeout-ms: 5000 + engine-read-timeout-ms: 240000 output-dir: /data/uploads/emotion-museum/tts public-url-prefix: /tts/audio max-text-length: 5000 diff --git a/backend-single/src/test/java/com/emotion/interceptor/WechatApiLoggingInterceptorTest.java b/backend-single/src/test/java/com/emotion/interceptor/WechatApiLoggingInterceptorTest.java index 8deeb02..696cd4e 100644 --- a/backend-single/src/test/java/com/emotion/interceptor/WechatApiLoggingInterceptorTest.java +++ b/backend-single/src/test/java/com/emotion/interceptor/WechatApiLoggingInterceptorTest.java @@ -3,6 +3,7 @@ package com.emotion.interceptor; import org.junit.jupiter.api.Test; import java.lang.reflect.Method; +import java.net.URI; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -103,4 +104,18 @@ class WechatApiLoggingInterceptorTest { assertTrue(result.contains("\"unionid\":\"***\"")); assertTrue(result.contains("\"refresh_token\":\"***\"")); } + + @Test + void maskSensitiveQuery_shouldMaskWechatCredentialsInUrl() throws Exception { + WechatApiLoggingInterceptor interceptor = new WechatApiLoggingInterceptor(); + Method method = WechatApiLoggingInterceptor.class.getDeclaredMethod("maskSensitiveQuery", URI.class); + method.setAccessible(true); + + String result = (String) method.invoke(interceptor, + URI.create("https://api.weixin.qq.com/sns/jscode2session?appid=wx123&secret=s1&js_code=c1&grant_type=authorization_code")); + + assertTrue(result.contains("appid=wx123")); + assertTrue(result.contains("secret=***")); + assertTrue(result.contains("js_code=***")); + } } diff --git a/backend-single/src/test/java/com/emotion/service/impl/WechatMiniProgramServiceImplTest.java b/backend-single/src/test/java/com/emotion/service/impl/WechatMiniProgramServiceImplTest.java index 453721c..897678e 100644 --- a/backend-single/src/test/java/com/emotion/service/impl/WechatMiniProgramServiceImplTest.java +++ b/backend-single/src/test/java/com/emotion/service/impl/WechatMiniProgramServiceImplTest.java @@ -4,38 +4,29 @@ import com.emotion.config.WechatMiniProgramProperties; import com.emotion.dto.wechat.WechatCodeSessionResponse; import com.emotion.exception.WechatApiException; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.http.*; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.ResourceAccessException; import org.springframework.web.client.RestTemplate; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; -/** - * WechatMiniProgramServiceImpl.code2Session 单元测试 - * - *

10 个测试场景覆盖 5 步防御性处理的所有分支: - *

    - *
  1. 正常 JSON 成功
  2. - *
  3. HTTP 4xx
  4. - *
  5. HTTP 5xx
  6. - *
  7. Content-Type: text/plain
  8. - *
  9. Content-Type: text/html(WAF 拦截页)
  10. - *
  11. JSON 解析失败
  12. - *
  13. 业务错误码 40029(invalid code)
  14. - *
  15. 业务错误码 40163(code been used)
  16. - *
  17. 网络异常(ResourceAccessException)
  18. - *
  19. 响应缺少 session_key
  20. - *
- */ @ExtendWith(MockitoExtension.class) class WechatMiniProgramServiceImplTest { @@ -56,13 +47,9 @@ class WechatMiniProgramServiceImplTest { } @Test - @DisplayName("1. 正常 JSON 成功 - 返回 openid 和 session_key") - void code2Session_NormalJsonSuccess_ReturnsOpenIdAndSessionKey() { - String successBody = "{\"errcode\":0,\"openid\":\"oXyz123\",\"session_key\":\"abc456\"}"; - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - ResponseEntity responseEntity = new ResponseEntity<>(successBody, headers, HttpStatus.OK); - when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity); + void code2Session_applicationJsonSuccess_returnsOpenIdAndSessionKey() { + mockResponse(HttpStatus.OK, MediaType.APPLICATION_JSON, + "{\"errcode\":0,\"openid\":\"oXyz123\",\"session_key\":\"abc456\"}"); WechatCodeSessionResponse result = service.code2Session("validJsCode"); @@ -73,152 +60,126 @@ class WechatMiniProgramServiceImplTest { } @Test - @DisplayName("2. HTTP 4xx - 抛 WechatApiException(502)") - void code2Session_Http4xx_ThrowsWechatApiException_502() { - ResponseEntity responseEntity = new ResponseEntity<>("", HttpStatus.BAD_REQUEST); - when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity); + void code2Session_http4xx_throwsWechatApiException502() { + mockResponse(HttpStatus.BAD_REQUEST, null, ""); + + WechatApiException ex = assertThrows(WechatApiException.class, () -> service.code2Session("code")); - WechatApiException ex = assertThrows(WechatApiException.class, - () -> service.code2Session("code")); assertEquals(502, ex.getCode()); - assertTrue(ex.getMessage().contains("非2xx") || ex.getMessage().contains("HTTP")); + assertTrue(ex.getMessage().contains("non-2xx") || ex.getMessage().contains("status")); } @Test - @DisplayName("3. HTTP 5xx - 抛 WechatApiException(502)") - void code2Session_Http5xx_ThrowsWechatApiException_502() { - ResponseEntity responseEntity = new ResponseEntity<>("", HttpStatus.INTERNAL_SERVER_ERROR); - when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity); + void code2Session_http5xx_throwsWechatApiException502() { + mockResponse(HttpStatus.INTERNAL_SERVER_ERROR, null, ""); + + WechatApiException ex = assertThrows(WechatApiException.class, () -> service.code2Session("code")); - WechatApiException ex = assertThrows(WechatApiException.class, - () -> service.code2Session("code")); assertEquals(502, ex.getCode()); } @Test - @DisplayName("4. Content-Type: text/plain - 抛 WechatApiException(502)") - void code2Session_ContentTypeTextPlain_ThrowsWechatApiException_502() { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.TEXT_PLAIN); - String body = "WAF Challenge"; - ResponseEntity responseEntity = new ResponseEntity<>(body, headers, HttpStatus.OK); - when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity); + void code2Session_textPlainJsonSuccess_returnsOpenIdAndSessionKey() { + mockResponse(HttpStatus.OK, MediaType.TEXT_PLAIN, + "{\"session_key\":\"abc456\",\"openid\":\"oXyz123\"}"); - WechatApiException ex = assertThrows(WechatApiException.class, - () -> service.code2Session("code")); - assertEquals(502, ex.getCode()); - assertTrue(ex.getMessage().contains("Content-Type") || ex.getMessage().contains("JSON")); + WechatCodeSessionResponse result = service.code2Session("code"); + + assertNotNull(result); + assertEquals("oXyz123", result.getOpenid()); + assertEquals("abc456", result.getSessionKey()); } @Test - @DisplayName("5. Content-Type: text/html(WAF 拦截页) - 抛 WechatApiException(502)") - void code2Session_ContentTypeTextHtml_WafChallenge_ThrowsWechatApiException_502() { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.TEXT_HTML); - String body = "WAF Challenge"; - ResponseEntity responseEntity = new ResponseEntity<>(body, headers, HttpStatus.OK); - when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity); + void code2Session_textHtmlWafChallenge_throwsWechatApiException502() { + mockResponse(HttpStatus.OK, MediaType.TEXT_HTML, "WAF Challenge"); + + WechatApiException ex = assertThrows(WechatApiException.class, () -> service.code2Session("code")); - WechatApiException ex = assertThrows(WechatApiException.class, - () -> service.code2Session("code")); assertEquals(502, ex.getCode()); } @Test - @DisplayName("6. JSON 解析失败 - 抛 WechatApiException(500)") - void code2Session_JsonParseFailure_ThrowsWechatApiException_500() { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - String body = "not valid json{"; - ResponseEntity responseEntity = new ResponseEntity<>(body, headers, HttpStatus.OK); - when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity); + void code2Session_applicationJsonParseFailure_throwsWechatApiException500() { + mockResponse(HttpStatus.OK, MediaType.APPLICATION_JSON, "not valid json{"); + + WechatApiException ex = assertThrows(WechatApiException.class, () -> service.code2Session("code")); - WechatApiException ex = assertThrows(WechatApiException.class, - () -> service.code2Session("code")); assertEquals(500, ex.getCode()); assertTrue(ex.getMessage().contains("JSON")); } @Test - @DisplayName("7. 业务错误码 40029(invalid code) - 抛 WechatApiException(400)") - void code2Session_BusinessErrorCode40029_InvalidCode_ThrowsWechatApiException_400() { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - String body = "{\"errcode\":40029,\"errmsg\":\"invalid code\"}"; - ResponseEntity responseEntity = new ResponseEntity<>(body, headers, HttpStatus.OK); - when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity); + void code2Session_businessErrorInvalidCode_throwsWechatApiException400() { + mockResponse(HttpStatus.OK, MediaType.APPLICATION_JSON, + "{\"errcode\":40029,\"errmsg\":\"invalid code\"}"); + + WechatApiException ex = assertThrows(WechatApiException.class, () -> service.code2Session("invalidCode")); - WechatApiException ex = assertThrows(WechatApiException.class, - () -> service.code2Session("invalidCode")); assertEquals(400, ex.getCode()); assertTrue(ex.getMessage().contains("40029") || ex.getMessage().contains("invalid")); } @Test - @DisplayName("8. 业务错误码 40163(code been used) - 抛 WechatApiException(400)") - void code2Session_BusinessErrorCode40163_CodeUsed_ThrowsWechatApiException_400() { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - String body = "{\"errcode\":40163,\"errmsg\":\"code been used\"}"; - ResponseEntity responseEntity = new ResponseEntity<>(body, headers, HttpStatus.OK); - when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity); + void code2Session_businessErrorCodeUsed_throwsWechatApiException400() { + mockResponse(HttpStatus.OK, MediaType.APPLICATION_JSON, + "{\"errcode\":40163,\"errmsg\":\"code been used\"}"); + + WechatApiException ex = assertThrows(WechatApiException.class, () -> service.code2Session("usedCode")); - WechatApiException ex = assertThrows(WechatApiException.class, - () -> service.code2Session("usedCode")); assertEquals(400, ex.getCode()); } @Test - @DisplayName("9. 网络异常(ResourceAccessException) - 抛 WechatApiException(502)") - void code2Session_NetworkException_ResourceAccessException_ThrowsWechatApiException_502() { + void code2Session_resourceAccessException_throwsWechatApiException502() { when(restTemplate.getForEntity(anyString(), eq(String.class))) - .thenThrow(new ResourceAccessException("Connection refused")); + .thenThrow(new ResourceAccessException("Connection refused")); + + WechatApiException ex = assertThrows(WechatApiException.class, () -> service.code2Session("code")); - WechatApiException ex = assertThrows(WechatApiException.class, - () -> service.code2Session("code")); assertEquals(502, ex.getCode()); - assertTrue(ex.getMessage().contains("网络") || ex.getMessage().contains("network")); + assertTrue(ex.getMessage().contains("network")); } @Test - @DisplayName("10. 响应缺少 session_key - 抛 WechatApiException(502)") - void code2Session_MissingSessionKey_ThrowsWechatApiException_502() { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - String body = "{\"errcode\":0,\"openid\":\"oXyz123\"}"; - ResponseEntity responseEntity = new ResponseEntity<>(body, headers, HttpStatus.OK); - when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity); + void code2Session_missingSessionKey_throwsWechatApiException502() { + mockResponse(HttpStatus.OK, MediaType.APPLICATION_JSON, + "{\"errcode\":0,\"openid\":\"oXyz123\"}"); + + WechatApiException ex = assertThrows(WechatApiException.class, () -> service.code2Session("code")); - WechatApiException ex = assertThrows(WechatApiException.class, - () -> service.code2Session("code")); assertEquals(502, ex.getCode()); assertTrue(ex.getMessage().contains("session_key")); } @Test - @DisplayName("11. 其他 RestClientException(非 ResourceAccessException)- 抛 WechatApiException(502)") - void code2Session_OtherRestClientException_ThrowsWechatApiException_502() { + void code2Session_otherRestClientException_throwsWechatApiException502() { when(restTemplate.getForEntity(anyString(), eq(String.class))) - .thenThrow(new HttpClientErrorException(HttpStatus.FORBIDDEN, "Forbidden", null, null, null)); + .thenThrow(new HttpClientErrorException(HttpStatus.FORBIDDEN, "Forbidden", null, null, null)); + + WechatApiException ex = assertThrows(WechatApiException.class, () -> service.code2Session("code")); - WechatApiException ex = assertThrows(WechatApiException.class, - () -> service.code2Session("code")); assertEquals(502, ex.getCode()); - assertTrue(ex.getMessage().contains("RestClient") || ex.getMessage().contains("客户端")); + assertTrue(ex.getMessage().contains("client error") || ex.getMessage().contains("403")); } @Test - @DisplayName("12. 响应缺少 openid(有 session_key)- 抛 WechatApiException(502)") - void code2Session_MissingOpenId_ThrowsWechatApiException_502() { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - String body = "{\"errcode\":0,\"session_key\":\"abc456\"}"; // no openid - ResponseEntity responseEntity = new ResponseEntity<>(body, headers, HttpStatus.OK); - when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity); + void code2Session_missingOpenId_throwsWechatApiException502() { + mockResponse(HttpStatus.OK, MediaType.APPLICATION_JSON, + "{\"errcode\":0,\"session_key\":\"abc456\"}"); + + WechatApiException ex = assertThrows(WechatApiException.class, () -> service.code2Session("code")); - WechatApiException ex = assertThrows(WechatApiException.class, - () -> service.code2Session("code")); assertEquals(502, ex.getCode()); assertTrue(ex.getMessage().contains("openid") || ex.getMessage().contains("session_key")); } + + private void mockResponse(HttpStatus status, MediaType contentType, String body) { + HttpHeaders headers = new HttpHeaders(); + if (contentType != null) { + headers.setContentType(contentType); + } + ResponseEntity responseEntity = new ResponseEntity<>(body, headers, status); + when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity); + } } diff --git a/docs/superpowers/plans/2026-06-03-login-route-fix.md b/docs/superpowers/plans/2026-06-03-login-route-fix.md new file mode 100644 index 0000000..5384057 --- /dev/null +++ b/docs/superpowers/plans/2026-06-03-login-route-fix.md @@ -0,0 +1,167 @@ +# 登录路由跳转修复 Implementation Plan + +> **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:** 修复小程序登录成功后错误进入编辑资料页的问题,确保登录后统一进入爽文生成页面。 + +**Architecture:** 移除登录流程中的 `hasProfile` 分支判断,将 `login/index.vue` 和 `splash/index.vue` 中的登录后路由固定指向 `/pages/main/index?tab=script`。编辑资料页面保留,仅通过"我的" tab 主动进入。 + +**Tech Stack:** Vue 3, uni-app, JavaScript + +--- + +### Task 1: 修复 login/index.vue 登录后跳转逻辑 + +**Files:** +- Modify: `mini-program/src/pages/login/index.vue:152-158` +- Modify: `mini-program/src/pages/login/index.vue:187` +- Modify: `mini-program/src/pages/login/index.vue:209` + +**Context:** 当前 `routeAfterLogin` 函数根据 `profileReady` 参数分支:有资料去爽文生成页,无资料去编辑资料页。需要移除该判断,统一跳转至爽文生成页。 + +- [ ] **Step 1: 修改 `routeAfterLogin` 函数,移除 `profileReady` 参数和条件分支** + +将: +```javascript +const routeAfterLogin = (profileReady) => { + if (profileReady) { + uni.redirectTo({ url: '/pages/main/index?tab=script' }) + } else { + uni.redirectTo({ url: '/pages/onboarding/index' }) + } +} +``` + +改为: +```javascript +const routeAfterLogin = () => { + uni.redirectTo({ url: '/pages/main/index?tab=script' }) +} +``` + +- [ ] **Step 2: 修改 `handleWechatLogin` 中的调用,移除参数** + +将: +```javascript +routeAfterLogin(result.hasProfile) +``` + +改为: +```javascript +routeAfterLogin() +``` + +- [ ] **Step 3: 修改 `handleLogin` 中的调用,移除参数** + +将: +```javascript +routeAfterLogin(result.hasProfile) +``` + +改为: +```javascript +routeAfterLogin() +``` + +- [ ] **Step 4: Commit** + +```bash +git add mini-program/src/pages/login/index.vue +git commit -m "fix(login): remove profile check, always redirect to script page after login" +``` + +--- + +### Task 2: 修复 splash/index.vue 会话恢复后的跳转逻辑 + +**Files:** +- Modify: `mini-program/src/pages/splash/index.vue:54-56` + +**Context:** `resolveInitialRoute` 在检测到已登录用户时,会根据 `session.hasProfile` 决定跳转到爽文生成页或编辑资料页。需要移除该分支。 + +- [ ] **Step 1: 修改已登录用户的跳转目标** + +将: +```javascript +if (session.status === store.SESSION_STATUS.AUTHENTICATED) { + const target = session.hasProfile ? '/pages/main/index?tab=script' : '/pages/onboarding/index' + routeOnce(target, { reason: session.reason, hasProfile: session.hasProfile }) + return +} +``` + +改为: +```javascript +if (session.status === store.SESSION_STATUS.AUTHENTICATED) { + routeOnce('/pages/main/index?tab=script', { reason: session.reason }) + return +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add mini-program/src/pages/splash/index.vue +git commit -m "fix(splash): remove profile check on session restore, always go to script page" +``` + +--- + +### Task 3: 验证修复 + +**Files:** +- Read: `mini-program/src/pages/login/index.vue` +- Read: `mini-program/src/pages/splash/index.vue` + +- [ ] **Step 1: 验证 login/index.vue 中无残留的分支逻辑** + +运行: +```bash +grep -n "onboarding" mini-program/src/pages/login/index.vue +``` + +预期:无输出(`onboarding` 不再被 `login/index.vue` 引用) + +- [ ] **Step 2: 验证 splash/index.vue 中无残留的分支逻辑** + +运行: +```bash +grep -n "onboarding" mini-program/src/pages/splash/index.vue +``` + +预期:无输出(`onboarding` 不再被 `splash/index.vue` 引用) + +- [ ] **Step 3: 确认 `onboarding` 页面本身未被修改** + +运行: +```bash +git diff --name-only +``` + +预期:输出不包含 `mini-program/src/pages/onboarding/index.vue` + +- [ ] **Step 4: 最终提交(如需要)** + +```bash +git log --oneline -3 +``` + +预期:显示两次提交,分别为 login 和 splash 的修复。 + +--- + +## Self-Review Checklist + +**1. Spec coverage:** +- ✅ `login/index.vue` 的 `routeAfterLogin` 及两处调用 — Task 1 +- ✅ `splash/index.vue` 的会话恢复路由分支 — Task 2 +- ✅ `hasProfile` 和 `onboarding` 页面保留不动 — 确认无相关修改 +- ✅ 测试验证点 — Task 3 + +**2. Placeholder scan:** +- ✅ 无 "TBD"/"TODO" 等占位符 +- ✅ 每步均含完整代码和命令 +- ✅ 无 "类似 Task N" 的引用 + +**3. Type consistency:** +- ✅ 文件路径和函数名与 spec 及代码库一致 diff --git a/docs/superpowers/plans/2026-06-03-mine-view-dynamic-data.md b/docs/superpowers/plans/2026-06-03-mine-view-dynamic-data.md new file mode 100644 index 0000000..dfd4c32 --- /dev/null +++ b/docs/superpowers/plans/2026-06-03-mine-view-dynamic-data.md @@ -0,0 +1,241 @@ +# 个人中心页面动态数据修复 Implementation Plan + +> **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:** 修复"我的"页面显示写死信息的问题,改为从 store 动态读取用户头像、昵称、身份标签和统计数据。 + +**Architecture:** 仅修改 `MineView.vue` 一个文件。复用 `main/index.vue` 已加载的 store 数据(`userProfile` 和 `events`),通过新增 computed 属性生成头像 URL、觉醒深度等级和星历契合百分比,替换模板中的硬编码值。 + +**Tech Stack:** Vue 3, uni-app, JavaScript + +--- + +### Task 1: 替换头像为动态生成头像 + +**Files:** +- Modify: `mini-program/src/pages/main/MineView.vue:12-19`(模板) +- Modify: `mini-program/src/pages/main/MineView.vue:64-78`(script) + +**Context:** 当前头像区域用 `.person-head` 和 `.person-body` 两个 div 画了一个通用人形。需要替换为 `` 组件显示 Dicebear 根据昵称生成的头像。 + +- [ ] **Step 1: 在 template 中替换 CSS 人形为 image 组件** + +将: +```html + + + + +``` + +改为: +```html + + + +``` + +- [ ] **Step 2: 在 script 中新增 `avatarUrl` computed** + +在 `profile` computed 之后、`displayName` 之前添加: + +```javascript +const avatarUrl = computed(() => { + const seed = profile.value.nickname || 'user' + return `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(seed)}&backgroundColor=b982ff` +}) +``` + +- [ ] **Step 3: 给 avatar-img 添加样式(在 style 区块末尾追加)** + +```css +.avatar-img { + width: 100%; + height: 100%; + border-radius: 50%; +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add mini-program/src/pages/main/MineView.vue && git commit -m "fix(mine): replace static avatar with dynamic dicebear image" +``` + +--- + +### Task 2: 替换写死的用户信息与统计数据 + +**Files:** +- Modify: `mini-program/src/pages/main/MineView.vue:71-78`(displayName + metaLine) +- Modify: `mini-program/src/pages/main/MineView.vue:24-33`(stats-grid 模板) + +**Context:** `displayName` fallback 为 `'张义明'`,`metaLine` fallback 为 `'ENFJ · 狮子座 · 星民'`,统计数值 `Lv.4` 和 `98%` 硬编码。 + +- [ ] **Step 1: 修改 `displayName` 的 fallback** + +将: +```javascript +const displayName = computed(() => profile.value.nickname || '张义明') +``` + +改为: +```javascript +const displayName = computed(() => profile.value.nickname || '未设置昵称') +``` + +- [ ] **Step 2: 修改 `metaLine` computed** + +将: +```javascript +const metaLine = computed(() => { + const mbti = profile.value.mbti || 'ENFJ' + const zodiac = profile.value.zodiac || '狮子座' + const identity = profile.value.profession || '星民' + return `${mbti} · ${zodiac} · ${identity}` +}) +``` + +改为: +```javascript +const metaLine = computed(() => { + const mbti = profile.value.mbti + const zodiac = profile.value.zodiac + const identity = profile.value.profession + if (!mbti && !zodiac && !identity) { + return '点击设置个人档案' + } + return `${mbti || ''} · ${zodiac || ''} · ${identity || ''}`.replace(/(^ · | · $)/g, '').replace(/ · · /g, ' · ') +}) +``` + +- [ ] **Step 3: 新增 `awakenLevel` computed** + +在 `metaLine` 之后添加: + +```javascript +const awakenLevel = computed(() => { + const count = store.events?.length || 0 + if (count >= 10) return 'Lv.5' + if (count >= 6) return 'Lv.4' + if (count >= 3) return 'Lv.3' + if (count >= 1) return 'Lv.2' + return 'Lv.1' +}) +``` + +- [ ] **Step 4: 新增 `harmonyPercent` computed** + +在 `awakenLevel` 之后添加: + +```javascript +const harmonyPercent = computed(() => { + const hasLifeStory = profile.value.childhood?.text || + profile.value.joy?.text || + profile.value.low?.text || + profile.value.future?.ideal + const fields = [ + profile.value.nickname, + profile.value.gender, + profile.value.zodiac, + profile.value.mbti, + profile.value.profession, + profile.value.hobbies?.length, + hasLifeStory + ] + const filled = fields.filter(Boolean).length + return Math.round((filled / 7) * 100) + '%' +}) +``` + +- [ ] **Step 5: 替换模板中的硬编码统计值** + +将: +```html + + + 觉醒深度 + Lv.4 + + + 星历契合 + 98% + + +``` + +改为: +```html + + + 觉醒深度 + {{ awakenLevel }} + + + 星历契合 + {{ harmonyPercent }} + + +``` + +- [ ] **Step 6: Commit** + +```bash +git add mini-program/src/pages/main/MineView.vue && git commit -m "fix(mine): replace hardcoded user info and stats with dynamic store data" +``` + +--- + +### Task 3: 验证修复 + +**Files:** +- Read: `mini-program/src/pages/main/MineView.vue` + +- [ ] **Step 1: 确认模板中无硬编码用户信息** + +运行: +```bash +grep -n "张义明\|ENFJ\|狮子座\|星民\|Lv\.4\|98%" mini-program/src/pages/main/MineView.vue +``` + +预期:无输出(所有硬编码值已被替换) + +- [ ] **Step 2: 确认 `` 和 computed 属性存在** + +运行: +```bash +grep -n "avatarUrl\|awakenLevel\|harmonyPercent" mini-program/src/pages/main/MineView.vue +``` + +预期:输出包含 `avatarUrl`、`awakenLevel`、`harmonyPercent` 的定义和引用 + +- [ ] **Step 3: 查看提交记录** + +运行: +```bash +git log --oneline -3 +``` + +预期:显示两次提交,分别为头像修复和统计数据修复 + +--- + +## Self-Review Checklist + +**1. Spec coverage:** +- ✅ 头像替换为 Dicebear 动态生成 — Task 1 +- ✅ displayName fallback 改为 `'未设置昵称'` — Task 2 Step 1 +- ✅ metaLine 无资料时显示 `'点击设置个人档案'` — Task 2 Step 2 +- ✅ 觉醒深度基于 `store.events.length` — Task 2 Step 3 +- ✅ 星历契合基于资料完整度 — Task 2 Step 4 +- ✅ 模板硬编码值替换 — Task 2 Step 5 +- ✅ 验证步骤 — Task 3 + +**2. Placeholder scan:** +- ✅ 无 "TBD"/"TODO" 等占位符 +- ✅ 每步均含完整代码和命令 +- ✅ 无 "类似 Task N" 的引用 + +**3. Type consistency:** +- ✅ 字段名(`nickname`、`mbti`、`zodiac`、`profession`、`events`、`hobbies`、`childhood`、`joy`、`low`、`future`)与 store 和 spec 一致 +- ✅ `avatarUrl` 生成逻辑与 onboarding 中一致 diff --git a/mini-program/src/composables/useTtsPlayer.js b/mini-program/src/composables/useTtsPlayer.js index 7086292..2b64a64 100644 --- a/mini-program/src/composables/useTtsPlayer.js +++ b/mini-program/src/composables/useTtsPlayer.js @@ -2,7 +2,13 @@ import { computed, onUnmounted, ref } from 'vue' import { createTtsTask, getTtsTask, getTtsTaskBySource } from '../services/tts.js' import analytics from '../services/analytics.js' -const readResponseData = (response) => response?.data ?? response ?? null +const readResponseData = (response) => { + if (!response) return null + if (Object.prototype.hasOwnProperty.call(response, 'data')) { + return response.data ?? null + } + return response +} export const useTtsPlayer = ({ pagePath = '', sourceType = 'epic_script' } = {}) => { const task = ref(null) @@ -11,6 +17,8 @@ export const useTtsPlayer = ({ pagePath = '', sourceType = 'epic_script' } = {}) const statusText = ref('') let audio = null let timer = null + let prewarmPromise = null + let prewarmSourceId = '' const buttonText = computed(() => { if (loading.value) return '正在生成朗读' @@ -155,6 +163,49 @@ export const useTtsPlayer = ({ pagePath = '', sourceType = 'epic_script' } = {}) } } + const prewarmSource = async (sourceId) => { + if (!sourceId) return null + if (task.value?.status === 'success' && task.value?.sourceId === sourceId) { + return task.value + } + if (prewarmPromise && prewarmSourceId === sourceId) { + return prewarmPromise + } + + prewarmSourceId = sourceId + prewarmPromise = (async () => { + try { + const existingResponse = await getTtsTaskBySource({ sourceType, sourceId }) + const existingTask = readResponseData(existingResponse) + if (existingTask?.id) { + setTask(existingTask) + } + if (existingTask?.status === 'success' || existingTask?.status === 'pending' || existingTask?.status === 'processing') { + return existingTask + } + } catch (error) { + // Old scripts may not have a task yet; create one silently below. + } + + try { + const createResponse = await createTtsTask({ sourceType, sourceId }) + const nextTask = readResponseData(createResponse) + if (nextTask?.id) { + setTask(nextTask) + } + return nextTask + } catch (error) { + track('script_tts_error', sourceId, { error: error?.message || error?.errMsg || 'prewarm failed' }) + return null + } finally { + prewarmPromise = null + prewarmSourceId = '' + } + })() + + return prewarmPromise + } + const playSource = async (sourceId) => { if (!sourceId) { uni.showToast({ title: '生成保存后可播放', icon: 'none' }) @@ -162,6 +213,21 @@ export const useTtsPlayer = ({ pagePath = '', sourceType = 'epic_script' } = {}) } if (loading.value) return + if (prewarmPromise && prewarmSourceId === sourceId) { + const warmedTask = await prewarmPromise + if (warmedTask?.status === 'success') { + setTask(warmedTask) + play(sourceId) + return + } + if ((warmedTask?.status === 'pending' || warmedTask?.status === 'processing') && warmedTask?.id) { + setTask(warmedTask) + loading.value = true + pollTask(warmedTask.id, sourceId) + return + } + } + if (task.value?.status === 'success') { play(sourceId) return @@ -195,6 +261,8 @@ export const useTtsPlayer = ({ pagePath = '', sourceType = 'epic_script' } = {}) setTask(null) statusText.value = '' loading.value = false + prewarmPromise = null + prewarmSourceId = '' } onUnmounted(reset) @@ -205,6 +273,7 @@ export const useTtsPlayer = ({ pagePath = '', sourceType = 'epic_script' } = {}) playing, statusText, buttonText, + prewarmSource, playSource, reset } diff --git a/mini-program/src/pages/life-event/detail.vue b/mini-program/src/pages/life-event/detail.vue index 2dd5e9b..6c8287c 100644 --- a/mini-program/src/pages/life-event/detail.vue +++ b/mini-program/src/pages/life-event/detail.vue @@ -45,23 +45,14 @@ - - - {{ isFavorite ? '已收藏' : '收藏' }} + + {{ isFavorite ? '★' : '☆' }} + {{ isFavorite ? '已收藏' : '收藏' }} - - - 分析 - - + @@ -75,12 +66,9 @@ - - - 编辑 - - - {{ isDescriptionCollapsed ? '展开' : '收起' }} ^ + ✎ 编辑 + + {{ isDescriptionCollapsed ? '展开 ▼' : '收起 ▲' }} @@ -238,8 +226,18 @@ const locationText = computed(() => { return profile.value.city ? `中国 · ${profile.value.city}` : '中国 · 深圳' }) + // 将【】格式标题转换为 Markdown # 标题格式 +const convertToMarkdown = (text) => { + if (!text) return text + // 将 "【数字 标题】" 或 "【标题】" 转换为 "# 标题",保留完整内容 + return text.replace(/^【\s*(.+?)\s*】$/gm, (match, title) => { + return `# ${title.trim()}` + }) +} + const eventDescription = computed(() => { - return displayEvent.value.content || displayEvent.value.description || '经过了长时间的纠结和准备,我终于辞去了稳定的工作,全职投入到自己热爱的AI产品创业中。这是我人生中最大的一次冒险,也是我第一次真正为自己而活。' + const raw = displayEvent.value.content || displayEvent.value.description || '经过了长时间的纠结和准备,我终于辞去了稳定的工作,全职投入到自己热爱的AI产品创业中。这是我人生中最大的一次冒险,也是我第一次真正为自己而活。' + return convertToMarkdown(raw) }) const relatedTags = computed(() => { @@ -300,16 +298,6 @@ const onContentScroll = (event) => { currentScrollTop.value = event.detail?.scrollTop || 0 } -const scrollToAnalysis = () => { - uni.createSelectorQuery() - .select('#analysisPanel') - .boundingClientRect((rect) => { - if (!rect) return - scrollTop.value = Math.max(0, currentScrollTop.value + rect.top - 24) - }) - .exec() -} - const editEvent = () => { if (!eventId.value) return uni.navigateTo({ url: `/pages/life-event/form?id=${eventId.value}` }) @@ -652,55 +640,37 @@ const goBack = () => { display: flex; align-items: center; justify-content: space-between; - gap: 18rpx; + gap: 12rpx; } .event-body-toolbar { - min-height: 64rpx; + min-height: 48rpx; } -.toolbar-right { +.action-btn { display: flex; align-items: center; - gap: 14rpx; + gap: 8rpx; + color: rgba(238, 231, 255, 0.72); + font-size: 20rpx; + font-weight: 700; + line-height: 1; + padding: 8rpx 0; } -.content-action { - min-height: 64rpx; - display: flex; - align-items: center; - justify-content: center; - gap: 10rpx; - color: rgba(238, 231, 255, 0.88); - font-size: 23rpx; - font-weight: 800; +.action-icon { + font-size: 26rpx; + line-height: 1; + color: rgba(238, 231, 255, 0.82); } -.favorite-action, -.share-action, -.edit-action { - padding: 0 4rpx; -} - -.analysis-pill { - min-width: 92rpx; - height: 54rpx; - padding: 0 24rpx; - box-sizing: border-box; - border-radius: 0 18rpx 18rpx 18rpx; - display: flex; - align-items: center; - justify-content: center; - color: rgba(231, 217, 255, 0.86); - font-size: 23rpx; - font-weight: 900; - background: rgba(157, 104, 255, 0.18); - border: 1rpx solid rgba(193, 154, 255, 0.24); - box-shadow: inset 0 0 18rpx rgba(185, 124, 255, 0.08); +.favorite-btn .action-icon { + color: #ffd36e; } .description-title-row { margin-top: 16rpx; + position: relative; } .section-title-row { @@ -735,25 +705,28 @@ const goBack = () => { .description-markdown { margin-top: 22rpx; + border-radius: 22rpx; + padding: 24rpx; + background: rgba(255, 255, 255, 0.042); + border: 1rpx solid rgba(180, 139, 255, 0.18); overflow: hidden; } .description-markdown.collapsed { - max-height: 168rpx; + max-height: 280rpx; } .event-body-footer { - margin-top: 18rpx; + margin-top: 14rpx; } -.collapse-action { - min-height: 64rpx; - display: flex; - align-items: center; - justify-content: flex-end; - color: #a970ff; - font-size: 24rpx; - font-weight: 800; +.edit-link, +.collapse-link { + color: rgba(169, 112, 255, 0.78); + font-size: 22rpx; + font-weight: 700; + line-height: 1; + padding: 8rpx 0; } .analysis-panel { @@ -930,101 +903,5 @@ const goBack = () => { font-weight: 900; } -.edit-icon { - position: relative; - width: 34rpx; - height: 34rpx; - border-left: 4rpx solid currentColor; - border-bottom: 4rpx solid currentColor; - transform: skew(-12deg); -} - -.edit-icon::after { - content: ''; - position: absolute; - left: 4rpx; - bottom: 7rpx; - width: 31rpx; - height: 5rpx; - border-radius: 999rpx; - background: currentColor; - transform: rotate(-45deg); -} - -.bookmark-icon { - width: 26rpx; - height: 34rpx; - border: 4rpx solid currentColor; - border-bottom: 0; - border-radius: 5rpx 5rpx 0 0; - position: relative; - box-sizing: border-box; -} - -.bookmark-icon::after { - content: ''; - position: absolute; - left: 3rpx; - right: 3rpx; - bottom: -8rpx; - height: 16rpx; - background: #0d0d2b; - transform: rotate(45deg); - border-right: 4rpx solid currentColor; - border-bottom: 4rpx solid currentColor; -} - -.bookmark-icon.active { - color: #ffd36e; -} - -.share-icon { - position: relative; - width: 38rpx; - height: 34rpx; -} - -.share-icon view { - position: absolute; - width: 9rpx; - height: 9rpx; - border: 4rpx solid currentColor; - border-radius: 50%; -} - -.share-icon view:nth-child(1) { - left: 0; - top: 12rpx; -} - -.share-icon view:nth-child(2) { - right: 0; - top: 0; -} - -.share-icon view:nth-child(3) { - right: 0; - bottom: 0; -} - -.share-icon::before, -.share-icon::after { - content: ''; - position: absolute; - left: 12rpx; - width: 22rpx; - height: 3rpx; - background: currentColor; - transform-origin: left center; -} - -.share-icon::before { - top: 14rpx; - transform: rotate(-28deg); -} - -.share-icon::after { - bottom: 12rpx; - transform: rotate(28deg); -} +/* 操作按钮使用 Unicode 图标,无需自定义 CSS 图标 */ diff --git a/mini-program/src/pages/main/MineView.vue b/mini-program/src/pages/main/MineView.vue index a928024..e622aff 100644 --- a/mini-program/src/pages/main/MineView.vue +++ b/mini-program/src/pages/main/MineView.vue @@ -146,17 +146,14 @@ const terminateLifeHarmony = () => { box-sizing: border-box; overflow: hidden; color: #fff; - background: - radial-gradient(circle at 50% 18%, rgba(125, 73, 255, 0.22), transparent 36%), - radial-gradient(circle at 10% 74%, rgba(24, 88, 156, 0.2), transparent 32%), - linear-gradient(180deg, #120a36 0%, #05061c 46%, #03020d 100%); + background: transparent; } .profile-center::before { content: ''; position: absolute; left: 50%; - top: -80rpx; + top: 80rpx; width: 720rpx; height: 720rpx; transform: translateX(-50%); diff --git a/mini-program/src/pages/main/ScriptDetailView.vue b/mini-program/src/pages/main/ScriptDetailView.vue index da11149..d030ae3 100644 --- a/mini-program/src/pages/main/ScriptDetailView.vue +++ b/mini-program/src/pages/main/ScriptDetailView.vue @@ -123,6 +123,9 @@ const loadScript = async () => { await store.fetchScripts() script.value = store.getScriptById(scriptId.value) } + if (script.value?.id) { + ttsPlayer.prewarmSource(script.value.id) + } } const continueCurrent = () => { diff --git a/mini-program/src/pages/main/ScriptLibraryView.vue b/mini-program/src/pages/main/ScriptLibraryView.vue index 342c98a..d537a2a 100644 --- a/mini-program/src/pages/main/ScriptLibraryView.vue +++ b/mini-program/src/pages/main/ScriptLibraryView.vue @@ -1,5 +1,5 @@ @@ -126,6 +157,9 @@ const keyword = ref('') const sortMode = ref('updated') const viewMode = ref('list') const localFavorites = ref(uni.getStorageSync('script_favorites') || {}) +const activeMenuId = ref('') +const deleteTarget = ref(null) +const deletingScript = ref(false) const typeTabs = [ { label: '长篇', value: 'long' }, @@ -350,23 +384,73 @@ const toggleViewMode = () => { viewMode.value = viewMode.value === 'list' ? 'grid' : 'list' } -const openScriptMenu = (script) => { +const closeScriptMenu = () => { + activeMenuId.value = '' +} + +const toggleScriptMenu = (script) => { + const id = String(script?.id || '') + activeMenuId.value = activeMenuId.value === id ? '' : id +} + +const toggleFavorite = (script) => { const favorite = isFavorite(script) - uni.showActionSheet({ - itemList: [favorite ? '取消收藏' : '收藏剧本', '继续生成', '查看详情'], - success: ({ tapIndex }) => { - if (tapIndex === 0) { - const next = { ...localFavorites.value } - if (favorite) delete next[String(script.id)] - else next[String(script.id)] = true - localFavorites.value = next - uni.setStorageSync('script_favorites', next) - uni.showToast({ title: favorite ? '已取消收藏' : '已收藏', icon: 'success' }) - } - if (tapIndex === 1) viewScript(script) - if (tapIndex === 2) openScriptDetail(script) - } - }) + const next = { ...localFavorites.value } + if (favorite) delete next[String(script.id)] + else next[String(script.id)] = true + localFavorites.value = next + uni.setStorageSync('script_favorites', next) + closeScriptMenu() + uni.showToast({ title: favorite ? '已取消收藏' : '已收藏', icon: 'success' }) +} + +const continueScript = (script) => { + closeScriptMenu() + viewScript(script) +} + +const openScriptDetailFromMenu = (script) => { + closeScriptMenu() + openScriptDetail(script) +} + +const requestDeleteScript = (script) => { + closeScriptMenu() + if (!script?.id || String(script.id).startsWith('demo-')) { + uni.showToast({ title: '示例剧本不可删除', icon: 'none' }) + return + } + deleteTarget.value = script +} + +const cancelDeleteScript = () => { + if (deletingScript.value) return + deleteTarget.value = null +} + +const confirmDeleteScript = async () => { + if (!deleteTarget.value?.id || deletingScript.value) return + deletingScript.value = true + const id = String(deleteTarget.value.id) + const res = await store.deleteScript(id) + deletingScript.value = false + + if (!res.success) { + uni.showToast({ title: res.error || '删除失败', icon: 'none' }) + return + } + + const next = { ...localFavorites.value } + delete next[id] + localFavorites.value = next + uni.setStorageSync('script_favorites', next) + const conversationId = deleteTarget.value.conversationId || deleteTarget.value.plotJson?.conversationId + if (conversationId) uni.removeStorageSync(`script_conversation_history_${conversationId}`) + uni.removeStorageSync(`script_chat_history_${id}`) + const pending = uni.getStorageSync('pending_open_script_chat') + if (pending?.id === id) uni.removeStorageSync('pending_open_script_chat') + deleteTarget.value = null + uni.showToast({ title: '已删除剧本', icon: 'success' }) } @@ -670,6 +754,7 @@ const openScriptMenu = (script) => { } .right-state { + position: relative; flex-shrink: 0; gap: 14rpx; } @@ -699,11 +784,89 @@ const openScriptMenu = (script) => { } .ellipsis { + min-width: 48rpx; + height: 44rpx; + display: flex; + align-items: center; + justify-content: center; + border-radius: 18rpx; color: rgba(224, 214, 243, 0.66); font-size: 24rpx; letter-spacing: 3rpx; } +.ellipsis.active { + color: #fff; + background: rgba(149, 55, 255, 0.22); + box-shadow: 0 0 20rpx rgba(168, 67, 255, 0.28); +} + +.menu-wrap { + position: relative; +} + +.card-menu { + position: absolute; + top: 52rpx; + right: 0; + z-index: 30; + width: 202rpx; + padding: 10rpx; + border-radius: 22rpx; + border: 1rpx solid rgba(192, 132, 252, 0.32); + background: + radial-gradient(circle at 90% 0%, rgba(168, 85, 247, 0.22), transparent 36%), + rgba(12, 8, 34, 0.96); + box-shadow: + inset 0 1rpx 0 rgba(255, 255, 255, 0.08), + 0 18rpx 42rpx rgba(0, 0, 0, 0.34); + backdrop-filter: blur(22rpx); + -webkit-backdrop-filter: blur(22rpx); +} + +.card-menu::before { + content: ''; + position: absolute; + right: 22rpx; + top: -10rpx; + width: 18rpx; + height: 18rpx; + border-left: 1rpx solid rgba(192, 132, 252, 0.3); + border-top: 1rpx solid rgba(192, 132, 252, 0.3); + background: rgba(12, 8, 34, 0.96); + transform: rotate(45deg); +} + +.menu-action { + position: relative; + z-index: 1; + height: 58rpx; + padding: 0 14rpx; + display: flex; + align-items: center; + gap: 12rpx; + border-radius: 16rpx; + color: rgba(240, 232, 255, 0.9); + font-size: 23rpx; + font-weight: 800; +} + +.menu-action:active { + background: rgba(149, 55, 255, 0.18); +} + +.menu-action.danger { + color: #ff8fa3; +} + +.menu-dot { + width: 8rpx; + height: 8rpx; + border-radius: 50%; + background: currentColor; + opacity: 0.72; +} + .tag-row { display: flex; flex-wrap: wrap; @@ -826,4 +989,79 @@ const openScriptMenu = (script) => { font-weight: 800; background: linear-gradient(135deg, #b346ff, #7330ff); } + +.confirm-mask { + position: fixed; + inset: 0; + z-index: 80; + display: flex; + align-items: center; + justify-content: center; + padding: 0 48rpx; + background: rgba(3, 2, 13, 0.62); + backdrop-filter: blur(12rpx); + -webkit-backdrop-filter: blur(12rpx); +} + +.confirm-panel { + width: 100%; + box-sizing: border-box; + padding: 34rpx 30rpx 28rpx; + border-radius: 28rpx; + border: 1rpx solid rgba(192, 132, 252, 0.34); + background: + radial-gradient(circle at 92% 0%, rgba(168, 85, 247, 0.2), transparent 36%), + linear-gradient(145deg, rgba(22, 13, 58, 0.98), rgba(8, 7, 26, 0.98)); + box-shadow: 0 24rpx 72rpx rgba(0, 0, 0, 0.42); +} + +.confirm-title { + display: block; + color: #fff; + font-size: 32rpx; + font-weight: 900; + text-align: center; +} + +.confirm-copy { + display: block; + margin-top: 18rpx; + color: rgba(229, 219, 247, 0.76); + font-size: 24rpx; + line-height: 1.55; + text-align: center; +} + +.confirm-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18rpx; + margin-top: 30rpx; +} + +.confirm-btn { + height: 70rpx; + border-radius: 24rpx; + display: flex; + align-items: center; + justify-content: center; + font-size: 25rpx; + font-weight: 900; +} + +.confirm-btn.secondary { + color: #e8ccff; + border: 1rpx solid rgba(192, 132, 252, 0.3); + background: rgba(88, 28, 135, 0.2); +} + +.confirm-btn.danger { + color: #fff; + background: linear-gradient(145deg, #e05276, #8d2444); + box-shadow: 0 12rpx 30rpx rgba(224, 82, 118, 0.24); +} + +.confirm-btn.disabled { + opacity: 0.55; +} diff --git a/mini-program/src/pages/main/ScriptView.vue b/mini-program/src/pages/main/ScriptView.vue index 2954afb..611e092 100644 --- a/mini-program/src/pages/main/ScriptView.vue +++ b/mini-program/src/pages/main/ScriptView.vue @@ -90,12 +90,11 @@ @@ -137,12 +136,15 @@ + @@ -166,7 +168,7 @@ - + {{ currentResult?.title || '我的人生剧本' }} @@ -186,23 +188,25 @@ - - {{ displayedResultContent }} - - {{ displayedResultContent }} - - {{ storyCollapsed ? '展开全文' : '收起全文' }} - - - + @@ -222,8 +226,16 @@ class="chat-bubble" :class="{ user: message.role === 'user', system: message.role === 'assistant', pending: message.pending }" > - {{ message.content }} + {{ getMessageDisplayContent(message) }} | + + {{ isMessageCollapsed(message) ? '展开' : '收起' }} + + {{ message.time }} @@ -232,6 +244,11 @@ + + 顶部 + 底部 + + currentResult.value?.id, (sourceId, previousSourceId) => { + if (sourceId && sourceId !== previousSourceId) { + ttsPlayer.prewarmSource(sourceId) + } +}) + const fallbackRecommendations = [ { text: '如果老板今天突然夸我,我的人生会怎样展开?', tag: '职场逆袭' }, { text: '我不再内耗,专注搞钱,逆袭成行业顶尖', tag: '成长' }, @@ -374,38 +406,148 @@ const displayedResultContent = computed(() => { return resultContent.value || '' }) +const ASSISTANT_MESSAGE_PREVIEW_LENGTH = 120 + +const isAssistantMessage = (message) => { + return message?.role === 'assistant' && !message.pending +} + +const isMessageCollapsed = (message) => { + if (!isAssistantMessage(message)) return false + return Boolean(collapsedMessageIds.value[message.id]) +} + +const getMessageDisplayContent = (message) => { + const content = String(message?.content || '') + if (!isMessageCollapsed(message)) return content + if (content.length <= ASSISTANT_MESSAGE_PREVIEW_LENGTH) return content + return `${content.slice(0, ASSISTANT_MESSAGE_PREVIEW_LENGTH)}...` +} + +const toggleMessageCollapse = (message) => { + if (!isAssistantMessage(message)) return + collapsedMessageIds.value = { + ...collapsedMessageIds.value, + [message.id]: !isMessageCollapsed(message) + } + nextTick(measureResultViewport) +} + const visibleStreamContent = computed(() => streamWriter.visibleText.value) +const resultCanScroll = computed(() => resultScrollHeight.value > resultViewportHeight.value + 24) +const resultAtTop = computed(() => resultScrollTop.value <= 24) +const resultAtBottom = computed(() => { + if (!resultCanScroll.value) return true + return resultScrollTop.value + resultViewportHeight.value >= resultScrollHeight.value - 36 +}) +const showScrollTopBtn = computed(() => resultCanScroll.value && !resultAtTop.value) +const showScrollBottomBtn = computed(() => resultCanScroll.value && !resultAtBottom.value) +const resultHasScriptReplies = computed(() => { + return resultMessages.value.some(item => item.role === 'assistant' && item.kind === 'script') +}) + +const measureResultViewport = () => { + nextTick(() => { + const query = uni.createSelectorQuery() + query.select('.result-view').boundingClientRect() + query.select('.result-scroll-content').boundingClientRect() + query.exec((rects = []) => { + const viewport = rects[0] + const content = rects[1] + if (viewport?.height) resultViewportHeight.value = viewport.height + if (content?.height) resultScrollHeight.value = content.height + }) + }) +} + +const handleResultScroll = (event) => { + const detail = event?.detail || {} + resultScrollTop.value = Number(detail.scrollTop || 0) + resultScrollHeight.value = Number(detail.scrollHeight || resultScrollHeight.value || 0) + if (!resultViewportHeight.value) measureResultViewport() +} + +const setResultScrollTarget = (target) => { + if (!target) return + if (resultScrollTargetTimer) { + clearTimeout(resultScrollTargetTimer) + resultScrollTargetTimer = null + } + resultScrollTarget.value = '' + nextTick(() => { + resultScrollTarget.value = target + resultScrollTargetTimer = setTimeout(() => { + if (resultScrollTarget.value === target) resultScrollTarget.value = '' + resultScrollTargetTimer = null + }, 480) + }) +} + +const pushResultScrollCommand = (top) => { + const nextTop = Number(top || 0) + resultCommandScrollTop.value = resultCommandScrollTop.value === nextTop + ? nextTop + 1 + : nextTop +} const scrollGenerationToLatest = () => { if (viewState.value !== 'generating') return if (!generationAutoFollow.value) return - if (generationScrollTimer) clearTimeout(generationScrollTimer) + if (generationScrollTimer) return generationScrollTimer = setTimeout(() => { + generationScrollTimer = null + if (viewState.value !== 'generating' || !generationAutoFollow.value) return nextTick(() => { + generationScrollTop.value = generationScrollTop.value >= 100000000 + ? 100000 + : generationScrollTop.value + 100000 + generationScrollTarget.value = '' generationScrollAnchor.value = generationScrollAnchor.value === 'generation-stream-anchor-a' ? 'generation-stream-anchor-b' : 'generation-stream-anchor-a' - generationScrollTarget.value = generationScrollAnchor.value + nextTick(() => { + generationScrollTarget.value = generationScrollAnchor.value + }) }) - }, 80) + }, 48) } -const pauseGenerationAutoScroll = () => { - generationAutoFollow.value = false -} - -const resumeGenerationAutoScroll = () => { - generationAutoFollow.value = true - scrollGenerationToLatest() +const keepGenerationAtBottom = () => { + ;[0, 60, 140, 260, 420].forEach((delay) => { + setTimeout(scrollGenerationToLatest, delay) + }) } const scrollResultToLatest = () => { if (viewState.value !== 'result') return nextTick(() => { + measureResultViewport() + pushResultScrollCommand(1000000) resultScrollAnchor.value = resultScrollAnchor.value === 'result-scroll-anchor-a' ? 'result-scroll-anchor-b' : 'result-scroll-anchor-a' - resultScrollTarget.value = resultScrollAnchor.value + setResultScrollTarget(resultScrollAnchor.value) + }) +} + +const scrollResultToBottom = () => { + resultScrollTop.value = Math.max(0, resultScrollHeight.value - resultViewportHeight.value) + scrollResultToLatest() +} + +const keepResultAtBottom = () => { + ;[0, 80, 180, 360, 720].forEach((delay) => { + setTimeout(scrollResultToLatest, delay) + }) +} + +const scrollResultToTop = () => { + if (viewState.value !== 'result') return + resultScrollTop.value = 0 + resultCommandScrollTop.value = 1 + nextTick(() => { + resultCommandScrollTop.value = 0 + setResultScrollTarget('result-scroll-top') }) } @@ -418,12 +560,17 @@ watch(generationStatus, () => { scrollGenerationToLatest() }) +watch(viewState, (state) => { + if (state !== 'result') return + measureResultViewport() +}) + watch(resultChatWriter.visibleText, (text) => { if (!activeAssistantMessageId.value) return const message = resultMessages.value.find(item => item.id === activeAssistantMessageId.value) if (!message) return message.content = text - scrollResultToLatest() + keepResultAtBottom() }) const generationTitle = computed(() => { @@ -526,6 +673,7 @@ const normalizeGeneratedScript = (data) => { const script = data?.script || data || {} const latestScript = Array.isArray(store.scripts) && store.scripts.length ? store.scripts[0] : null const merged = script?.id ? script : { ...latestScript, ...script } + const plotJson = merged?.plotJson || {} const content = normalizeGeneratedContent(merged?.content || merged?.plotJson?.fullContent || merged?.summary || '') return { @@ -536,7 +684,12 @@ const normalizeGeneratedScript = (data) => { length: merged?.length || 'medium', tags: merged?.tags || [merged?.style || '爽文', '成长', '被看见'], summary: normalizeGeneratedContent(merged?.summary || content.slice(0, 90)), - content + content, + plotJson, + conversationId: merged?.conversationId || plotJson.conversationId || '', + parentScriptId: merged?.parentScriptId || plotJson.parentScriptId || '', + revisionIndex: Number(merged?.revisionIndex || plotJson.revisionIndex || 1), + revisionOf: merged?.revisionOf || plotJson.revisionOf || '' } } @@ -662,71 +815,139 @@ const setupRecorder = () => { }) } -const SCRIPT_CHAT_HISTORY_PREFIX = 'script_chat_history_' +const SCRIPT_CHAT_HISTORY_PREFIX = 'script_conversation_history_' +const LEGACY_SCRIPT_CHAT_HISTORY_PREFIX = 'script_chat_history_' const PENDING_SCRIPT_CHAT_KEY = 'pending_open_script_chat' const createMessageId = () => `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` +const createConversationId = () => `conv-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` const getCurrentScriptId = () => String(currentResult.value?.id || '') +const getCurrentConversationId = () => String(currentConversationId.value || currentResult.value?.conversationId || '') -const getScriptChatHistoryKey = (scriptId = getCurrentScriptId()) => { - return scriptId ? `${SCRIPT_CHAT_HISTORY_PREFIX}${scriptId}` : '' +const ensureConversationId = (script = currentResult.value) => { + const existing = String(script?.conversationId || script?.plotJson?.conversationId || currentConversationId.value || '') + currentConversationId.value = existing || createConversationId() + return currentConversationId.value } -const readScriptChatHistory = (scriptId) => { - const key = getScriptChatHistoryKey(scriptId) +const getScriptChatHistoryKey = (conversationId = getCurrentConversationId()) => { + return conversationId ? `${SCRIPT_CHAT_HISTORY_PREFIX}${conversationId}` : '' +} + +const readStoredMessages = (key) => { if (!key) return [] const value = uni.getStorageSync(key) - return Array.isArray(value) ? value.filter(item => item?.role && typeof item.content === 'string') : [] + return Array.isArray(value) ? value.filter(item => { + if (!item?.role) return false + if (item.role === 'script') return false + return typeof item.content === 'string' + }) : [] +} + +const readScriptChatHistory = ({ conversationId, scriptId } = {}) => { + const messages = readStoredMessages(getScriptChatHistoryKey(conversationId)) + if (messages.length || !scriptId) return messages + return readStoredMessages(`${LEGACY_SCRIPT_CHAT_HISTORY_PREFIX}${scriptId}`) } const persistResultMessages = () => { const key = getScriptChatHistoryKey() if (!key) return const messages = resultMessages.value - .filter(item => item?.role && item.content && !item.pending) + .filter(item => item?.role && item.role !== 'script' && !item.pending && item.content) .map(item => ({ id: item.id, role: item.role, + kind: item.kind || '', content: item.content, time: item.time })) uni.setStorageSync(key, messages) } -const hydrateResultMessages = (scriptId) => { - resultMessages.value = readScriptChatHistory(scriptId).map(item => ({ +const hydrateResultMessages = ({ conversationId, scriptId } = {}) => { + resultMessages.value = readScriptChatHistory({ conversationId, scriptId }).map(item => ({ id: item.id || createMessageId(), role: item.role, - content: item.content, + kind: item.kind || '', + content: item.content || '', pending: false, time: item.time || formatMessageTime() })) + collapsedMessageIds.value = {} } -const addResultMessage = ({ role, content, pending = false }) => { +const addResultMessage = ({ role, content, pending = false, kind = '' }) => { const message = { id: createMessageId(), role, + kind, content, pending, time: formatMessageTime() } resultMessages.value.push(message) if (!pending) persistResultMessages() - scrollResultToLatest() + keepResultAtBottom() return message } +const getScriptVersions = (script = currentResult.value) => { + const versions = script?.plotJson?.versions + return Array.isArray(versions) ? versions : [] +} + +const createScriptVersion = ({ script, content, revisionIndex, prompt, source }) => ({ + id: `version-${revisionIndex}-${Date.now()}`, + revisionIndex, + title: script?.title || wishText.value || '我的人生剧本', + content, + summary: normalizeGeneratedContent(content).slice(0, 90), + prompt: prompt || '', + source: source || 'mini-program-stream', + createdAt: new Date().toISOString() +}) + +const buildVersionedPlotJson = ({ baseScript, content, conversationId, revisionIndex, prompt, source }) => { + const existingPlotJson = baseScript?.plotJson || {} + const existingVersions = Array.isArray(existingPlotJson.versions) ? existingPlotJson.versions : [] + const nextVersion = createScriptVersion({ + script: baseScript, + content, + revisionIndex, + prompt, + source + }) + return { + ...existingPlotJson, + mode: existingPlotJson.mode || 'inspiration', + prompt, + source, + fullContent: content, + conversationId, + currentRevisionIndex: revisionIndex, + versions: [...existingVersions, nextVersion] + } +} + + const buildRevisionPrompt = (suggestion) => { + const history = resultMessages.value + .filter(item => item.role === 'user' || item.role === 'assistant') + .map(item => `${item.role === 'user' ? '用户' : 'AI'}:${item.content}`) + .slice(-10) + .join('\n') return [ - '请基于下面的当前小说和用户最新修改需求,重新生成一篇完整中文爽文小说。', - '要求:保留原始心愿的核心情绪,吸收用户修改建议,输出完整正文,不要输出解释说明。', + '请基于同一个对话中的上一版小说和用户最新修改需求,重新生成一篇完整中文爽文小说。', + '要求:保留原始心愿的核心情绪,沿用上一版有效设定,只针对用户要求进行调衡和改写,输出完整正文,不要输出解释说明。', + `对话ID:${getCurrentConversationId()}`, `原始心愿:${wishText.value || currentResult.value?.theme || ''}`, - `当前小说标题:${currentResult.value?.title || '我的人生剧本'}`, - `当前小说正文:${resultContent.value || ''}`, + `上一版小说标题:${currentResult.value?.title || '我的人生剧本'}`, + `上一版小说正文:${resultContent.value || ''}`, + history ? `最近对话历史:\n${history}` : '', `用户修改需求:${suggestion}` - ].join('\n\n') + ].filter(Boolean).join('\n\n') } const buildChatPrompt = (message) => { @@ -735,14 +956,40 @@ const buildChatPrompt = (message) => { '请围绕当前小说回应用户的修改意见,先帮助用户确认方向、给出简短建议,不要直接重写全文。', '如果用户表达已确认要重新生成,请提醒用户发送“确认生成”或给出更明确的修改方向。', `原始心愿:${wishText.value || ''}`, + `对话ID:${getCurrentConversationId()}`, `当前小说标题:${currentResult.value?.title || '我的人生剧本'}`, `当前小说正文:${resultContent.value || ''}`, `用户消息:${message}` ].join('\n\n') } +const normalizeCommandText = (message) => { + return String(message || '') + .replace(/\s+/g, '') + .replace(/[,。!?、;:,.!?;:"'“”‘’]/g, '') +} + const shouldRegenerateFromMessage = (message) => { - return /确认|生成|重写|重新|开始|就这样/.test(message) + const text = normalizeCommandText(message) + if (!text) return false + const hasRegenerateAction = /(\u751f\u6210|\u91cd\u5199|\u91cd\u65b0)/u.test(text) + const hasNegation = /(\u4e0d|\u522b|\u4e0d\u8981|\u5148\u4e0d|\u6682\u4e0d).*(\u751f\u6210|\u91cd\u5199|\u91cd\u65b0)/u.test(text) + if (hasRegenerateAction && hasNegation) return false + const exactCommands = new Set([ + '\u786e\u8ba4\u751f\u6210', + '\u786e\u8ba4\u91cd\u5199', + '\u5f00\u59cb\u751f\u6210', + '\u5f00\u59cb\u91cd\u5199', + '\u91cd\u65b0\u751f\u6210', + '\u76f4\u63a5\u751f\u6210', + '\u5c31\u8fd9\u6837\u751f\u6210', + '\u5c31\u8fd9\u6837', + '\u751f\u6210', + '\u91cd\u5199' + ]) + return exactCommands.has(text) + || /\u786e\u8ba4.*(\u751f\u6210|\u91cd\u5199|\u91cd\u65b0)/u.test(text) + || /(\u5f00\u59cb|\u76f4\u63a5|\u91cd\u65b0).*(\u751f\u6210|\u91cd\u5199)/u.test(text) } const removeLeadingRepeatedPrefix = (text) => { @@ -840,10 +1087,11 @@ const openScriptChat = async (payload = {}) => { ttsPlayer.reset() currentResult.value = normalizeGeneratedScript({ script }) + currentConversationId.value = ensureConversationId(currentResult.value) wishText.value = script.theme || script.prompt || script.title || '继续修改这个人生剧本' currentMessageTime.value = formatMessageTime() currentResultTime.value = script.updateTime || script.updatedAt || script.createTime || script.createdAt || formatMessageTime() - hydrateResultMessages(script.id) + hydrateResultMessages({ conversationId: currentConversationId.value, scriptId: script.id }) viewState.value = 'result' analytics.track('script_chat_open_from_library', { script_id: script.id, @@ -863,11 +1111,72 @@ const handleOpenScriptChat = (payload) => { openScriptChat(payload) } -const generateScriptByStream = async (text, options = {}) => { +const saveGeneratedScriptContent = async ({ + prompt, + content, + saveTheme, + conversationId, + revisionIndex = 1, + updateScriptId = '', + parentScriptId = '', + revisionOf = '' +}) => { const profile = store.userProfile || store.registrationData || {} const characterInfo = epicScriptService.buildCharacterInfo(profile) const lifeEventsSummary = epicScriptService.buildLifeEventsSummary(store.events || [], profile) + const theme = saveTheme || prompt + const baseScript = updateScriptId ? currentResult.value : { + title: theme.length > 22 ? `${theme.slice(0, 22)}...` : theme, + theme, + style: style.value, + length: 'medium', + plotJson: {} + } + const title = baseScript?.title || (theme.length > 22 ? `${theme.slice(0, 22)}...` : theme) + const plotJson = buildVersionedPlotJson({ + baseScript, + content, + conversationId, + revisionIndex, + prompt, + source: 'mini-program-stream' + }) + const payload = { + id: updateScriptId || undefined, + title, + theme, + style: baseScript?.style || style.value, + length: baseScript?.length || 'medium', + content, + plotJson, + conversationId, + parentScriptId, + revisionIndex, + revisionOf, + characterInfo, + lifeEventsSummary, + useSocialInsights: useSocialInsights.value + } + const saveRes = updateScriptId + ? await store.updateScript(payload) + : await store.createScript(payload) + if (!saveRes.success) { + throw new Error(saveRes.error || (updateScriptId ? '更新剧本失败' : '保存剧本失败')) + } + return { + success: true, + data: { + script: saveRes.data, + remainingCount: remainingCount.value + } + } +} + +const generateScriptByStream = async (text, options = {}) => { const saveTheme = options.saveTheme || text + const conversationId = options.conversationId || ensureConversationId() + const revisionIndex = options.revisionIndex || 1 + const updateScriptId = options.updateScriptId || '' const streamRes = await streamAiScene({ sceneCode: 'script_generate', inputs: { @@ -877,12 +1186,15 @@ const generateScriptByStream = async (text, options = {}) => { useSocialInsights: useSocialInsights.value }, onDelta: (_delta, output) => { - streamContent.value = output + const preview = normalizeGeneratedContent(output) + streamContent.value = preview markGenerationStreaming() - streamWriter.push(output) + streamWriter.push(preview) + scrollGenerationToLatest() }, onDone: (_event, output) => { - streamWriter.finish(output) + streamWriter.finish(normalizeGeneratedContent(output)) + keepGenerationAtBottom() }, onError: (message) => { streamWriter.fail(message || 'AI 流式生成失败') @@ -894,38 +1206,30 @@ const generateScriptByStream = async (text, options = {}) => { } streamWriter.finish(content) await streamWriter.waitForDone() - const saveRes = await store.createScript({ - title: saveTheme.length > 22 ? `${saveTheme.slice(0, 22)}...` : saveTheme, - theme: saveTheme, - style: style.value, - length: 'medium', + return await saveGeneratedScriptContent({ + prompt: text, content, - plotJson: { - mode: 'inspiration', - prompt: text, - source: 'mini-program-stream', - fullContent: content - }, - characterInfo, - lifeEventsSummary, - useSocialInsights: useSocialInsights.value + saveTheme, + conversationId, + revisionIndex, + parentScriptId: options.parentScriptId || '', + revisionOf: options.revisionOf || '', + updateScriptId }) - if (!saveRes.success) { - throw new Error(saveRes.error || '保存剧本失败') - } - return { - success: true, - data: { - script: saveRes.data, - remainingCount: remainingCount.value - } - } } -const runGeneration = async ({ prompt, displayText, source = 'text', saveTheme }) => { +const runGeneration = async ({ prompt, displayText, source = 'text', saveTheme, revision = false }) => { const text = String(prompt || '').trim() const display = String(displayText || text).trim() if (!text || generating.value) return + const isRevision = Boolean(revision) + const conversationId = isRevision ? ensureConversationId() : createConversationId() + const parentScriptId = isRevision ? getCurrentScriptId() : '' + const revisionIndex = isRevision + ? getScriptVersions().length + 1 + : 1 + currentConversationId.value = conversationId + lastGenerationRevision.value = isRevision lastSubmitSource.value = source generationDisplayText.value = display lastGenerationPrompt.value = text @@ -940,12 +1244,14 @@ const runGeneration = async ({ prompt, displayText, source = 'text', saveTheme } generating.value = true streamContent.value = '' generationScrollTarget.value = '' + generationScrollTop.value = 0 generationScrollAnchor.value = 'generation-stream-anchor-a' generationAutoFollow.value = true streamWriter.reset() startGenerationFeedback() ttsPlayer.reset() viewState.value = 'generating' + keepGenerationAtBottom() analytics.track('script_generation_progress_view', { source, prompt_length: display.length @@ -953,7 +1259,14 @@ const runGeneration = async ({ prompt, displayText, source = 'text', saveTheme } let res try { - res = await generateScriptByStream(text, { saveTheme: saveTheme || display }) + res = await generateScriptByStream(text, { + saveTheme: saveTheme || display, + conversationId, + parentScriptId, + revisionIndex, + revisionOf: parentScriptId, + updateScriptId: isRevision ? getCurrentScriptId() : '' + }) } catch (streamError) { markGenerationFailed(streamError?.message || 'AI 流式生成失败') analytics.track('script_generate_stream_fail', { @@ -970,6 +1283,7 @@ const runGeneration = async ({ prompt, displayText, source = 'text', saveTheme } if (res.success) { clearGenerationFeedbackTimers() if (streamContent.value) streamWriter.finish(normalizeGeneratedContent(streamContent.value)) + keepGenerationAtBottom() } if (!res.success) { @@ -983,8 +1297,12 @@ const runGeneration = async ({ prompt, displayText, source = 'text', saveTheme } } currentResult.value = normalizeGeneratedScript(res.data) + currentConversationId.value = ensureConversationId(currentResult.value) currentResultTime.value = formatMessageTime() - resultMessages.value = [] + if (!isRevision) { + resultMessages.value = [] + collapsedMessageIds.value = {} + } if (typeof res.data?.remainingCount === 'number') remainingCount.value = res.data.remainingCount analytics.track('script_generate_success', { @@ -1002,6 +1320,7 @@ const runGeneration = async ({ prompt, displayText, source = 'text', saveTheme } generationStatus.value = 'idle' viewState.value = 'result' + keepResultAtBottom() } const submitWish = async (source = 'text') => { @@ -1021,7 +1340,8 @@ const retryGeneration = () => { prompt: lastGenerationPrompt.value || wishText.value.trim(), displayText: wishText.value.trim(), source: lastSubmitSource.value || 'text', - saveTheme: wishText.value.trim() + saveTheme: wishText.value.trim(), + revision: lastGenerationRevision.value }) } @@ -1043,7 +1363,9 @@ const closeResult = () => { generationStatus.value = 'idle' viewState.value = 'home' currentResult.value = null + currentConversationId.value = '' resultMessages.value = [] + collapsedMessageIds.value = {} resultChatInput.value = '' revisionIntent.value = '' storyCollapsed.value = false @@ -1113,18 +1435,101 @@ const notLikeMe = () => { } const regenerateFromRevision = async (message, source = 'result_chat') => { + const conversationId = ensureConversationId() const prompt = buildRevisionPrompt(message) + const parentScriptId = getCurrentScriptId() + const revisionIndex = getScriptVersions().length + 1 revisionIntent.value = '' resultChatInput.value = '' - resultChatting.value = false - resultMessages.value = [] + resultChatting.value = true storyCollapsed.value = false - await runGeneration({ - prompt, - displayText: wishText.value || currentResult.value?.title || '根据当前故事继续修改', - source, - saveTheme: wishText.value || currentResult.value?.theme || '根据当前故事继续修改' + resultChatWriter.reset() + const assistant = addResultMessage({ + role: 'assistant', + kind: 'script', + content: '', + pending: true }) + activeAssistantMessageId.value = assistant.id + + let generatedContent = '' + try { + const response = await streamAiScene({ + sceneCode: 'script_generate', + inputs: { + prompt, + style: style.value, + length: 'medium', + useSocialInsights: useSocialInsights.value + }, + onDelta: (_delta, output) => { + const normalized = normalizeGeneratedContent(output) + // 避免内容回退时重置导致的闪烁;只推进增量内容 + if (normalized.length >= resultChatWriter.visibleText.value.length) { + resultChatWriter.push(normalized) + } + keepResultAtBottom() + }, + onDone: (_event, output) => { + resultChatWriter.finish(normalizeGeneratedContent(output)) + keepResultAtBottom() + }, + onError: (errorMessage) => { + resultChatWriter.fail(errorMessage || 'AI 流式生成失败') + } + }) + generatedContent = normalizeGeneratedContent(response.output) + if (!generatedContent) { + throw new Error('AI 流式输出为空') + } + resultChatWriter.finish(generatedContent) + await resultChatWriter.waitForDone() + const current = resultMessages.value.find(item => item.id === assistant.id) + if (current) current.content = generatedContent + + const saveRes = await saveGeneratedScriptContent({ + prompt, + content: generatedContent, + saveTheme: wishText.value || currentResult.value?.theme || currentResult.value?.title || '根据当前故事继续修改', + conversationId, + revisionIndex, + updateScriptId: parentScriptId, + parentScriptId, + revisionOf: parentScriptId + }) + currentResult.value = normalizeGeneratedScript(saveRes.data) + currentConversationId.value = ensureConversationId(currentResult.value) + currentResultTime.value = formatMessageTime() + if (typeof saveRes.data?.remainingCount === 'number') remainingCount.value = saveRes.data.remainingCount + analytics.track('script_generate_success', { + source, + style: currentResult.value.style || '', + length: currentResult.value.length || '', + use_social_insights: useSocialInsights.value, + duration_ms: 0 + }, { eventType: 'script', pagePath }) + } catch (error) { + resultChatWriter.fail(error?.message || '这次生成没有成功,可以稍后再试。') + const current = resultMessages.value.find(item => item.id === assistant.id) + if (current && !current.content) { + current.content = error?.message || '这次生成没有成功,可以稍后再试。' + } + analytics.track('script_generate_fail', { + source, + error: error?.message || 'unknown' + }, { eventType: 'script', pagePath }) + } finally { + const current = resultMessages.value.find(item => item.id === assistant.id) + if (current) { + current.pending = false + current.kind = generatedContent ? 'script' : '' + if (!current.content && resultChatWriter.visibleText.value) current.content = resultChatWriter.visibleText.value + } + activeAssistantMessageId.value = '' + resultChatting.value = false + persistResultMessages() + keepResultAtBottom() + } } const sendResultChat = async (source = 'text') => { @@ -1163,10 +1568,15 @@ const sendResultChat = async (source = 'text') => { useSocialInsights: false }, onDelta: (_delta, output) => { - resultChatWriter.push(output) + const text = String(output || '') + if (text.length >= resultChatWriter.visibleText.value.length) { + resultChatWriter.push(text) + } + keepResultAtBottom() }, onDone: (_event, output) => { - resultChatWriter.finish(output) + resultChatWriter.finish(String(output || '')) + keepResultAtBottom() }, onError: (errorMessage) => { resultChatWriter.fail(errorMessage || '暂时没有收到回复') @@ -1175,15 +1585,26 @@ const sendResultChat = async (source = 'text') => { const output = response.output?.trim() || '我收到了。你可以继续补充,也可以直接说“确认生成”。' resultChatWriter.finish(output) await resultChatWriter.waitForDone() + const current = resultMessages.value.find(item => item.id === assistant.id) + if (current) current.content = output } catch (error) { resultChatWriter.fail(error?.message || '这次回复没有成功,可以稍后再试。') + const current = resultMessages.value.find(item => item.id === assistant.id) + if (current && !current.content) { + current.content = error?.message || '这次回复没有成功,可以稍后再试。' + } } finally { const current = resultMessages.value.find(item => item.id === assistant.id) - if (current) current.pending = false + if (current) { + current.pending = false + if (!current.content && resultChatWriter.visibleText.value) { + current.content = resultChatWriter.visibleText.value + } + } activeAssistantMessageId.value = '' resultChatting.value = false persistResultMessages() - scrollResultToLatest() + keepResultAtBottom() } } @@ -1207,6 +1628,10 @@ onUnmounted(() => { clearTimeout(generationScrollTimer) generationScrollTimer = null } + if (resultScrollTargetTimer) { + clearTimeout(resultScrollTargetTimer) + resultScrollTargetTimer = null + } if (pendingOpenTimer) { clearTimeout(pendingOpenTimer) pendingOpenTimer = null @@ -1532,6 +1957,11 @@ onUnmounted(() => { padding: 0 0 220rpx; } +.result-scroll-top { + width: 1rpx; + height: 1rpx; +} + .result-top-actions { display: flex; align-items: center; @@ -1721,67 +2151,68 @@ onUnmounted(() => { .mic-symbol { position: relative; - width: 88rpx; - height: 128rpx; + width: 118rpx; + height: 156rpx; z-index: 2; } .mic-head { position: relative; - width: 58rpx; - height: 78rpx; + width: 66rpx; + height: 106rpx; margin: 0 auto; - border-radius: 30rpx; - border: 8rpx solid rgba(255, 255, 255, 0.94); + border-radius: 999rpx; + border: 10rpx solid rgba(255, 255, 255, 0.96); box-sizing: border-box; - background: - linear-gradient(90deg, transparent 46%, rgba(255, 255, 255, 0.44) 47% 53%, transparent 54%), - repeating-linear-gradient(180deg, transparent 0 13rpx, rgba(255, 255, 255, 0.34) 14rpx 17rpx); + background: transparent; box-shadow: - inset 0 0 18rpx rgba(255, 255, 255, 0.18), - 0 0 10rpx rgba(255, 255, 255, 0.18); + inset 0 0 14rpx rgba(255, 255, 255, 0.12), + 0 0 16rpx rgba(255, 255, 255, 0.22); } .mic-head::before { content: ''; position: absolute; - left: 8rpx; - top: 10rpx; - width: 10rpx; - height: 22rpx; + left: 9rpx; + top: 16rpx; + width: 8rpx; + height: 34rpx; border-radius: 999rpx; - background: rgba(255, 255, 255, 0.32); + background: rgba(255, 255, 255, 0.18); animation: micHighlightFloat 2.6s ease-in-out infinite; } .mic-symbol::after { content: ''; position: absolute; - left: 14rpx; - top: 62rpx; - width: 60rpx; - height: 40rpx; + left: 8rpx; + top: 56rpx; + width: 102rpx; + height: 70rpx; box-sizing: border-box; - border-left: 6rpx solid rgba(255, 255, 255, 0.9); - border-right: 6rpx solid rgba(255, 255, 255, 0.9); - border-bottom: 6rpx solid rgba(255, 255, 255, 0.9); - border-radius: 0 0 34rpx 34rpx; + border-left: 10rpx solid rgba(255, 255, 255, 0.95); + border-right: 10rpx solid rgba(255, 255, 255, 0.95); + border-bottom: 10rpx solid rgba(255, 255, 255, 0.95); + border-radius: 0 0 64rpx 64rpx; + box-shadow: 0 0 14rpx rgba(255, 255, 255, 0.18); } .mic-stem { - width: 8rpx; - height: 38rpx; - margin: 6rpx auto 0; + width: 12rpx; + height: 34rpx; + margin: -4rpx auto 0; border-radius: 999rpx; - background: rgba(255, 255, 255, 0.92); + background: rgba(255, 255, 255, 0.96); + box-shadow: 0 0 12rpx rgba(255, 255, 255, 0.2); } .mic-base { - width: 58rpx; - height: 8rpx; + width: 76rpx; + height: 12rpx; margin: 0 auto; border-radius: 999rpx; - background: rgba(255, 255, 255, 0.92); + background: rgba(255, 255, 255, 0.96); + box-shadow: 0 0 12rpx rgba(255, 255, 255, 0.2); } .voice-copy { @@ -2011,6 +2442,31 @@ onUnmounted(() => { border-color: rgba(255, 216, 107, 0.28); } +.message-toggle { + align-self: flex-start; + display: inline-flex; + align-items: center; + gap: 8rpx; + padding: 8rpx 18rpx; + border-radius: 999rpx; + color: #ffd86b; + font-size: 24rpx; + line-height: 32rpx; + font-weight: 700; + background: rgba(140, 68, 242, 0.18); + border: 1rpx solid rgba(255, 216, 107, 0.18); +} + +.message-toggle-icon { + font-size: 22rpx; + line-height: 28rpx; + transition: transform 0.2s ease; +} + +.message-toggle-icon.open { + transform: rotate(180deg); +} + .thinking-dots { display: flex; align-items: center; @@ -2420,9 +2876,12 @@ onUnmounted(() => { .story-body-scroll { max-height: 420rpx; + height: auto; margin-top: 28rpx; padding-right: 8rpx; box-sizing: border-box; + overflow-y: auto; + -webkit-overflow-scrolling: touch; } .story-body-scroll .story-body { @@ -2518,6 +2977,33 @@ onUnmounted(() => { height: 28rpx; } +.scroll-jump-group { + position: absolute; + right: 22rpx; + bottom: 258rpx; + z-index: 13; + display: flex; + flex-direction: column; + gap: 12rpx; +} + +.scroll-jump-btn { + width: 86rpx; + height: 58rpx; + display: flex; + align-items: center; + justify-content: center; + border-radius: 999rpx; + color: #e8ccff; + font-size: 23rpx; + font-weight: 900; + background: rgba(43, 19, 83, 0.72); + border: 1rpx solid rgba(192, 132, 252, 0.34); + box-shadow: 0 12rpx 34rpx rgba(0, 0, 0, 0.28); + backdrop-filter: blur(18rpx); + -webkit-backdrop-filter: blur(18rpx); +} + .result-chat-bar { position: absolute; left: 0; diff --git a/mini-program/src/pages/onboarding/index.vue b/mini-program/src/pages/onboarding/index.vue index f2fa457..5623d17 100644 --- a/mini-program/src/pages/onboarding/index.vue +++ b/mini-program/src/pages/onboarding/index.vue @@ -3,10 +3,10 @@ - + 编辑资料 - 保存 + {{ saving ? '保存中' : '保存' }} @@ -188,7 +188,7 @@ import { useAppStore } from '../../stores/app.js' import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js' const store = useAppStore() -const { capsuleTopReservePx, topbarStyle } = useMenuButtonSafeArea({ extraTopPx: 2 }) +const { capsuleTopReservePx } = useMenuButtonSafeArea({ extraTopPx: 2 }) const isEdit = ref(false) const saving = ref(false) const birthday = ref('') @@ -319,6 +319,7 @@ const saveProfile = async () => { return } + syncFromStore() uni.showToast({ title: '已保存', icon: 'success' }) setTimeout(() => { if (isEdit.value) uni.navigateBack() @@ -331,9 +332,12 @@ const goBack = () => { else uni.reLaunch({ url: '/pages/main/index?tab=script' }) } -onMounted(() => { +onMounted(async () => { const pages = getCurrentPages() isEdit.value = pages[pages.length - 1]?.options?.edit === '1' + if (isEdit.value) { + await store.fetchUserProfile() + } syncFromStore() }) @@ -374,7 +378,7 @@ onMounted(() => { .topbar { height: 72rpx; display: grid; - grid-template-columns: 76rpx 1fr 76rpx; + grid-template-columns: 76rpx 1fr 112rpx; align-items: center; padding: 0 28rpx; } @@ -401,6 +405,12 @@ onMounted(() => { color: #b94cff; font-size: 26rpx; text-align: right; + justify-self: end; + min-width: 96rpx; +} + +.save.disabled { + opacity: 0.58; } .scroll { diff --git a/mini-program/src/services/auth.js b/mini-program/src/services/auth.js index 71fe9ea..b015fa6 100644 --- a/mini-program/src/services/auth.js +++ b/mini-program/src/services/auth.js @@ -40,6 +40,22 @@ export const login = async ({ phone, smsCode }) => { return response } +export const wechatLogin = async ({ code, nickname, avatar }) => { + const response = await post('/auth/wechat/login', { code, nickname, avatar }) + + if (response.data) { + const { accessToken, refreshToken } = response.data + if (accessToken) { + uni.setStorageSync('access_token', accessToken) + } + if (refreshToken) { + uni.setStorageSync('refresh_token', refreshToken) + } + } + + return response +} + /** * 用户登出 * @returns {Promise} @@ -120,6 +136,7 @@ export const checkPhone = async (phone) => { export default { getSmsCode, login, + wechatLogin, logout, refreshToken, validateToken, diff --git a/mini-program/src/services/epicScript.js b/mini-program/src/services/epicScript.js index 89dd39b..c3e9bd3 100644 --- a/mini-program/src/services/epicScript.js +++ b/mini-program/src/services/epicScript.js @@ -95,6 +95,10 @@ const transformToBackendFormat = (frontendData) => { character, events, plotJson, + conversationId, + parentScriptId, + revisionIndex, + revisionOf, useSocialInsights } = frontendData @@ -112,7 +116,14 @@ const transformToBackendFormat = (frontendData) => { plotTurning: frontendData.plotTurning || '', plotClimax: frontendData.plotClimax || '', plotEnding: frontendData.plotEnding || '', - plotJson: plotJson || (content ? { fullContent: content } : null), + plotJson: { + ...(content ? { fullContent: content } : {}), + ...(plotJson || {}), + ...(conversationId ? { conversationId } : {}), + ...(parentScriptId ? { parentScriptId } : {}), + ...(revisionIndex ? { revisionIndex } : {}), + ...(revisionOf ? { revisionOf } : {}) + }, isSelected, characterInfo, lifeEventsSummary, @@ -171,6 +182,10 @@ export const transformToFrontendFormat = (backendData) => { date: createTime ? String(createTime).slice(0, 10) : new Date().toLocaleDateString(), mode: plotJson?.mode || 'custom', prompt: plotJson?.prompt || '', + conversationId: plotJson?.conversationId || '', + parentScriptId: plotJson?.parentScriptId || '', + revisionIndex: plotJson?.currentRevisionIndex || plotJson?.revisionIndex || 0, + revisionOf: plotJson?.revisionOf || '', wordCount: content ? content.length : 0 } } diff --git a/mini-program/src/services/userProfile.js b/mini-program/src/services/userProfile.js index e5bd393..c8d0982 100644 --- a/mini-program/src/services/userProfile.js +++ b/mini-program/src/services/userProfile.js @@ -6,6 +6,24 @@ import { get, post, put } from './request.js' +const normalizeDate = (value) => { + if (!value) return '' + return String(value).slice(0, 10) +} + +const parseJsonArray = (value) => { + if (Array.isArray(value)) return value + if (!value) return [] + if (typeof value !== 'string') return [] + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) ? parsed : [] + } catch (error) { + console.warn('[userProfile] JSON array parse failed', error) + return [] + } +} + /** * 获取当前用户档案 * @returns {Promise} 用户档案 @@ -85,9 +103,9 @@ const transformToBackendFormat = (frontendData) => { futureVision: future?.vision || null, // 理想生活状态 idealLife: future?.ideal || null, - city: city || null, - industry: industry || null, - company: company || null, + city: city ?? '', + industry: industry ?? '', + company: company ?? '', personalityTags: Array.isArray(personalityTags) ? JSON.stringify(personalityTags) : personalityTags, birthday: birthday || null } @@ -134,20 +152,20 @@ export const transformToFrontendFormat = (backendData) => { profession: profession || '', mbti: mbti || '', // 兴趣爱好从JSON字符串解析 - hobbies: hobbies ? (typeof hobbies === 'string' ? JSON.parse(hobbies) : hobbies) : [], + hobbies: parseJsonArray(hobbies), // 童年经历 childhood: { - date: childhoodDate || '', + date: normalizeDate(childhoodDate), text: childhoodContent || '' }, // 高光时刻 joy: { - date: peakDate || '', + date: normalizeDate(peakDate), text: peakContent || '' }, // 低谷时期 low: { - date: valleyDate || '', + date: normalizeDate(valleyDate), text: valleyContent || '' }, // 未来期许 @@ -158,8 +176,8 @@ export const transformToFrontendFormat = (backendData) => { city: city || '', industry: industry || '', company: company || '', - personalityTags: personalityTags ? (typeof personalityTags === 'string' ? JSON.parse(personalityTags) : personalityTags) : [], - birthday: birthday || '' + personalityTags: parseJsonArray(personalityTags), + birthday: normalizeDate(birthday) } } diff --git a/mini-program/src/stores/app.js b/mini-program/src/stores/app.js index 47f9c62..d1b6f0f 100644 --- a/mini-program/src/stores/app.js +++ b/mini-program/src/stores/app.js @@ -55,6 +55,15 @@ const resetAuthState = () => { state.userProfile = null } +const applyProfileResponse = (profileData) => { + const profile = userProfileService.transformToFrontendFormat(profileData) + state.userProfile = profile + if (profile) { + Object.assign(state.registrationData, profile) + } + return profile +} + const clearStoredTokens = () => { uni.removeStorageSync('access_token') uni.removeStorageSync('refresh_token') @@ -98,14 +107,14 @@ const fetchUserProfile = async (options = {}) => { try { const res = await userProfileService.getCurrentProfile() if (res.data) { - state.userProfile = userProfileService.transformToFrontendFormat(res.data) - Object.assign(state.registrationData, state.userProfile) + const profile = applyProfileResponse(res.data) logAuth('profile:success', { hasProfile: true }) + return profile } else { state.userProfile = null logAuth('profile:empty', { hasProfile: false }) } - return res.data + return null } catch (error) { state.userProfile = null logAuth('profile:fail', { @@ -125,13 +134,17 @@ const saveUserProfile = async () => { state.isLoading = true try { const dataToSave = { ...state.registrationData } + let response if (state.userProfile?.id) { - await userProfileService.updateProfile({ + response = await userProfileService.updateProfile({ id: state.userProfile.id, ...dataToSave }) } else { - await userProfileService.createProfile(dataToSave) + response = await userProfileService.createProfile(dataToSave) + } + if (response?.data) { + applyProfileResponse(response.data) } await fetchUserProfile() return { success: true } @@ -243,8 +256,30 @@ const fetchScripts = async () => { const createScript = async (scriptData) => { try { const res = await epicScriptService.createScript(scriptData) + const script = epicScriptService.transformToFrontendFormat(res.data) await fetchScripts() - return { success: true, data: res.data } + return { success: true, data: script || res.data } + } catch (error) { + return { success: false, error: error.message } + } +} + +const updateScript = async (scriptData) => { + try { + const res = await epicScriptService.updateScript(scriptData) + const script = epicScriptService.transformToFrontendFormat(res.data) + await fetchScripts() + return { success: true, data: script || res.data } + } catch (error) { + return { success: false, error: error.message } + } +} + +const deleteScript = async (id) => { + try { + await epicScriptService.deleteScript(id) + await fetchScripts() + return { success: true } } catch (error) { return { success: false, error: error.message } } @@ -416,8 +451,18 @@ const initialize = async () => { export const useAppStore = () => { return readonly({ - ...state, - hasProfile, + get isLoggedIn() { return state.isLoggedIn }, + get isLoading() { return state.isLoading }, + get currentStep() { return state.currentStep }, + get userInfo() { return state.userInfo }, + get userProfile() { return state.userProfile }, + get events() { return state.events }, + get scripts() { return state.scripts }, + get inspirationRecommendations() { return state.inspirationRecommendations }, + get paths() { return state.paths }, + get currentPath() { return state.currentPath }, + get registrationData() { return state.registrationData }, + get hasProfile() { return hasProfile.value }, login, loginWithWechat, logout, @@ -436,6 +481,8 @@ export const useAppStore = () => { getEventById, fetchScripts, createScript, + updateScript, + deleteScript, fetchInspirationRecommendations, fetchRandomInspirations, generateScriptFromInspiration,