guofei 2024-12-03 16:50:32 +08:00
parent a54e26eff6
commit e370e1089e
12 changed files with 1486 additions and 240 deletions

View File

@ -4,7 +4,7 @@
"version": "0.0.0",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"build": "vite build",
"preview": "vite preview",
"deploy": "npm run build && node ./devops/deploy.js"
},

View File

@ -1,26 +1,71 @@
import request from "@/utils/request";
import qs from 'qs'
import qs from "qs";
export function addSystemCharterAPI(data: Record<string, any>) {
return request({
url: "/system/charter/add",
method: "post",
data: data,
});
return request({
url: "/system/charter/add",
method: "post",
data: data,
});
}
export function editSystemCharterAPI(data: Record<string, any>) {
return request({
url: "/system/charter/update",
method: "post",
data: data,
});
return request({
url: "/system/charter/update",
method: "post",
data: data,
});
}
export const querySystemCharterAPI = (data: Record<string, any> = {}): Promise<API.ResponstList<never[]>> => {
return request({
url: "/system/charter/getList?" + qs.stringify(data),
method: "get",
}) as any;
return request({
url: "/system/charter/getList?" + qs.stringify(data),
method: "get",
}) as any;
};
/**
*
*/
interface ICloneSpeaker {
roleId: number;
roleName: string;
url: string;
}
export const cloneSpeakerAPI = (data: ICloneSpeaker): Promise<API.ResponstList<never[]>> => {
return request({
url: "/system/charter/cloneSpeaker",
method: "post",
data: data,
}) as any;
};
// 获取任务列表
export const getTaskListAPI = (params: { current: number; pageSize: number }) => {
return request.get("/system/charter/taskList", { params });
};
// 添加获取详情接口
export const getSystemCharterDetailAPI = (id: string) => {
return request.get(`/system/charter/detail/${id}`);
};
// 获取克隆历史记录
export const getCloneHistoryAPI = (roleId: string) => {
return request.get(`/system/charter/cloneHistory/${roleId}`);
};
// 添加重试接口
export const retryCloneTaskAPI = (taskId: string) => {
return request.post(`/system/charter/retryTask/${taskId}`);
};
// 添加删除任务接口
export const deleteTaskAPI = (taskId: string) => {
return request.delete(`/system/charter/deleteTask/${taskId}`);
};
// 添加检查克隆任务接口
export const checkCloneTaskAPI = (roleId: string) => {
return request.get(`/system/charter/checkCloneTask/${roleId}`);
};

View File

@ -0,0 +1,167 @@
<template>
<el-drawer v-model="drawerVisible" :title="`${character?.roleName} 克隆历史`" size="800px" direction="rtl" @close="handleClose">
<div class="source-audio p-4 mb-4 bg-gray-50 rounded">
<div class="text-gray-500 mb-2 font-medium">源声音</div>
<audio v-if="character?.originAudioUrl" controls class="w-full" :src="character.originAudioUrl" @play="handleAudioPlay"></audio>
<div v-else class="text-gray-400 text-sm">暂无源声音</div>
</div>
<el-table :data="historyList" v-loading="loading" style="width: 100%">
<el-table-column label="克隆音频" min-width="300">
<template #default="{ row }">
<audio controls class="w-full" :src="row.url" @play="handleAudioPlay"></audio>
</template>
</el-table-column>
<el-table-column prop="status" label="状态">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">{{ getStatusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="任务完成时间" width="180">
<template #default="{ row }">
{{ formatDate(row.createdAt) }}
</template>
</el-table-column>
</el-table>
</el-drawer>
</template>
<script lang="ts" setup>
import { ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { getCloneHistoryAPI, retryCloneTaskAPI } from "@/api/SystemCharter";
import AudioManager from "@/utils/audioManager";
import { TaskStatus, TaskStatusType, TaskStatusText } from "@/utils/dict";
const props = defineProps<{
visible: boolean;
character: {
id: string;
roleName: string;
originAudioUrl?: string;
} | null;
}>();
const emit = defineEmits<{
(e: "update:visible", value: boolean): void;
}>();
const drawerVisible = ref(props.visible);
const loading = ref(false);
const historyList = ref([]);
watch(
() => props.visible,
(val) => {
drawerVisible.value = val;
if (val && props.character) {
fetchHistory();
}
}
);
watch(
() => drawerVisible.value,
(val) => {
emit("update:visible", val);
}
);
const fetchHistory = async () => {
if (!props.character?.id) return;
loading.value = true;
try {
const res = await getCloneHistoryAPI(props.character.id);
historyList.value = res.data.data.map((item: any) => ({
url: item.cloneUrl,
status: item.status,
attempts: item.attempts,
createdAt: item.createdAt,
}));
} catch (error) {
console.error(error);
ElMessage.error("获取历史记录失败");
} finally {
loading.value = false;
}
};
const getStatusType = (status: string) => {
return TaskStatusType[status.toLowerCase() as keyof typeof TaskStatus] || "info";
};
const getStatusText = (status: string) => {
return TaskStatusText[status.toLowerCase() as keyof typeof TaskStatus] || status;
};
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
};
const handleAudioPlay = (event: Event) => {
const audio = event.target as HTMLAudioElement;
AudioManager.play(audio);
};
const handleClose = () => {
AudioManager.stopAll();
};
const handleRetry = async (row: any) => {
try {
row.retrying = true;
await retryCloneTaskAPI(row.id);
ElMessage.success("重试任务已提交");
//
fetchHistory();
} catch (error) {
ElMessage.error("重试失败");
} finally {
row.retrying = false;
}
};
</script>
<style scoped>
audio {
height: 36px;
border-radius: 18px;
width: 100%;
}
audio::-webkit-media-controls-panel {
background-color: #f5f7fa;
}
audio::-webkit-media-controls-play-button {
background-color: #409eff;
border-radius: 50%;
}
audio::-webkit-media-controls-play-button:hover {
background-color: #66b1ff;
}
audio::-webkit-media-controls-current-time-display,
audio::-webkit-media-controls-time-remaining-display {
color: #606266;
}
.source-audio {
border: 1px solid #ebeef5;
}
.source-audio audio {
height: 36px;
border-radius: 18px;
width: 100%;
}
</style>

