feat: 完善人生轨迹详情页功能及微信小程序登录优化
This commit is contained in:
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,7 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
|||||||
"/swagger-resources/**", // Swagger resources sub-paths
|
"/swagger-resources/**", // Swagger resources sub-paths
|
||||||
"/actuator/**", // Actuator endpoints
|
"/actuator/**", // Actuator endpoints
|
||||||
"/admin/**", // 排除管理员路径,由管理员拦截器处理
|
"/admin/**", // 排除管理员路径,由管理员拦截器处理
|
||||||
|
"/auth/wechat/login", // WeChat mini program login endpoint
|
||||||
"/error" // Spring Boot error page
|
"/error" // Spring Boot error page
|
||||||
)
|
)
|
||||||
.order(2); // 优先级2
|
.order(2); // 优先级2
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import com.emotion.dto.request.LoginRequest;
|
|||||||
import com.emotion.dto.request.RegisterRequest;
|
import com.emotion.dto.request.RegisterRequest;
|
||||||
import com.emotion.dto.request.RefreshTokenRequest;
|
import com.emotion.dto.request.RefreshTokenRequest;
|
||||||
import com.emotion.dto.request.ResetPasswordRequest;
|
import com.emotion.dto.request.ResetPasswordRequest;
|
||||||
|
import com.emotion.dto.request.WechatLoginRequest;
|
||||||
import com.emotion.dto.response.ResetPasswordResponse;
|
import com.emotion.dto.response.ResetPasswordResponse;
|
||||||
|
|
||||||
import com.emotion.dto.response.AuthResponse;
|
import com.emotion.dto.response.AuthResponse;
|
||||||
@@ -53,6 +54,13 @@ public class AuthController {
|
|||||||
/**
|
/**
|
||||||
* 用户注册(简化版:仅需手机号、密码和短信验证码)
|
* 用户注册(简化版:仅需手机号、密码和短信验证码)
|
||||||
*/
|
*/
|
||||||
|
@PostMapping("/wechat/login")
|
||||||
|
@Operation(summary = "微信小程序登录", description = "使用微信小程序登录 code 换取 openid 并登录")
|
||||||
|
public Result<AuthResponse> wechatLogin(@Valid @RequestBody WechatLoginRequest request) {
|
||||||
|
AuthResponse response = authService.wechatLogin(request);
|
||||||
|
return Result.success("登录成功", response);
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping(value = "/register")
|
@PostMapping(value = "/register")
|
||||||
@Operation(summary = "用户注册", description = "使用手机号、密码和短信验证码进行注册")
|
@Operation(summary = "用户注册", description = "使用手机号、密码和短信验证码进行注册")
|
||||||
public Result<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
|
public Result<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
|
||||||
@@ -177,4 +185,4 @@ public class AuthController {
|
|||||||
boolean exists = authService.existsByPhone(phone);
|
boolean exists = authService.existsByPhone(phone);
|
||||||
return Result.success(exists);
|
return Result.success(exists);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -98,6 +98,7 @@ public class JwtAuthInterceptor implements HandlerInterceptor {
|
|||||||
// 公开接口列表
|
// 公开接口列表
|
||||||
String[] publicEndpoints = {
|
String[] publicEndpoints = {
|
||||||
"/api/auth/login",
|
"/api/auth/login",
|
||||||
|
"/api/auth/wechat/login",
|
||||||
"/api/auth/register",
|
"/api/auth/register",
|
||||||
"/api/auth/captcha",
|
"/api/auth/captcha",
|
||||||
"/api/auth/refresh-token",
|
"/api/auth/refresh-token",
|
||||||
|
|||||||
+13
-2
@@ -7,6 +7,7 @@ import org.springframework.http.client.ClientHttpRequestInterceptor;
|
|||||||
import org.springframework.http.client.ClientHttpResponse;
|
import org.springframework.http.client.ClientHttpResponse;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.net.URI;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
@@ -34,13 +35,14 @@ public class WechatApiLoggingInterceptor implements ClientHttpRequestInterceptor
|
|||||||
ClientHttpRequestExecution execution) throws IOException {
|
ClientHttpRequestExecution execution) throws IOException {
|
||||||
|
|
||||||
long start = System.currentTimeMillis();
|
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);
|
ClientHttpResponse response = execution.execute(request, body);
|
||||||
long duration = System.currentTimeMillis() - start;
|
long duration = System.currentTimeMillis() - start;
|
||||||
|
|
||||||
log.info("[WeChatAPI] <--- {} {} ({}ms) content-type={}",
|
log.info("[WeChatAPI] <--- {} {} ({}ms) content-type={}",
|
||||||
response.getStatusCode(), request.getURI(), duration,
|
response.getStatusCode(), safeUri, duration,
|
||||||
response.getHeaders().getContentType());
|
response.getHeaders().getContentType());
|
||||||
|
|
||||||
// body 读取(已由 BufferingClientHttpRequestFactory 缓存,可重复读)
|
// body 读取(已由 BufferingClientHttpRequestFactory 缓存,可重复读)
|
||||||
@@ -66,4 +68,13 @@ public class WechatApiLoggingInterceptor implements ClientHttpRequestInterceptor
|
|||||||
}
|
}
|
||||||
return SENSITIVE_FIELDS.matcher(body).replaceAll("\"$1\":\"***\"");
|
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***");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.emotion.service;
|
|||||||
import com.emotion.dto.request.LoginRequest;
|
import com.emotion.dto.request.LoginRequest;
|
||||||
import com.emotion.dto.request.RegisterRequest;
|
import com.emotion.dto.request.RegisterRequest;
|
||||||
import com.emotion.dto.request.ResetPasswordRequest;
|
import com.emotion.dto.request.ResetPasswordRequest;
|
||||||
|
import com.emotion.dto.request.WechatLoginRequest;
|
||||||
import com.emotion.dto.response.ResetPasswordResponse;
|
import com.emotion.dto.response.ResetPasswordResponse;
|
||||||
|
|
||||||
import com.emotion.dto.response.AuthResponse;
|
import com.emotion.dto.response.AuthResponse;
|
||||||
@@ -28,6 +29,8 @@ public interface AuthService {
|
|||||||
*/
|
*/
|
||||||
AuthResponse login(LoginRequest request);
|
AuthResponse login(LoginRequest request);
|
||||||
|
|
||||||
|
AuthResponse wechatLogin(WechatLoginRequest request);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户注册
|
* 用户注册
|
||||||
*
|
*
|
||||||
@@ -165,4 +168,4 @@ public interface AuthService {
|
|||||||
* @return 是否验证成功
|
* @return 是否验证成功
|
||||||
*/
|
*/
|
||||||
boolean validateSmsCode(String phone, String code);
|
boolean validateSmsCode(String phone, String code);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ public interface TtsTaskService extends IService<TtsTask> {
|
|||||||
|
|
||||||
TtsTaskResponse createOrReuse(TtsTaskCreateRequest request);
|
TtsTaskResponse createOrReuse(TtsTaskCreateRequest request);
|
||||||
|
|
||||||
|
void prewarmEpicScript(String userId, String sourceId);
|
||||||
|
|
||||||
TtsTaskResponse getTask(String id);
|
TtsTaskResponse getTask(String id);
|
||||||
|
|
||||||
TtsTaskResponse getBySource(String sourceType, String sourceId, String voice,
|
TtsTaskResponse getBySource(String sourceType, String sourceId, String voice,
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ public interface UserService extends IService<User> {
|
|||||||
*/
|
*/
|
||||||
User getByPhone(String phone);
|
User getByPhone(String phone);
|
||||||
|
|
||||||
|
User getByThirdParty(String thirdPartyType, String thirdPartyId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建用户
|
* 创建用户
|
||||||
*
|
*
|
||||||
@@ -88,4 +90,4 @@ public interface UserService extends IService<User> {
|
|||||||
* @param lastActiveTime 最后活跃时间
|
* @param lastActiveTime 最后活跃时间
|
||||||
*/
|
*/
|
||||||
void updateLastActiveTime(String userId, LocalDateTime lastActiveTime);
|
void updateLastActiveTime(String userId, LocalDateTime lastActiveTime);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.emotion.service;
|
||||||
|
|
||||||
|
import com.emotion.dto.wechat.WechatCodeSessionResponse;
|
||||||
|
|
||||||
|
public interface WechatMiniProgramService {
|
||||||
|
|
||||||
|
WechatCodeSessionResponse code2Session(String code);
|
||||||
|
}
|
||||||
@@ -3,11 +3,13 @@ package com.emotion.service.impl;
|
|||||||
import com.emotion.dto.request.LoginRequest;
|
import com.emotion.dto.request.LoginRequest;
|
||||||
import com.emotion.dto.request.RegisterRequest;
|
import com.emotion.dto.request.RegisterRequest;
|
||||||
import com.emotion.dto.request.ResetPasswordRequest;
|
import com.emotion.dto.request.ResetPasswordRequest;
|
||||||
|
import com.emotion.dto.request.WechatLoginRequest;
|
||||||
import com.emotion.dto.response.AuthResponse;
|
import com.emotion.dto.response.AuthResponse;
|
||||||
import com.emotion.dto.response.CaptchaResponse;
|
import com.emotion.dto.response.CaptchaResponse;
|
||||||
import com.emotion.dto.response.ResetPasswordResponse;
|
import com.emotion.dto.response.ResetPasswordResponse;
|
||||||
import com.emotion.dto.response.SmsCodeResponse;
|
import com.emotion.dto.response.SmsCodeResponse;
|
||||||
import com.emotion.dto.response.UserInfoResponse;
|
import com.emotion.dto.response.UserInfoResponse;
|
||||||
|
import com.emotion.dto.wechat.WechatCodeSessionResponse;
|
||||||
import com.emotion.entity.User;
|
import com.emotion.entity.User;
|
||||||
import com.emotion.exception.AuthException;
|
import com.emotion.exception.AuthException;
|
||||||
import com.emotion.exception.BusinessException;
|
import com.emotion.exception.BusinessException;
|
||||||
@@ -15,6 +17,7 @@ import com.emotion.exception.CaptchaException;
|
|||||||
import com.emotion.exception.TokenException;
|
import com.emotion.exception.TokenException;
|
||||||
import com.emotion.service.AuthService;
|
import com.emotion.service.AuthService;
|
||||||
import com.emotion.service.UserService;
|
import com.emotion.service.UserService;
|
||||||
|
import com.emotion.service.WechatMiniProgramService;
|
||||||
import com.emotion.util.JwtUtil;
|
import com.emotion.util.JwtUtil;
|
||||||
import com.emotion.util.TokenUtil;
|
import com.emotion.util.TokenUtil;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@@ -51,6 +54,9 @@ public class AuthServiceImpl implements AuthService {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private UserService userService;
|
private UserService userService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private WechatMiniProgramService wechatMiniProgramService;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private RedisTemplate<String, Object> redisTemplate;
|
private RedisTemplate<String, Object> redisTemplate;
|
||||||
|
|
||||||
@@ -72,6 +78,7 @@ public class AuthServiceImpl implements AuthService {
|
|||||||
private static final int TOKEN_EXPIRE_HOURS = 24;
|
private static final int TOKEN_EXPIRE_HOURS = 24;
|
||||||
private static final int REFRESH_TOKEN_EXPIRE_DAYS = 7;
|
private static final int REFRESH_TOKEN_EXPIRE_DAYS = 7;
|
||||||
private static final String DEFAULT_SMS_CODE = "123456";
|
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");
|
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -114,6 +121,32 @@ public class AuthServiceImpl implements AuthService {
|
|||||||
return response;
|
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
|
@Override
|
||||||
public AuthResponse register(RegisterRequest request) {
|
public AuthResponse register(RegisterRequest request) {
|
||||||
// 验证短信验证码
|
// 验证短信验证码
|
||||||
@@ -626,6 +659,24 @@ public class AuthServiceImpl implements AuthService {
|
|||||||
*
|
*
|
||||||
* @return 随机密码
|
* @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() {
|
private String generateRandomPassword() {
|
||||||
String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
Random random = new Random();
|
Random random = new Random();
|
||||||
@@ -638,4 +689,4 @@ public class AuthServiceImpl implements AuthService {
|
|||||||
|
|
||||||
return sb.toString();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import com.emotion.service.AiRuntimeService;
|
|||||||
import com.emotion.service.EpicScriptService;
|
import com.emotion.service.EpicScriptService;
|
||||||
import com.emotion.service.LifePathService;
|
import com.emotion.service.LifePathService;
|
||||||
import com.emotion.service.ScriptContextService;
|
import com.emotion.service.ScriptContextService;
|
||||||
|
import com.emotion.service.TtsTaskService;
|
||||||
import com.emotion.util.UserContextHolder;
|
import com.emotion.util.UserContextHolder;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.BeanUtils;
|
import org.springframework.beans.BeanUtils;
|
||||||
@@ -75,6 +76,9 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
|
|||||||
@Autowired
|
@Autowired
|
||||||
private ScriptContextService scriptContextService;
|
private ScriptContextService scriptContextService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private TtsTaskService ttsTaskService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public PageResult<EpicScriptResponse> getPageByCurrentUser(EpicScriptPageRequest request) {
|
public PageResult<EpicScriptResponse> getPageByCurrentUser(EpicScriptPageRequest request) {
|
||||||
String currentUserId = UserContextHolder.getCurrentUserId();
|
String currentUserId = UserContextHolder.getCurrentUserId();
|
||||||
@@ -195,6 +199,7 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.save(script);
|
this.save(script);
|
||||||
|
prewarmScriptTts(script);
|
||||||
return convertToResponse(script);
|
return convertToResponse(script);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,9 +518,22 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.updateById(script);
|
this.updateById(script);
|
||||||
|
prewarmScriptTts(script);
|
||||||
return convertToResponse(script);
|
return convertToResponse(script);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void prewarmScriptTts(EpicScript script) {
|
||||||
|
if (script == null || !StringUtils.hasText(script.getId()) || !StringUtils.hasText(script.getUserId())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ttsTaskService.prewarmEpicScript(script.getUserId(), script.getId());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Epic script TTS prewarm failed, scriptId={}, userId={}, error={}",
|
||||||
|
script.getId(), script.getUserId(), e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 调用Coze AI重新生成爽文剧本内容
|
* 调用Coze AI重新生成爽文剧本内容
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ package com.emotion.service.impl;
|
|||||||
|
|
||||||
import com.emotion.service.TtsEngineClient;
|
import com.emotion.service.TtsEngineClient;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.web.client.RestTemplate;
|
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.time.Duration;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@@ -14,12 +17,19 @@ import java.util.Map;
|
|||||||
public class HttpTtsEngineClient implements TtsEngineClient {
|
public class HttpTtsEngineClient implements TtsEngineClient {
|
||||||
|
|
||||||
private final RestTemplate restTemplate;
|
private final RestTemplate restTemplate;
|
||||||
|
private final String engineUrl;
|
||||||
|
|
||||||
@Value("${emotion.tts.engine-url:http://127.0.0.1:19110}")
|
public HttpTtsEngineClient(RestTemplateBuilder restTemplateBuilder,
|
||||||
private String engineUrl;
|
@Value("${emotion.tts.engine-url:http://127.0.0.1:19110}") String engineUrl,
|
||||||
|
@Value("${emotion.tts.engine-connect-timeout-ms:5000}") long connectTimeoutMs,
|
||||||
public HttpTtsEngineClient(RestTemplate restTemplate) {
|
@Value("${emotion.tts.engine-read-timeout-ms:240000}") long readTimeoutMs) {
|
||||||
this.restTemplate = restTemplate;
|
this.engineUrl = normalizeEngineUrl(engineUrl);
|
||||||
|
this.restTemplate = restTemplateBuilder
|
||||||
|
.setConnectTimeout(Duration.ofMillis(connectTimeoutMs))
|
||||||
|
.setReadTimeout(Duration.ofMillis(readTimeoutMs))
|
||||||
|
.defaultHeader("User-Agent", "EmotionMuseum-TTS/1.0")
|
||||||
|
.defaultHeader("Accept", "application/json, text/plain, */*")
|
||||||
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -45,6 +55,9 @@ public class HttpTtsEngineClient implements TtsEngineClient {
|
|||||||
boolean success = data != null && Boolean.TRUE.equals(data.get("success"));
|
boolean success = data != null && Boolean.TRUE.equals(data.get("success"));
|
||||||
if (!success) {
|
if (!success) {
|
||||||
String message = data == null ? "empty response" : String.valueOf(data.get("errorMessage"));
|
String message = data == null ? "empty response" : String.valueOf(data.get("errorMessage"));
|
||||||
|
if (hasGeneratedAudio(outputPath)) {
|
||||||
|
return new TtsEngineResult(true, outputPath, null, null);
|
||||||
|
}
|
||||||
return new TtsEngineResult(false, null, null, message);
|
return new TtsEngineResult(false, null, null, message);
|
||||||
}
|
}
|
||||||
Long durationMs = data.get("durationMs") instanceof Number
|
Long durationMs = data.get("durationMs") instanceof Number
|
||||||
@@ -52,7 +65,26 @@ public class HttpTtsEngineClient implements TtsEngineClient {
|
|||||||
: null;
|
: null;
|
||||||
return new TtsEngineResult(true, String.valueOf(data.get("audioPath")), durationMs, null);
|
return new TtsEngineResult(true, String.valueOf(data.get("audioPath")), durationMs, null);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
if (hasGeneratedAudio(outputPath)) {
|
||||||
|
return new TtsEngineResult(true, outputPath, null, null);
|
||||||
|
}
|
||||||
return new TtsEngineResult(false, null, null, e.getMessage());
|
return new TtsEngineResult(false, null, null, e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String normalizeEngineUrl(String value) {
|
||||||
|
if (!StringUtils.hasText(value)) {
|
||||||
|
return "http://127.0.0.1:19110";
|
||||||
|
}
|
||||||
|
String trimmed = value.trim();
|
||||||
|
return trimmed.endsWith("/") ? trimmed.substring(0, trimmed.length() - 1) : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasGeneratedAudio(String outputPath) {
|
||||||
|
if (!StringUtils.hasText(outputPath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
File file = new File(outputPath);
|
||||||
|
return file.isFile() && file.length() > 0L;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.util.DigestUtils;
|
import org.springframework.util.DigestUtils;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -83,6 +84,25 @@ public class TtsTaskServiceImpl extends ServiceImpl<TtsTaskMapper, TtsTask> impl
|
|||||||
}
|
}
|
||||||
|
|
||||||
String userId = currentUserId();
|
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 sourceType = normalizeSourceType(request.getSourceType());
|
||||||
String sourceId = request.getSourceId().trim();
|
String sourceId = request.getSourceId().trim();
|
||||||
String voice = resolveVoice(request.getVoice());
|
String voice = resolveVoice(request.getVoice());
|
||||||
@@ -98,8 +118,13 @@ public class TtsTaskServiceImpl extends ServiceImpl<TtsTaskMapper, TtsTask> impl
|
|||||||
String hash = DigestUtils.md5DigestAsHex((voice + "\n" + options.cacheKey() + "\n" + cleaned).getBytes(StandardCharsets.UTF_8));
|
String hash = DigestUtils.md5DigestAsHex((voice + "\n" + options.cacheKey() + "\n" + cleaned).getBytes(StandardCharsets.UTF_8));
|
||||||
TtsTask owned = findOwnedTask(userId, sourceType, sourceId, voice, hash);
|
TtsTask owned = findOwnedTask(userId, sourceType, sourceId, voice, hash);
|
||||||
if (owned != null) {
|
if (owned != null) {
|
||||||
incrementRequestCount(owned);
|
if (STATUS_FAILED.equals(owned.getStatus()) && !recoverGeneratedAudio(owned)) {
|
||||||
return toResponse(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);
|
TtsTask cachedSuccess = findSuccessfulCache(voice, hash);
|
||||||
@@ -128,6 +153,9 @@ public class TtsTaskServiceImpl extends ServiceImpl<TtsTaskMapper, TtsTask> impl
|
|||||||
if (task == null || !userId.equals(task.getUserId())) {
|
if (task == null || !userId.equals(task.getUserId())) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (STATUS_FAILED.equals(task.getStatus())) {
|
||||||
|
recoverGeneratedAudio(task);
|
||||||
|
}
|
||||||
return toResponse(task);
|
return toResponse(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,9 +172,27 @@ public class TtsTaskServiceImpl extends ServiceImpl<TtsTaskMapper, TtsTask> impl
|
|||||||
}
|
}
|
||||||
String hash = DigestUtils.md5DigestAsHex((normalizedVoice + "\n" + options.cacheKey() + "\n" + cleaned).getBytes(StandardCharsets.UTF_8));
|
String hash = DigestUtils.md5DigestAsHex((normalizedVoice + "\n" + options.cacheKey() + "\n" + cleaned).getBytes(StandardCharsets.UTF_8));
|
||||||
TtsTask task = findOwnedTask(userId, normalizedSourceType, sourceId, normalizedVoice, hash);
|
TtsTask task = findOwnedTask(userId, normalizedSourceType, sourceId, normalizedVoice, hash);
|
||||||
|
if (task != null && STATUS_FAILED.equals(task.getStatus())) {
|
||||||
|
recoverGeneratedAudio(task);
|
||||||
|
}
|
||||||
return task == null ? null : toResponse(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) {
|
private void process(String taskId, String text, String voice, String outputPath, TtsEngineClient.SynthesisOptions options) {
|
||||||
try {
|
try {
|
||||||
TtsTask task = getById(taskId);
|
TtsTask task = getById(taskId);
|
||||||
@@ -178,6 +224,9 @@ public class TtsTaskServiceImpl extends ServiceImpl<TtsTaskMapper, TtsTask> impl
|
|||||||
log.warn("TTS task processing failed, taskId={}", taskId, e);
|
log.warn("TTS task processing failed, taskId={}", taskId, e);
|
||||||
TtsTask task = getById(taskId);
|
TtsTask task = getById(taskId);
|
||||||
if (task != null) {
|
if (task != null) {
|
||||||
|
if (recoverGeneratedAudio(task)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
task.setStatus(STATUS_FAILED);
|
task.setStatus(STATUS_FAILED);
|
||||||
task.setErrorMessage(limitError(e.getMessage()));
|
task.setErrorMessage(limitError(e.getMessage()));
|
||||||
updateById(task);
|
updateById(task);
|
||||||
|
|||||||
@@ -278,6 +278,18 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
|||||||
return this.getOne(wrapper);
|
return this.getOne(wrapper);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public User getByThirdParty(String thirdPartyType, String thirdPartyId) {
|
||||||
|
if (!StringUtils.hasText(thirdPartyType) || !StringUtils.hasText(thirdPartyId)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
wrapper.eq(User::getThirdPartyType, thirdPartyType)
|
||||||
|
.eq(User::getThirdPartyId, thirdPartyId)
|
||||||
|
.eq(User::getIsDeleted, 0);
|
||||||
|
return this.getOne(wrapper);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public User createUser(String account, String username, String password, String email, String phone) {
|
public User createUser(String account, String username, String password, String email, String phone) {
|
||||||
User user = new User();
|
User user = new User();
|
||||||
@@ -326,4 +338,4 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements Us
|
|||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-36
@@ -17,9 +17,6 @@ import org.springframework.web.client.RestClientException;
|
|||||||
import org.springframework.web.client.RestTemplate;
|
import org.springframework.web.client.RestTemplate;
|
||||||
import org.springframework.web.util.UriComponentsBuilder;
|
import org.springframework.web.util.UriComponentsBuilder;
|
||||||
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -43,56 +40,37 @@ public class WechatMiniProgramServiceImpl implements WechatMiniProgramService {
|
|||||||
try {
|
try {
|
||||||
responseEntity = restTemplate.getForEntity(url, String.class);
|
responseEntity = restTemplate.getForEntity(url, String.class);
|
||||||
} catch (ResourceAccessException e) {
|
} catch (ResourceAccessException e) {
|
||||||
// 网络层异常(连接超时、连接拒绝、DNS 失败)
|
log.error("[WeChatAPI] network error, jsCode={}", code, e);
|
||||||
log.error("[WeChatAPI] 网络异常,jsCode={}", code, e);
|
throw new WechatApiException(502, "Wechat API network error: " + e.getMessage(), null);
|
||||||
throw new WechatApiException(502, "微信API网络异常: " + e.getMessage(), null);
|
|
||||||
} catch (RestClientException e) {
|
} catch (RestClientException e) {
|
||||||
// 其他 RestTemplate 异常(如 SSL、URI 解析)
|
log.error("[WeChatAPI] client error, jsCode={}", code, e);
|
||||||
log.error("[WeChatAPI] 客户端异常,jsCode={}", code, e);
|
throw new WechatApiException(502, "Wechat API client error: " + e.getMessage(), null);
|
||||||
throw new WechatApiException(502, "微信API客户端异常: " + e.getMessage(), null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: HTTP 状态码检查(4xx/5xx 不会抛异常,因为 WechatResponseErrorHandler 处理了)
|
String responseBody = responseEntity.getBody();
|
||||||
if (!responseEntity.getStatusCode().is2xxSuccessful()) {
|
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,
|
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 拦截页)
|
JSONObject result = parseWechatJsonBody(responseEntity);
|
||||||
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());
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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");
|
int errcode = result.getIntValue("errcode");
|
||||||
if (errcode != 0) {
|
if (errcode != 0) {
|
||||||
String errmsg = result.getString("errmsg");
|
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,
|
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 openid = result.getString("openid");
|
||||||
String sessionKey = result.getString("session_key");
|
String sessionKey = result.getString("session_key");
|
||||||
if (openid == null || sessionKey == null) {
|
if (!StringUtils.hasText(openid) || !StringUtils.hasText(sessionKey)) {
|
||||||
log.error("[WeChatAPI] 响应缺少必要字段 (openid 或 session_key)");
|
log.error("[WeChatAPI] response missing openid or session_key, body={}", responseBody);
|
||||||
throw new WechatApiException(502,
|
throw new WechatApiException(502,
|
||||||
"微信API响应缺少 openid 或 session_key", responseEntity.getBody());
|
"Wechat API response missing openid or session_key", responseBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
WechatCodeSessionResponse response = new WechatCodeSessionResponse();
|
WechatCodeSessionResponse response = new WechatCodeSessionResponse();
|
||||||
@@ -102,6 +80,28 @@ public class WechatMiniProgramServiceImpl implements WechatMiniProgramService {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private JSONObject parseWechatJsonBody(ResponseEntity<String> 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() {
|
private void validateConfigured() {
|
||||||
if (!StringUtils.hasText(properties.getAppId()) || !StringUtils.hasText(properties.getAppSecret())) {
|
if (!StringUtils.hasText(properties.getAppId()) || !StringUtils.hasText(properties.getAppSecret())) {
|
||||||
throw new BusinessException("微信小程序登录未配置");
|
throw new BusinessException("微信小程序登录未配置");
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ emotion:
|
|||||||
tts:
|
tts:
|
||||||
enabled: true
|
enabled: true
|
||||||
engine-url: http://127.0.0.1:19110
|
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
|
output-dir: /data/uploads/emotion-museum/tts
|
||||||
public-url-prefix: /tts/audio
|
public-url-prefix: /tts/audio
|
||||||
max-text-length: 5000
|
max-text-length: 5000
|
||||||
|
|||||||
@@ -73,6 +73,12 @@ emotion:
|
|||||||
header: Authorization
|
header: Authorization
|
||||||
prefix: "Bearer "
|
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配置 - 所有环境统一
|
||||||
coze:
|
coze:
|
||||||
api:
|
api:
|
||||||
@@ -102,6 +108,8 @@ emotion:
|
|||||||
tts:
|
tts:
|
||||||
enabled: true
|
enabled: true
|
||||||
engine-url: http://127.0.0.1:19110
|
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
|
output-dir: /data/uploads/emotion-museum/tts
|
||||||
public-url-prefix: /tts/audio
|
public-url-prefix: /tts/audio
|
||||||
max-text-length: 5000
|
max-text-length: 5000
|
||||||
|
|||||||
+15
@@ -3,6 +3,7 @@ package com.emotion.interceptor;
|
|||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
import java.lang.reflect.Method;
|
import java.lang.reflect.Method;
|
||||||
|
import java.net.URI;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
@@ -103,4 +104,18 @@ class WechatApiLoggingInterceptorTest {
|
|||||||
assertTrue(result.contains("\"unionid\":\"***\""));
|
assertTrue(result.contains("\"unionid\":\"***\""));
|
||||||
assertTrue(result.contains("\"refresh_token\":\"***\""));
|
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=***"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+80
-119
@@ -4,38 +4,29 @@ import com.emotion.config.WechatMiniProgramProperties;
|
|||||||
import com.emotion.dto.wechat.WechatCodeSessionResponse;
|
import com.emotion.dto.wechat.WechatCodeSessionResponse;
|
||||||
import com.emotion.exception.WechatApiException;
|
import com.emotion.exception.WechatApiException;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.DisplayName;
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
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.HttpClientErrorException;
|
||||||
import org.springframework.web.client.ResourceAccessException;
|
import org.springframework.web.client.ResourceAccessException;
|
||||||
import org.springframework.web.client.RestTemplate;
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.mockito.ArgumentMatchers.*;
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
import static org.mockito.Mockito.*;
|
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 单元测试
|
|
||||||
*
|
|
||||||
* <p>10 个测试场景覆盖 5 步防御性处理的所有分支:
|
|
||||||
* <ol>
|
|
||||||
* <li>正常 JSON 成功</li>
|
|
||||||
* <li>HTTP 4xx</li>
|
|
||||||
* <li>HTTP 5xx</li>
|
|
||||||
* <li>Content-Type: text/plain</li>
|
|
||||||
* <li>Content-Type: text/html(WAF 拦截页)</li>
|
|
||||||
* <li>JSON 解析失败</li>
|
|
||||||
* <li>业务错误码 40029(invalid code)</li>
|
|
||||||
* <li>业务错误码 40163(code been used)</li>
|
|
||||||
* <li>网络异常(ResourceAccessException)</li>
|
|
||||||
* <li>响应缺少 session_key</li>
|
|
||||||
* </ol>
|
|
||||||
*/
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class WechatMiniProgramServiceImplTest {
|
class WechatMiniProgramServiceImplTest {
|
||||||
|
|
||||||
@@ -56,13 +47,9 @@ class WechatMiniProgramServiceImplTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("1. 正常 JSON 成功 - 返回 openid 和 session_key")
|
void code2Session_applicationJsonSuccess_returnsOpenIdAndSessionKey() {
|
||||||
void code2Session_NormalJsonSuccess_ReturnsOpenIdAndSessionKey() {
|
mockResponse(HttpStatus.OK, MediaType.APPLICATION_JSON,
|
||||||
String successBody = "{\"errcode\":0,\"openid\":\"oXyz123\",\"session_key\":\"abc456\"}";
|
"{\"errcode\":0,\"openid\":\"oXyz123\",\"session_key\":\"abc456\"}");
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
|
||||||
ResponseEntity<String> responseEntity = new ResponseEntity<>(successBody, headers, HttpStatus.OK);
|
|
||||||
when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity);
|
|
||||||
|
|
||||||
WechatCodeSessionResponse result = service.code2Session("validJsCode");
|
WechatCodeSessionResponse result = service.code2Session("validJsCode");
|
||||||
|
|
||||||
@@ -73,152 +60,126 @@ class WechatMiniProgramServiceImplTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("2. HTTP 4xx - 抛 WechatApiException(502)")
|
void code2Session_http4xx_throwsWechatApiException502() {
|
||||||
void code2Session_Http4xx_ThrowsWechatApiException_502() {
|
mockResponse(HttpStatus.BAD_REQUEST, null, "");
|
||||||
ResponseEntity<String> responseEntity = new ResponseEntity<>("", HttpStatus.BAD_REQUEST);
|
|
||||||
when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity);
|
WechatApiException ex = assertThrows(WechatApiException.class, () -> service.code2Session("code"));
|
||||||
|
|
||||||
WechatApiException ex = assertThrows(WechatApiException.class,
|
|
||||||
() -> service.code2Session("code"));
|
|
||||||
assertEquals(502, ex.getCode());
|
assertEquals(502, ex.getCode());
|
||||||
assertTrue(ex.getMessage().contains("非2xx") || ex.getMessage().contains("HTTP"));
|
assertTrue(ex.getMessage().contains("non-2xx") || ex.getMessage().contains("status"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("3. HTTP 5xx - 抛 WechatApiException(502)")
|
void code2Session_http5xx_throwsWechatApiException502() {
|
||||||
void code2Session_Http5xx_ThrowsWechatApiException_502() {
|
mockResponse(HttpStatus.INTERNAL_SERVER_ERROR, null, "");
|
||||||
ResponseEntity<String> responseEntity = new ResponseEntity<>("", HttpStatus.INTERNAL_SERVER_ERROR);
|
|
||||||
when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity);
|
WechatApiException ex = assertThrows(WechatApiException.class, () -> service.code2Session("code"));
|
||||||
|
|
||||||
WechatApiException ex = assertThrows(WechatApiException.class,
|
|
||||||
() -> service.code2Session("code"));
|
|
||||||
assertEquals(502, ex.getCode());
|
assertEquals(502, ex.getCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("4. Content-Type: text/plain - 抛 WechatApiException(502)")
|
void code2Session_textPlainJsonSuccess_returnsOpenIdAndSessionKey() {
|
||||||
void code2Session_ContentTypeTextPlain_ThrowsWechatApiException_502() {
|
mockResponse(HttpStatus.OK, MediaType.TEXT_PLAIN,
|
||||||
HttpHeaders headers = new HttpHeaders();
|
"{\"session_key\":\"abc456\",\"openid\":\"oXyz123\"}");
|
||||||
headers.setContentType(MediaType.TEXT_PLAIN);
|
|
||||||
String body = "<html>WAF Challenge</html>";
|
|
||||||
ResponseEntity<String> responseEntity = new ResponseEntity<>(body, headers, HttpStatus.OK);
|
|
||||||
when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity);
|
|
||||||
|
|
||||||
WechatApiException ex = assertThrows(WechatApiException.class,
|
WechatCodeSessionResponse result = service.code2Session("code");
|
||||||
() -> service.code2Session("code"));
|
|
||||||
assertEquals(502, ex.getCode());
|
assertNotNull(result);
|
||||||
assertTrue(ex.getMessage().contains("Content-Type") || ex.getMessage().contains("JSON"));
|
assertEquals("oXyz123", result.getOpenid());
|
||||||
|
assertEquals("abc456", result.getSessionKey());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("5. Content-Type: text/html(WAF 拦截页) - 抛 WechatApiException(502)")
|
void code2Session_textHtmlWafChallenge_throwsWechatApiException502() {
|
||||||
void code2Session_ContentTypeTextHtml_WafChallenge_ThrowsWechatApiException_502() {
|
mockResponse(HttpStatus.OK, MediaType.TEXT_HTML, "<html>WAF Challenge</html>");
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.setContentType(MediaType.TEXT_HTML);
|
WechatApiException ex = assertThrows(WechatApiException.class, () -> service.code2Session("code"));
|
||||||
String body = "<html>WAF Challenge</html>";
|
|
||||||
ResponseEntity<String> responseEntity = new ResponseEntity<>(body, headers, HttpStatus.OK);
|
|
||||||
when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity);
|
|
||||||
|
|
||||||
WechatApiException ex = assertThrows(WechatApiException.class,
|
|
||||||
() -> service.code2Session("code"));
|
|
||||||
assertEquals(502, ex.getCode());
|
assertEquals(502, ex.getCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("6. JSON 解析失败 - 抛 WechatApiException(500)")
|
void code2Session_applicationJsonParseFailure_throwsWechatApiException500() {
|
||||||
void code2Session_JsonParseFailure_ThrowsWechatApiException_500() {
|
mockResponse(HttpStatus.OK, MediaType.APPLICATION_JSON, "not valid json{");
|
||||||
HttpHeaders headers = new HttpHeaders();
|
|
||||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
WechatApiException ex = assertThrows(WechatApiException.class, () -> service.code2Session("code"));
|
||||||
String body = "not valid json{";
|
|
||||||
ResponseEntity<String> responseEntity = new ResponseEntity<>(body, headers, HttpStatus.OK);
|
|
||||||
when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity);
|
|
||||||
|
|
||||||
WechatApiException ex = assertThrows(WechatApiException.class,
|
|
||||||
() -> service.code2Session("code"));
|
|
||||||
assertEquals(500, ex.getCode());
|
assertEquals(500, ex.getCode());
|
||||||
assertTrue(ex.getMessage().contains("JSON"));
|
assertTrue(ex.getMessage().contains("JSON"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("7. 业务错误码 40029(invalid code) - 抛 WechatApiException(400)")
|
void code2Session_businessErrorInvalidCode_throwsWechatApiException400() {
|
||||||
void code2Session_BusinessErrorCode40029_InvalidCode_ThrowsWechatApiException_400() {
|
mockResponse(HttpStatus.OK, MediaType.APPLICATION_JSON,
|
||||||
HttpHeaders headers = new HttpHeaders();
|
"{\"errcode\":40029,\"errmsg\":\"invalid code\"}");
|
||||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
|
||||||
String body = "{\"errcode\":40029,\"errmsg\":\"invalid code\"}";
|
WechatApiException ex = assertThrows(WechatApiException.class, () -> service.code2Session("invalidCode"));
|
||||||
ResponseEntity<String> responseEntity = new ResponseEntity<>(body, headers, HttpStatus.OK);
|
|
||||||
when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity);
|
|
||||||
|
|
||||||
WechatApiException ex = assertThrows(WechatApiException.class,
|
|
||||||
() -> service.code2Session("invalidCode"));
|
|
||||||
assertEquals(400, ex.getCode());
|
assertEquals(400, ex.getCode());
|
||||||
assertTrue(ex.getMessage().contains("40029") || ex.getMessage().contains("invalid"));
|
assertTrue(ex.getMessage().contains("40029") || ex.getMessage().contains("invalid"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("8. 业务错误码 40163(code been used) - 抛 WechatApiException(400)")
|
void code2Session_businessErrorCodeUsed_throwsWechatApiException400() {
|
||||||
void code2Session_BusinessErrorCode40163_CodeUsed_ThrowsWechatApiException_400() {
|
mockResponse(HttpStatus.OK, MediaType.APPLICATION_JSON,
|
||||||
HttpHeaders headers = new HttpHeaders();
|
"{\"errcode\":40163,\"errmsg\":\"code been used\"}");
|
||||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
|
||||||
String body = "{\"errcode\":40163,\"errmsg\":\"code been used\"}";
|
WechatApiException ex = assertThrows(WechatApiException.class, () -> service.code2Session("usedCode"));
|
||||||
ResponseEntity<String> responseEntity = new ResponseEntity<>(body, headers, HttpStatus.OK);
|
|
||||||
when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity);
|
|
||||||
|
|
||||||
WechatApiException ex = assertThrows(WechatApiException.class,
|
|
||||||
() -> service.code2Session("usedCode"));
|
|
||||||
assertEquals(400, ex.getCode());
|
assertEquals(400, ex.getCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("9. 网络异常(ResourceAccessException) - 抛 WechatApiException(502)")
|
void code2Session_resourceAccessException_throwsWechatApiException502() {
|
||||||
void code2Session_NetworkException_ResourceAccessException_ThrowsWechatApiException_502() {
|
|
||||||
when(restTemplate.getForEntity(anyString(), eq(String.class)))
|
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());
|
assertEquals(502, ex.getCode());
|
||||||
assertTrue(ex.getMessage().contains("网络") || ex.getMessage().contains("network"));
|
assertTrue(ex.getMessage().contains("network"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("10. 响应缺少 session_key - 抛 WechatApiException(502)")
|
void code2Session_missingSessionKey_throwsWechatApiException502() {
|
||||||
void code2Session_MissingSessionKey_ThrowsWechatApiException_502() {
|
mockResponse(HttpStatus.OK, MediaType.APPLICATION_JSON,
|
||||||
HttpHeaders headers = new HttpHeaders();
|
"{\"errcode\":0,\"openid\":\"oXyz123\"}");
|
||||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
|
||||||
String body = "{\"errcode\":0,\"openid\":\"oXyz123\"}";
|
WechatApiException ex = assertThrows(WechatApiException.class, () -> service.code2Session("code"));
|
||||||
ResponseEntity<String> responseEntity = new ResponseEntity<>(body, headers, HttpStatus.OK);
|
|
||||||
when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity);
|
|
||||||
|
|
||||||
WechatApiException ex = assertThrows(WechatApiException.class,
|
|
||||||
() -> service.code2Session("code"));
|
|
||||||
assertEquals(502, ex.getCode());
|
assertEquals(502, ex.getCode());
|
||||||
assertTrue(ex.getMessage().contains("session_key"));
|
assertTrue(ex.getMessage().contains("session_key"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("11. 其他 RestClientException(非 ResourceAccessException)- 抛 WechatApiException(502)")
|
void code2Session_otherRestClientException_throwsWechatApiException502() {
|
||||||
void code2Session_OtherRestClientException_ThrowsWechatApiException_502() {
|
|
||||||
when(restTemplate.getForEntity(anyString(), eq(String.class)))
|
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());
|
assertEquals(502, ex.getCode());
|
||||||
assertTrue(ex.getMessage().contains("RestClient") || ex.getMessage().contains("客户端"));
|
assertTrue(ex.getMessage().contains("client error") || ex.getMessage().contains("403"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("12. 响应缺少 openid(有 session_key)- 抛 WechatApiException(502)")
|
void code2Session_missingOpenId_throwsWechatApiException502() {
|
||||||
void code2Session_MissingOpenId_ThrowsWechatApiException_502() {
|
mockResponse(HttpStatus.OK, MediaType.APPLICATION_JSON,
|
||||||
HttpHeaders headers = new HttpHeaders();
|
"{\"errcode\":0,\"session_key\":\"abc456\"}");
|
||||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
|
||||||
String body = "{\"errcode\":0,\"session_key\":\"abc456\"}"; // no openid
|
WechatApiException ex = assertThrows(WechatApiException.class, () -> service.code2Session("code"));
|
||||||
ResponseEntity<String> responseEntity = new ResponseEntity<>(body, headers, HttpStatus.OK);
|
|
||||||
when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity);
|
|
||||||
|
|
||||||
WechatApiException ex = assertThrows(WechatApiException.class,
|
|
||||||
() -> service.code2Session("code"));
|
|
||||||
assertEquals(502, ex.getCode());
|
assertEquals(502, ex.getCode());
|
||||||
assertTrue(ex.getMessage().contains("openid") || ex.getMessage().contains("session_key"));
|
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<String> responseEntity = new ResponseEntity<>(body, headers, status);
|
||||||
|
when(restTemplate.getForEntity(anyString(), eq(String.class))).thenReturn(responseEntity);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 及代码库一致
|
||||||
@@ -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 画了一个通用人形。需要替换为 `<image>` 组件显示 Dicebear 根据昵称生成的头像。
|
||||||
|
|
||||||
|
- [ ] **Step 1: 在 template 中替换 CSS 人形为 image 组件**
|
||||||
|
|
||||||
|
将:
|
||||||
|
```html
|
||||||
|
<view class="avatar-circle">
|
||||||
|
<view class="person-head"></view>
|
||||||
|
<view class="person-body"></view>
|
||||||
|
</view>
|
||||||
|
```
|
||||||
|
|
||||||
|
改为:
|
||||||
|
```html
|
||||||
|
<view class="avatar-circle">
|
||||||
|
<image class="avatar-img" :src="avatarUrl" mode="aspectFill" />
|
||||||
|
</view>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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
|
||||||
|
<view class="stats-grid">
|
||||||
|
<view class="stat-card">
|
||||||
|
<text class="stat-label">觉醒深度</text>
|
||||||
|
<text class="stat-value">Lv.4</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-card">
|
||||||
|
<text class="stat-label">星历契合</text>
|
||||||
|
<text class="stat-value">98%</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
```
|
||||||
|
|
||||||
|
改为:
|
||||||
|
```html
|
||||||
|
<view class="stats-grid">
|
||||||
|
<view class="stat-card">
|
||||||
|
<text class="stat-label">觉醒深度</text>
|
||||||
|
<text class="stat-value">{{ awakenLevel }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-card">
|
||||||
|
<text class="stat-label">星历契合</text>
|
||||||
|
<text class="stat-value">{{ harmonyPercent }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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: 确认 `<image>` 和 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 中一致
|
||||||
@@ -2,7 +2,13 @@ import { computed, onUnmounted, ref } from 'vue'
|
|||||||
import { createTtsTask, getTtsTask, getTtsTaskBySource } from '../services/tts.js'
|
import { createTtsTask, getTtsTask, getTtsTaskBySource } from '../services/tts.js'
|
||||||
import analytics from '../services/analytics.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' } = {}) => {
|
export const useTtsPlayer = ({ pagePath = '', sourceType = 'epic_script' } = {}) => {
|
||||||
const task = ref(null)
|
const task = ref(null)
|
||||||
@@ -11,6 +17,8 @@ export const useTtsPlayer = ({ pagePath = '', sourceType = 'epic_script' } = {})
|
|||||||
const statusText = ref('')
|
const statusText = ref('')
|
||||||
let audio = null
|
let audio = null
|
||||||
let timer = null
|
let timer = null
|
||||||
|
let prewarmPromise = null
|
||||||
|
let prewarmSourceId = ''
|
||||||
|
|
||||||
const buttonText = computed(() => {
|
const buttonText = computed(() => {
|
||||||
if (loading.value) return '正在生成朗读'
|
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) => {
|
const playSource = async (sourceId) => {
|
||||||
if (!sourceId) {
|
if (!sourceId) {
|
||||||
uni.showToast({ title: '生成保存后可播放', icon: 'none' })
|
uni.showToast({ title: '生成保存后可播放', icon: 'none' })
|
||||||
@@ -162,6 +213,21 @@ export const useTtsPlayer = ({ pagePath = '', sourceType = 'epic_script' } = {})
|
|||||||
}
|
}
|
||||||
if (loading.value) return
|
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') {
|
if (task.value?.status === 'success') {
|
||||||
play(sourceId)
|
play(sourceId)
|
||||||
return
|
return
|
||||||
@@ -195,6 +261,8 @@ export const useTtsPlayer = ({ pagePath = '', sourceType = 'epic_script' } = {})
|
|||||||
setTask(null)
|
setTask(null)
|
||||||
statusText.value = ''
|
statusText.value = ''
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
prewarmPromise = null
|
||||||
|
prewarmSourceId = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
onUnmounted(reset)
|
onUnmounted(reset)
|
||||||
@@ -205,6 +273,7 @@ export const useTtsPlayer = ({ pagePath = '', sourceType = 'epic_script' } = {})
|
|||||||
playing,
|
playing,
|
||||||
statusText,
|
statusText,
|
||||||
buttonText,
|
buttonText,
|
||||||
|
prewarmSource,
|
||||||
playSource,
|
playSource,
|
||||||
reset
|
reset
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,23 +45,14 @@
|
|||||||
|
|
||||||
<view class="event-body-section">
|
<view class="event-body-section">
|
||||||
<view class="event-body-toolbar">
|
<view class="event-body-toolbar">
|
||||||
<view class="content-action favorite-action" @click="toggleFavorite">
|
<view class="action-btn favorite-btn" @click="toggleFavorite">
|
||||||
<view class="bookmark-icon" :class="{ active: isFavorite }"></view>
|
<text class="action-icon">{{ isFavorite ? '★' : '☆' }}</text>
|
||||||
<text>{{ isFavorite ? '已收藏' : '收藏' }}</text>
|
<text class="action-label">{{ isFavorite ? '已收藏' : '收藏' }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="toolbar-right">
|
<view class="action-btn share-btn" @click="shareCurrent">
|
||||||
<view class="analysis-pill" @click="scrollToAnalysis">
|
<text class="action-icon">↗</text>
|
||||||
<text>分析</text>
|
<text class="action-label">分享</text>
|
||||||
</view>
|
|
||||||
<view class="content-action share-action" @click="shareCurrent">
|
|
||||||
<view class="share-icon">
|
|
||||||
<view></view>
|
|
||||||
<view></view>
|
|
||||||
<view></view>
|
|
||||||
</view>
|
|
||||||
<text>分享</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -75,12 +66,9 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="event-body-footer">
|
<view class="event-body-footer">
|
||||||
<view class="content-action edit-action" @click="editEvent">
|
<text class="edit-link" @click="editEvent">✎ 编辑</text>
|
||||||
<view class="edit-icon"></view>
|
<text class="collapse-link" @click="isDescriptionCollapsed = !isDescriptionCollapsed">
|
||||||
<text>编辑</text>
|
{{ isDescriptionCollapsed ? '展开 ▼' : '收起 ▲' }}
|
||||||
</view>
|
|
||||||
<text class="collapse-action" @click="isDescriptionCollapsed = !isDescriptionCollapsed">
|
|
||||||
{{ isDescriptionCollapsed ? '展开' : '收起' }} ^
|
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -238,8 +226,18 @@ const locationText = computed(() => {
|
|||||||
return profile.value.city ? `中国 · ${profile.value.city}` : '中国 · 深圳'
|
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(() => {
|
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(() => {
|
const relatedTags = computed(() => {
|
||||||
@@ -300,16 +298,6 @@ const onContentScroll = (event) => {
|
|||||||
currentScrollTop.value = event.detail?.scrollTop || 0
|
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 = () => {
|
const editEvent = () => {
|
||||||
if (!eventId.value) return
|
if (!eventId.value) return
|
||||||
uni.navigateTo({ url: `/pages/life-event/form?id=${eventId.value}` })
|
uni.navigateTo({ url: `/pages/life-event/form?id=${eventId.value}` })
|
||||||
@@ -652,55 +640,37 @@ const goBack = () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 18rpx;
|
gap: 12rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-body-toolbar {
|
.event-body-toolbar {
|
||||||
min-height: 64rpx;
|
min-height: 48rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-right {
|
.action-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
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 {
|
.action-icon {
|
||||||
min-height: 64rpx;
|
font-size: 26rpx;
|
||||||
display: flex;
|
line-height: 1;
|
||||||
align-items: center;
|
color: rgba(238, 231, 255, 0.82);
|
||||||
justify-content: center;
|
|
||||||
gap: 10rpx;
|
|
||||||
color: rgba(238, 231, 255, 0.88);
|
|
||||||
font-size: 23rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.favorite-action,
|
.favorite-btn .action-icon {
|
||||||
.share-action,
|
color: #ffd36e;
|
||||||
.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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.description-title-row {
|
.description-title-row {
|
||||||
margin-top: 16rpx;
|
margin-top: 16rpx;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title-row {
|
.section-title-row {
|
||||||
@@ -735,25 +705,28 @@ const goBack = () => {
|
|||||||
|
|
||||||
.description-markdown {
|
.description-markdown {
|
||||||
margin-top: 22rpx;
|
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;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.description-markdown.collapsed {
|
.description-markdown.collapsed {
|
||||||
max-height: 168rpx;
|
max-height: 280rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.event-body-footer {
|
.event-body-footer {
|
||||||
margin-top: 18rpx;
|
margin-top: 14rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.collapse-action {
|
.edit-link,
|
||||||
min-height: 64rpx;
|
.collapse-link {
|
||||||
display: flex;
|
color: rgba(169, 112, 255, 0.78);
|
||||||
align-items: center;
|
font-size: 22rpx;
|
||||||
justify-content: flex-end;
|
font-weight: 700;
|
||||||
color: #a970ff;
|
line-height: 1;
|
||||||
font-size: 24rpx;
|
padding: 8rpx 0;
|
||||||
font-weight: 800;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.analysis-panel {
|
.analysis-panel {
|
||||||
@@ -930,101 +903,5 @@ const goBack = () => {
|
|||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-icon {
|
/* 操作按钮使用 Unicode 图标,无需自定义 CSS 图标 */
|
||||||
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);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -146,17 +146,14 @@ const terminateLifeHarmony = () => {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background:
|
background: transparent;
|
||||||
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%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-center::before {
|
.profile-center::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
top: -80rpx;
|
top: 80rpx;
|
||||||
width: 720rpx;
|
width: 720rpx;
|
||||||
height: 720rpx;
|
height: 720rpx;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
|
|||||||
@@ -123,6 +123,9 @@ const loadScript = async () => {
|
|||||||
await store.fetchScripts()
|
await store.fetchScripts()
|
||||||
script.value = store.getScriptById(scriptId.value)
|
script.value = store.getScriptById(scriptId.value)
|
||||||
}
|
}
|
||||||
|
if (script.value?.id) {
|
||||||
|
ttsPlayer.prewarmSource(script.value.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const continueCurrent = () => {
|
const continueCurrent = () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="script-library">
|
<view class="script-library" @click="closeScriptMenu">
|
||||||
<view class="page-head">
|
<view class="page-head">
|
||||||
<view class="back-title" @click="backToScript">
|
<view class="back-title" @click="backToScript">
|
||||||
<text class="back-arrow">‹</text>
|
<text class="back-arrow">‹</text>
|
||||||
@@ -71,7 +71,27 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="right-state">
|
<view class="right-state">
|
||||||
<text class="state-pill" :class="'state-' + getStatus(script)">{{ getStatusLabel(script) }}</text>
|
<text class="state-pill" :class="'state-' + getStatus(script)">{{ getStatusLabel(script) }}</text>
|
||||||
<text class="ellipsis" @click.stop="openScriptMenu(script)">•••</text>
|
<view class="menu-wrap">
|
||||||
|
<text class="ellipsis" :class="{ active: activeMenuId === String(script.id) }" @click.stop="toggleScriptMenu(script)">•••</text>
|
||||||
|
<view v-if="activeMenuId === String(script.id)" class="card-menu" @click.stop>
|
||||||
|
<view class="menu-action" @click="toggleFavorite(script)">
|
||||||
|
<text class="menu-dot"></text>
|
||||||
|
<text>{{ isFavorite(script) ? '取消收藏' : '收藏剧本' }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="menu-action" @click="continueScript(script)">
|
||||||
|
<text class="menu-dot"></text>
|
||||||
|
<text>继续生成</text>
|
||||||
|
</view>
|
||||||
|
<view class="menu-action" @click="openScriptDetailFromMenu(script)">
|
||||||
|
<text class="menu-dot"></text>
|
||||||
|
<text>查看详情</text>
|
||||||
|
</view>
|
||||||
|
<view class="menu-action danger" @click="requestDeleteScript(script)">
|
||||||
|
<text class="menu-dot"></text>
|
||||||
|
<text>删除剧本</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -112,6 +132,17 @@
|
|||||||
<text class="empty-text">去爽文生成页写下一句灵感,生成你的第一段平行人生。</text>
|
<text class="empty-text">去爽文生成页写下一句灵感,生成你的第一段平行人生。</text>
|
||||||
<view class="empty-action" @click="createScript">新建剧本</view>
|
<view class="empty-action" @click="createScript">新建剧本</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view v-if="deleteTarget" class="confirm-mask" @click.stop="cancelDeleteScript">
|
||||||
|
<view class="confirm-panel" @click.stop>
|
||||||
|
<text class="confirm-title">删除剧本</text>
|
||||||
|
<text class="confirm-copy">确定删除《{{ deleteTarget.title || '未命名剧本' }}》吗?删除后将无法在历史剧本中查看。</text>
|
||||||
|
<view class="confirm-actions">
|
||||||
|
<view class="confirm-btn secondary" @click="cancelDeleteScript">取消</view>
|
||||||
|
<view class="confirm-btn danger" :class="{ disabled: deletingScript }" @click="confirmDeleteScript">{{ deletingScript ? '删除中' : '确认删除' }}</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -126,6 +157,9 @@ const keyword = ref('')
|
|||||||
const sortMode = ref('updated')
|
const sortMode = ref('updated')
|
||||||
const viewMode = ref('list')
|
const viewMode = ref('list')
|
||||||
const localFavorites = ref(uni.getStorageSync('script_favorites') || {})
|
const localFavorites = ref(uni.getStorageSync('script_favorites') || {})
|
||||||
|
const activeMenuId = ref('')
|
||||||
|
const deleteTarget = ref(null)
|
||||||
|
const deletingScript = ref(false)
|
||||||
|
|
||||||
const typeTabs = [
|
const typeTabs = [
|
||||||
{ label: '长篇', value: 'long' },
|
{ label: '长篇', value: 'long' },
|
||||||
@@ -350,23 +384,73 @@ const toggleViewMode = () => {
|
|||||||
viewMode.value = viewMode.value === 'list' ? 'grid' : 'list'
|
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)
|
const favorite = isFavorite(script)
|
||||||
uni.showActionSheet({
|
const next = { ...localFavorites.value }
|
||||||
itemList: [favorite ? '取消收藏' : '收藏剧本', '继续生成', '查看详情'],
|
if (favorite) delete next[String(script.id)]
|
||||||
success: ({ tapIndex }) => {
|
else next[String(script.id)] = true
|
||||||
if (tapIndex === 0) {
|
localFavorites.value = next
|
||||||
const next = { ...localFavorites.value }
|
uni.setStorageSync('script_favorites', next)
|
||||||
if (favorite) delete next[String(script.id)]
|
closeScriptMenu()
|
||||||
else next[String(script.id)] = true
|
uni.showToast({ title: favorite ? '已取消收藏' : '已收藏', icon: 'success' })
|
||||||
localFavorites.value = next
|
}
|
||||||
uni.setStorageSync('script_favorites', next)
|
|
||||||
uni.showToast({ title: favorite ? '已取消收藏' : '已收藏', icon: 'success' })
|
const continueScript = (script) => {
|
||||||
}
|
closeScriptMenu()
|
||||||
if (tapIndex === 1) viewScript(script)
|
viewScript(script)
|
||||||
if (tapIndex === 2) openScriptDetail(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' })
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
@@ -670,6 +754,7 @@ const openScriptMenu = (script) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.right-state {
|
.right-state {
|
||||||
|
position: relative;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
gap: 14rpx;
|
gap: 14rpx;
|
||||||
}
|
}
|
||||||
@@ -699,11 +784,89 @@ const openScriptMenu = (script) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ellipsis {
|
.ellipsis {
|
||||||
|
min-width: 48rpx;
|
||||||
|
height: 44rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 18rpx;
|
||||||
color: rgba(224, 214, 243, 0.66);
|
color: rgba(224, 214, 243, 0.66);
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
letter-spacing: 3rpx;
|
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 {
|
.tag-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -826,4 +989,79 @@ const openScriptMenu = (script) => {
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
background: linear-gradient(135deg, #b346ff, #7330ff);
|
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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,10 +3,10 @@
|
|||||||
<view class="space-bg"></view>
|
<view class="space-bg"></view>
|
||||||
<view class="status-space" :style="{ height: capsuleTopReservePx + 'px' }"></view>
|
<view class="status-space" :style="{ height: capsuleTopReservePx + 'px' }"></view>
|
||||||
|
|
||||||
<view class="topbar" :style="topbarStyle">
|
<view class="topbar">
|
||||||
<text class="back" @click="goBack">‹</text>
|
<text class="back" @click="goBack">‹</text>
|
||||||
<text class="title">编辑资料 <text class="gold">✦</text></text>
|
<text class="title">编辑资料 <text class="gold">✦</text></text>
|
||||||
<text class="save" @click="saveProfile">保存</text>
|
<text class="save" :class="{ disabled: saving }" @click="saveProfile">{{ saving ? '保存中' : '保存' }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<scroll-view class="scroll" scroll-y :show-scrollbar="false">
|
<scroll-view class="scroll" scroll-y :show-scrollbar="false">
|
||||||
@@ -188,7 +188,7 @@ import { useAppStore } from '../../stores/app.js'
|
|||||||
import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js'
|
import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js'
|
||||||
|
|
||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
const { capsuleTopReservePx, topbarStyle } = useMenuButtonSafeArea({ extraTopPx: 2 })
|
const { capsuleTopReservePx } = useMenuButtonSafeArea({ extraTopPx: 2 })
|
||||||
const isEdit = ref(false)
|
const isEdit = ref(false)
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const birthday = ref('')
|
const birthday = ref('')
|
||||||
@@ -319,6 +319,7 @@ const saveProfile = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
syncFromStore()
|
||||||
uni.showToast({ title: '已保存', icon: 'success' })
|
uni.showToast({ title: '已保存', icon: 'success' })
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (isEdit.value) uni.navigateBack()
|
if (isEdit.value) uni.navigateBack()
|
||||||
@@ -331,9 +332,12 @@ const goBack = () => {
|
|||||||
else uni.reLaunch({ url: '/pages/main/index?tab=script' })
|
else uni.reLaunch({ url: '/pages/main/index?tab=script' })
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
const pages = getCurrentPages()
|
const pages = getCurrentPages()
|
||||||
isEdit.value = pages[pages.length - 1]?.options?.edit === '1'
|
isEdit.value = pages[pages.length - 1]?.options?.edit === '1'
|
||||||
|
if (isEdit.value) {
|
||||||
|
await store.fetchUserProfile()
|
||||||
|
}
|
||||||
syncFromStore()
|
syncFromStore()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -374,7 +378,7 @@ onMounted(() => {
|
|||||||
.topbar {
|
.topbar {
|
||||||
height: 72rpx;
|
height: 72rpx;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 76rpx 1fr 76rpx;
|
grid-template-columns: 76rpx 1fr 112rpx;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 28rpx;
|
padding: 0 28rpx;
|
||||||
}
|
}
|
||||||
@@ -401,6 +405,12 @@ onMounted(() => {
|
|||||||
color: #b94cff;
|
color: #b94cff;
|
||||||
font-size: 26rpx;
|
font-size: 26rpx;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
justify-self: end;
|
||||||
|
min-width: 96rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save.disabled {
|
||||||
|
opacity: 0.58;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scroll {
|
.scroll {
|
||||||
|
|||||||
@@ -40,6 +40,22 @@ export const login = async ({ phone, smsCode }) => {
|
|||||||
return response
|
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<void>}
|
* @returns {Promise<void>}
|
||||||
@@ -120,6 +136,7 @@ export const checkPhone = async (phone) => {
|
|||||||
export default {
|
export default {
|
||||||
getSmsCode,
|
getSmsCode,
|
||||||
login,
|
login,
|
||||||
|
wechatLogin,
|
||||||
logout,
|
logout,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
validateToken,
|
validateToken,
|
||||||
|
|||||||
@@ -95,6 +95,10 @@ const transformToBackendFormat = (frontendData) => {
|
|||||||
character,
|
character,
|
||||||
events,
|
events,
|
||||||
plotJson,
|
plotJson,
|
||||||
|
conversationId,
|
||||||
|
parentScriptId,
|
||||||
|
revisionIndex,
|
||||||
|
revisionOf,
|
||||||
useSocialInsights
|
useSocialInsights
|
||||||
} = frontendData
|
} = frontendData
|
||||||
|
|
||||||
@@ -112,7 +116,14 @@ const transformToBackendFormat = (frontendData) => {
|
|||||||
plotTurning: frontendData.plotTurning || '',
|
plotTurning: frontendData.plotTurning || '',
|
||||||
plotClimax: frontendData.plotClimax || '',
|
plotClimax: frontendData.plotClimax || '',
|
||||||
plotEnding: frontendData.plotEnding || '',
|
plotEnding: frontendData.plotEnding || '',
|
||||||
plotJson: plotJson || (content ? { fullContent: content } : null),
|
plotJson: {
|
||||||
|
...(content ? { fullContent: content } : {}),
|
||||||
|
...(plotJson || {}),
|
||||||
|
...(conversationId ? { conversationId } : {}),
|
||||||
|
...(parentScriptId ? { parentScriptId } : {}),
|
||||||
|
...(revisionIndex ? { revisionIndex } : {}),
|
||||||
|
...(revisionOf ? { revisionOf } : {})
|
||||||
|
},
|
||||||
isSelected,
|
isSelected,
|
||||||
characterInfo,
|
characterInfo,
|
||||||
lifeEventsSummary,
|
lifeEventsSummary,
|
||||||
@@ -171,6 +182,10 @@ export const transformToFrontendFormat = (backendData) => {
|
|||||||
date: createTime ? String(createTime).slice(0, 10) : new Date().toLocaleDateString(),
|
date: createTime ? String(createTime).slice(0, 10) : new Date().toLocaleDateString(),
|
||||||
mode: plotJson?.mode || 'custom',
|
mode: plotJson?.mode || 'custom',
|
||||||
prompt: plotJson?.prompt || '',
|
prompt: plotJson?.prompt || '',
|
||||||
|
conversationId: plotJson?.conversationId || '',
|
||||||
|
parentScriptId: plotJson?.parentScriptId || '',
|
||||||
|
revisionIndex: plotJson?.currentRevisionIndex || plotJson?.revisionIndex || 0,
|
||||||
|
revisionOf: plotJson?.revisionOf || '',
|
||||||
wordCount: content ? content.length : 0
|
wordCount: content ? content.length : 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,24 @@
|
|||||||
|
|
||||||
import { get, post, put } from './request.js'
|
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<Object|null>} 用户档案
|
* @returns {Promise<Object|null>} 用户档案
|
||||||
@@ -85,9 +103,9 @@ const transformToBackendFormat = (frontendData) => {
|
|||||||
futureVision: future?.vision || null,
|
futureVision: future?.vision || null,
|
||||||
// 理想生活状态
|
// 理想生活状态
|
||||||
idealLife: future?.ideal || null,
|
idealLife: future?.ideal || null,
|
||||||
city: city || null,
|
city: city ?? '',
|
||||||
industry: industry || null,
|
industry: industry ?? '',
|
||||||
company: company || null,
|
company: company ?? '',
|
||||||
personalityTags: Array.isArray(personalityTags) ? JSON.stringify(personalityTags) : personalityTags,
|
personalityTags: Array.isArray(personalityTags) ? JSON.stringify(personalityTags) : personalityTags,
|
||||||
birthday: birthday || null
|
birthday: birthday || null
|
||||||
}
|
}
|
||||||
@@ -134,20 +152,20 @@ export const transformToFrontendFormat = (backendData) => {
|
|||||||
profession: profession || '',
|
profession: profession || '',
|
||||||
mbti: mbti || '',
|
mbti: mbti || '',
|
||||||
// 兴趣爱好从JSON字符串解析
|
// 兴趣爱好从JSON字符串解析
|
||||||
hobbies: hobbies ? (typeof hobbies === 'string' ? JSON.parse(hobbies) : hobbies) : [],
|
hobbies: parseJsonArray(hobbies),
|
||||||
// 童年经历
|
// 童年经历
|
||||||
childhood: {
|
childhood: {
|
||||||
date: childhoodDate || '',
|
date: normalizeDate(childhoodDate),
|
||||||
text: childhoodContent || ''
|
text: childhoodContent || ''
|
||||||
},
|
},
|
||||||
// 高光时刻
|
// 高光时刻
|
||||||
joy: {
|
joy: {
|
||||||
date: peakDate || '',
|
date: normalizeDate(peakDate),
|
||||||
text: peakContent || ''
|
text: peakContent || ''
|
||||||
},
|
},
|
||||||
// 低谷时期
|
// 低谷时期
|
||||||
low: {
|
low: {
|
||||||
date: valleyDate || '',
|
date: normalizeDate(valleyDate),
|
||||||
text: valleyContent || ''
|
text: valleyContent || ''
|
||||||
},
|
},
|
||||||
// 未来期许
|
// 未来期许
|
||||||
@@ -158,8 +176,8 @@ export const transformToFrontendFormat = (backendData) => {
|
|||||||
city: city || '',
|
city: city || '',
|
||||||
industry: industry || '',
|
industry: industry || '',
|
||||||
company: company || '',
|
company: company || '',
|
||||||
personalityTags: personalityTags ? (typeof personalityTags === 'string' ? JSON.parse(personalityTags) : personalityTags) : [],
|
personalityTags: parseJsonArray(personalityTags),
|
||||||
birthday: birthday || ''
|
birthday: normalizeDate(birthday)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,15 @@ const resetAuthState = () => {
|
|||||||
state.userProfile = null
|
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 = () => {
|
const clearStoredTokens = () => {
|
||||||
uni.removeStorageSync('access_token')
|
uni.removeStorageSync('access_token')
|
||||||
uni.removeStorageSync('refresh_token')
|
uni.removeStorageSync('refresh_token')
|
||||||
@@ -98,14 +107,14 @@ const fetchUserProfile = async (options = {}) => {
|
|||||||
try {
|
try {
|
||||||
const res = await userProfileService.getCurrentProfile()
|
const res = await userProfileService.getCurrentProfile()
|
||||||
if (res.data) {
|
if (res.data) {
|
||||||
state.userProfile = userProfileService.transformToFrontendFormat(res.data)
|
const profile = applyProfileResponse(res.data)
|
||||||
Object.assign(state.registrationData, state.userProfile)
|
|
||||||
logAuth('profile:success', { hasProfile: true })
|
logAuth('profile:success', { hasProfile: true })
|
||||||
|
return profile
|
||||||
} else {
|
} else {
|
||||||
state.userProfile = null
|
state.userProfile = null
|
||||||
logAuth('profile:empty', { hasProfile: false })
|
logAuth('profile:empty', { hasProfile: false })
|
||||||
}
|
}
|
||||||
return res.data
|
return null
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
state.userProfile = null
|
state.userProfile = null
|
||||||
logAuth('profile:fail', {
|
logAuth('profile:fail', {
|
||||||
@@ -125,13 +134,17 @@ const saveUserProfile = async () => {
|
|||||||
state.isLoading = true
|
state.isLoading = true
|
||||||
try {
|
try {
|
||||||
const dataToSave = { ...state.registrationData }
|
const dataToSave = { ...state.registrationData }
|
||||||
|
let response
|
||||||
if (state.userProfile?.id) {
|
if (state.userProfile?.id) {
|
||||||
await userProfileService.updateProfile({
|
response = await userProfileService.updateProfile({
|
||||||
id: state.userProfile.id,
|
id: state.userProfile.id,
|
||||||
...dataToSave
|
...dataToSave
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
await userProfileService.createProfile(dataToSave)
|
response = await userProfileService.createProfile(dataToSave)
|
||||||
|
}
|
||||||
|
if (response?.data) {
|
||||||
|
applyProfileResponse(response.data)
|
||||||
}
|
}
|
||||||
await fetchUserProfile()
|
await fetchUserProfile()
|
||||||
return { success: true }
|
return { success: true }
|
||||||
@@ -243,8 +256,30 @@ const fetchScripts = async () => {
|
|||||||
const createScript = async (scriptData) => {
|
const createScript = async (scriptData) => {
|
||||||
try {
|
try {
|
||||||
const res = await epicScriptService.createScript(scriptData)
|
const res = await epicScriptService.createScript(scriptData)
|
||||||
|
const script = epicScriptService.transformToFrontendFormat(res.data)
|
||||||
await fetchScripts()
|
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) {
|
} catch (error) {
|
||||||
return { success: false, error: error.message }
|
return { success: false, error: error.message }
|
||||||
}
|
}
|
||||||
@@ -416,8 +451,18 @@ const initialize = async () => {
|
|||||||
|
|
||||||
export const useAppStore = () => {
|
export const useAppStore = () => {
|
||||||
return readonly({
|
return readonly({
|
||||||
...state,
|
get isLoggedIn() { return state.isLoggedIn },
|
||||||
hasProfile,
|
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,
|
login,
|
||||||
loginWithWechat,
|
loginWithWechat,
|
||||||
logout,
|
logout,
|
||||||
@@ -436,6 +481,8 @@ export const useAppStore = () => {
|
|||||||
getEventById,
|
getEventById,
|
||||||
fetchScripts,
|
fetchScripts,
|
||||||
createScript,
|
createScript,
|
||||||
|
updateScript,
|
||||||
|
deleteScript,
|
||||||
fetchInspirationRecommendations,
|
fetchInspirationRecommendations,
|
||||||
fetchRandomInspirations,
|
fetchRandomInspirations,
|
||||||
generateScriptFromInspiration,
|
generateScriptFromInspiration,
|
||||||
|
|||||||
Reference in New Issue
Block a user