Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 094953f1c3 | |||
| acca1d84f3 | |||
| d09f600751 | |||
| a51d225897 | |||
| c289097ca0 | |||
| 2d7776dd4d | |||
| 613e8ec3ff | |||
| 436145a5c8 | |||
| d2e449ec4c | |||
| d818367a32 |
@@ -61,8 +61,11 @@ public class TtsController {
|
|||||||
@GetMapping("/tasks/by-source")
|
@GetMapping("/tasks/by-source")
|
||||||
public Result<TtsTaskResponse> bySource(@Parameter(description = "来源类型") @RequestParam String sourceType,
|
public Result<TtsTaskResponse> bySource(@Parameter(description = "来源类型") @RequestParam String sourceType,
|
||||||
@Parameter(description = "来源 ID") @RequestParam String sourceId,
|
@Parameter(description = "来源 ID") @RequestParam String sourceId,
|
||||||
@Parameter(description = "音色") @RequestParam(required = false) String voice) {
|
@Parameter(description = "音色") @RequestParam(required = false) String voice,
|
||||||
return Result.success(ttsTaskService.getBySource(sourceType, sourceId, voice));
|
@Parameter(description = "语速") @RequestParam(required = false) Double speechRate,
|
||||||
|
@Parameter(description = "音调") @RequestParam(required = false) Double pitch,
|
||||||
|
@Parameter(description = "情绪") @RequestParam(required = false) String emotion) {
|
||||||
|
return Result.success(ttsTaskService.getBySource(sourceType, sourceId, voice, speechRate, pitch, emotion));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "获取音频文件", description = "返回已合成的音频音频文件(MP3 或 WAV 格式)。")
|
@Operation(summary = "获取音频文件", description = "返回已合成的音频音频文件(MP3 或 WAV 格式)。")
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.emotion.dto.request.tts;
|
|||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import javax.validation.constraints.NotBlank;
|
import javax.validation.constraints.NotBlank;
|
||||||
|
import javax.validation.constraints.DecimalMax;
|
||||||
|
import javax.validation.constraints.DecimalMin;
|
||||||
import javax.validation.constraints.Size;
|
import javax.validation.constraints.Size;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@@ -18,4 +20,15 @@ public class TtsTaskCreateRequest {
|
|||||||
|
|
||||||
@Size(max = 64)
|
@Size(max = 64)
|
||||||
private String voice;
|
private String voice;
|
||||||
|
|
||||||
|
@DecimalMin("0.60")
|
||||||
|
@DecimalMax("1.40")
|
||||||
|
private Double speechRate;
|
||||||
|
|
||||||
|
@DecimalMin("-20.00")
|
||||||
|
@DecimalMax("20.00")
|
||||||
|
private Double pitch;
|
||||||
|
|
||||||
|
@Size(max = 32)
|
||||||
|
private String emotion;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ public class TtsTaskResponse {
|
|||||||
private String sourceId;
|
private String sourceId;
|
||||||
private String status;
|
private String status;
|
||||||
private String voice;
|
private String voice;
|
||||||
|
private Double speechRate;
|
||||||
|
private Double pitch;
|
||||||
|
private String emotion;
|
||||||
private String audioUrl;
|
private String audioUrl;
|
||||||
private Long durationMs;
|
private Long durationMs;
|
||||||
private String errorMessage;
|
private String errorMessage;
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ public class AiCallLog extends BaseEntity {
|
|||||||
@TableField("user_id")
|
@TableField("user_id")
|
||||||
private String userId;
|
private String userId;
|
||||||
|
|
||||||
|
@TableField("user_name")
|
||||||
|
private String userName;
|
||||||
|
|
||||||
@TableField("request_id")
|
@TableField("request_id")
|
||||||
private String requestId;
|
private String requestId;
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,37 @@ package com.emotion.service;
|
|||||||
|
|
||||||
public interface TtsEngineClient {
|
public interface TtsEngineClient {
|
||||||
|
|
||||||
TtsEngineResult synthesize(String text, String voice, String outputPath);
|
TtsEngineResult synthesize(String text, String voice, String outputPath, SynthesisOptions options);
|
||||||
|
|
||||||
|
class SynthesisOptions {
|
||||||
|
private final Double speechRate;
|
||||||
|
private final Double pitch;
|
||||||
|
private final String emotion;
|
||||||
|
|
||||||
|
public SynthesisOptions(Double speechRate, Double pitch, String emotion) {
|
||||||
|
this.speechRate = speechRate;
|
||||||
|
this.pitch = pitch;
|
||||||
|
this.emotion = emotion;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Double getSpeechRate() {
|
||||||
|
return speechRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Double getPitch() {
|
||||||
|
return pitch;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getEmotion() {
|
||||||
|
return emotion;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String cacheKey() {
|
||||||
|
return "rate=" + (speechRate == null ? "" : speechRate)
|
||||||
|
+ ";pitch=" + (pitch == null ? "" : pitch)
|
||||||
|
+ ";emotion=" + (emotion == null ? "" : emotion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class TtsEngineResult {
|
class TtsEngineResult {
|
||||||
private final boolean success;
|
private final boolean success;
|
||||||
|
|||||||
@@ -11,5 +11,6 @@ public interface TtsTaskService extends IService<TtsTask> {
|
|||||||
|
|
||||||
TtsTaskResponse getTask(String id);
|
TtsTaskResponse getTask(String id);
|
||||||
|
|
||||||
TtsTaskResponse getBySource(String sourceType, String sourceId, String voice);
|
TtsTaskResponse getBySource(String sourceType, String sourceId, String voice,
|
||||||
|
Double speechRate, Double pitch, String emotion);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ public class AiRuntimeServiceImpl implements AiRuntimeService {
|
|||||||
callLog.setRequestId(requestId);
|
callLog.setRequestId(requestId);
|
||||||
callLog.setSceneCode(request.getSceneCode());
|
callLog.setSceneCode(request.getSceneCode());
|
||||||
callLog.setUserId(resolveUserId(request));
|
callLog.setUserId(resolveUserId(request));
|
||||||
|
callLog.setUserName(request.getUserName());
|
||||||
callLog.setInputText(JSON.toJSONString(request.getInputs()));
|
callLog.setInputText(JSON.toJSONString(request.getInputs()));
|
||||||
callLog.setStatus("running");
|
callLog.setStatus("running");
|
||||||
|
|
||||||
@@ -221,6 +222,7 @@ public class AiRuntimeServiceImpl implements AiRuntimeService {
|
|||||||
callLog.setEndpointCode(endpoint.getEndpointCode());
|
callLog.setEndpointCode(endpoint.getEndpointCode());
|
||||||
callLog.setProviderCode(provider.getProviderCode());
|
callLog.setProviderCode(provider.getProviderCode());
|
||||||
callLog.setUserId(request.getUserId());
|
callLog.setUserId(request.getUserId());
|
||||||
|
callLog.setUserName(request.getUserName());
|
||||||
callLog.setInputText(JSON.toJSONString(request.getInputs()));
|
callLog.setInputText(JSON.toJSONString(request.getInputs()));
|
||||||
callLog.setStatus("running");
|
callLog.setStatus("running");
|
||||||
callLogService.save(callLog);
|
callLogService.save(callLog);
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import org.springframework.beans.factory.annotation.Value;
|
|||||||
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.web.client.RestTemplate;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -21,13 +23,23 @@ public class HttpTtsEngineClient implements TtsEngineClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public TtsEngineResult synthesize(String text, String voice, String outputPath) {
|
public TtsEngineResult synthesize(String text, String voice, String outputPath, SynthesisOptions options) {
|
||||||
try {
|
try {
|
||||||
Map<String, Object> body = Map.of(
|
Map<String, Object> body = new HashMap<>();
|
||||||
"text", text,
|
body.put("text", text);
|
||||||
"voice", voice,
|
body.put("voice", voice);
|
||||||
"outputPath", outputPath
|
body.put("outputPath", outputPath);
|
||||||
);
|
if (options != null) {
|
||||||
|
if (options.getSpeechRate() != null) {
|
||||||
|
body.put("speechRate", options.getSpeechRate());
|
||||||
|
}
|
||||||
|
if (options.getPitch() != null) {
|
||||||
|
body.put("pitch", options.getPitch());
|
||||||
|
}
|
||||||
|
if (StringUtils.hasText(options.getEmotion())) {
|
||||||
|
body.put("emotion", options.getEmotion());
|
||||||
|
}
|
||||||
|
}
|
||||||
ResponseEntity<Map> response = restTemplate.postForEntity(engineUrl + "/synthesize", body, Map.class);
|
ResponseEntity<Map> response = restTemplate.postForEntity(engineUrl + "/synthesize", body, Map.class);
|
||||||
Map<?, ?> data = response.getBody();
|
Map<?, ?> data = response.getBody();
|
||||||
boolean success = data != null && Boolean.TRUE.equals(data.get("success"));
|
boolean success = data != null && Boolean.TRUE.equals(data.get("success"));
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ import org.springframework.util.DigestUtils;
|
|||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.Executor;
|
import java.util.concurrent.Executor;
|
||||||
@@ -32,6 +35,10 @@ public class TtsTaskServiceImpl extends ServiceImpl<TtsTaskMapper, TtsTask> impl
|
|||||||
private static final String STATUS_PROCESSING = "processing";
|
private static final String STATUS_PROCESSING = "processing";
|
||||||
private static final String STATUS_SUCCESS = "success";
|
private static final String STATUS_SUCCESS = "success";
|
||||||
private static final String STATUS_FAILED = "failed";
|
private static final String STATUS_FAILED = "failed";
|
||||||
|
private static final double FALLBACK_SPEECH_RATE = 0.92D;
|
||||||
|
private static final double FALLBACK_PITCH = 0D;
|
||||||
|
private static final String FALLBACK_EMOTION = "story";
|
||||||
|
private static final int NATURAL_PARAGRAPH_LIMIT = 140;
|
||||||
|
|
||||||
private final EpicScriptMapper epicScriptMapper;
|
private final EpicScriptMapper epicScriptMapper;
|
||||||
private final TtsEngineClient ttsEngineClient;
|
private final TtsEngineClient ttsEngineClient;
|
||||||
@@ -52,6 +59,15 @@ public class TtsTaskServiceImpl extends ServiceImpl<TtsTaskMapper, TtsTask> impl
|
|||||||
@Value("${emotion.tts.default-voice:default_zh_female}")
|
@Value("${emotion.tts.default-voice:default_zh_female}")
|
||||||
private String defaultVoice;
|
private String defaultVoice;
|
||||||
|
|
||||||
|
@Value("${emotion.tts.default-speech-rate:0.92}")
|
||||||
|
private double defaultSpeechRate;
|
||||||
|
|
||||||
|
@Value("${emotion.tts.default-pitch:0}")
|
||||||
|
private double defaultPitch;
|
||||||
|
|
||||||
|
@Value("${emotion.tts.default-emotion:story}")
|
||||||
|
private String defaultEmotion;
|
||||||
|
|
||||||
public TtsTaskServiceImpl(EpicScriptMapper epicScriptMapper,
|
public TtsTaskServiceImpl(EpicScriptMapper epicScriptMapper,
|
||||||
TtsEngineClient ttsEngineClient,
|
TtsEngineClient ttsEngineClient,
|
||||||
@Qualifier("taskExecutor") Executor taskExecutor) {
|
@Qualifier("taskExecutor") Executor taskExecutor) {
|
||||||
@@ -70,15 +86,16 @@ public class TtsTaskServiceImpl extends ServiceImpl<TtsTaskMapper, TtsTask> impl
|
|||||||
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());
|
||||||
|
TtsEngineClient.SynthesisOptions options = resolveOptions(request);
|
||||||
String cleaned = cleanText(loadSourceText(userId, sourceType, sourceId));
|
String cleaned = cleanText(loadSourceText(userId, sourceType, sourceId));
|
||||||
if (!StringUtils.hasText(cleaned)) {
|
if (!StringUtils.hasText(cleaned)) {
|
||||||
throw new IllegalArgumentException("Source text is empty");
|
throw new IllegalArgumentException("Source text is empty");
|
||||||
}
|
}
|
||||||
if (cleaned.length() > maxTextLength) {
|
if (cleaned.length() > maxTextLength) {
|
||||||
cleaned = cleaned.substring(0, maxTextLength);
|
cleaned = limitReadableText(cleaned, maxTextLength);
|
||||||
}
|
}
|
||||||
|
|
||||||
String hash = DigestUtils.md5DigestAsHex((voice + "\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);
|
incrementRequestCount(owned);
|
||||||
@@ -100,7 +117,7 @@ public class TtsTaskServiceImpl extends ServiceImpl<TtsTaskMapper, TtsTask> impl
|
|||||||
TtsTask task = buildTask(userId, sourceType, sourceId, voice, hash, cleaned.length());
|
TtsTask task = buildTask(userId, sourceType, sourceId, voice, hash, cleaned.length());
|
||||||
save(task);
|
save(task);
|
||||||
String synthesisText = cleaned;
|
String synthesisText = cleaned;
|
||||||
CompletableFuture.runAsync(() -> process(task.getId(), synthesisText, voice, task.getAudioPath()), taskExecutor);
|
CompletableFuture.runAsync(() -> process(task.getId(), synthesisText, voice, task.getAudioPath(), options), taskExecutor);
|
||||||
return toResponse(task);
|
return toResponse(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,20 +132,22 @@ public class TtsTaskServiceImpl extends ServiceImpl<TtsTaskMapper, TtsTask> impl
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public TtsTaskResponse getBySource(String sourceType, String sourceId, String voice) {
|
public TtsTaskResponse getBySource(String sourceType, String sourceId, String voice,
|
||||||
|
Double speechRate, Double pitch, String emotion) {
|
||||||
String userId = currentUserId();
|
String userId = currentUserId();
|
||||||
TtsTask task = getOne(new LambdaQueryWrapper<TtsTask>()
|
String normalizedSourceType = normalizeSourceType(sourceType);
|
||||||
.eq(TtsTask::getUserId, userId)
|
String normalizedVoice = resolveVoice(voice);
|
||||||
.eq(TtsTask::getSourceType, normalizeSourceType(sourceType))
|
TtsEngineClient.SynthesisOptions options = resolveOptions(speechRate, pitch, emotion);
|
||||||
.eq(TtsTask::getSourceId, sourceId)
|
String cleaned = cleanText(loadSourceText(userId, normalizedSourceType, sourceId));
|
||||||
.eq(TtsTask::getVoice, resolveVoice(voice))
|
if (cleaned.length() > maxTextLength) {
|
||||||
.eq(TtsTask::getIsDeleted, 0)
|
cleaned = limitReadableText(cleaned, maxTextLength);
|
||||||
.orderByDesc(TtsTask::getCreateTime)
|
}
|
||||||
.last("LIMIT 1"));
|
String hash = DigestUtils.md5DigestAsHex((normalizedVoice + "\n" + options.cacheKey() + "\n" + cleaned).getBytes(StandardCharsets.UTF_8));
|
||||||
|
TtsTask task = findOwnedTask(userId, normalizedSourceType, sourceId, normalizedVoice, hash);
|
||||||
return task == null ? null : toResponse(task);
|
return task == null ? null : toResponse(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void process(String taskId, String text, String voice, String outputPath) {
|
private void process(String taskId, String text, String voice, String outputPath, TtsEngineClient.SynthesisOptions options) {
|
||||||
try {
|
try {
|
||||||
TtsTask task = getById(taskId);
|
TtsTask task = getById(taskId);
|
||||||
if (task == null) {
|
if (task == null) {
|
||||||
@@ -138,7 +157,7 @@ public class TtsTaskServiceImpl extends ServiceImpl<TtsTaskMapper, TtsTask> impl
|
|||||||
task.setErrorMessage(null);
|
task.setErrorMessage(null);
|
||||||
updateById(task);
|
updateById(task);
|
||||||
|
|
||||||
TtsEngineClient.TtsEngineResult result = ttsEngineClient.synthesize(text, voice, outputPath);
|
TtsEngineClient.TtsEngineResult result = ttsEngineClient.synthesize(text, voice, outputPath, options);
|
||||||
task = getById(taskId);
|
task = getById(taskId);
|
||||||
if (task == null) {
|
if (task == null) {
|
||||||
return;
|
return;
|
||||||
@@ -220,13 +239,15 @@ public class TtsTaskServiceImpl extends ServiceImpl<TtsTaskMapper, TtsTask> impl
|
|||||||
|
|
||||||
StringBuilder text = new StringBuilder();
|
StringBuilder text = new StringBuilder();
|
||||||
append(text, script.getTitle());
|
append(text, script.getTitle());
|
||||||
append(text, script.getPlotIntro());
|
|
||||||
append(text, script.getPlotTurning());
|
|
||||||
append(text, script.getPlotClimax());
|
|
||||||
append(text, script.getPlotEnding());
|
|
||||||
Map<String, Object> plotJson = script.getPlotJson();
|
Map<String, Object> plotJson = script.getPlotJson();
|
||||||
if (plotJson != null && plotJson.get("fullContent") != null) {
|
Object fullContent = plotJson == null ? null : plotJson.get("fullContent");
|
||||||
append(text, String.valueOf(plotJson.get("fullContent")));
|
if (fullContent != null && StringUtils.hasText(String.valueOf(fullContent))) {
|
||||||
|
append(text, String.valueOf(fullContent));
|
||||||
|
} else {
|
||||||
|
append(text, script.getPlotIntro());
|
||||||
|
append(text, script.getPlotTurning());
|
||||||
|
append(text, script.getPlotClimax());
|
||||||
|
append(text, script.getPlotEnding());
|
||||||
}
|
}
|
||||||
return text.toString();
|
return text.toString();
|
||||||
}
|
}
|
||||||
@@ -235,9 +256,39 @@ public class TtsTaskServiceImpl extends ServiceImpl<TtsTaskMapper, TtsTask> impl
|
|||||||
if (text == null) {
|
if (text == null) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
return text.replaceAll("[#>*_`\\-]", "")
|
String normalized = text.replace("\r\n", "\n")
|
||||||
.replaceAll("\\s+", " ")
|
.replace('\r', '\n')
|
||||||
.trim();
|
.replaceAll("!\\[[^\\]]*]\\([^)]*\\)", "")
|
||||||
|
.replaceAll("\\[([^\\]]+)]\\([^)]*\\)", "$1")
|
||||||
|
.replaceAll("(?m)^\\s{0,3}#{1,6}\\s*", "")
|
||||||
|
.replaceAll("(?m)^\\s*>\\s?", "")
|
||||||
|
.replaceAll("(?m)^\\s*[-*+]\\s+", "")
|
||||||
|
.replaceAll("(?m)^\\s*\\d+[.)、]\\s+", "")
|
||||||
|
.replaceAll("<[^>]+>", "")
|
||||||
|
.replaceAll("[*_`~]", "")
|
||||||
|
.replaceAll("[“”]", "\"")
|
||||||
|
.replaceAll("[‘’]", "'")
|
||||||
|
.replaceAll("\\.{3,}", "……")
|
||||||
|
.replaceAll("-{2,}", ",")
|
||||||
|
.replaceAll("[\\t\\u00A0]+", " ")
|
||||||
|
.replaceAll(" {2,}", " ")
|
||||||
|
.replaceAll("(?<=[\\p{IsHan}])[ \\t\\u00A0]+(?=[\\p{IsHan}])", "")
|
||||||
|
.replaceAll("[ \\t\\u00A0]*([,。!?;:、,.!?;:])[ \\t\\u00A0]*", "$1")
|
||||||
|
.replaceAll(",", ",")
|
||||||
|
.replaceAll("!", "!")
|
||||||
|
.replaceAll("\\?", "?")
|
||||||
|
.replaceAll(";", ";")
|
||||||
|
.replaceAll(":", ":");
|
||||||
|
|
||||||
|
List<String> paragraphs = new ArrayList<>();
|
||||||
|
for (String paragraph : normalized.split("\\n+")) {
|
||||||
|
String trimmed = paragraph.trim();
|
||||||
|
if (!StringUtils.hasText(trimmed)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
paragraphs.addAll(toReadableParagraphs(trimmed));
|
||||||
|
}
|
||||||
|
return String.join("\n\n", paragraphs).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
private TtsTaskResponse toResponse(TtsTask task) {
|
private TtsTaskResponse toResponse(TtsTask task) {
|
||||||
@@ -253,6 +304,23 @@ public class TtsTaskServiceImpl extends ServiceImpl<TtsTaskMapper, TtsTask> impl
|
|||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private TtsEngineClient.SynthesisOptions resolveOptions(TtsTaskCreateRequest request) {
|
||||||
|
return resolveOptions(request.getSpeechRate(), request.getPitch(), request.getEmotion());
|
||||||
|
}
|
||||||
|
|
||||||
|
private TtsEngineClient.SynthesisOptions resolveOptions(Double requestSpeechRate, Double requestPitch, String requestEmotion) {
|
||||||
|
double speechRate = requestSpeechRate == null ? defaultOrFallback(defaultSpeechRate, FALLBACK_SPEECH_RATE) : requestSpeechRate;
|
||||||
|
double pitch = requestPitch == null ? defaultPitch : requestPitch;
|
||||||
|
String emotion = StringUtils.hasText(requestEmotion)
|
||||||
|
? requestEmotion.trim()
|
||||||
|
: (StringUtils.hasText(defaultEmotion) ? defaultEmotion.trim() : FALLBACK_EMOTION);
|
||||||
|
return new TtsEngineClient.SynthesisOptions(
|
||||||
|
round(clamp(speechRate, 0.60D, 1.40D)),
|
||||||
|
round(clamp(pitch, -20D, 20D)),
|
||||||
|
emotion.toLowerCase(Locale.ROOT)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private String currentUserId() {
|
private String currentUserId() {
|
||||||
String userId = UserContextHolder.getCurrentUserId();
|
String userId = UserContextHolder.getCurrentUserId();
|
||||||
if (!StringUtils.hasText(userId)) {
|
if (!StringUtils.hasText(userId)) {
|
||||||
@@ -275,6 +343,91 @@ public class TtsTaskServiceImpl extends ServiceImpl<TtsTaskMapper, TtsTask> impl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static List<String> toReadableParagraphs(String paragraph) {
|
||||||
|
List<String> parts = new ArrayList<>();
|
||||||
|
StringBuilder current = new StringBuilder();
|
||||||
|
for (int index = 0; index < paragraph.length(); index++) {
|
||||||
|
char ch = paragraph.charAt(index);
|
||||||
|
current.append(ch);
|
||||||
|
if (isHardSentenceEnd(ch) || (current.length() >= NATURAL_PARAGRAPH_LIMIT && isSoftPause(ch))) {
|
||||||
|
addReadablePart(parts, current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addReadablePart(parts, current);
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void addReadablePart(List<String> parts, StringBuilder current) {
|
||||||
|
String value = current.toString().trim();
|
||||||
|
current.setLength(0);
|
||||||
|
if (!StringUtils.hasText(value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value.length() > NATURAL_PARAGRAPH_LIMIT + 40) {
|
||||||
|
splitLongText(parts, value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
parts.add(ensureSentenceEnding(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void splitLongText(List<String> parts, String value) {
|
||||||
|
StringBuilder chunk = new StringBuilder();
|
||||||
|
for (int index = 0; index < value.length(); index++) {
|
||||||
|
char ch = value.charAt(index);
|
||||||
|
chunk.append(ch);
|
||||||
|
if (chunk.length() >= NATURAL_PARAGRAPH_LIMIT) {
|
||||||
|
parts.add(ensureSentenceEnding(chunk.toString().trim()));
|
||||||
|
chunk.setLength(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (chunk.length() > 0) {
|
||||||
|
parts.add(ensureSentenceEnding(chunk.toString().trim()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String ensureSentenceEnding(String value) {
|
||||||
|
if (!StringUtils.hasText(value)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
char last = value.charAt(value.length() - 1);
|
||||||
|
return isHardSentenceEnd(last) || isSoftPause(last) ? value : value + "。";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isHardSentenceEnd(char ch) {
|
||||||
|
return ch == '。' || ch == '!' || ch == '?' || ch == ';' || ch == '…';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isSoftPause(char ch) {
|
||||||
|
return ch == ',' || ch == '、' || ch == ':';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String limitReadableText(String text, int limit) {
|
||||||
|
if (text.length() <= limit) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
String truncated = text.substring(0, limit);
|
||||||
|
int cut = Math.max(
|
||||||
|
Math.max(truncated.lastIndexOf('。'), truncated.lastIndexOf('!')),
|
||||||
|
Math.max(truncated.lastIndexOf('?'), truncated.lastIndexOf('\n'))
|
||||||
|
);
|
||||||
|
if (cut > limit * 0.75) {
|
||||||
|
return truncated.substring(0, cut + 1).trim();
|
||||||
|
}
|
||||||
|
return ensureSentenceEnding(truncated.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double clamp(double value, double min, double max) {
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double round(double value) {
|
||||||
|
return Math.round(value * 100D) / 100D;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double defaultOrFallback(double value, double fallback) {
|
||||||
|
return value <= 0D ? fallback : value;
|
||||||
|
}
|
||||||
|
|
||||||
private static String joinPath(String prefix, String filename) {
|
private static String joinPath(String prefix, String filename) {
|
||||||
if (prefix.endsWith("/")) {
|
if (prefix.endsWith("/")) {
|
||||||
return prefix + filename;
|
return prefix + filename;
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ emotion:
|
|||||||
public-url-prefix: /tts/audio
|
public-url-prefix: /tts/audio
|
||||||
max-text-length: 5000
|
max-text-length: 5000
|
||||||
default-voice: default_zh_female
|
default-voice: default_zh_female
|
||||||
|
default-speech-rate: 0.92
|
||||||
|
default-pitch: 0
|
||||||
|
default-emotion: story
|
||||||
|
|
||||||
# Speech-to-text config
|
# Speech-to-text config
|
||||||
asr:
|
asr:
|
||||||
|
|||||||
@@ -106,6 +106,9 @@ emotion:
|
|||||||
public-url-prefix: /tts/audio
|
public-url-prefix: /tts/audio
|
||||||
max-text-length: 5000
|
max-text-length: 5000
|
||||||
default-voice: default_zh_female
|
default-voice: default_zh_female
|
||||||
|
default-speech-rate: 0.92
|
||||||
|
default-pitch: 0
|
||||||
|
default-emotion: story
|
||||||
|
|
||||||
# Speech-to-text config
|
# Speech-to-text config
|
||||||
asr:
|
asr:
|
||||||
|
|||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
-- 调用日志表新增 user_name 字段
|
||||||
|
ALTER TABLE t_ai_call_log ADD COLUMN user_name VARCHAR(100) DEFAULT NULL COMMENT '用户昵称' AFTER user_id;
|
||||||
@@ -80,6 +80,7 @@ class AiRuntimeServiceImplTest {
|
|||||||
request.setSceneCode("script_generate");
|
request.setSceneCode("script_generate");
|
||||||
request.setUserId("user-1");
|
request.setUserId("user-1");
|
||||||
request.setRequestId("client-request-1");
|
request.setRequestId("client-request-1");
|
||||||
|
request.setUserName("测试用户");
|
||||||
List<AiStreamEvent> events = new ArrayList<>();
|
List<AiStreamEvent> events = new ArrayList<>();
|
||||||
|
|
||||||
service.invokeStream(request, events::add);
|
service.invokeStream(request, events::add);
|
||||||
@@ -98,5 +99,6 @@ class AiRuntimeServiceImplTest {
|
|||||||
assertEquals("client-request-1", savedLog.getRequestId());
|
assertEquals("client-request-1", savedLog.getRequestId());
|
||||||
assertEquals("success", savedLog.getStatus());
|
assertEquals("success", savedLog.getStatus());
|
||||||
assertEquals("完整输出", savedLog.getOutputText());
|
assertEquals("完整输出", savedLog.getOutputText());
|
||||||
|
assertEquals("测试用户", savedLog.getUserName());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,19 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
|||||||
class TtsTaskServiceTest {
|
class TtsTaskServiceTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@DisplayName("cleanText strips markdown and normalizes whitespace")
|
@DisplayName("cleanText strips markdown but keeps Chinese narration rhythm")
|
||||||
void cleanTextStripsMarkdownAndNormalizesWhitespace() {
|
void cleanTextStripsMarkdownButKeepsChineseNarrationRhythm() {
|
||||||
String cleaned = TtsTaskServiceImpl.cleanText("# Title\n\n> **hello** `world` - ok");
|
String cleaned = TtsTaskServiceImpl.cleanText("# 第一章\n\n> **她 终于** 看见了自己\n\n- 转身离开");
|
||||||
|
|
||||||
assertEquals("Title hello world ok", cleaned);
|
assertEquals("第一章。\n\n她终于看见了自己。\n\n转身离开。", cleaned);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("cleanText preserves sentence punctuation for natural pauses")
|
||||||
|
void cleanTextPreservesSentencePunctuationForNaturalPauses() {
|
||||||
|
String cleaned = TtsTaskServiceImpl.cleanText("他说: 这一次,我想自己选择!\n\n你听见了吗?");
|
||||||
|
|
||||||
|
assertEquals("他说:这一次,我想自己选择!\n\n你听见了吗?", cleaned);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -28,6 +36,9 @@ class TtsTaskServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
@DisplayName("TtsEngineResult exposes synthesis result fields")
|
@DisplayName("TtsEngineResult exposes synthesis result fields")
|
||||||
void ttsEngineResultExposesFields() {
|
void ttsEngineResultExposesFields() {
|
||||||
|
TtsEngineClient.SynthesisOptions options = new TtsEngineClient.SynthesisOptions(0.92D, 0D, "story");
|
||||||
|
assertEquals("rate=0.92;pitch=0.0;emotion=story", options.cacheKey());
|
||||||
|
|
||||||
TtsEngineClient.TtsEngineResult result =
|
TtsEngineClient.TtsEngineResult result =
|
||||||
new TtsEngineClient.TtsEngineResult(true, "/tmp/a.mp3", 1200L, null);
|
new TtsEngineClient.TtsEngineResult(true, "/tmp/a.mp3", 1200L, null);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@@ -16,6 +17,42 @@ class SynthesizeRequest(BaseModel):
|
|||||||
text: str = Field(min_length=1, max_length=5000)
|
text: str = Field(min_length=1, max_length=5000)
|
||||||
voice: str = "default_zh_female"
|
voice: str = "default_zh_female"
|
||||||
outputPath: str
|
outputPath: str
|
||||||
|
speechRate: Optional[float] = Field(default=0.92, ge=0.6, le=1.4)
|
||||||
|
pitch: Optional[float] = Field(default=0.0, ge=-20.0, le=20.0)
|
||||||
|
emotion: Optional[str] = "story"
|
||||||
|
|
||||||
|
|
||||||
|
def clamp(value: float, minimum: float, maximum: float) -> float:
|
||||||
|
return max(minimum, min(maximum, value))
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_piper_args(request: SynthesizeRequest) -> list[str]:
|
||||||
|
speech_rate = clamp(float(request.speechRate or 0.92), 0.6, 1.4)
|
||||||
|
emotion = (request.emotion or "story").lower()
|
||||||
|
length_scale = round(1.0 / speech_rate, 2)
|
||||||
|
sentence_silence = 0.46
|
||||||
|
noise_scale = 0.64
|
||||||
|
noise_w = 0.72
|
||||||
|
|
||||||
|
if emotion in {"calm", "soft", "warm"}:
|
||||||
|
sentence_silence = 0.5
|
||||||
|
noise_scale = 0.58
|
||||||
|
noise_w = 0.68
|
||||||
|
elif emotion in {"story", "narration", "expressive"}:
|
||||||
|
sentence_silence = 0.48
|
||||||
|
noise_scale = 0.68
|
||||||
|
noise_w = 0.76
|
||||||
|
|
||||||
|
return [
|
||||||
|
"--sentence-silence",
|
||||||
|
str(sentence_silence),
|
||||||
|
"--length_scale",
|
||||||
|
str(length_scale),
|
||||||
|
"--noise_scale",
|
||||||
|
str(noise_scale),
|
||||||
|
"--noise_w",
|
||||||
|
str(noise_w),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
@@ -47,8 +84,7 @@ def synthesize(request: SynthesizeRequest):
|
|||||||
str(PIPER_CONFIG),
|
str(PIPER_CONFIG),
|
||||||
"--output_file",
|
"--output_file",
|
||||||
str(output),
|
str(output),
|
||||||
"--sentence-silence",
|
*resolve_piper_args(request),
|
||||||
"0.35",
|
|
||||||
],
|
],
|
||||||
input=request.text,
|
input=request.text,
|
||||||
text=True,
|
text=True,
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
# 如愿星球小程序修改需求整理
|
||||||
|
|
||||||
|
## 1. 需求背景
|
||||||
|
|
||||||
|
根据图片中的《如愿星球小程序修改意见整理》,本需求文档用于记录小程序后续 UI、交互和生成后对话能力的调整方向。
|
||||||
|
|
||||||
|
本次需求主要围绕两个页面状态:
|
||||||
|
|
||||||
|
- 爽文生成首页
|
||||||
|
- 生成后的小说对话界面
|
||||||
|
|
||||||
|
目标是在不破坏现有 AI 生成、流式输出、语音输入、历史记录、小说保存等流程的前提下,优化首页布局、底部导航、输入区位置,以及生成后继续对话修改小说的能力。
|
||||||
|
|
||||||
|
## 2. 爽文生成首页需求
|
||||||
|
|
||||||
|
### 2.1 默认进入爽文生成页
|
||||||
|
|
||||||
|
打开小程序后,默认进入爽文生成页面。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
- 用户登录后进入小程序首页时,默认展示爽文生成页面。
|
||||||
|
- 不应默认进入个人资料页面、人生轨迹页面或其他页面。
|
||||||
|
- 如果后续存在 tab 参数或跳转来源,也应优先保证普通启动路径进入爽文生成页面。
|
||||||
|
|
||||||
|
### 2.2 底部导航位置调整
|
||||||
|
|
||||||
|
当前底部导航需要调整为:
|
||||||
|
|
||||||
|
- 爽文生成位于底部导航中间位置。
|
||||||
|
- 人生轨迹位于底部导航最左侧。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
- 底部导航仍保留现有核心入口。
|
||||||
|
- 调整后不影响原有 tab 切换能力。
|
||||||
|
- 导航文案、图标和选中态需要与现有主题风格保持一致。
|
||||||
|
|
||||||
|
### 2.3 首页主标题放大
|
||||||
|
|
||||||
|
首页文案“今天有什么心愿想实现”需要更醒目。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
- “今天有什么心愿想实现”相关文案整体可以比当前更大一些。
|
||||||
|
- 标题需要居中展示。
|
||||||
|
- 标题放大后不能与顶部操作区、灵感区、输入区重叠。
|
||||||
|
- 保持当前星空、星球、紫色主题风格。
|
||||||
|
|
||||||
|
### 2.4 “灵感一下”和“换一换”尺寸统一
|
||||||
|
|
||||||
|
“灵感一下”和“换一换”按钮/文案的视觉大小需要调整一致,并且整体再小一些。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
- 两个文案在视觉上高度、字号、对齐方式保持协调。
|
||||||
|
- “换一换”仍然保持可点击。
|
||||||
|
- 调整后不影响灵感卡片刷新逻辑。
|
||||||
|
|
||||||
|
### 2.5 文字输入框放到界面最下方
|
||||||
|
|
||||||
|
首页文字输入框需要放到界面最下方。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
- 输入框固定或稳定展示在页面底部区域。
|
||||||
|
- 不应被底部导航遮挡。
|
||||||
|
- 不应与小程序系统安全区冲突。
|
||||||
|
- 保留发送按钮能力。
|
||||||
|
- 保留语音输入入口和现有输入能力。
|
||||||
|
|
||||||
|
### 2.6 输入框点击后可变高
|
||||||
|
|
||||||
|
文字输入框点击后可以变高一些,方便用户查看已经输入的文字。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
- 未聚焦时保持紧凑,不占用过多空间。
|
||||||
|
- 聚焦后输入区域高度增加。
|
||||||
|
- 支持查看较长输入内容。
|
||||||
|
- 高度变化不应导致页面元素错位或遮挡。
|
||||||
|
- 输入、发送、语音识别流程不受影响。
|
||||||
|
|
||||||
|
## 3. 生成后对话界面需求
|
||||||
|
|
||||||
|
### 3.1 生成后支持继续对话
|
||||||
|
|
||||||
|
用户输入并生成小说后,页面需要进入对话界面。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
- 生成完成后,用户可以继续输入文字。
|
||||||
|
- 支持语音转文字输入。
|
||||||
|
- 用户可以直接与 agent 对话。
|
||||||
|
- 对话内容用于对当前生成的小说提出修改建议。
|
||||||
|
|
||||||
|
### 3.2 支持对当前小说提出修改建议
|
||||||
|
|
||||||
|
生成后的对话不是重新开始一个无上下文聊天,而是围绕当前生成小说继续沟通。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
- agent 需要知道当前小说内容。
|
||||||
|
- 用户可以提出“换个方向”“这里不像我”“增加某种情节”等修改需求。
|
||||||
|
- 后台需要能识别当前修改建议与当前小说的关联关系。
|
||||||
|
- 后续重新生成时,需要基于当前小说和用户新需求生成。
|
||||||
|
|
||||||
|
### 3.3 左上角增加历史回顾功能
|
||||||
|
|
||||||
|
生成后对话界面左上角需要增加历史回顾入口。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
- 用户可以查看历史生成记录或历史对话记录。
|
||||||
|
- 历史回顾入口不应与微信原生右上角胶囊按钮冲突。
|
||||||
|
- 历史入口需要与当前页面主题风格一致。
|
||||||
|
|
||||||
|
### 3.4 小说卡片支持收缩/折叠
|
||||||
|
|
||||||
|
生成出来的小说卡片最好可以收缩或折叠。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
- 默认状态可以展示小说核心内容。
|
||||||
|
- 用户可以收起长文本,减少页面占用。
|
||||||
|
- 用户也可以重新展开查看完整内容。
|
||||||
|
- 收缩/展开不影响后续对话输入。
|
||||||
|
- 收缩/展开状态需要有明确的视觉提示。
|
||||||
|
|
||||||
|
### 3.5 生成过程中的透明卡片 UI 保留
|
||||||
|
|
||||||
|
生成过程中,当前透明卡片 UI 效果可以保留。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
- 生成过程中继续使用透明卡片承载流式输出。
|
||||||
|
- 保持当前逐字输出体验。
|
||||||
|
- 内容过长时支持滚动查看最新输出。
|
||||||
|
- 不要因为新增对话能力而破坏生成过程中的视觉样式。
|
||||||
|
|
||||||
|
### 3.6 生成结束后样式不改变
|
||||||
|
|
||||||
|
生成结束后,不要改变当前生成结果的整体样式。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
- 生成完成后,小说展示样式应尽量延续生成过程中的透明卡片视觉。
|
||||||
|
- 不应突然切换成完全不同风格的卡片。
|
||||||
|
- 下方原有操作按钮保持当前视觉样式。
|
||||||
|
|
||||||
|
### 3.7 保留“换个方向”和“不像我”按钮
|
||||||
|
|
||||||
|
下方原有按钮:
|
||||||
|
|
||||||
|
- “换个方向”
|
||||||
|
- “不像我”
|
||||||
|
|
||||||
|
不需要改变。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
- 按钮文案不变。
|
||||||
|
- 按钮位置和风格尽量保持现有设计。
|
||||||
|
- 按钮点击逻辑需要升级为进入对话确认模式。
|
||||||
|
|
||||||
|
### 3.8 点击“换个方向”或“不像我”后进入聊天确认模式
|
||||||
|
|
||||||
|
用户点击“换个方向”或“不像我”后,不应立即盲目重新生成。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
- 点击后进入聊天对话模式。
|
||||||
|
- agent 需要先与用户确认具体修改需求。
|
||||||
|
- 用户确认需求后,再触发重新生成。
|
||||||
|
- 重新生成需要基于当前小说内容和用户确认后的修改方向。
|
||||||
|
|
||||||
|
## 4. 功能影响范围
|
||||||
|
|
||||||
|
预计涉及小程序以下模块:
|
||||||
|
|
||||||
|
- 爽文生成首页布局
|
||||||
|
- 底部导航配置
|
||||||
|
- 生成中流式输出展示
|
||||||
|
- 生成后小说结果展示
|
||||||
|
- 生成后对话输入区
|
||||||
|
- 语音转文字输入
|
||||||
|
- 历史记录入口
|
||||||
|
- 小说卡片折叠/展开
|
||||||
|
- “换个方向”“不像我”按钮交互
|
||||||
|
- AI 场景调用和上下文传参
|
||||||
|
|
||||||
|
## 5. 非目标
|
||||||
|
|
||||||
|
本需求整理文档只记录需求,不直接约束具体实现方案。
|
||||||
|
|
||||||
|
以下内容不在本次需求整理范围内:
|
||||||
|
|
||||||
|
- 后台 AI 配置管理页面改版
|
||||||
|
- Dify/Coze 服务商配置调整
|
||||||
|
- 管理后台埋点分析改版
|
||||||
|
- 新增付费、会员或权限体系
|
||||||
|
- 大规模重做小程序视觉主题
|
||||||
|
|
||||||
|
## 6. 验收标准
|
||||||
|
|
||||||
|
### 6.1 首页验收
|
||||||
|
|
||||||
|
- 打开小程序后默认进入爽文生成页面。
|
||||||
|
- 底部导航中,爽文生成位于中间,人生轨迹位于最左侧。
|
||||||
|
- 首页标题更醒目,居中展示。
|
||||||
|
- “灵感一下”和“换一换”大小一致,并比当前更小。
|
||||||
|
- 输入框位于界面最下方,不被底部导航和安全区遮挡。
|
||||||
|
- 输入框聚焦后可以变高,方便查看已输入文字。
|
||||||
|
|
||||||
|
### 6.2 生成后对话验收
|
||||||
|
|
||||||
|
- 小说生成完成后,用户可以继续输入文字与 agent 对话。
|
||||||
|
- 支持语音转文字后继续对话。
|
||||||
|
- 用户可以围绕当前小说提出修改意见。
|
||||||
|
- 左上角有历史回顾入口。
|
||||||
|
- 小说卡片支持收缩和展开。
|
||||||
|
- 生成过程透明卡片 UI 保留。
|
||||||
|
- 生成结束后整体样式不突变。
|
||||||
|
- “换个方向”“不像我”按钮保留。
|
||||||
|
- 点击“换个方向”或“不像我”后,先进入对话确认模式,再根据确认后的需求重新生成。
|
||||||
|
|
||||||
|
### 6.3 回归验收
|
||||||
|
|
||||||
|
- 原有爽文生成能力正常。
|
||||||
|
- 原有流式输出和逐字展示正常。
|
||||||
|
- 原有语音输入能力正常。
|
||||||
|
- 原有历史记录和小说保存能力正常。
|
||||||
|
- 底部导航切换正常。
|
||||||
|
- 页面顶部按钮不与微信原生胶囊按钮重叠。
|
||||||
@@ -18,6 +18,33 @@ const parseSseFrame = (frame) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const findOverlapLength = (current, next) => {
|
||||||
|
const max = Math.min(current.length, next.length);
|
||||||
|
for (let size = max; size > 0; size -= 1) {
|
||||||
|
if (current.slice(-size) === next.slice(0, size)) return size;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergeStreamOutput = (current, chunk) => {
|
||||||
|
const next = String(chunk || '');
|
||||||
|
if (!next) return { output: current, delta: '' };
|
||||||
|
if (!current) return { output: next, delta: next };
|
||||||
|
if (next === current) return { output: current, delta: '' };
|
||||||
|
if (next.length >= 16 && current.endsWith(next)) return { output: current, delta: '' };
|
||||||
|
if (next.startsWith(current)) {
|
||||||
|
return { output: next, delta: next.slice(current.length) };
|
||||||
|
}
|
||||||
|
const currentIndex = next.length > current.length ? next.indexOf(current) : -1;
|
||||||
|
if (currentIndex >= 0) {
|
||||||
|
return { output: next, delta: next.slice(currentIndex + current.length) };
|
||||||
|
}
|
||||||
|
const overlap = findOverlapLength(current, next);
|
||||||
|
if (overlap < 8) return { output: current + next, delta: next };
|
||||||
|
const delta = next.slice(overlap);
|
||||||
|
return { output: current + delta, delta };
|
||||||
|
};
|
||||||
|
|
||||||
const authHeaders = () => {
|
const authHeaders = () => {
|
||||||
const token = localStorage.getItem('access_token');
|
const token = localStorage.getItem('access_token');
|
||||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
@@ -73,6 +100,7 @@ export const streamAiScene = async ({
|
|||||||
let output = '';
|
let output = '';
|
||||||
let closed = false;
|
let closed = false;
|
||||||
let recovered = false;
|
let recovered = false;
|
||||||
|
let streamStarted = false;
|
||||||
let recoveryTimer;
|
let recoveryTimer;
|
||||||
let recoveryPromise;
|
let recoveryPromise;
|
||||||
|
|
||||||
@@ -92,6 +120,7 @@ export const streamAiScene = async ({
|
|||||||
|
|
||||||
const completeFromRecoveredOutput = async () => {
|
const completeFromRecoveredOutput = async () => {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
|
if (streamStarted || output.trim()) return;
|
||||||
try {
|
try {
|
||||||
const recoveredOutput = await recoverOnce();
|
const recoveredOutput = await recoverOnce();
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
@@ -107,7 +136,7 @@ export const streamAiScene = async ({
|
|||||||
|
|
||||||
recoveryTimer = setTimeout(() => {
|
recoveryTimer = setTimeout(() => {
|
||||||
completeFromRecoveredOutput();
|
completeFromRecoveredOutput();
|
||||||
}, 8000);
|
}, 25000);
|
||||||
|
|
||||||
const finishRecovered = (event, message) => {
|
const finishRecovered = (event, message) => {
|
||||||
if (!output.trim()) return false;
|
if (!output.trim()) return false;
|
||||||
@@ -127,13 +156,13 @@ export const streamAiScene = async ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const recoverOrThrow = async (message, event) => {
|
const recoverOrThrow = async (message, event) => {
|
||||||
if (finishRecovered(event, message)) return;
|
|
||||||
try {
|
try {
|
||||||
output = await recoverOnce();
|
output = await recoverOnce();
|
||||||
recovered = true;
|
recovered = true;
|
||||||
closed = true;
|
closed = true;
|
||||||
clearRecoveryTimer();
|
clearRecoveryTimer();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (finishRecovered(event, message || error?.message)) return;
|
||||||
const finalMessage = message || error?.message || 'AI 生成结果暂时没有返回';
|
const finalMessage = message || error?.message || 'AI 生成结果暂时没有返回';
|
||||||
onError?.(finalMessage, event);
|
onError?.(finalMessage, event);
|
||||||
throw new Error(finalMessage);
|
throw new Error(finalMessage);
|
||||||
@@ -148,12 +177,19 @@ export const streamAiScene = async ({
|
|||||||
const event = parseSseFrame(frame);
|
const event = parseSseFrame(frame);
|
||||||
if (!event) return;
|
if (!event) return;
|
||||||
if (event.type === 'start') {
|
if (event.type === 'start') {
|
||||||
|
streamStarted = true;
|
||||||
|
clearRecoveryTimer();
|
||||||
onStart?.(event);
|
onStart?.(event);
|
||||||
} else if (event.type === 'delta') {
|
} else if (event.type === 'delta') {
|
||||||
const delta = event.content || '';
|
streamStarted = true;
|
||||||
output += delta;
|
clearRecoveryTimer();
|
||||||
onDelta?.(delta, output, event);
|
const merged = mergeStreamOutput(output, event.content);
|
||||||
|
output = merged.output;
|
||||||
|
if (merged.delta) {
|
||||||
|
onDelta?.(merged.delta, output, event);
|
||||||
|
}
|
||||||
} else if (event.type === 'done') {
|
} else if (event.type === 'done') {
|
||||||
|
streamStarted = true;
|
||||||
closed = true;
|
closed = true;
|
||||||
clearRecoveryTimer();
|
clearRecoveryTimer();
|
||||||
onDone?.(event, output);
|
onDone?.(event, output);
|
||||||
@@ -191,6 +227,8 @@ export const streamAiScene = async ({
|
|||||||
while (true) {
|
while (true) {
|
||||||
const { value, done } = await reader.read();
|
const { value, done } = await reader.read();
|
||||||
if (done) break;
|
if (done) break;
|
||||||
|
streamStarted = true;
|
||||||
|
clearRecoveryTimer();
|
||||||
consumeText(decoder.decode(value, { stream: true }));
|
consumeText(decoder.decode(value, { stream: true }));
|
||||||
if (closed || recovered) break;
|
if (closed || recovered) break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const PathView = ({ onGoToScript }) => {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const [streamPath, setStreamPath] = useState('');
|
const [streamPath, setStreamPath] = useState('');
|
||||||
const pathWriter = useTypewriterStream({ interval: 18, step: 1 });
|
const pathWriter = useTypewriterStream({ interval: 30, step: 1 });
|
||||||
|
|
||||||
const selectedScript = getSelectedScript();
|
const selectedScript = getSelectedScript();
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ const ScriptView = ({ onOpenProfile }) => {
|
|||||||
const [length, setLength] = useState(scriptLengths[0].value);
|
const [length, setLength] = useState(scriptLengths[0].value);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [streamContent, setStreamContent] = useState('');
|
const [streamContent, setStreamContent] = useState('');
|
||||||
const scriptWriter = useTypewriterStream({ interval: 18, step: 1 });
|
const scriptWriter = useTypewriterStream({ interval: 30, step: 1 });
|
||||||
|
|
||||||
// 编辑模态框状态
|
// 编辑模态框状态
|
||||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ const TimelineView = () => {
|
|||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [streamFeedback, setStreamFeedback] = useState('');
|
const [streamFeedback, setStreamFeedback] = useState('');
|
||||||
const feedbackWriter = useTypewriterStream({ interval: 18, step: 1 });
|
const feedbackWriter = useTypewriterStream({ interval: 30, step: 1 });
|
||||||
|
|
||||||
// 编辑模式状态:null 表示新增模式,有值表示编辑模式(存储事件 ID)
|
// 编辑模式状态:null 表示新增模式,有值表示编辑模式(存储事件 ID)
|
||||||
const [editingEventId, setEditingEventId] = useState(null);
|
const [editingEventId, setEditingEventId] = useState(null);
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="music-player" :style="{ bottom: bottomPosition }">
|
<view
|
||||||
|
v-if="positionReady"
|
||||||
|
class="music-player"
|
||||||
|
:style="playerStyle"
|
||||||
|
@touchstart.stop="handleTouchStart"
|
||||||
|
@touchmove.stop.prevent="handleTouchMove"
|
||||||
|
@touchend.stop="handleTouchEnd"
|
||||||
|
@touchcancel.stop="handleTouchEnd"
|
||||||
|
>
|
||||||
<view
|
<view
|
||||||
class="music-toggle"
|
class="music-toggle"
|
||||||
:class="{ playing: isPlaying }"
|
:class="{ playing: isPlaying, dragging: isDragging }"
|
||||||
@click="toggleMusic"
|
@click="toggleMusic"
|
||||||
>
|
>
|
||||||
<view class="music-disc" :class="{ spinning: isPlaying }"></view>
|
<view class="music-disc" :class="{ spinning: isPlaying }"></view>
|
||||||
@@ -12,14 +20,62 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
const isPlaying = ref(false)
|
const isPlaying = ref(false)
|
||||||
const bottomPosition = ref('180rpx')
|
const positionReady = ref(false)
|
||||||
|
const isDragging = ref(false)
|
||||||
|
const playerPosition = ref({ x: 0, y: 0 })
|
||||||
let audioInstance = null
|
let audioInstance = null
|
||||||
|
let windowMetrics = {
|
||||||
|
width: 375,
|
||||||
|
height: 667,
|
||||||
|
statusBarHeight: 20,
|
||||||
|
safeAreaBottom: 0,
|
||||||
|
buttonSize: 44
|
||||||
|
}
|
||||||
|
let dragStart = null
|
||||||
|
let suppressNextClick = false
|
||||||
|
|
||||||
// 背景音乐 URL - 使用原型中的音乐
|
// 背景音乐 URL - 使用原型中的音乐
|
||||||
const MUSIC_URL = 'https://v3b.fal.media/files/b/0a8c9a0b/rStj8V-2tCe6bVYpCCcLN_output.mp3'
|
const MUSIC_URL = 'https://v3b.fal.media/files/b/0a8c9a0b/rStj8V-2tCe6bVYpCCcLN_output.mp3'
|
||||||
|
const STORAGE_KEY = 'music_player_position'
|
||||||
|
|
||||||
|
const playerStyle = computed(() => ({
|
||||||
|
left: `${playerPosition.value.x}px`,
|
||||||
|
top: `${playerPosition.value.y}px`
|
||||||
|
}))
|
||||||
|
|
||||||
|
const rpxToPx = (rpx, windowWidth = windowMetrics.width) => windowWidth * rpx / 750
|
||||||
|
|
||||||
|
const clamp = (value, min, max) => Math.max(min, Math.min(max, value))
|
||||||
|
|
||||||
|
const clampPosition = (position) => {
|
||||||
|
const margin = 8
|
||||||
|
const minY = Math.max(margin, windowMetrics.statusBarHeight + 8)
|
||||||
|
const maxX = windowMetrics.width - windowMetrics.buttonSize - margin
|
||||||
|
const maxY = windowMetrics.height - windowMetrics.safeAreaBottom - windowMetrics.buttonSize - margin
|
||||||
|
return {
|
||||||
|
x: clamp(Number(position?.x) || margin, margin, Math.max(margin, maxX)),
|
||||||
|
y: clamp(Number(position?.y) || minY, minY, Math.max(minY, maxY))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const savePosition = () => {
|
||||||
|
uni.setStorageSync(STORAGE_KEY, playerPosition.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const restorePosition = () => {
|
||||||
|
const saved = uni.getStorageSync(STORAGE_KEY)
|
||||||
|
if (saved && typeof saved === 'object') {
|
||||||
|
playerPosition.value = clampPosition(saved)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
playerPosition.value = clampPosition({
|
||||||
|
x: windowMetrics.width - windowMetrics.buttonSize - rpxToPx(16),
|
||||||
|
y: windowMetrics.height - windowMetrics.safeAreaBottom - windowMetrics.buttonSize - 96
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const initAudio = () => {
|
const initAudio = () => {
|
||||||
if (!audioInstance) {
|
if (!audioInstance) {
|
||||||
@@ -49,6 +105,7 @@ const initAudio = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const toggleMusic = async () => {
|
const toggleMusic = async () => {
|
||||||
|
if (suppressNextClick || isDragging.value) return
|
||||||
initAudio()
|
initAudio()
|
||||||
|
|
||||||
if (isPlaying.value) {
|
if (isPlaying.value) {
|
||||||
@@ -63,11 +120,58 @@ const toggleMusic = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleTouchStart = (event) => {
|
||||||
|
const touch = event.touches?.[0]
|
||||||
|
if (!touch) return
|
||||||
|
dragStart = {
|
||||||
|
x: touch.clientX,
|
||||||
|
y: touch.clientY,
|
||||||
|
originX: playerPosition.value.x,
|
||||||
|
originY: playerPosition.value.y,
|
||||||
|
moved: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTouchMove = (event) => {
|
||||||
|
const touch = event.touches?.[0]
|
||||||
|
if (!touch || !dragStart) return
|
||||||
|
const deltaX = touch.clientX - dragStart.x
|
||||||
|
const deltaY = touch.clientY - dragStart.y
|
||||||
|
if (Math.abs(deltaX) > 3 || Math.abs(deltaY) > 3) {
|
||||||
|
dragStart.moved = true
|
||||||
|
isDragging.value = true
|
||||||
|
}
|
||||||
|
if (!dragStart.moved) return
|
||||||
|
playerPosition.value = clampPosition({
|
||||||
|
x: dragStart.originX + deltaX,
|
||||||
|
y: dragStart.originY + deltaY
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
if (dragStart?.moved) {
|
||||||
|
playerPosition.value = clampPosition(playerPosition.value)
|
||||||
|
savePosition()
|
||||||
|
suppressNextClick = true
|
||||||
|
setTimeout(() => {
|
||||||
|
suppressNextClick = false
|
||||||
|
}, 180)
|
||||||
|
}
|
||||||
|
dragStart = null
|
||||||
|
isDragging.value = false
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 使用新的推荐 API 替代已弃用的 getSystemInfoSync
|
const windowInfo = uni.getWindowInfo ? uni.getWindowInfo() : uni.getSystemInfoSync()
|
||||||
const windowInfo = uni.getWindowInfo()
|
windowMetrics = {
|
||||||
const safeAreaBottom = windowInfo.safeAreaInsets?.bottom || 0
|
width: windowInfo.windowWidth || 375,
|
||||||
bottomPosition.value = `${safeAreaBottom + 96}px`
|
height: windowInfo.windowHeight || 667,
|
||||||
|
statusBarHeight: windowInfo.statusBarHeight || 20,
|
||||||
|
safeAreaBottom: windowInfo.safeAreaInsets?.bottom || 0,
|
||||||
|
buttonSize: rpxToPx(88, windowInfo.windowWidth || 375)
|
||||||
|
}
|
||||||
|
restorePosition()
|
||||||
|
positionReady.value = true
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -86,8 +190,9 @@ defineExpose({
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.music-player {
|
.music-player {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 16rpx;
|
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
|
width: 88rpx;
|
||||||
|
height: 88rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.music-toggle {
|
.music-toggle {
|
||||||
@@ -106,6 +211,13 @@ defineExpose({
|
|||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.music-toggle.dragging {
|
||||||
|
transform: scale(1.06);
|
||||||
|
opacity: 0.78;
|
||||||
|
border-color: rgba(216, 180, 254, 0.42);
|
||||||
|
box-shadow: 0 0 34rpx rgba(168, 85, 247, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
.music-toggle:active {
|
.music-toggle:active {
|
||||||
transform: scale(0.95);
|
transform: scale(0.95);
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ const pagePath = '/pages/life-event/form'
|
|||||||
const { capsuleTopReservePx } = useMenuButtonSafeArea({ extraTopPx: 10 })
|
const { capsuleTopReservePx } = useMenuButtonSafeArea({ extraTopPx: 10 })
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const assisting = ref(false)
|
const assisting = ref(false)
|
||||||
const assistWriter = useTypewriterStream({ interval: 24, step: 1 })
|
const assistWriter = useTypewriterStream({ interval: 32, step: 1 })
|
||||||
const currentYear = new Date().getFullYear()
|
const currentYear = new Date().getFullYear()
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -53,7 +53,7 @@ const { capsuleTopReservePx } = useMenuButtonSafeArea({ extraTopPx: 10 })
|
|||||||
|
|
||||||
const pathData = ref(null)
|
const pathData = ref(null)
|
||||||
const pathGenerating = ref(false)
|
const pathGenerating = ref(false)
|
||||||
const pathWriter = useTypewriterStream({ interval: 24, step: 1 })
|
const pathWriter = useTypewriterStream({ interval: 32, step: 1 })
|
||||||
|
|
||||||
const selectedScript = computed(() => {
|
const selectedScript = computed(() => {
|
||||||
return store.scripts.find(s => s.isSelected)
|
return store.scripts.find(s => s.isSelected)
|
||||||
|
|||||||
@@ -66,9 +66,8 @@
|
|||||||
</view>
|
</view>
|
||||||
<text class="section-subtitle">你的成长之路,正在展开</text>
|
<text class="section-subtitle">你的成长之路,正在展开</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="map-btn kos-pill" @click="openMap">
|
<view class="social-import-btn" @click="openSocialImport">
|
||||||
<view class="map-icon"></view>
|
<text>导入社交数据</text>
|
||||||
<text>轨迹地图</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -263,8 +262,8 @@ const editProfile = () => {
|
|||||||
uni.navigateTo({ url: '/pages/onboarding/index?edit=1' })
|
uni.navigateTo({ url: '/pages/onboarding/index?edit=1' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const openMap = () => {
|
const openSocialImport = () => {
|
||||||
uni.navigateTo({ url: '/pages/main/PathView' })
|
uni.navigateTo({ url: '/pages/social-import/index' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const addFilter = () => {
|
const addFilter = () => {
|
||||||
@@ -567,23 +566,21 @@ const addFilter = () => {
|
|||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-btn {
|
.social-import-btn {
|
||||||
height: 56rpx;
|
height: 64rpx;
|
||||||
padding: 0 20rpx;
|
padding: 0 22rpx;
|
||||||
border-radius: 999rpx;
|
border-radius: 999rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10rpx;
|
justify-content: center;
|
||||||
color: #caa9ff;
|
color: #fff;
|
||||||
font-size: 22rpx;
|
font-size: 25rpx;
|
||||||
}
|
font-weight: 800;
|
||||||
|
white-space: nowrap;
|
||||||
.map-icon {
|
background:
|
||||||
width: 24rpx;
|
radial-gradient(circle at 80% 10%, rgba(255, 255, 255, 0.26), transparent 26%),
|
||||||
height: 20rpx;
|
linear-gradient(135deg, #b045ff, #612eff);
|
||||||
border: 3rpx solid currentColor;
|
box-shadow: 0 14rpx 34rpx rgba(129, 66, 255, 0.34);
|
||||||
border-radius: 4rpx;
|
|
||||||
transform: skewY(-12deg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filters {
|
.filters {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<view class="topbar" :style="topbarStyle">
|
<view class="topbar" :style="topbarStyle">
|
||||||
<button class="back-btn" @click="goBack">‹</button>
|
<button class="back-btn" @click="goBack">‹</button>
|
||||||
<text class="top-title">人生剧本 ✦</text>
|
<text class="top-title">人生剧本 ✦</text>
|
||||||
<button class="save-btn kos-pill" @click="selectCurrent">映射</button>
|
<button class="save-btn kos-pill" @click="continueCurrent">继续</button>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<scroll-view class="scroll" scroll-y :show-scrollbar="false">
|
<scroll-view class="scroll" scroll-y :show-scrollbar="false">
|
||||||
@@ -28,10 +28,6 @@
|
|||||||
<text class="stat-label">字数</text>
|
<text class="stat-label">字数</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="audio-inline" @click="trackTtsClick">
|
|
||||||
<text class="audio-inline-icon">▶</text>
|
|
||||||
<text class="audio-inline-text">{{ detailTtsButtonText }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="tabs kos-card">
|
<view class="tabs kos-card">
|
||||||
@@ -56,7 +52,11 @@
|
|||||||
|
|
||||||
<view class="bottom-actions">
|
<view class="bottom-actions">
|
||||||
<button class="secondary-btn kos-pill" @click="goBack">返回列表</button>
|
<button class="secondary-btn kos-pill" @click="goBack">返回列表</button>
|
||||||
<button class="primary-btn kos-primary" @click="selectCurrent">映射实现路径</button>
|
<button class="voice-btn kos-pill" @click="trackTtsClick">
|
||||||
|
<text class="voice-icon">{{ detailTtsIcon }}</text>
|
||||||
|
<text>{{ detailTtsActionText }}</text>
|
||||||
|
</button>
|
||||||
|
<button class="primary-btn kos-primary" @click="continueCurrent">继续生成</button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
@@ -83,9 +83,14 @@ const lengthText = computed(() => {
|
|||||||
return map[script.value?.length] || script.value?.length || '中篇'
|
return map[script.value?.length] || script.value?.length || '中篇'
|
||||||
})
|
})
|
||||||
|
|
||||||
const detailTtsButtonText = computed(() => {
|
const detailTtsActionText = computed(() => {
|
||||||
if (!script.value?.id) return '生成保存后可语音播放'
|
if (!script.value?.id) return '播放'
|
||||||
return ttsPlayer.buttonText.value
|
if (ttsPlayer.loading.value) return '生成中'
|
||||||
|
return ttsPlayer.playing.value ? '暂停' : '播放'
|
||||||
|
})
|
||||||
|
|
||||||
|
const detailTtsIcon = computed(() => {
|
||||||
|
return ttsPlayer.playing.value ? 'Ⅱ' : '▶'
|
||||||
})
|
})
|
||||||
|
|
||||||
const outline = computed(() => {
|
const outline = computed(() => {
|
||||||
@@ -120,19 +125,17 @@ const loadScript = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectCurrent = async () => {
|
const continueCurrent = () => {
|
||||||
if (!script.value?.id) return
|
if (!script.value?.id) return
|
||||||
analytics.track('path_select', {
|
uni.setStorageSync('pending_open_script_chat', {
|
||||||
|
id: script.value.id
|
||||||
|
})
|
||||||
|
analytics.track('script_detail_continue_click', {
|
||||||
script_id: script.value.id,
|
script_id: script.value.id,
|
||||||
style: script.value.style || '',
|
style: script.value.style || '',
|
||||||
length: script.value.length || ''
|
length: script.value.length || ''
|
||||||
}, { eventType: 'script', pagePath })
|
}, { eventType: 'script', pagePath })
|
||||||
const res = await store.selectScript(script.value.id)
|
uni.reLaunch({ url: '/pages/main/index?tab=script' })
|
||||||
if (!res.success) {
|
|
||||||
uni.showToast({ title: res.error || '映射失败', icon: 'none' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
uni.navigateTo({ url: '/pages/main/PathView' })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const trackTtsClick = () => {
|
const trackTtsClick = () => {
|
||||||
@@ -272,41 +275,6 @@ onUnmounted(() => {
|
|||||||
margin-top: 28rpx;
|
margin-top: 28rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.audio-inline {
|
|
||||||
height: 76rpx;
|
|
||||||
margin-top: 26rpx;
|
|
||||||
border-radius: 999rpx;
|
|
||||||
padding: 0 26rpx;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 14rpx;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 25rpx;
|
|
||||||
font-weight: 800;
|
|
||||||
line-height: 1;
|
|
||||||
background: linear-gradient(135deg, #24c6dc, #7f5af0);
|
|
||||||
box-shadow: 0 12rpx 30rpx rgba(36, 198, 220, 0.22);
|
|
||||||
}
|
|
||||||
|
|
||||||
.audio-inline-icon {
|
|
||||||
width: 34rpx;
|
|
||||||
height: 34rpx;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: rgba(5, 6, 21, 0.86);
|
|
||||||
font-size: 18rpx;
|
|
||||||
font-weight: 900;
|
|
||||||
background: rgba(255, 255, 255, 0.86);
|
|
||||||
}
|
|
||||||
|
|
||||||
.audio-inline-text {
|
|
||||||
line-height: 1.2;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat {
|
.stat {
|
||||||
padding: 18rpx 10rpx;
|
padding: 18rpx 10rpx;
|
||||||
border-radius: 20rpx;
|
border-radius: 20rpx;
|
||||||
@@ -416,13 +384,14 @@ onUnmounted(() => {
|
|||||||
padding: 16rpx 30rpx 26rpx;
|
padding: 16rpx 30rpx 26rpx;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1.45fr;
|
grid-template-columns: 1fr 1fr 1.24fr;
|
||||||
gap: 18rpx;
|
gap: 18rpx;
|
||||||
background: rgba(5, 6, 21, 0.72);
|
background: rgba(5, 6, 21, 0.72);
|
||||||
backdrop-filter: blur(24rpx);
|
backdrop-filter: blur(24rpx);
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondary-btn,
|
.secondary-btn,
|
||||||
|
.voice-btn,
|
||||||
.primary-btn {
|
.primary-btn {
|
||||||
height: 82rpx;
|
height: 82rpx;
|
||||||
border-radius: 999rpx;
|
border-radius: 999rpx;
|
||||||
@@ -439,4 +408,15 @@ onUnmounted(() => {
|
|||||||
.secondary-btn {
|
.secondary-btn {
|
||||||
color: #caa0ff;
|
color: #caa0ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.voice-btn {
|
||||||
|
gap: 8rpx;
|
||||||
|
color: #e8ccff;
|
||||||
|
border: 1rpx solid rgba(192, 132, 252, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.voice-icon {
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,829 @@
|
|||||||
|
<template>
|
||||||
|
<view class="script-library">
|
||||||
|
<view class="page-head">
|
||||||
|
<view class="back-title" @click="backToScript">
|
||||||
|
<text class="back-arrow">‹</text>
|
||||||
|
<text class="back-text">返回</text>
|
||||||
|
</view>
|
||||||
|
<view class="head-actions">
|
||||||
|
<view class="circle-btn" @click="openSearch">
|
||||||
|
<view class="search-icon"></view>
|
||||||
|
</view>
|
||||||
|
<view class="circle-btn" @click="openMoreMenu">
|
||||||
|
<view class="more-icon">
|
||||||
|
<view></view>
|
||||||
|
<view></view>
|
||||||
|
<view></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="type-tabs">
|
||||||
|
<text
|
||||||
|
v-for="tab in typeTabs"
|
||||||
|
:key="tab.value"
|
||||||
|
class="type-tab"
|
||||||
|
:class="{ active: activeType === tab.value }"
|
||||||
|
@click="activeType = tab.value"
|
||||||
|
>{{ tab.label }}</text>
|
||||||
|
<view class="new-script" @click="createScript">
|
||||||
|
<text class="plus">+</text>
|
||||||
|
<text>新建剧本</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="filter-bar">
|
||||||
|
<scroll-view class="status-scroll" scroll-x :show-scrollbar="false">
|
||||||
|
<view class="status-row">
|
||||||
|
<text
|
||||||
|
v-for="filter in statusFilters"
|
||||||
|
:key="filter.value"
|
||||||
|
class="status-chip"
|
||||||
|
:class="{ active: activeStatus === filter.value }"
|
||||||
|
@click="activeStatus = filter.value"
|
||||||
|
>{{ filter.label }}</text>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
<view class="sort-tools">
|
||||||
|
<text class="sort-text" @click="toggleSort">{{ sortLabel }}</text>
|
||||||
|
<view class="grid-icon" :class="{ active: viewMode === 'grid' }" @click="toggleViewMode">
|
||||||
|
<view v-for="i in 4" :key="i"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="visibleScripts.length" class="script-list" :class="{ grid: viewMode === 'grid' }">
|
||||||
|
<view
|
||||||
|
v-for="(script, index) in visibleScripts"
|
||||||
|
:key="script.id || index"
|
||||||
|
class="script-card"
|
||||||
|
@click="viewScript(script)"
|
||||||
|
>
|
||||||
|
<view class="cover" :class="'cover-' + (index % 6)">
|
||||||
|
<text>{{ getInitial(script) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="card-main">
|
||||||
|
<view class="card-top">
|
||||||
|
<view class="title-wrap">
|
||||||
|
<text class="script-title">{{ script.title }}</text>
|
||||||
|
<text class="length-badge">{{ getLengthLabel(script.length) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="right-state">
|
||||||
|
<text class="state-pill" :class="'state-' + getStatus(script)">{{ getStatusLabel(script) }}</text>
|
||||||
|
<text class="ellipsis" @click.stop="openScriptMenu(script)">•••</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="tag-row">
|
||||||
|
<text v-for="tag in getTags(script)" :key="tag" class="tag">{{ tag }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<text class="summary">{{ script.summary || script.content || '一段正在生成中的平行人生剧本。' }}</text>
|
||||||
|
|
||||||
|
<view class="meta-row">
|
||||||
|
<text>{{ getChapterCount(script) }}章</text>
|
||||||
|
<text>|</text>
|
||||||
|
<text>{{ getWordCount(script) }}</text>
|
||||||
|
<text>|</text>
|
||||||
|
<text>{{ getDateText(script) }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="getStatus(script) === 'progress'" class="progress-row">
|
||||||
|
<view class="progress-track">
|
||||||
|
<view class="progress-fill" :style="{ width: getProgress(script) + '%' }"></view>
|
||||||
|
</view>
|
||||||
|
<text>{{ getProgress(script) }}%</text>
|
||||||
|
</view>
|
||||||
|
<view v-else-if="isFavorite(script)" class="favorite-row">
|
||||||
|
<text class="favorite-star">★</text>
|
||||||
|
<text>已收藏</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else class="empty-card">
|
||||||
|
<view class="empty-book">
|
||||||
|
<view></view>
|
||||||
|
<view></view>
|
||||||
|
</view>
|
||||||
|
<text class="empty-title">还没有人生剧本</text>
|
||||||
|
<text class="empty-text">去爽文生成页写下一句灵感,生成你的第一段平行人生。</text>
|
||||||
|
<view class="empty-action" @click="createScript">新建剧本</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useAppStore } from '../../stores/app.js'
|
||||||
|
|
||||||
|
const store = useAppStore()
|
||||||
|
const activeType = ref('long')
|
||||||
|
const activeStatus = ref('all')
|
||||||
|
const keyword = ref('')
|
||||||
|
const sortMode = ref('updated')
|
||||||
|
const viewMode = ref('list')
|
||||||
|
const localFavorites = ref(uni.getStorageSync('script_favorites') || {})
|
||||||
|
|
||||||
|
const typeTabs = [
|
||||||
|
{ label: '长篇', value: 'long' },
|
||||||
|
{ label: '短篇', value: 'short' },
|
||||||
|
{ label: '风格', value: 'style' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const statusFilters = [
|
||||||
|
{ label: '全部', value: 'all' },
|
||||||
|
{ label: '进行中', value: 'progress' },
|
||||||
|
{ label: '已完成', value: 'done' },
|
||||||
|
{ label: '草稿箱', value: 'draft' },
|
||||||
|
{ label: '收藏夹', value: 'favorite' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const fallbackScripts = [
|
||||||
|
{
|
||||||
|
id: 'demo-1',
|
||||||
|
title: '逆袭人生:从低谷到巅峰',
|
||||||
|
length: 'long',
|
||||||
|
status: 'progress',
|
||||||
|
tags: ['逆袭成长', '都市', '事业', '热血'],
|
||||||
|
summary: '从被分手、被否定的低谷开始,凭借天赋、努力与智慧,一步步逆袭成为行业巅峰,收获事业、财富...',
|
||||||
|
chapterCount: 28,
|
||||||
|
wordCount: 128000,
|
||||||
|
updatedAt: '今天 21:30',
|
||||||
|
progress: 28
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-2',
|
||||||
|
title: '如果那年我没有放弃',
|
||||||
|
length: 'long',
|
||||||
|
status: 'done',
|
||||||
|
tags: ['成长治愈', '校园', '爱情', '温暖'],
|
||||||
|
summary: '重回十八岁,弥补遗憾,勇敢追梦,守护那些曾经错过的人和事。',
|
||||||
|
chapterCount: 36,
|
||||||
|
wordCount: 156000,
|
||||||
|
completedAt: '2025.05.10',
|
||||||
|
isFavorite: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-3',
|
||||||
|
title: '重生之我在未来等你',
|
||||||
|
length: 'long',
|
||||||
|
status: 'progress',
|
||||||
|
tags: ['重生', '科幻', '爱情', '未来'],
|
||||||
|
summary: '一觉醒来,回到十年前的那一天。这一次,我不仅要改变自己的人生,还要找到你。',
|
||||||
|
chapterCount: 18,
|
||||||
|
wordCount: 83000,
|
||||||
|
updatedAt: '昨天 18:47',
|
||||||
|
progress: 46
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-4',
|
||||||
|
title: '天才作曲家的璀璨之路',
|
||||||
|
length: 'long',
|
||||||
|
status: 'draft',
|
||||||
|
tags: ['音乐', '励志', '天赋', '梦想'],
|
||||||
|
summary: '从默默无闻到享誉全球,用音符征服世界,写下属于自己的传奇乐章。',
|
||||||
|
chapterCount: 9,
|
||||||
|
wordCount: 31000,
|
||||||
|
createdAt: '2025.05.08'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-5',
|
||||||
|
title: '咖啡馆里的奇遇',
|
||||||
|
length: 'short',
|
||||||
|
status: 'done',
|
||||||
|
tags: ['生活', '治愈', '奇幻', '温暖'],
|
||||||
|
summary: '一杯咖啡,一次奇遇,改变了我平凡的生活,也让我遇见了最特别的你。',
|
||||||
|
chapterCount: 1,
|
||||||
|
wordCount: 23000,
|
||||||
|
completedAt: '2025.05.01',
|
||||||
|
isFavorite: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'demo-6',
|
||||||
|
title: '赛博时代的追光者',
|
||||||
|
length: 'long',
|
||||||
|
status: 'draft',
|
||||||
|
tags: ['科幻', '未来', '冒险', '热血'],
|
||||||
|
summary: '在数据与代码构建的世界里,我追寻光明,也在黑暗中寻找真正的自由。',
|
||||||
|
chapterCount: 3,
|
||||||
|
wordCount: 12000,
|
||||||
|
createdAt: '2025.05.12'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const scripts = computed(() => {
|
||||||
|
const list = store.scripts || []
|
||||||
|
return list.length ? list : fallbackScripts
|
||||||
|
})
|
||||||
|
|
||||||
|
const visibleScripts = computed(() => {
|
||||||
|
const filtered = scripts.value.filter(script => {
|
||||||
|
const status = getStatus(script)
|
||||||
|
if (keyword.value) {
|
||||||
|
const haystack = [script.title, script.summary, script.content, script.style, ...(script.tags || [])].join(' ')
|
||||||
|
if (!haystack.includes(keyword.value)) return false
|
||||||
|
}
|
||||||
|
if (activeStatus.value === 'favorite') return isFavorite(script)
|
||||||
|
if (activeStatus.value !== 'all' && status !== activeStatus.value) return false
|
||||||
|
if (activeType.value === 'short') return script.length === 'short'
|
||||||
|
if (activeType.value === 'long') return script.length !== 'short'
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return [...filtered].sort((a, b) => {
|
||||||
|
if (sortMode.value === 'words') return Number(b.wordCount || 0) - Number(a.wordCount || 0)
|
||||||
|
if (sortMode.value === 'progress') return getProgress(b) - getProgress(a)
|
||||||
|
return String(b.updateTime || b.updatedAt || b.createTime || b.date || '').localeCompare(String(a.updateTime || a.updatedAt || a.createTime || a.date || ''))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const sortLabel = computed(() => {
|
||||||
|
const map = { updated: '最近更新⌄', words: '字数最多⌄', progress: '进度最高⌄' }
|
||||||
|
return map[sortMode.value] || '最近更新⌄'
|
||||||
|
})
|
||||||
|
|
||||||
|
const getStatus = (script) => {
|
||||||
|
if (script.status) return script.status
|
||||||
|
if (script.isDraft) return 'draft'
|
||||||
|
if (script.isCompleted || script.completedAt) return 'done'
|
||||||
|
return script.progress ? 'progress' : 'done'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusLabel = (script) => {
|
||||||
|
const map = { progress: '进行中', done: '已完成', draft: '草稿' }
|
||||||
|
return map[getStatus(script)] || '已完成'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLengthLabel = (length) => {
|
||||||
|
return length === 'short' ? '短篇' : '长篇'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTags = (script) => {
|
||||||
|
if (Array.isArray(script.tags) && script.tags.length) return script.tags.slice(0, 4)
|
||||||
|
return [script.style || '逆袭成长', '都市', '事业', '热血']
|
||||||
|
}
|
||||||
|
|
||||||
|
const getChapterCount = (script) => script.chapterCount || script.chapters || Math.max(1, Math.round((script.wordCount || 30000) / 4500))
|
||||||
|
|
||||||
|
const getWordCount = (script) => {
|
||||||
|
const count = Number(script.wordCount || 0)
|
||||||
|
if (!count) return '3.1万字'
|
||||||
|
if (count >= 10000) return `${(count / 10000).toFixed(1)}万字`
|
||||||
|
return `${count}字`
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDateText = (script) => {
|
||||||
|
if (getStatus(script) === 'done') return `完成于:${script.completedAt || script.date || '2025.05.10'}`
|
||||||
|
if (getStatus(script) === 'draft') return `创建于:${script.createdAt || script.date || '2025.05.08'}`
|
||||||
|
return `最近更新:${script.updatedAt || script.date || '今天 21:30'}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const getProgress = (script) => Math.max(1, Math.min(99, Number(script.progress || 28)))
|
||||||
|
|
||||||
|
const getInitial = (script) => (script.title || '剧').slice(0, 1)
|
||||||
|
|
||||||
|
const isFavorite = (script) => {
|
||||||
|
return Boolean(script.isFavorite || script.favorite || localFavorites.value[String(script.id)])
|
||||||
|
}
|
||||||
|
|
||||||
|
const openScriptChat = (script) => {
|
||||||
|
if (!script?.id || String(script.id).startsWith('demo-')) return
|
||||||
|
uni.setStorageSync('pending_open_script_chat', {
|
||||||
|
id: script.id
|
||||||
|
})
|
||||||
|
uni.$emit('switchTab', 'script')
|
||||||
|
setTimeout(() => {
|
||||||
|
uni.$emit('openScriptChat', { id: script.id, script })
|
||||||
|
}, 80)
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewScript = (script) => {
|
||||||
|
openScriptChat(script)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openScriptDetail = (script) => {
|
||||||
|
if (!script?.id || String(script.id).startsWith('demo-')) return
|
||||||
|
uni.navigateTo({ url: `/pages/main/ScriptDetailView?id=${script.id}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
const createScript = () => {
|
||||||
|
uni.$emit('switchTab', 'script')
|
||||||
|
}
|
||||||
|
|
||||||
|
const backToScript = () => {
|
||||||
|
uni.$emit('switchTab', 'script')
|
||||||
|
}
|
||||||
|
|
||||||
|
const openSearch = () => {
|
||||||
|
uni.showModal({
|
||||||
|
title: '搜索剧本',
|
||||||
|
editable: true,
|
||||||
|
placeholderText: '输入标题、标签或关键词',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) keyword.value = String(res.content || '').trim()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const openMoreMenu = () => {
|
||||||
|
uni.showActionSheet({
|
||||||
|
itemList: ['清空搜索', '只看收藏', '查看全部'],
|
||||||
|
success: ({ tapIndex }) => {
|
||||||
|
if (tapIndex === 0) keyword.value = ''
|
||||||
|
if (tapIndex === 1) activeStatus.value = 'favorite'
|
||||||
|
if (tapIndex === 2) {
|
||||||
|
keyword.value = ''
|
||||||
|
activeStatus.value = 'all'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleSort = () => {
|
||||||
|
const order = ['updated', 'words', 'progress']
|
||||||
|
sortMode.value = order[(order.indexOf(sortMode.value) + 1) % order.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleViewMode = () => {
|
||||||
|
viewMode.value = viewMode.value === 'list' ? 'grid' : 'list'
|
||||||
|
}
|
||||||
|
|
||||||
|
const openScriptMenu = (script) => {
|
||||||
|
const favorite = isFavorite(script)
|
||||||
|
uni.showActionSheet({
|
||||||
|
itemList: [favorite ? '取消收藏' : '收藏剧本', '继续生成', '查看详情'],
|
||||||
|
success: ({ tapIndex }) => {
|
||||||
|
if (tapIndex === 0) {
|
||||||
|
const next = { ...localFavorites.value }
|
||||||
|
if (favorite) delete next[String(script.id)]
|
||||||
|
else next[String(script.id)] = true
|
||||||
|
localFavorites.value = next
|
||||||
|
uni.setStorageSync('script_favorites', next)
|
||||||
|
uni.showToast({ title: favorite ? '已取消收藏' : '已收藏', icon: 'success' })
|
||||||
|
}
|
||||||
|
if (tapIndex === 1) viewScript(script)
|
||||||
|
if (tapIndex === 2) openScriptDetail(script)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.script-library {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24rpx;
|
||||||
|
padding-bottom: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-head,
|
||||||
|
.back-title,
|
||||||
|
.head-actions,
|
||||||
|
.type-tabs,
|
||||||
|
.filter-bar,
|
||||||
|
.sort-tools,
|
||||||
|
.card-top,
|
||||||
|
.title-wrap,
|
||||||
|
.right-state,
|
||||||
|
.meta-row,
|
||||||
|
.progress-row,
|
||||||
|
.favorite-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-head {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-title {
|
||||||
|
height: 60rpx;
|
||||||
|
gap: 10rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.94);
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-arrow {
|
||||||
|
font-size: 58rpx;
|
||||||
|
line-height: 1;
|
||||||
|
transform: translateY(-2rpx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-text {
|
||||||
|
font-size: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head-actions {
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circle-btn {
|
||||||
|
width: 58rpx;
|
||||||
|
height: 58rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1rpx solid rgba(142, 105, 255, 0.36);
|
||||||
|
background: rgba(10, 11, 38, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
width: 25rpx;
|
||||||
|
height: 25rpx;
|
||||||
|
border: 4rpx solid #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
right: -9rpx;
|
||||||
|
bottom: -8rpx;
|
||||||
|
width: 14rpx;
|
||||||
|
height: 4rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: #fff;
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-icon {
|
||||||
|
display: flex;
|
||||||
|
gap: 5rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-icon view {
|
||||||
|
width: 6rpx;
|
||||||
|
height: 6rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-tabs {
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1rpx solid rgba(126, 87, 255, 0.18);
|
||||||
|
padding-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-tab {
|
||||||
|
position: relative;
|
||||||
|
color: rgba(224, 214, 243, 0.7);
|
||||||
|
font-size: 31rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
padding: 0 20rpx 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-tab.active {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-tab.active::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 20rpx;
|
||||||
|
right: 20rpx;
|
||||||
|
bottom: -17rpx;
|
||||||
|
height: 5rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: #b246ff;
|
||||||
|
box-shadow: 0 0 18rpx rgba(178, 70, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-script {
|
||||||
|
margin-left: auto;
|
||||||
|
height: 64rpx;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
background: linear-gradient(135deg, #b346ff, #7330ff);
|
||||||
|
box-shadow: 0 0 26rpx rgba(168, 85, 247, 0.54);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plus {
|
||||||
|
font-size: 32rpx;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar {
|
||||||
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-row {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip {
|
||||||
|
height: 52rpx;
|
||||||
|
min-width: 88rpx;
|
||||||
|
padding: 0 24rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: rgba(224, 214, 243, 0.78);
|
||||||
|
font-size: 23rpx;
|
||||||
|
border: 1rpx solid rgba(151, 111, 255, 0.42);
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-chip.active {
|
||||||
|
color: #fff;
|
||||||
|
border-color: rgba(206, 82, 255, 0.92);
|
||||||
|
background: rgba(130, 48, 220, 0.42);
|
||||||
|
box-shadow: 0 0 18rpx rgba(168, 67, 255, 0.46);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-tools {
|
||||||
|
gap: 14rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-text {
|
||||||
|
color: #c99fff;
|
||||||
|
font-size: 23rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-icon {
|
||||||
|
width: 48rpx;
|
||||||
|
height: 48rpx;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 6rpx;
|
||||||
|
padding: 11rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1rpx solid rgba(151, 111, 255, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-icon view {
|
||||||
|
border: 2rpx solid rgba(230, 222, 250, 0.78);
|
||||||
|
border-radius: 3rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-list.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-list.grid .script-card {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-list.grid .cover {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-icon.active {
|
||||||
|
border-color: rgba(206, 82, 255, 0.9);
|
||||||
|
background: rgba(130, 48, 220, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 150rpx 1fr;
|
||||||
|
gap: 22rpx;
|
||||||
|
min-height: 196rpx;
|
||||||
|
padding: 20rpx;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
border: 1rpx solid rgba(105, 79, 210, 0.34);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 100% 0%, rgba(112, 72, 255, 0.14), transparent 38%),
|
||||||
|
rgba(9, 12, 42, 0.72);
|
||||||
|
box-shadow: inset 0 0 28rpx rgba(92, 57, 197, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
width: 150rpx;
|
||||||
|
height: 150rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 54rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(135deg, #3b1a90, #d65cff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-0 { background: linear-gradient(135deg, #29135f, #9037ff 48%, #191b5e); }
|
||||||
|
.cover-1 { background: linear-gradient(135deg, #3c1c4a, #f2b3cc 48%, #16143b); }
|
||||||
|
.cover-2 { background: linear-gradient(135deg, #1a225f, #7d4cff 48%, #0a0f2c); }
|
||||||
|
.cover-3 { background: linear-gradient(135deg, #2f240b, #f7b44a 48%, #0d0a16); }
|
||||||
|
.cover-4 { background: linear-gradient(135deg, #3f2417, #d8b58a 48%, #17101d); }
|
||||||
|
.cover-5 { background: linear-gradient(135deg, #141451, #cc46ff 48%, #0c0b28); }
|
||||||
|
|
||||||
|
.card-main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-top {
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-wrap {
|
||||||
|
min-width: 0;
|
||||||
|
gap: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.script-title {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 27rpx;
|
||||||
|
line-height: 1.25;
|
||||||
|
font-weight: 900;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.length-badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 4rpx 9rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
color: #c985ff;
|
||||||
|
font-size: 18rpx;
|
||||||
|
border: 1rpx solid rgba(182, 92, 255, 0.5);
|
||||||
|
background: rgba(128, 55, 204, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-state {
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-pill {
|
||||||
|
height: 34rpx;
|
||||||
|
padding: 0 14rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 19rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-progress {
|
||||||
|
color: #ffbf4c;
|
||||||
|
background: rgba(170, 103, 20, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-done {
|
||||||
|
color: #79e6a9;
|
||||||
|
background: rgba(44, 146, 88, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-draft {
|
||||||
|
color: rgba(224, 214, 243, 0.76);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ellipsis {
|
||||||
|
color: rgba(224, 214, 243, 0.66);
|
||||||
|
font-size: 24rpx;
|
||||||
|
letter-spacing: 3rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
height: 34rpx;
|
||||||
|
padding: 0 14rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: #d49cff;
|
||||||
|
font-size: 19rpx;
|
||||||
|
background: rgba(149, 55, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
display: -webkit-box;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
color: rgba(226, 215, 246, 0.72);
|
||||||
|
font-size: 22rpx;
|
||||||
|
line-height: 1.55;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-row {
|
||||||
|
gap: 14rpx;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
color: rgba(218, 204, 243, 0.66);
|
||||||
|
font-size: 21rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-row {
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 14rpx;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
color: #bd72ff;
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-track {
|
||||||
|
width: 118rpx;
|
||||||
|
height: 6rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
background: rgba(173, 160, 210, 0.18);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(90deg, #b246ff, #d878ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorite-row {
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8rpx;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
color: #b768ff;
|
||||||
|
font-size: 23rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorite-star {
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-card {
|
||||||
|
margin-top: 30rpx;
|
||||||
|
border-radius: 26rpx;
|
||||||
|
padding: 44rpx 30rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
border: 1rpx solid rgba(105, 79, 210, 0.34);
|
||||||
|
background: rgba(9, 12, 42, 0.72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-book {
|
||||||
|
display: flex;
|
||||||
|
gap: 6rpx;
|
||||||
|
margin-bottom: 18rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-book view {
|
||||||
|
width: 32rpx;
|
||||||
|
height: 46rpx;
|
||||||
|
border: 4rpx solid #b768ff;
|
||||||
|
border-radius: 8rpx 4rpx 4rpx 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
color: rgba(226, 215, 246, 0.68);
|
||||||
|
font-size: 22rpx;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-action {
|
||||||
|
margin-top: 22rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
padding: 0 30rpx;
|
||||||
|
border-radius: 999rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 23rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
background: linear-gradient(135deg, #b346ff, #7330ff);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -8,24 +8,17 @@
|
|||||||
|
|
||||||
<view class="safe-top" :style="{ height: capsuleTopReservePx + 'px' }"></view>
|
<view class="safe-top" :style="{ height: capsuleTopReservePx + 'px' }"></view>
|
||||||
|
|
||||||
<scroll-view class="content" scroll-y :enhanced="true" :show-scrollbar="false">
|
<scroll-view class="content" :class="{ 'content-immersive': isImmersiveTab }" scroll-y :enhanced="true" :show-scrollbar="false">
|
||||||
<ScriptView v-if="activeTab === 'script'" />
|
<ScriptView v-if="activeTab === 'script'" />
|
||||||
<RecordView v-if="activeTab === 'record'" />
|
<RecordView v-if="activeTab === 'record'" />
|
||||||
<MineView v-if="activeTab === 'mine'" />
|
<MineView v-if="activeTab === 'mine'" />
|
||||||
|
<ScriptLibraryView v-if="activeTab === 'library'" />
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
|
|
||||||
<MusicPlayer ref="musicPlayer" />
|
<MusicPlayer ref="musicPlayer" />
|
||||||
|
|
||||||
<view class="bottom-nav">
|
<view class="bottom-nav">
|
||||||
<view class="nav-inner">
|
<view class="nav-inner">
|
||||||
<view class="nav-item" :class="{ active: activeTab === 'script' }" @click="switchTab('script')">
|
|
||||||
<view class="tab-icon book-star-icon">
|
|
||||||
<view class="book-page left"></view>
|
|
||||||
<view class="book-page right"></view>
|
|
||||||
<view class="book-sparkle"></view>
|
|
||||||
</view>
|
|
||||||
<text>爽文生成</text>
|
|
||||||
</view>
|
|
||||||
<view class="nav-item" :class="{ active: activeTab === 'record' }" @click="switchTab('record')">
|
<view class="nav-item" :class="{ active: activeTab === 'record' }" @click="switchTab('record')">
|
||||||
<view class="tab-icon planet-ring-icon">
|
<view class="tab-icon planet-ring-icon">
|
||||||
<view class="planet-core"></view>
|
<view class="planet-core"></view>
|
||||||
@@ -33,6 +26,14 @@
|
|||||||
</view>
|
</view>
|
||||||
<text>人生轨迹</text>
|
<text>人生轨迹</text>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="nav-item" :class="{ active: activeTab === 'script' || activeTab === 'library' }" @click="switchTab('script')">
|
||||||
|
<view class="tab-icon book-star-icon">
|
||||||
|
<view class="book-page left"></view>
|
||||||
|
<view class="book-page right"></view>
|
||||||
|
<view class="book-sparkle"></view>
|
||||||
|
</view>
|
||||||
|
<text>爽文生成</text>
|
||||||
|
</view>
|
||||||
<view class="nav-item" :class="{ active: activeTab === 'mine' }" @click="switchTab('mine')">
|
<view class="nav-item" :class="{ active: activeTab === 'mine' }" @click="switchTab('mine')">
|
||||||
<view class="tab-icon smile-face-icon">
|
<view class="tab-icon smile-face-icon">
|
||||||
<view class="smile-eye left"></view>
|
<view class="smile-eye left"></view>
|
||||||
@@ -47,12 +48,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { onLoad } from '@dcloudio/uni-app'
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
import { useAppStore } from '../../stores/app.js'
|
import { useAppStore } from '../../stores/app.js'
|
||||||
import RecordView from './RecordView.vue'
|
import RecordView from './RecordView.vue'
|
||||||
import ScriptView from './ScriptView.vue'
|
import ScriptView from './ScriptView.vue'
|
||||||
import MineView from './MineView.vue'
|
import MineView from './MineView.vue'
|
||||||
|
import ScriptLibraryView from './ScriptLibraryView.vue'
|
||||||
import MusicPlayer from '../../components/MusicPlayer.vue'
|
import MusicPlayer from '../../components/MusicPlayer.vue'
|
||||||
import analytics from '../../services/analytics.js'
|
import analytics from '../../services/analytics.js'
|
||||||
import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js'
|
import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js'
|
||||||
@@ -61,7 +63,8 @@ const store = useAppStore()
|
|||||||
const activeTab = ref('script')
|
const activeTab = ref('script')
|
||||||
const pagePath = '/pages/main/index'
|
const pagePath = '/pages/main/index'
|
||||||
const { capsuleTopReservePx } = useMenuButtonSafeArea({ extraTopPx: 10 })
|
const { capsuleTopReservePx } = useMenuButtonSafeArea({ extraTopPx: 10 })
|
||||||
const validTabs = ['script', 'record', 'mine']
|
const validTabs = ['script', 'record', 'mine', 'library']
|
||||||
|
const isImmersiveTab = computed(() => ['script', 'mine'].includes(activeTab.value))
|
||||||
|
|
||||||
const normalizeTab = (tab) => validTabs.includes(tab) ? tab : 'script'
|
const normalizeTab = (tab) => validTabs.includes(tab) ? tab : 'script'
|
||||||
|
|
||||||
@@ -174,6 +177,11 @@ onUnmounted(() => {
|
|||||||
padding: 0 28rpx 132rpx;
|
padding: 0 28rpx 132rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content-immersive {
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.bottom-nav {
|
.bottom-nav {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|||||||
@@ -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: 10 })
|
const { capsuleTopReservePx, topbarStyle } = 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('')
|
||||||
@@ -372,23 +372,23 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
height: 92rpx;
|
height: 72rpx;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 90rpx 1fr 90rpx;
|
grid-template-columns: 76rpx 1fr 76rpx;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 32rpx;
|
padding: 0 28rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.back {
|
.back {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 68rpx;
|
font-size: 56rpx;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 36rpx;
|
font-size: 32rpx;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,7 +399,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
.save {
|
.save {
|
||||||
color: #b94cff;
|
color: #b94cff;
|
||||||
font-size: 28rpx;
|
font-size: 26rpx;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,35 +408,36 @@ onMounted(() => {
|
|||||||
height: 0;
|
height: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 0 28rpx 28rpx;
|
padding: 0 24rpx 28rpx;
|
||||||
|
margin-top: -6rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glass-card {
|
.glass-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1rpx solid rgba(124, 75, 255, 0.34);
|
border: 1rpx solid rgba(155, 110, 255, 0.14);
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 92% 12%, rgba(104, 66, 255, 0.14), transparent 34%),
|
radial-gradient(circle at 92% 12%, rgba(104, 66, 255, 0.1), transparent 34%),
|
||||||
rgba(10, 13, 43, 0.74);
|
rgba(10, 13, 43, 0.54);
|
||||||
box-shadow: inset 0 0 34rpx rgba(123, 60, 255, 0.08), 0 14rpx 48rpx rgba(0, 0, 0, 0.22);
|
box-shadow: inset 0 0 30rpx rgba(123, 60, 255, 0.05), 0 10rpx 36rpx rgba(0, 0, 0, 0.16);
|
||||||
backdrop-filter: blur(24rpx);
|
backdrop-filter: blur(24rpx);
|
||||||
-webkit-backdrop-filter: blur(24rpx);
|
-webkit-backdrop-filter: blur(24rpx);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-card {
|
.hero-card {
|
||||||
min-height: 190rpx;
|
min-height: 156rpx;
|
||||||
border-radius: 24rpx;
|
border-radius: 22rpx;
|
||||||
margin-bottom: 22rpx;
|
margin-bottom: 18rpx;
|
||||||
padding: 22rpx 28rpx;
|
padding: 18rpx 24rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 28rpx;
|
gap: 22rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-wrap {
|
.avatar-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 136rpx;
|
width: 118rpx;
|
||||||
height: 136rpx;
|
height: 118rpx;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 5rpx;
|
padding: 5rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@@ -455,8 +456,8 @@ onMounted(() => {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
right: -4rpx;
|
right: -4rpx;
|
||||||
bottom: -2rpx;
|
bottom: -2rpx;
|
||||||
width: 42rpx;
|
width: 38rpx;
|
||||||
height: 42rpx;
|
height: 38rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: linear-gradient(135deg, #8f4dff, #582cff);
|
background: linear-gradient(135deg, #8f4dff, #582cff);
|
||||||
box-shadow: 0 0 18rpx rgba(158, 91, 255, 0.62);
|
box-shadow: 0 0 18rpx rgba(158, 91, 255, 0.62);
|
||||||
@@ -465,7 +466,7 @@ onMounted(() => {
|
|||||||
.pen-icon {
|
.pen-icon {
|
||||||
width: 17rpx;
|
width: 17rpx;
|
||||||
height: 6rpx;
|
height: 6rpx;
|
||||||
margin: 18rpx auto;
|
margin: 16rpx auto;
|
||||||
border-radius: 999rpx;
|
border-radius: 999rpx;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
transform: rotate(-45deg);
|
transform: rotate(-45deg);
|
||||||
@@ -486,20 +487,20 @@ onMounted(() => {
|
|||||||
|
|
||||||
.hero-name {
|
.hero-name {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 42rpx;
|
font-size: 34rpx;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-star {
|
.hero-star {
|
||||||
font-size: 26rpx;
|
font-size: 22rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-sub {
|
.hero-sub {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 12rpx;
|
margin-top: 8rpx;
|
||||||
color: rgba(239, 232, 255, 0.84);
|
color: rgba(239, 232, 255, 0.84);
|
||||||
font-size: 25rpx;
|
font-size: 23rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-line {
|
.hero-line {
|
||||||
@@ -511,9 +512,9 @@ onMounted(() => {
|
|||||||
|
|
||||||
.hero-quote {
|
.hero-quote {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 14rpx;
|
margin-top: 12rpx;
|
||||||
color: #b94cff;
|
color: #b94cff;
|
||||||
font-size: 25rpx;
|
font-size: 23rpx;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -550,9 +551,9 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
border-radius: 24rpx;
|
border-radius: 22rpx;
|
||||||
margin-bottom: 18rpx;
|
margin-bottom: 18rpx;
|
||||||
padding: 24rpx;
|
padding: 22rpx 24rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-head,
|
.section-head,
|
||||||
@@ -572,7 +573,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
color: rgba(239, 232, 255, 0.9);
|
color: rgba(239, 232, 255, 0.9);
|
||||||
font-size: 25rpx;
|
font-size: 24rpx;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -659,9 +660,9 @@ onMounted(() => {
|
|||||||
.bio-title-icon::after { top: 15rpx; }
|
.bio-title-icon::after { top: 15rpx; }
|
||||||
|
|
||||||
.profile-row {
|
.profile-row {
|
||||||
min-height: 64rpx;
|
min-height: 60rpx;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 154rpx 1fr 24rpx;
|
grid-template-columns: 142rpx 1fr 24rpx;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-top: 1rpx solid rgba(180, 139, 255, 0.16);
|
border-top: 1rpx solid rgba(180, 139, 255, 0.16);
|
||||||
}
|
}
|
||||||
@@ -672,14 +673,14 @@ onMounted(() => {
|
|||||||
|
|
||||||
.row-label {
|
.row-label {
|
||||||
color: rgba(205, 191, 238, 0.82);
|
color: rgba(205, 191, 238, 0.82);
|
||||||
font-size: 24rpx;
|
font-size: 23rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row-input,
|
.row-input,
|
||||||
.row-value {
|
.row-value {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
color: rgba(255, 255, 255, 0.9);
|
color: rgba(255, 255, 255, 0.9);
|
||||||
font-size: 24rpx;
|
font-size: 23rpx;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -693,7 +694,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
.chevron {
|
.chevron {
|
||||||
color: rgba(218, 204, 243, 0.7);
|
color: rgba(218, 204, 243, 0.7);
|
||||||
font-size: 44rpx;
|
font-size: 38rpx;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
@@ -729,26 +730,28 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.astro-panel {
|
.astro-panel {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 1fr 1fr;
|
flex-direction: column;
|
||||||
margin-top: 8rpx;
|
gap: 22rpx;
|
||||||
|
margin-top: 10rpx;
|
||||||
|
padding-top: 16rpx;
|
||||||
border-top: 1rpx solid rgba(180, 139, 255, 0.16);
|
border-top: 1rpx solid rgba(180, 139, 255, 0.16);
|
||||||
}
|
}
|
||||||
|
|
||||||
.astro-col {
|
.astro-col {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 18rpx 18rpx 0 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mbti-col {
|
.mbti-col {
|
||||||
border-left: 1rpx solid rgba(180, 139, 255, 0.18);
|
border-left: 0;
|
||||||
padding-left: 18rpx;
|
padding-top: 18rpx;
|
||||||
padding-right: 0;
|
border-top: 1rpx solid rgba(180, 139, 255, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.astro-title-row {
|
.astro-title-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 34rpx 74rpx 1fr 18rpx;
|
grid-template-columns: 34rpx 76rpx 1fr 18rpx;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8rpx;
|
gap: 8rpx;
|
||||||
}
|
}
|
||||||
@@ -773,26 +776,26 @@ onMounted(() => {
|
|||||||
|
|
||||||
.astro-title {
|
.astro-title {
|
||||||
color: rgba(222, 211, 240, 0.76);
|
color: rgba(222, 211, 240, 0.76);
|
||||||
font-size: 24rpx;
|
font-size: 23rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.astro-current {
|
.astro-current {
|
||||||
color: rgba(255, 255, 255, 0.9);
|
color: rgba(255, 255, 255, 0.9);
|
||||||
font-size: 23rpx;
|
font-size: 22rpx;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.select-title {
|
.select-title {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 22rpx;
|
margin-top: 16rpx;
|
||||||
color: rgba(222, 211, 240, 0.72);
|
color: rgba(222, 211, 240, 0.72);
|
||||||
font-size: 21rpx;
|
font-size: 21rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zodiac-grid {
|
.zodiac-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 14rpx 8rpx;
|
gap: 14rpx 10rpx;
|
||||||
margin-top: 14rpx;
|
margin-top: 14rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -801,20 +804,20 @@ onMounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8rpx;
|
gap: 7rpx;
|
||||||
color: rgba(226, 217, 246, 0.84);
|
color: rgba(226, 217, 246, 0.84);
|
||||||
font-size: 19rpx;
|
font-size: 20rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.zodiac-bubble {
|
.zodiac-bubble {
|
||||||
width: 48rpx;
|
width: 44rpx;
|
||||||
height: 48rpx;
|
height: 44rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: #a855ff;
|
color: #a855ff;
|
||||||
font-size: 28rpx;
|
font-size: 26rpx;
|
||||||
background: rgba(124, 58, 237, 0.28);
|
background: rgba(124, 58, 237, 0.28);
|
||||||
border: 1rpx solid rgba(173, 84, 255, 0.36);
|
border: 1rpx solid rgba(173, 84, 255, 0.36);
|
||||||
}
|
}
|
||||||
@@ -829,12 +832,12 @@ onMounted(() => {
|
|||||||
.mbti-grid {
|
.mbti-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
gap: 12rpx;
|
gap: 12rpx 14rpx;
|
||||||
margin-top: 14rpx;
|
margin-top: 14rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mbti-chip {
|
.mbti-chip {
|
||||||
height: 42rpx;
|
height: 40rpx;
|
||||||
font-size: 20rpx;
|
font-size: 20rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,33 @@ const parseSseFrame = (frame) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const findOverlapLength = (current, next) => {
|
||||||
|
const max = Math.min(current.length, next.length)
|
||||||
|
for (let size = max; size > 0; size--) {
|
||||||
|
if (current.slice(-size) === next.slice(0, size)) return size
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergeStreamOutput = (current, chunk) => {
|
||||||
|
const next = String(chunk || '')
|
||||||
|
if (!next) return { output: current, delta: '' }
|
||||||
|
if (!current) return { output: next, delta: next }
|
||||||
|
if (next === current) return { output: current, delta: '' }
|
||||||
|
if (next.length >= 16 && current.endsWith(next)) return { output: current, delta: '' }
|
||||||
|
if (next.startsWith(current)) {
|
||||||
|
return { output: next, delta: next.slice(current.length) }
|
||||||
|
}
|
||||||
|
const currentIndex = next.length > current.length ? next.indexOf(current) : -1
|
||||||
|
if (currentIndex >= 0) {
|
||||||
|
return { output: next, delta: next.slice(currentIndex + current.length) }
|
||||||
|
}
|
||||||
|
const overlap = findOverlapLength(current, next)
|
||||||
|
if (overlap < 8) return { output: current + next, delta: next }
|
||||||
|
const delta = next.slice(overlap)
|
||||||
|
return { output: current + delta, delta }
|
||||||
|
}
|
||||||
|
|
||||||
const queryRuntimeResult = (requestId) => {
|
const queryRuntimeResult = (requestId) => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
uni.request({
|
uni.request({
|
||||||
@@ -113,6 +140,7 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
|
|||||||
let requestTask
|
let requestTask
|
||||||
let recoveryTimer
|
let recoveryTimer
|
||||||
let recoveryPromise
|
let recoveryPromise
|
||||||
|
let streamStarted = false
|
||||||
|
|
||||||
const clearRecoveryTimer = () => {
|
const clearRecoveryTimer = () => {
|
||||||
if (recoveryTimer) {
|
if (recoveryTimer) {
|
||||||
@@ -130,6 +158,7 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
|
|||||||
|
|
||||||
const completeFromRecoveredOutput = async () => {
|
const completeFromRecoveredOutput = async () => {
|
||||||
if (closed) return
|
if (closed) return
|
||||||
|
if (streamStarted || output.trim()) return
|
||||||
try {
|
try {
|
||||||
const recoveredOutput = await recoverOnce()
|
const recoveredOutput = await recoverOnce()
|
||||||
if (closed) return
|
if (closed) return
|
||||||
@@ -149,7 +178,7 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
|
|||||||
clearRecoveryTimer()
|
clearRecoveryTimer()
|
||||||
recoveryTimer = setTimeout(() => {
|
recoveryTimer = setTimeout(() => {
|
||||||
completeFromRecoveredOutput()
|
completeFromRecoveredOutput()
|
||||||
}, 8000)
|
}, 25000)
|
||||||
}
|
}
|
||||||
|
|
||||||
const finishRecovered = (message, event) => {
|
const finishRecovered = (message, event) => {
|
||||||
@@ -172,7 +201,6 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
|
|||||||
|
|
||||||
const recoverOrFail = async (message, event) => {
|
const recoverOrFail = async (message, event) => {
|
||||||
if (closed) return
|
if (closed) return
|
||||||
if (finishRecovered(message, event)) return
|
|
||||||
try {
|
try {
|
||||||
const recoveredOutput = await recoverOnce()
|
const recoveredOutput = await recoverOnce()
|
||||||
if (closed) return
|
if (closed) return
|
||||||
@@ -182,6 +210,7 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
|
|||||||
resolve({ output })
|
resolve({ output })
|
||||||
} catch (recoverError) {
|
} catch (recoverError) {
|
||||||
if (closed) return
|
if (closed) return
|
||||||
|
if (finishRecovered(message || recoverError.message, event)) return
|
||||||
const finalMessage = message || recoverError.message || 'AI 生成结果暂时没有返回'
|
const finalMessage = message || recoverError.message || 'AI 生成结果暂时没有返回'
|
||||||
closed = true
|
closed = true
|
||||||
clearRecoveryTimer()
|
clearRecoveryTimer()
|
||||||
@@ -196,10 +225,7 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
|
|||||||
|
|
||||||
const failWithoutRecovery = (message, event) => {
|
const failWithoutRecovery = (message, event) => {
|
||||||
if (closed) return
|
if (closed) return
|
||||||
closed = true
|
recoverOrFail(message, event)
|
||||||
clearRecoveryTimer()
|
|
||||||
onError?.(message, event)
|
|
||||||
reject(new Error(message))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const finishWithOutputOrRecover = async () => {
|
const finishWithOutputOrRecover = async () => {
|
||||||
@@ -247,6 +273,8 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
|
|||||||
|
|
||||||
requestTask?.onChunkReceived?.((res) => {
|
requestTask?.onChunkReceived?.((res) => {
|
||||||
try {
|
try {
|
||||||
|
streamStarted = true
|
||||||
|
clearRecoveryTimer()
|
||||||
consumeText(decodeChunk(res.data), failStream)
|
consumeText(decodeChunk(res.data), failStream)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failStream(error.message || 'AI流式请求失败')
|
failStream(error.message || 'AI流式请求失败')
|
||||||
@@ -262,11 +290,20 @@ export const streamAiScene = ({ sceneCode, inputs = {}, onStart, onDelta, onDone
|
|||||||
const event = parseSseFrame(frame)
|
const event = parseSseFrame(frame)
|
||||||
if (!event) return
|
if (!event) return
|
||||||
if (event.type === 'start') {
|
if (event.type === 'start') {
|
||||||
|
streamStarted = true
|
||||||
|
clearRecoveryTimer()
|
||||||
onStart?.(event)
|
onStart?.(event)
|
||||||
} else if (event.type === 'delta') {
|
} else if (event.type === 'delta') {
|
||||||
output += event.content || ''
|
streamStarted = true
|
||||||
onDelta?.(event.content || '', output, event)
|
clearRecoveryTimer()
|
||||||
|
const merged = mergeStreamOutput(output, event.content)
|
||||||
|
output = merged.output
|
||||||
|
if (merged.delta) {
|
||||||
|
onDelta?.(merged.delta, output, event)
|
||||||
|
}
|
||||||
} else if (event.type === 'done') {
|
} else if (event.type === 'done') {
|
||||||
|
streamStarted = true
|
||||||
|
clearRecoveryTimer()
|
||||||
onDone?.(event, output)
|
onDone?.(event, output)
|
||||||
} else if (event.type === 'error') {
|
} else if (event.type === 'error') {
|
||||||
const message = event.message || event.code || 'AI流式请求失败'
|
const message = event.message || event.code || 'AI流式请求失败'
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { getEnvValue } from '../config/env.js'
|
|||||||
|
|
||||||
const DEFAULT_SOURCE_TYPE = 'epic_script'
|
const DEFAULT_SOURCE_TYPE = 'epic_script'
|
||||||
const DEFAULT_VOICE = 'default_zh_female'
|
const DEFAULT_VOICE = 'default_zh_female'
|
||||||
|
const DEFAULT_SPEECH_RATE = 0.92
|
||||||
|
const DEFAULT_EMOTION = 'story'
|
||||||
|
|
||||||
const normalizeAudioUrl = (task) => {
|
const normalizeAudioUrl = (task) => {
|
||||||
if (!task?.audioUrl || /^https?:\/\//.test(task.audioUrl)) {
|
if (!task?.audioUrl || /^https?:\/\//.test(task.audioUrl)) {
|
||||||
@@ -25,9 +27,12 @@ const normalizeResponse = (response) => {
|
|||||||
export const createTtsTask = ({
|
export const createTtsTask = ({
|
||||||
sourceType = DEFAULT_SOURCE_TYPE,
|
sourceType = DEFAULT_SOURCE_TYPE,
|
||||||
sourceId,
|
sourceId,
|
||||||
voice = DEFAULT_VOICE
|
voice = DEFAULT_VOICE,
|
||||||
|
speechRate = DEFAULT_SPEECH_RATE,
|
||||||
|
pitch = 0,
|
||||||
|
emotion = DEFAULT_EMOTION
|
||||||
}) => {
|
}) => {
|
||||||
return post('/tts/tasks', { sourceType, sourceId, voice }).then(normalizeResponse)
|
return post('/tts/tasks', { sourceType, sourceId, voice, speechRate, pitch, emotion }).then(normalizeResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getTtsTask = (id) => {
|
export const getTtsTask = (id) => {
|
||||||
@@ -37,9 +42,12 @@ export const getTtsTask = (id) => {
|
|||||||
export const getTtsTaskBySource = ({
|
export const getTtsTaskBySource = ({
|
||||||
sourceType = DEFAULT_SOURCE_TYPE,
|
sourceType = DEFAULT_SOURCE_TYPE,
|
||||||
sourceId,
|
sourceId,
|
||||||
voice = DEFAULT_VOICE
|
voice = DEFAULT_VOICE,
|
||||||
|
speechRate = DEFAULT_SPEECH_RATE,
|
||||||
|
pitch = 0,
|
||||||
|
emotion = DEFAULT_EMOTION
|
||||||
}) => {
|
}) => {
|
||||||
return get('/tts/tasks/by-source', { sourceType, sourceId, voice }).then(normalizeResponse)
|
return get('/tts/tasks/by-source', { sourceType, sourceId, voice, speechRate, pitch, emotion }).then(normalizeResponse)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|||||||
@@ -319,6 +319,33 @@ export function normalizeAiText(value?: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findOverlapLength(current: string, next: string) {
|
||||||
|
const max = Math.min(current.length, next.length)
|
||||||
|
for (let size = max; size > 0; size -= 1) {
|
||||||
|
if (current.slice(-size) === next.slice(0, size)) return size
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeStreamOutput(current: string, chunk?: string) {
|
||||||
|
const next = normalizeAiText(chunk || '')
|
||||||
|
if (!next) return { output: current, delta: '' }
|
||||||
|
if (!current) return { output: next, delta: next }
|
||||||
|
if (next === current) return { output: current, delta: '' }
|
||||||
|
if (next.length >= 16 && current.endsWith(next)) return { output: current, delta: '' }
|
||||||
|
if (next.startsWith(current)) {
|
||||||
|
return { output: next, delta: next.slice(current.length) }
|
||||||
|
}
|
||||||
|
const currentIndex = next.length > current.length ? next.indexOf(current) : -1
|
||||||
|
if (currentIndex >= 0) {
|
||||||
|
return { output: next, delta: next.slice(currentIndex + current.length) }
|
||||||
|
}
|
||||||
|
const overlap = findOverlapLength(current, next)
|
||||||
|
if (overlap < 8) return { output: current + next, delta: next }
|
||||||
|
const delta = next.slice(overlap)
|
||||||
|
return { output: current + delta, delta }
|
||||||
|
}
|
||||||
|
|
||||||
function extractTextValue(value: any): string {
|
function extractTextValue(value: any): string {
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return ''
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return ''
|
||||||
for (const key of ['output', 'answer', 'content', 'text', 'result']) {
|
for (const key of ['output', 'answer', 'content', 'text', 'result']) {
|
||||||
@@ -383,7 +410,7 @@ async function fetchSseStream(
|
|||||||
const event = parseSseFrame(frame)
|
const event = parseSseFrame(frame)
|
||||||
if (!event) return
|
if (!event) return
|
||||||
if (event.type === 'delta') {
|
if (event.type === 'delta') {
|
||||||
output += normalizeAiText(event.content || '')
|
output = mergeStreamOutput(output, event.content).output
|
||||||
}
|
}
|
||||||
onEvent(event, output)
|
onEvent(event, output)
|
||||||
if (event.type === 'error' && finishRecovered(event)) {
|
if (event.type === 'error' && finishRecovered(event)) {
|
||||||
|
|||||||
@@ -247,6 +247,7 @@ export interface AiCallLog {
|
|||||||
providerCode?: string
|
providerCode?: string
|
||||||
endpointCode?: string
|
endpointCode?: string
|
||||||
userId?: string
|
userId?: string
|
||||||
|
userName?: string
|
||||||
requestId?: string
|
requestId?: string
|
||||||
status?: string
|
status?: string
|
||||||
inputText?: string
|
inputText?: string
|
||||||
|
|||||||
@@ -202,6 +202,11 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="createTime" label="调用时间" width="175" />
|
<el-table-column prop="createTime" label="调用时间" width="175" />
|
||||||
|
<el-table-column label="调用用户" width="180" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ userDisplay(row) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="sceneCode" label="场景" width="160" show-overflow-tooltip />
|
<el-table-column prop="sceneCode" label="场景" width="160" show-overflow-tooltip />
|
||||||
<el-table-column prop="providerCode" label="服务商" width="150" show-overflow-tooltip />
|
<el-table-column prop="providerCode" label="服务商" width="150" show-overflow-tooltip />
|
||||||
<el-table-column prop="endpointCode" label="接口" min-width="180" show-overflow-tooltip />
|
<el-table-column prop="endpointCode" label="接口" min-width="180" show-overflow-tooltip />
|
||||||
@@ -640,6 +645,15 @@ function formatMs(ms?: number) {
|
|||||||
return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`
|
return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function userDisplay(row: AiCallLog): string {
|
||||||
|
const name = row.userName
|
||||||
|
const id = row.userId
|
||||||
|
if (name && id) return `${name}(${id})`
|
||||||
|
if (name) return name
|
||||||
|
if (id) return `-(ID: ${id})`
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
|
||||||
function testOutput(result?: AiRuntimeTestResponse | null) {
|
function testOutput(result?: AiRuntimeTestResponse | null) {
|
||||||
return normalizeAiText(result?.output || result?.errorMessage || '暂无输出')
|
return normalizeAiText(result?.output || result?.errorMessage || '暂无输出')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,8 +48,8 @@
|
|||||||
<span class="info-value">{{ log.streamChunks ?? '-' }}</span>
|
<span class="info-value">{{ log.streamChunks ?? '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<span class="info-label">用户 ID</span>
|
<span class="info-label">调用用户</span>
|
||||||
<span class="info-value">{{ log.userId || '-' }}</span>
|
<span class="info-value">{{ userDisplay(log) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<span class="info-label">请求 ID</span>
|
<span class="info-label">请求 ID</span>
|
||||||
@@ -112,6 +112,15 @@ function formatMs(ms?: number) {
|
|||||||
if (ms == null) return '-'
|
if (ms == null) return '-'
|
||||||
return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`
|
return ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms}ms`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function userDisplay(log: AiCallLog): string {
|
||||||
|
const name = log.userName
|
||||||
|
const id = log.userId
|
||||||
|
if (name && id) return `${name}(${id})`
|
||||||
|
if (name) return name
|
||||||
|
if (id) return `-(ID: ${id})`
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
Reference in New Issue
Block a user