View File

@ -0,0 +1,231 @@
<template>
<el-dialog v-model="dialogVisible" title="克隆角色音频" width="500px" @close="handleClose">
<template #header>
<div class="flex justify-between items-center w-full">
<span>克隆角色音频</span>
<el-button type="primary" link @click="showHistory">
<el-icon class="mr-1"><List /></el-icon>
克隆历史
</el-button>
</div>
</template>
<el-form :model="form" label-width="100px">
<el-form-item label="角色名称">
<span>{{ character?.roleName }}</span>
</el-form-item>
<el-form-item label="语音样本">
<el-upload class="upload-demo w-full" drag action="#" :show-file-list="false" :before-upload="beforeUpload" :http-request="handleVoiceUpload" accept="audio/*" :disabled="uploading">
<div v-if="!form.voiceSample">
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">
<template v-if="!uploading">
拖拽文件或 <em>点击上传</em>
<div class="text-gray-400 text-sm mt-1">支持 mp3, wav 格式</div>
</template>
<template v-else>
<el-icon class="is-loading"><Loading /></el-icon>
<div>正在上传...</div>
</template>
</div>
</div>
<div v-else class="flex items-center justify-center flex-col">
<div class="flex items-center mb-2">
<el-icon class="text-green-500 mr-2"><Check /></el-icon>
<span>已上传音频文件</span>
<el-button type="danger" link class="ml-2" @click.stop="handleRemoveAudio">删除</el-button>
</div>
<audio ref="audioPlayer" controls class="w-full max-w-[300px]" :src="form.voiceSample">您的浏览器不支持音频播放</audio>
</div>
</el-upload>
</el-form-item>
</el-form>
<CloneHistory
v-model:visible="historyVisible"
:character="
currentCharacter || {
id: character.id,
roleName: character.roleName,
originAudioUrl: character.originAudioUrl,
}
"
/>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit"></el-button>
</span>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { ref, watch } from "vue";
import { ElMessage, ElLoading, UploadProps } from "element-plus";
import { UploadFilled, Check, Loading, List } from "@element-plus/icons-vue";
import { cloneSpeakerAPI, checkCloneTaskAPI, getSystemCharterDetailAPI } from "@/api/SystemCharter";
import createOss, { OssPath } from "@/utils/oss";
import AudioManager from "@/utils/audioManager";
import CloneHistory from "./CloneHistory.vue";
const props = defineProps<{
visible: boolean;
character: any;
}>();
const emit = defineEmits<{
(e: "update:visible", value: boolean): void;
}>();
const dialogVisible = ref(props.visible);
const form = ref({
voiceSample: null,
});
const audioPlayer = ref<HTMLAudioElement | null>(null);
// loading
const uploading = ref(false);
//
const historyVisible = ref(false);
// currentCharacter
const currentCharacter = ref<any>(null);
// visible
watch(
() => props.visible,
(val) => {
dialogVisible.value = val;
}
);
// dialogVisible
watch(
() => dialogVisible.value,
(val) => {
emit("update:visible", val);
}
);
const beforeUpload: UploadProps["beforeUpload"] = (rawFile) => {
const isVoiceUpload = rawFile.type.indexOf("audio") > -1;
if (!isVoiceUpload) {
ElMessage.error("仅支持音频文件");
return false;
}
if (rawFile.size / 1024 / 1024 > 20) {
ElMessage.error("音频文件大小不能超过20M");
return false;
}
return true;
};
const handleVoiceUpload = async (params: any) => {
const loading = ElLoading.service({
text: "检测中...",
background: "rgba(255, 255, 255, 0.7)",
});
try {
//
await checkCloneTaskAPI(props.character.id);
loading.close();
uploading.value = true;
const oss = await createOss();
const timestamp = Date.now();
const fileExt = params.file.name.split(".").pop(); //
const fileName = `${props.character.roleName}_${timestamp}.${fileExt}`; //
const result = await oss.put(`${OssPath.CLONE_VOICE}/${props.character.roleName}/${fileName}`, params.file);
form.value.voiceSample = result.url;
ElMessage.success("上传成功");
} catch (error: any) {
console.error("克隆音频-->>", error);
throw error;
} finally {
loading.close();
uploading.value = false;
}
};
const handleSubmit = async () => {
if (!form.value.voiceSample) {
ElMessage.error("请上传音频样本");
return;
}
const loading = ElLoading.service();
try {
await cloneSpeakerAPI({
roleId: props.character.id,
roleName: props.character.roleName,
url: form.value.voiceSample,
});
ElMessage.success("克隆任务已提交");
dialogVisible.value = false;
} catch (error) {
console.error("克隆任务已提交", error);
} finally {
loading.close();
}
};
const handleRemoveAudio = () => {
AudioManager.stopAll();
form.value.voiceSample = null;
};
const handleClose = () => {
AudioManager.stopAll();
form.value = {
voiceSample: null,
};
historyVisible.value = false;
};
//
const showHistory = async () => {
//
try {
const detail = await getSystemCharterDetailAPI(props.character.id);
currentCharacter.value = {
...props.character,
...detail.data,
};
historyVisible.value = true;
} catch (error) {
ElMessage.error("获取角色信息失败");
}
};
</script>
<style scoped>
audio {
height: 36px;
border-radius: 18px;
}
audio::-webkit-media-controls-panel {
background-color: #f5f7fa;
}
audio::-webkit-media-controls-play-button {
background-color: #409eff;
border-radius: 50%;
}
audio::-webkit-media-controls-play-button:hover {
background-color: #66b1ff;
}
audio::-webkit-media-controls-current-time-display,
audio::-webkit-media-controls-time-remaining-display {
color: #606266;
}
</style>

View File

@ -0,0 +1,98 @@
<template>
<el-dialog v-model="dialogVisible" :title="`${character?.roleName} 音频试听`" width="500px" @close="handleClose">
<div class="flex flex-col gap-4">
<div class="audio-item">
<div class="text-gray-500 mb-2">原始音频</div>
<audio
controls
class="w-full"
:src="character?.originUrl"
@play="handleAudioPlay"
ref="originAudio"
>
您的浏览器不支持音频播放
</audio>
</div>
<div class="audio-item">
<div class="text-gray-500 mb-2">克隆音频</div>
<audio
controls
class="w-full"
:src="character?.cloneUrl"
@play="handleAudioPlay"
ref="cloneAudio"
>
您的浏览器不支持音频播放
</audio>
</div>
</div>
</el-dialog>
</template>
<script lang="ts" setup>
import { ref, watch } from "vue";
import AudioManager from "@/utils/audioManager";
const props = defineProps<{
visible: boolean;
character: {
roleName: string;
originUrl: string;
cloneUrl: string;
} | null;
}>();
const emit = defineEmits<{
(e: "update:visible", value: boolean): void;
}>();
const dialogVisible = ref(props.visible);
watch(
() => props.visible,
(val) => {
dialogVisible.value = val;
}
);
watch(
() => dialogVisible.value,
(val) => {
emit("update:visible", val);
}
);
const handleAudioPlay = (event: Event) => {
const audio = event.target as HTMLAudioElement;
AudioManager.play(audio);
};
const handleClose = () => {
AudioManager.stopAll();
};
</script>
<style scoped>
audio {
height: 36px;
border-radius: 18px;
}
audio::-webkit-media-controls-panel {
background-color: #f5f7fa;
}
audio::-webkit-media-controls-play-button {
background-color: #409eff;
border-radius: 50%;
}
audio::-webkit-media-controls-play-button:hover {
background-color: #66b1ff;
}
audio::-webkit-media-controls-current-time-display,
audio::-webkit-media-controls-time-remaining-display {
color: #606266;
}
</style>

View File

@ -0,0 +1,296 @@
<template>
<el-dialog v-model="dialogVisible" title="克隆任务列表" width="1200px">
<div class="task-list">
<el-table :data="taskList" v-loading="loading" style="width: 100%">
<el-table-column label="角色名称" min-width="120">
<template #default="{ row }">
{{ getTaskData(row.data).roleName }}
</template>
</el-table-column>
<el-table-column label="音频样本" min-width="300">
<template #default="{ row }">
<audio controls class="task-audio" :src="getTaskData(row.data).url" @play="handleAudioPlay" ref="audioRefs">您的浏览器不支持音频播放</audio>
</template>
</el-table-column>
<el-table-column label="重试次数" align="center">
<template #default="{ row }">
<div class="text-center">{{ row.attempts }}</div>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="180">
<template #default="{ row }">
{{ formatDate(row.createdAt) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" align="center" width="230">
<template #default="{ row }">
<div class="flex items-center gap-2">
<el-tag :type="getStatusType(row.status)">{{ getStatusText(row.status) }}</el-tag>
<template v-if="row.status.toLowerCase() === TaskStatus.FAILED">
<div class="flex items-center gap-1">
<el-button type="primary" link size="small" :loading="row.retrying" @click="handleRetry(row)"></el-button>
<el-button style="margin-left: 5px" type="danger" link size="small" :loading="row.deleting" @click="handleDelete(row)"></el-button>
<el-button style="margin-left: 5px" v-if="row.error" type="warning" link size="small" @click="showError(row)"></el-button>
</div>
</template>
</div>
</template>
</el-table-column>
</el-table>
</div>
<!-- 错误信息抽屉 -->
<el-drawer v-model="errorDrawer" title="错误详情" size="500px" direction="rtl" :destroy-on-close="true">
<div v-if="currentError" class="error-content">
<div class="mb-4">
<div class="text-gray-500 mb-1">角色名称</div>
<div>{{ getTaskData(currentError.data).roleName }}</div>
</div>
<div class="mb-4">
<div class="text-gray-500 mb-1">创建时间</div>
<div>{{ formatDate(currentError.createdAt) }}</div>
</div>
<div>
<div class="text-gray-500 mb-1">错误信息</div>
<div class="error-message">{{ currentError.error }}</div>
</div>
</div>
</el-drawer>
</el-dialog>
</template>
<script lang="ts" setup>
import { ref, watch, onMounted, onUnmounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { getTaskListAPI, retryCloneTaskAPI, deleteTaskAPI } from "@/api/SystemCharter";
import AudioManager from "@/utils/audioManager";
import { TaskStatus, TaskStatusType, TaskStatusText } from "@/utils/dict";
const props = defineProps<{
visible: boolean;
}>();
const emit = defineEmits<{
(e: "update:visible", value: boolean): void;
}>();
const dialogVisible = ref(props.visible);
const loading = ref(false);
const taskList = ref([]);
const currentPage = ref(1);
const pageSize = ref(10);
const total = ref(0);
//
const audioRefs = ref<HTMLAudioElement[]>([]);
//
const errorDrawer = ref(false);
const currentError = ref<any>(null);
watch(
() => props.visible,
(val) => {
dialogVisible.value = val;
if (val) {
fetchTaskList(true);
}
}
);
watch(
() => dialogVisible.value,
(val) => {
emit("update:visible", val);
if (!val) {
handleClose();
}
}
);
const getStatusType = (status: string) => {
return TaskStatusType[status.toLowerCase() as keyof typeof TaskStatus] || "info";
};
const getStatusText = (status: string) => {
return TaskStatusText[status.toLowerCase() as keyof typeof TaskStatus] || status;
};
const fetchTaskList = async (showLoading = true) => {
if (showLoading) {
loading.value = true;
}
try {
const res = await getTaskListAPI({
current: currentPage.value,
pageSize: pageSize.value,
});
taskList.value = res.data;
total.value = res.meta.total;
} catch (error) {
ElMessage.error("获取任务列表失败");
} finally {
if (showLoading) {
loading.value = false;
}
}
};
const handleSizeChange = (val: number) => {
pageSize.value = val;
fetchTaskList(true);
};
const handleCurrentChange = (val: number) => {
currentPage.value = val;
fetchTaskList(true);
};
//
let timer: number;
onMounted(() => {
timer = window.setInterval(() => {
if (dialogVisible.value) {
fetchTaskList(false);
}
}, 5000);
});
onUnmounted(() => {
if (timer) {
clearInterval(timer);
}
});
//
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
};
//
const getTaskData = (dataStr: string) => {
try {
return JSON.parse(dataStr);
} catch {
return { roleName: "-", url: "" };
}
};
//
const handleAudioPlay = (event: Event) => {
const audio = event.target as HTMLAudioElement;
AudioManager.play(audio);
};
//
const handleClose = () => {
AudioManager.stopAll();
};
//
const handleRetry = async (row: any) => {
try {
row.retrying = true;
await retryCloneTaskAPI(row.id);
ElMessage.success("重试任务已提交");
//
fetchTaskList(false);
} catch (error) {
ElMessage.error("重试失败");
} finally {
row.retrying = false;
}
};
//
const handleDelete = async (row: any) => {
try {
await ElMessageBox.confirm("确定要删除这个克隆任务吗?", "删除确认", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
});
row.deleting = true;
await deleteTaskAPI(row.id);
ElMessage.success("删除成功");
//
fetchTaskList(false);
} catch (error) {
if (error !== "cancel") {
ElMessage.error("删除失败");
}
} finally {
row.deleting = false;
}
};
//
const showError = (row: any) => {
currentError.value = row;
errorDrawer.value = true;
};
</script>
<style scoped>
.task-audio {
height: 36px;
border-radius: 18px;
width: 100%;
max-width: 300px;
}
.task-audio::-webkit-media-controls-panel {
background-color: #f5f7fa;
}
.task-audio::-webkit-media-controls-play-button {
background-color: #409eff;
border-radius: 50%;
}
.task-audio::-webkit-media-controls-play-button:hover {
background-color: #66b1ff;
}
.task-audio::-webkit-media-controls-current-time-display,
.task-audio::-webkit-media-controls-time-remaining-display {
color: #606266;
}
/* 可选:添加重试按钮的样式 */
.el-button.el-button--primary.el-button--link {
padding: 0 4px;
height: 20px;
line-height: 20px;
}
/* 添加删除按钮样式 */
.el-button.el-button--danger.el-button--link {
padding: 0 4px;
height: 20px;
line-height: 20px;
}
.error-content {
padding: 20px;
}
.error-message {
color: #f56c6c;
white-space: pre-wrap;
word-break: break-all;
padding: 12px;
background-color: #fef0f0;
border-radius: 4px;
font-family: monospace;
}
</style>

View File

@ -1,124 +1,180 @@
<template>
<div v-if="!isAdd" class="m-4">
<ElButton @click="addSystemCharter()"></ElButton>
<div class="mx-2 my-2">
<ElTable :data="dataSource">
<el-table-column label="角色背景" width="180">
<template #default="{ row }">
<el-image v-if="row.bg" style="height: 200px" class="ml-4 mt-2" :src="row.bg" :zoom-rate="1.2"
:max-scale="7" :min-scale="0.2" :preview-src-list="[row.bg]" :initial-index="4"
fit="cover" />
</template>
</el-table-column>
<el-table-column prop="roleName" label="角色名称" />
<el-table-column prop="roleSetting" label="角色描述">
<template #default="scope">
<div class="truncate">{{ scope.row.roleSetting }}</div>
</template>
</el-table-column>
<el-table-column prop="prologue" label="开场白" />
<el-table-column>
<template #default="{ row }">
<ElButton @click="handleEdit(row)" type="warning">编辑</ElButton>
</template>
</el-table-column>
</ElTable>
<div class="flex justify-end mt-2 mr-2">
<ElPagination v-model:current-page="paginationInfo.currentPage"
v-model:page-size="paginationInfo.pageSize" background size="small" layout="sizes,prev, pager, next"
:total="count" />
</div>
</div>
</div>
<div v-else class="m-4 flex mt-6">
<el-row style="width: 100%" :gutter="20">
<el-col :span="18">
<el-card>
<el-form ref="formRef" :model="submitForm" label-width="auto" style="max-width: 1200px"
:rules="rules">
<el-form-item>
<div style="text-align: right; width: 100%">
<el-button type="primary" @click="handleSubmit()">{{ submitForm.id ? "" : ""
}}</el-button>
<el-button @click="handleBack()"></el-button>
</div>
</el-form-item>
<el-form-item label="角色背景">
<el-upload class="my-4 upload-warpper w-1/2" drag action="#" :show-file-list="false"
:before-upload="beforeUpload" :http-request="handleFileUpload">
<div class="flex flex-col justify-center items-center">
<el-icon class="el-icon--upload">
<UploadFilled />
</el-icon>
<div class="el-upload__text mt-2">点击或拖拽上图片 <span
class="text-red-500">(图片建议1920x1920)</span></div>
</div>
</el-upload>
<div v-if="submitForm.bg">
<el-image style="height: 200px" class="ml-4 mt-2" :src="submitForm.bg" :zoom-rate="1.2"
:max-scale="7" :min-scale="0.2" :preview-src-list="[submitForm.bg]"
:initial-index="4" fit="cover" />
</div>
</el-form-item>
<el-form-item label="角色名称" prop="roleName">
<el-input v-model="submitForm.roleName" placeholder="请输入角色名称" />
</el-form-item>
<el-form-item label="角色描述" prop="roleSetting">
<el-input v-model="submitForm.roleSetting" type="textarea" />
</el-form-item>
<el-form-item label="角色简介" prop="intro">
<el-input v-model="submitForm.intro" type="textarea" />
</el-form-item>
<el-form-item label="角色开场白" prop="prologue">
<el-input v-model="submitForm.prologue" type="textarea" />
</el-form-item>
<el-form-item label="对话示例">
<ElButton @click="addChartLines()" type="primary">添加对话</ElButton>
<el-row :gutter="20" v-for="(item, index) in submitForm.roleLines" :key="index"
style="width: 100%; margin-top: 10px">
<el-col :span="12">
<ElSelect v-model="item.sender_name" @change="roleLinesChange(index)"
style="width: 100%" placeholder="选择机器人类型">
<el-option label="用户" value="用户"></el-option>
<el-option :label="submitForm.roleName"
:value="submitForm.roleName"></el-option>
</ElSelect>
</el-col>
<el-col :span="10">
<el-input v-model="item.text" style="width: 100%" placeholder="输入对话文本" />
</el-col>
<el-col :span="2">
<el-icon style="font-size: 20px; cursor: pointer; margin-top: 8px"
@click="handleRemoveSampleItem(index)">
<Remove />
</el-icon>
</el-col>
</el-row>
</el-form-item>
</el-form>
</el-card>
</el-col>
<el-col :span="6">
<div style="width: 100%">
<el-input v-model="formatJson" :rows="13" type="textarea" placeholder="粘贴格式化" />
<el-button type="primary" style="margin-top: 10px" @click="handleAutoFormat"></el-button>
</div>
</el-col>
</el-row>
</div>
<div v-if="!isAdd" class="m-4">
<div class="sticky-header">
<div class="flex justify-between items-center mb-1 bg-white py-2">
<div class="flex gap-2 items-center">
<el-input v-model="searchKeyword" placeholder="搜索角色名称" class="w-[200px]" clearable @input="handleSearch">
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-select v-model="cloneStatus" placeholder="克隆状态" clearable class="w-[150px]" @change="handleSearch">
<el-option label="全部" value="" />
<el-option label="已克隆" :value="true" />
<el-option label="未克隆" :value="false" />
</el-select>
<ElButton @click="addSystemCharter()"></ElButton>
<ElButton @click="showTaskList" type="info">克隆任务列表</ElButton>
</div>
<ElPagination v-model:current-page="paginationInfo.currentPage" v-model:page-size="paginationInfo.pageSize" background size="small" layout="sizes,prev, pager, next" :total="count" />
</div>
</div>
<div class="mx-2">
<el-row :gutter="20">
<el-col :xs="12" :sm="8" :md="6" :lg="4" :xl="3" v-for="item in dataSource" :key="item.id" class="mb-4">
<el-card :body-style="{ padding: '0px' }" class="character-card">
<div class="image-container">
<el-image
v-if="item.bg"
:src="item.bg"
class="character-image"
fit="cover"
:preview-src-list="[item.bg]"
:initial-index="0"
:preview-teleported="true"
:zoom-rate="1.2"
:max-scale="7"
:min-scale="0.2"
/>
</div>
<div class="p-3">
<h3 class="text-base font-bold mb-1 truncate">{{ item.roleName }}</h3>
<p class="text-gray-600 text-xs mb-2 line-clamp-2">{{ item.roleSetting }}</p>
<div class="flex justify-between items-center mb-2">
<div class="text-xs text-gray-500">
剩余克隆次数
<span :class="{ 'text-red-500': item.remainingCloneCount === 0, 'text-green-500': item.remainingCloneCount > 0 }">
{{ item.remainingCloneCount || 0 }}
</span>
</div>
</div>
<div class="flex justify-end">
<ElButton @click="handleEdit(item)" type="warning" size="small">编辑</ElButton>
<ElButton v-if="!item.activate" @click="handleClone(item)" type="primary" size="small" class="ml-2" :disabled="item.remainingCloneCount === 0"></ElButton>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</div>
<div v-else class="add-page">
<div class="sticky-header mb-4">
<div class="flex justify-between items-center bg-white py-2">
<h2 class="text-xl font-bold">{{ submitForm.id ? "编辑" : "添加" }}系统人物</h2>
<div>
<el-button @click="handleBack()"></el-button>
<el-button type="primary" @click="handleSubmit()">{{ submitForm.id ? "" : "" }}</el-button>
</div>
</div>
</div>
<el-row :gutter="20">
<el-col :span="18">
<el-card class="form-card">
<el-form ref="formRef" :model="submitForm" label-width="100px" :rules="rules" class="character-form">
<el-form-item label="角色背景" class="upload-section">
<div class="upload-preview" v-if="submitForm.bg">
<el-image :src="submitForm.bg" class="preview-image" :preview-src-list="[submitForm.bg]" fit="cover" />
<div class="upload-mask" @click="handleRemoveImage">
<el-icon><Delete /></el-icon>
<span>删除图片</span>
</div>
</div>
<el-upload v-else class="upload-area" drag action="#" :show-file-list="false" :before-upload="beforeUpload" :http-request="handleFileUpload">
<div class="upload-content">
<el-icon class="upload-icon"><UploadFilled /></el-icon>
<div class="upload-text">
<p>点击或拖拽上传图片</p>
<p class="text-gray-400 text-sm">(建议尺寸 1920x1920)</p>
</div>
</div>
</el-upload>
</el-form-item>
<el-form-item label="角色名称" prop="roleName">
<el-input v-model="submitForm.roleName" placeholder="请输入角色名称" />
</el-form-item>
<el-form-item label="角色描述" prop="roleSetting">
<el-input v-model="submitForm.roleSetting" type="textarea" :rows="4" placeholder="请详细描述角色的性格、背景等特征" />
</el-form-item>
<el-form-item label="角色简介" prop="intro">
<el-input v-model="submitForm.intro" type="textarea" :rows="3" placeholder="请输入角色简短介绍" />
</el-form-item>
<el-form-item label="开场白" prop="prologue">
<el-input v-model="submitForm.prologue" type="textarea" :rows="3" placeholder="请输入角色的开场白" />
</el-form-item>
<el-form-item label="对话示例">
<div class="dialog-section">
<div class="dialog-header">
<span class="font-bold">示例对话</span>
<el-button type="primary" link @click="addChartLines()">
<el-icon><Plus /></el-icon>
</el-button>
</div>
<div class="dialog-list">
<el-row v-for="(item, index) in submitForm.roleLines" :key="index" :gutter="20" class="dialog-item">
<el-col :span="6">
<ElSelect v-model="item.sender_name" @change="roleLinesChange(index)" class="w-full" placeholder="选择发言者">
<el-option label="用户" value="用户" />
<el-option :label="submitForm.roleName" :value="submitForm.roleName" />
</ElSelect>
</el-col>
<el-col :span="16">
<el-input v-model="item.text" type="textarea" :rows="1" placeholder="输入对话内容" />
</el-col>
<el-col :span="2" class="flex items-center justify-center">
<el-button type="danger" link @click="handleRemoveSampleItem(index)">
<el-icon><Delete /></el-icon>
</el-button>
</el-col>
</el-row>
</div>
</div>
</el-form-item>
</el-form>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="format-card">
<div class="format-header">
<el-icon><Document /></el-icon>
<span>快速导入</span>
</div>
<el-input v-model="formatJson" type="textarea" :rows="13" placeholder="粘贴格式化的JSON数据" class="format-input" />
<el-button type="primary" class="w-full mt-4" @click="handleAutoFormat">
<el-icon><Check /></el-icon>
</el-button>
</el-card>
</el-col>
</el-row>
</div>
<CloneSpeak v-model:visible="cloneDialogVisible" :character="currentCharacter" />
<TaskList v-model:visible="taskListVisible" />
<PreviewVoice v-model:visible="previewDialogVisible" :character="currentPreviewAudio" />
</template>
<script lang="ts" setup>
import { ref, onMounted, reactive, watch } from "vue";
import { addSystemCharterAPI, querySystemCharterAPI, editSystemCharterAPI } from "@/api/SystemCharter";
import { ElButton, ElLoading, ElMessage, ElTable, FormInstance, ElPagination, UploadProps } from "element-plus";
import { Remove, UploadFilled } from "@element-plus/icons-vue";
import Oss from "@/utils/oss";
import { addSystemCharterAPI, querySystemCharterAPI, editSystemCharterAPI, getSystemCharterDetailAPI } from "@/api/SystemCharter";
import { ElButton, ElLoading, ElMessage, FormInstance, ElPagination, UploadProps } from "element-plus";
import { UploadFilled, Delete, Plus, Document, Check, Search } from "@element-plus/icons-vue";
import createOss, { OssPath } from "@/utils/oss";
import CloneSpeak from "./CloneSpeak.vue";
import TaskList from "./TaskList.vue";
import PreviewVoice from "./PreviewVoice.vue";
const dataSource = ref([]);
const paginationInfo = reactive({
currentPage: 1,
pageSize: 10,
currentPage: 1,
pageSize: 20,
});
const count = ref(0);
const formRef = ref<FormInstance>();
@ -129,151 +185,452 @@ const formatJson = ref("");
const submitForm = ref();
const rules = ref({
roleName: [{ required: true, message: "请输入角色名称", trigger: "blur" }],
roleSetting: [{ required: true, message: "请输入角色描述", trigger: "blur" }],
prologue: [{ required: true, message: "请输入角色开场白", trigger: "blur" }],
// intro: [{ required: true, message: "", trigger: "blur" }],
roleName: [{ required: true, message: "请输入角色名称", trigger: "blur" }],
roleSetting: [{ required: true, message: "请输入角色描述", trigger: "blur" }],
prologue: [{ required: true, message: "请输入角色开场白", trigger: "blur" }],
// intro: [{ required: true, message: "", trigger: "blur" }],
});
// script setup
const cloneDialogVisible = ref(false);
const currentCharacter = ref<any>(null);
const taskListVisible = ref(false);
const previewDialogVisible = ref(false);
const currentPreviewAudio = ref<{
originUrl: string;
cloneUrl: string;
roleName: string;
} | null>(null);
//
const searchKeyword = ref("");
//
const cloneStatus = ref("");
onMounted(() => {
handleReset();
initSystemCharterDataSource();
handleReset();
initSystemCharterDataSource();
});
watch(() => [paginationInfo.currentPage, paginationInfo.pageSize], () => {
initSystemCharterDataSource()
})
watch(
() => [paginationInfo.currentPage, paginationInfo.pageSize],
() => {
initSystemCharterDataSource();
}
);
const handleReset = () => {
submitForm.value = {
id: null,
roleName: "",
roleSetting: "", //
prologue: "", //
intro: "", //
roleLines: [],
bg: ''
};
submitForm.value = {
id: null,
roleName: "",
roleSetting: "", //
prologue: "", //
intro: "", //
roleLines: [],
bg: "",
};
};
const addSystemCharter = () => {
handleReset();
isAdd.value = true;
handleReset();
isAdd.value = true;
};
const initSystemCharterDataSource = () => {
querySystemCharterAPI({ current: paginationInfo.currentPage, pageSize: paginationInfo.pageSize }).then((result) => {
dataSource.value = result.data;
count.value = result.meta.total;
});
const params = {
current: paginationInfo.currentPage,
pageSize: paginationInfo.pageSize,
roleName: searchKeyword.value,
originAudioUrl: cloneStatus.value ?? undefined, // undefined
};
querySystemCharterAPI(params).then((result) => {
dataSource.value = result.data;
count.value = result.meta.total;
});
};
//
const handleSearch = () => {
paginationInfo.currentPage = 1; //
initSystemCharterDataSource();
};
//
watch(
() => searchKeyword.value,
() => {
handleSearch();
}
);
//
const addChartLines = () => {
submitForm.value?.roleLines.push({
sender_type: null,
sender_name: null,
text: "",
});
submitForm.value?.roleLines.push({
sender_type: null,
sender_name: null,
text: "",
});
};
//
const roleLinesChange = (index) => {
console.error(index);
console.error(index);
};
const handleSubmit = async () => {
await formRef.value?.validate(async (valid: any) => {
if (valid) {
const loading = ElLoading.service();
try {
const _submitForm = JSON.parse(JSON.stringify(submitForm.value));
_submitForm.roleLines = JSON.stringify(_submitForm.roleLines);
if (!submitForm.value.id) {
await addSystemCharterAPI(_submitForm);
ElMessage.success("添加成功");
} else {
await editSystemCharterAPI(_submitForm);
ElMessage.success("修改成功");
}
await formRef.value?.validate(async (valid: any) => {
if (valid) {
const loading = ElLoading.service();
try {
const _submitForm = JSON.parse(JSON.stringify(submitForm.value));
_submitForm.roleLines = JSON.stringify(_submitForm.roleLines);
if (!submitForm.value.id) {
await addSystemCharterAPI(_submitForm);
ElMessage.success("添加成功");
} else {
await editSystemCharterAPI(_submitForm);
ElMessage.success("修改成功");
}
initSystemCharterDataSource();
isAdd.value = false;
} finally {
loading.close();
}
}
});
initSystemCharterDataSource();
isAdd.value = false;
} finally {
loading.close();
}
}
});
};
const handleRemoveSampleItem = (index) => {
submitForm.value.roleLines.splice(index, 1);
ElMessage.success("删除成功");
submitForm.value.roleLines.splice(index, 1);
ElMessage.success("删除成功");
};
const handleEdit = (row) => {
handleReset();
const _currentRow = JSON.parse(JSON.stringify(row));
Object.keys(submitForm.value).forEach((key) => {
if (key === "roleLines") {
submitForm.value[key] = JSON.parse(_currentRow[key]);
} else {
submitForm.value[key] = _currentRow[key];
}
});
isAdd.value = true;
const handleEdit = async (row: any) => {
const loading = ElLoading.service();
try {
//
handleReset();
//
const detail = await getSystemCharterDetailAPI(row.id);
//
const detailData = detail.data;
Object.keys(submitForm.value).forEach((key) => {
if (key === "roleLines") {
submitForm.value[key] = JSON.parse(detailData[key]);
} else {
submitForm.value[key] = detailData[key];
}
});
isAdd.value = true;
} catch (error) {
ElMessage.error("获取详情失败");
} finally {
loading.close();
}
};
const handleBack = () => {
isAdd.value = false;
isAdd.value = false;
};
//
const handleAutoFormat = () => {
const loading = ElLoading.service();
try {
const object = JSON.parse(formatJson.value);
const roleName = object.reply_constraints.sender_name;
const roleSettingItem = object.bot_setting.find((item) => item.bot_name.trim() == roleName.trim());
const sampleMessages = object.sample_messages.map((item) => {
return {
sender_type: item.sender_type,
sender_name: item.sender_type == "BOT" ? roleName : item.sender_name,
text: item.text,
};
});
submitForm.value = {
id: null,
roleName: roleName,
roleSetting: roleSettingItem.content, //
prologue: "", //
intro: "", //
roleLines: sampleMessages,
};
formatJson.value = "";
ElMessage.success("格式化成功");
} catch {
ElMessage.error("格式化失败");
} finally {
loading.close();
}
const loading = ElLoading.service();
try {
const object = JSON.parse(formatJson.value);
const roleName = object.reply_constraints.sender_name;
const roleSettingItem = object.bot_setting.find((item) => item.bot_name.trim() == roleName.trim());
const sampleMessages = object.sample_messages.map((item) => {
return {
sender_type: item.sender_type,
sender_name: item.sender_type == "BOT" ? roleName : item.sender_name,
text: item.text,
};
});
submitForm.value = {
id: null,
roleName: roleName,
roleSetting: roleSettingItem.content, //
prologue: "", //
intro: "", //
roleLines: sampleMessages,
};
formatJson.value = "";
ElMessage.success("格式化成功");
} catch {
ElMessage.error("格式化失败");
} finally {
loading.close();
}
};
const beforeUpload: UploadProps["beforeUpload"] = (rawFile) => {
if (!(rawFile.type.indexOf('image') > -1)) {
ElMessage.error("仅支持图片");
return false;
} else if (rawFile.size / 1024 / 1024 > 3) {
ElMessage.error("文件大小不能超过3M");
return false;
}
return true;
if (!(rawFile.type.indexOf("image") > -1)) {
ElMessage.error("仅支持图片");
return false;
} else if (rawFile.size / 1024 / 1024 > 3) {
ElMessage.error("文件大小不能超过3M");
return false;
}
return true;
};
const handleFileUpload = async (params) => {
const result = await (await Oss).put(`/system-character-bg/${params.file.name}`, params.file)
submitForm.value.bg = result.url
const oss = createOss();
const result = await oss.put(`${OssPath.CHARACTER_BG}/${params.file.name}`, params.file);
submitForm.value.bg = result.url;
};
//
const handleRemoveImage = () => {
submitForm.value.bg = "";
ElMessage.success("图片已删除");
};
//
const handleClone = (item: any) => {
currentCharacter.value = item;
cloneDialogVisible.value = true;
};
const showTaskList = () => {
taskListVisible.value = true;
};
</script>
<style scoped>
.character-card {
transition: all 0.3s;
}
.character-card:hover {
transform: translateY(-5px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.image-container {
width: 100%;
height: 0;
padding-bottom: 140%;
position: relative;
background: #f5f5f5;
overflow: hidden;
}
.character-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
cursor: pointer;
}
:deep(.el-image-viewer__wrapper) {
z-index: 2100;
}
:deep(.el-image-viewer__img) {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.sticky-header {
position: sticky;
top: 0;
z-index: 10;
background-color: white;
margin: -1rem -1rem 1rem -1rem;
padding: 10px;
border-bottom: 1px solid #eee;
}
.add-page {
min-height: 100vh;
background-color: #f5f7fa;
padding: 1rem;
}
.form-card {
background: white;
border-radius: 8px;
}
.character-form {
padding: 20px;
}
.upload-section {
margin-bottom: 2rem;
}
.upload-area {
width: 100%;
max-width: 400px;
border: 2px dashed #dcdfe6;
border-radius: 8px;
transition: all 0.3s;
}
.upload-area:hover {
border-color: var(--el-color-primary);
}
.upload-content {
padding: 40px 0;
text-align: center;
}
.upload-icon {
font-size: 48px;
color: #909399;
margin-bottom: 16px;
}
.preview-image {
width: 300px;
height: 300px;
object-fit: cover;
border-radius: 8px;
}
.upload-preview {
position: relative;
display: inline-block;
}
.upload-mask {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: white;
opacity: 0;
transition: opacity 0.3s;
cursor: pointer;
border-radius: 8px;
}
.upload-preview:hover .upload-mask {
opacity: 1;
}
.dialog-section {
background: #f8f9fa;
border-radius: 8px;
padding: 20px;
overflow-y: auto;
width: 100%;
}
.dialog-header {
position: sticky;
top: 0;
background: #f8f9fa;
z-index: 1;
padding-bottom: 16px;
margin-bottom: 16px;
border-bottom: 1px solid #eee;
}
.dialog-list {
padding-bottom: 10px;
}
.dialog-item {
margin-bottom: 16px;
padding: 16px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
align-items: flex-start;
transition: all 0.3s;
}
.dialog-item:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.dialog-section::-webkit-scrollbar {
width: 6px;
}
.dialog-section::-webkit-scrollbar-thumb {
background-color: #dcdfe6;
border-radius: 3px;
}
.dialog-section::-webkit-scrollbar-track {
background-color: transparent;
}
/* :deep(.el-textarea__inner) {
min-height: 32px !important;
resize: none;
line-height: 1.5;
padding: 8px 12px;
}
:deep(.el-textarea__inner:focus) {
box-shadow: none;
} */
.dialog-item .el-select {
width: 100%;
}
.dialog-item .el-col:last-child {
display: flex;
align-items: center;
justify-content: center;
padding-top: 4px;
}
.format-card {
position: sticky;
top: 24px;
}
.format-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
padding-bottom: 8px;
border-bottom: 1px solid #eee;
font-weight: 500;
}
.format-input {
margin-bottom: 16px;
}
/* 可选:添加搜索框样式 */
.el-input {
--el-input-height: 36px;
}
/* 调整下拉框样式 */
:deep(.el-select .el-input__wrapper) {
height: 36px;
}
/* 添加剩余次数样式 */
.remaining-count {
font-size: 12px;
padding: 2px 8px;
border-radius: 10px;
background-color: #f5f7fa;
}
</style>

View File

@ -0,0 +1,23 @@
class AudioManager {
private static currentAudio: HTMLAudioElement | null = null;
public static play(audio: HTMLAudioElement) {
// 如果有其他音频在播放,先停止它
if (this.currentAudio && this.currentAudio !== audio && !this.currentAudio.paused) {
this.currentAudio.pause();
this.currentAudio.currentTime = 0;
}
// 设置当前音频
this.currentAudio = audio;
}
public static stopAll() {
if (this.currentAudio && !this.currentAudio.paused) {
this.currentAudio.pause();
this.currentAudio.currentTime = 0;
}
this.currentAudio = null;
}
}
export default AudioManager;

23
src/utils/dict.ts 100644
View File

@ -0,0 +1,23 @@
// 任务状态
export const TaskStatus = {
PENDING: "pending",
PROCESSING: "processing",
COMPLETED: "completed",
FAILED: "failed",
} as const;
// 任务状态类型映射
export const TaskStatusType = {
[TaskStatus.PENDING]: "warning",
[TaskStatus.PROCESSING]: "primary",
[TaskStatus.COMPLETED]: "success",
[TaskStatus.FAILED]: "danger",
} as const;
// 任务状态文本映射
export const TaskStatusText = {
[TaskStatus.PENDING]: "等待中",
[TaskStatus.PROCESSING]: "任务处理中",
[TaskStatus.COMPLETED]: "已完成",
[TaskStatus.FAILED]: "任务失败",
} as const;

View File

@ -1,10 +1,18 @@
import aliOss from 'ali-oss'
import aliOss from "ali-oss";
const Oss = new aliOss({
export enum OssPath {
CHARACTER_BG = "system-character-bg",
CLONE_VOICE = "system-character-speak",
}
const createOss = (bucket = "stayby-static") => {
return new aliOss({
region: "oss-cn-shanghai",
accessKeyId: "LTAI5tEday8PJNaMTz5mp8g4",
accessKeySecret: "ck84eTxx4aSTjornlYrCy8RkurCHfc",
bucket: "stayby-static",
bucket,
secure: true,
});
export default Oss
});
};
export default createOss;

View File

@ -8,7 +8,7 @@ export let isRelogin = { show: false };
axios.defaults.headers["Content-Type"] = "application/json;charset=utf-8";
const service = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 10000,
timeout: 1000 * 30,
});
// request拦截器
@ -73,8 +73,6 @@ service.interceptors.response.use(
(error) => {
console.error(error);
let { message, code } = error.response.data;
if (code == 401) {
}
if (message == "Network Error") {
message = "后端接口连接异常";
} else if (message.includes("timeout")) {

View File

@ -1 +1 @@
{"root":["./src/global.d.ts","./src/main.ts","./src/vite-env.d.ts","./src/api/systemcharter/index.ts","./src/app.vue","./src/components/helloworld.vue","./src/pages/systemcharter/index.vue"],"version":"5.6.2"}
{"root":["./src/global.d.ts","./src/main.ts","./src/vite-env.d.ts","./src/api/systemcharter/index.ts","./src/utils/audiomanager.ts","./src/utils/dict.ts","./src/utils/oss.ts","./src/app.vue","./src/components/helloworld.vue","./src/pages/systemcharter/clonehistory.vue","./src/pages/systemcharter/clonespeak.vue","./src/pages/systemcharter/previewvoice.vue","./src/pages/systemcharter/tasklist.vue","./src/pages/systemcharter/index.vue"],"errors":true,"version":"5.6.2"}