初步实现,会员临期提醒功能,待完善

This commit is contained in:
DelLevin-Home
2026-02-04 23:24:49 +08:00
parent 8d11263e37
commit 9faa180627
31 changed files with 695 additions and 523 deletions

View File

@@ -1,3 +1,3 @@
NODE_ENV=development
VITE_APP_API=http://152.136.153.72:27005/admin
# VITE_APP_API=http://10.8.0.3/api/admin
# VITE_APP_API=http://152.136.153.72:27005/admin
VITE_APP_API=http://10.8.0.3:8888/admin

View File

@@ -21,28 +21,3 @@ npm install
# 启动项目
npm run dev
```
> 如网络不稳定,安装时出错或进度过慢!请移步 [cnpm](https://npmmirror.com/) 淘宝镜像进行安装。
启动完成后,会自动打开浏览器访问 [http://localhost:8001](http://localhost:8001),如您看到下面的页面代表`前端项目`运行成功!因为前后端分离项目,需保证`前端项目``后台项目`分别独立正常运行。
请留意下面的页面,其中`验证码`未能正常显示,控制台有`API请求`报错信息!这时需检查`后台项目`是否正常运行。
## 如何交流、反馈、参与贡献?
- 开发文档https://www.renren.io/guide/security
- 官方社区https://www.renren.io/community
- [人人开源](https://www.renren.io)https://www.renren.io
- 如需关注项目最新动态请Watch、Star项目同时也是对项目最好的支持
- 技术讨论、二次开发等咨询、问题和建议,请移步到官方社区,我会在第一时间进行解答和回复!
- 微信扫码并关注【人人开源】,获得项目最新动态及更新提醒<br>
<br>
## 微信交流群
我们提供了微信交流群,扫码下面的二维码,关注【人人开源】公众号,回复【加群】,即可根据提示加入微信群!
<br><br>
<br>
<br>

View File

@@ -28,7 +28,7 @@ export default defineComponent({
<span :class="`rr-header-ctx-logo-img-wrap ${'enabled-logo-' + app.enabledLogo}`">
<!-- 支持显示图片logo或者产品名称缩写二选一模式通过注释开启功能app.enabledLogo控制正常模式下图片logo是否显示如果有图片logo收起状态会强制显示图片logo -->
<!-- <img :src="props.logoUrl" class="rr-header-ctx-logo-img" :alt="props.logoName" /> -->
<span>人人</span>
<span>DL</span>
<span class="rr-header-ctx-logo-line"></span>
</span>
<span class="rr-header-ctx-logo-text">{{ props.logoName }}</span>

View File

@@ -1,437 +0,0 @@
<template>
<div class="tts-app">
<el-card class="box-card">
<template #header>
<div class="card-header">
<span>GPT-SoVITS 语音合成</span>
</div>
</template>
<!-- API 配置 -->
<!-- <div class="config-section">
<h3>API 配置</h3>
<el-form :model="apiConfig" label-width="120px">
<el-form-item label="API 地址">
<el-input v-model="apiConfig.baseUrl" placeholder="例如: http://127.0.0.1:9880" />
</el-form-item>
</el-form>
</div> -->
<!-- 角色选择 -->
<div class="character-section">
<h3>角色选择</h3>
<el-form label-width="120px">
<el-form-item label="选择角色">
<el-select
v-model="selectedCharacterId"
placeholder="请选择角色"
@change="switchModelByCharacter"
:loading="modelSwitching.gpt || modelSwitching.sovits"
>
<el-option
v-for="char in availableCharacters"
:key="char.id"
:label="char.name"
:value="char.id"
/>
</el-select>
</el-form-item>
</el-form>
</div>
<!-- 合成参数 -->
<div class="params-section">
<h3>合成参数</h3>
<el-form :model="ttsParams" :rules="ttsRules" ref="ttsFormRef" label-width="150px" :disabled="isGenerating">
<el-form-item label="待合成提示文本" prop="text">
<el-input
v-model="ttsParams.text"
type="textarea"
:rows="4"
placeholder="请输入要合成的文本..."
/>
</el-form-item>
<el-form-item label="提示文本语言" prop="prompt_lang">
<el-select v-model="ttsParams.prompt_lang" placeholder="请选择提示文本语言">
<el-option label="中文" value="zh" />
<el-option label="英文" value="en" />
<el-option label="日文" value="ja" />
</el-select>
</el-form-item>
<el-form-item label="生成语音语言" prop="text_lang">
<el-select v-model="ttsParams.text_lang" placeholder="请选择文本语言">
<el-option label="中文" value="zh" />
<el-option label="英文" value="en" />
<el-option label="日文" value="ja" />
</el-select>
</el-form-item>
<!-- 参考音频路径现在由 selectedCharacter 决定 -->
<!-- <el-form-item label="参考音频路径" prop="ref_audio_path">
<el-input
v-model="ttsParams.ref_audio_path"
readonly
placeholder="由角色自动设置"
:disabled="true"
/>
</el-form-item> -->
<!-- 提示文本 (可选) 现在由 selectedCharacter 决定 -->
<!-- <el-form-item label="提示文本 (可选)">
<el-input
v-model="ttsParams.prompt_text"
type="textarea"
:rows="2"
readonly
placeholder="由角色自动设置 (如果配置)"
:disabled="true"
/>
</el-form-item> -->
<el-form-item label="文本分割方式">
<el-select v-model="ttsParams.text_split_method">
<el-option label="不切" value="cut0" />
<el-option label="按。切" value="cut1" />
<el-option label="按,。切" value="cut2" />
<el-option label="按?!切" value="cut3" />
<el-option label="按,。?!切" value="cut4" />
<el-option label="按每行切" value="cut5" />
<el-option label="按换行切" value="cut6" />
</el-select>
</el-form-item>
<!-- <el-form-item label="流式传输">
<el-switch v-model="ttsParams.streaming_mode" />
</el-form-item> -->
<!-- <el-form-item label="批处理大小">
<el-input-number v-model="ttsParams.batch_size" :min="1" :max="10" />
</el-form-item> -->
<!-- <el-form-item label="Top-K">
<el-input-number v-model="ttsParams.top_k" :min="1" :max="100" />
</el-form-item> -->
<!-- <el-form-item label="Top-P">
<el-slider v-model="ttsParams.top_p" :min="0" :max="1" :step="0.01" />
</el-form-item> -->
<el-form-item label="音频随机参数">
<el-slider v-model="ttsParams.temperature" :min="0" :max="2" :step="0.01" />
</el-form-item>
<el-form-item label="音频语速倍率">
<el-slider v-model="ttsParams.speed_factor" :min="0.1" :max="3" :step="0.1" />
</el-form-item>
</el-form>
</div>
<!-- 控制按钮 -->
<div class="controls-section">
<el-button type="primary" @click="synthesize" :loading="isGenerating">
{{ isGenerating ? '生成中...' : '合成语音' }}
</el-button>
<el-button @click="stopGeneration" :disabled="!isGenerating">停止</el-button>
<el-button @click="clearAudio">清空音频</el-button>
</div>
<!-- 音频播放 -->
<div class="audio-section" v-if="audioUrl">
<h3>合成结果</h3>
<audio :src="audioUrl" controls class="audio-player" />
</div>
<!-- 日志 -->
<div class="log-section">
<h3>日志</h3>
<pre class="log-content">{{ log }}</pre>
</div>
</el-card>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onUnmounted } from 'vue';
import { ElMessage, FormInstance, FormRules } from 'element-plus';
// API 配置
const apiConfig = reactive({
baseUrl: 'http://127.0.0.1:9880', // 默认地址
});
// 可用角色列表 (在这里添加你的角色)
const availableCharacters = [
{
id: 'yier',
name: '一二(yier)',
gptPath: 'E:\\AI\\GPT-SoVITS-v4-20250529\\GPT_weights_v2\\yier-e20.ckpt',
sovitsPath: 'E:\\AI\\GPT-SoVITS-v4-20250529\\SoVITS_weights_v2\\yier_e8_s144.pth',
refAudioPath: 'D:\\UserData\\Desktop\\声音素材\\temp\\yier001.mp3',
// promptText: 'This is a prompt for 一二.' // 示例:可选的提示文本
},
{
id: 'bubu',
name: '布布(bubu)',
gptPath: 'E:\\AI\\GPT-SoVITS-v4-20250529\\GPT_weights_v2\\bubu-e20.ckpt',
sovitsPath: 'E:\\AI\\GPT-SoVITS-v4-20250529\\SoVITS_weights_v2\\bubu_e8_s136.pth',
refAudioPath: 'D:\\UserData\\Desktop\\声音素材\\temp\\bubu001.wav'
},
];
// 当前选中的角色 ID
const selectedCharacterId = ref('yier'); // 默认选择一二
// 计算属性:根据 ID 获取当前选中的角色信息
const currentCharacter = computed(() => {
return availableCharacters.find(char => char.id === selectedCharacterId.value) || null;
});
// TTS 参数
const ttsParams = reactive({
text: '欢迎来到后台配音系统,这是后台语音合成示例。',
text_lang: 'zh',
ref_audio_path: computed(() => currentCharacter.value?.refAudioPath || '').value, // 初始化时取默认角色的路径
aux_ref_audio_paths: [],
prompt_text: computed(() => currentCharacter.value?.promptText || '').value, // 初始化时取默认角色的提示文本
prompt_lang: 'zh',
top_k: 5,
top_p: 1.0,
temperature: 1.0,
text_split_method: 'cut5',
batch_size: 1,
batch_threshold: 0.75,
split_bucket: true,
speed_factor: 1.0,
streaming_mode: true,
seed: -1,
parallel_infer: true,
repetition_penalty: 1.35,
sample_steps: 32,
super_sampling: false,
media_type: 'wav',
});
// 表单验证规则
const ttsRules: FormRules = {
text: [
{ required: true, message: '请输入待合成的文本', trigger: 'blur' },
],
text_lang: [
{ required: true, message: '请选择文本语言', trigger: 'change' },
],
prompt_lang: [
{ required: true, message: '请选择提示文本语言', trigger: 'change' },
],
};
// 模型切换状态
const modelSwitching = reactive({
gpt: false,
sovits: false,
});
const ttsFormRef = ref<FormInstance>();
const isGenerating = ref(false);
const audioUrl = ref<string | null>(null); // 初始值设为 null
const log = ref('');
// 切换模型辅助函数
const switchGPTModel = async (path: string) => {
try {
const response = await fetch(`${apiConfig.baseUrl}/set_gpt_weights?weights_path=${encodeURIComponent(path)}`, {
method: 'GET',
});
const responseText = await response.text(); // 读取响应文本
if(response.status === 200) {
log.value += 'GPT 模型切换成功!\n';
ElMessage.success('GPT 模型切换成功');
return true;
} else {
log.value += `GPT 模型切换失败: ${responseText}\n`;
ElMessage.error(`GPT 模型切换失败: ${responseText}`);
return false;
}
} catch (error: any) {
log.value += `GPT 模型切换网络错误: ${error.message}\n`;
ElMessage.error(`GPT 模型切换网络错误: ${error.message}`);
return false;
}
};
const switchSoVITSModel = async (path: string) => {
try {
const response = await fetch(`${apiConfig.baseUrl}/set_sovits_weights?weights_path=${encodeURIComponent(path)}`, {
method: 'GET',
});
const responseText = await response.text(); // 读取响应文本
if(response.status === 200) {
log.value += 'SoVITS 模型切换成功!\n';
ElMessage.success('SoVITS 模型切换成功');
return true;
} else {
log.value += `SoVITS 模型切换失败: ${responseText}\n`;
ElMessage.error(`SoVITS 模型切换失败: ${responseText}`);
return false;
}
} catch (error: any) {
log.value += `SoVITS 模型切换网络错误: ${error.message}\n`;
ElMessage.error(`SoVITS 模型切换网络错误: ${error.message}`);
return false;
}
};
// 根据选中的角色切换模型
const switchModelByCharacter = async (characterId: string) => {
const char = currentCharacter.value;
if (!char) {
ElMessage.error(`找不到角色 ID "${characterId}" 的配置。`);
log.value += `错误: 找不到角色 ID "${characterId}" 的配置。\n`;
return;
}
// 更新 ttsParams 中由角色决定的字段
ttsParams.ref_audio_path = char.refAudioPath;
ttsParams.prompt_text = char.promptText || '';
log.value += `开始切换模型为角色 "${char.name}"...\n`;
// 1. 切换 GPT 模型
modelSwitching.gpt = true;
log.value += `正在切换 GPT 模型到: ${char.gptPath}\n`;
const gptSuccess = await switchGPTModel(char.gptPath);
modelSwitching.gpt = false;
// 2. 切换 SoVITS 模型
modelSwitching.sovits = true;
log.value += `正在切换 SoVITS 模型到: ${char.sovitsPath}\n`;
const sovitsSuccess = await switchSoVITSModel(char.sovitsPath);
modelSwitching.sovits = false;
if (gptSuccess && sovitsSuccess) {
log.value += `角色 "${char.name}" 的模型切换完成!\n`;
} else {
log.value += `角色 "${char.name}" 的模型切换部分失败或全部失败。\n`;
}
};
// 合成语音
const synthesize = async () => {
const validate = await ttsFormRef.value?.validate().catch(() => false);
if (!validate) {
ElMessage.error('请检查输入参数');
return;
}
if (!currentCharacter.value) {
ElMessage.error('当前角色配置无效,请重新选择。');
return;
}
isGenerating.value = true;
log.value += '开始合成语音...\n';
clearAudio(); // 清空之前的音频
try {
// 构建 POST 请求体
const requestBody = { ...ttsParams };
// 如果 prompt_text 为空,则从 payload 中移除
if (!requestBody.prompt_text) {
delete requestBody.prompt_text;
}
const response = await fetch(`${apiConfig.baseUrl}/tts`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: `HTTP Error ${response.status}` }));
throw new Error(errorData.message || `HTTP Error ${response.status}`);
}
// 检查返回的是否为音频流
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('audio')) {
const errorText = await response.text();
throw new Error(`API returned non-audio content: ${errorText}`);
}
const audioBlob = await response.blob();
// 创建临时 URL 用于播放和下载
const newUrl = URL.createObjectURL(audioBlob);
audioUrl.value = newUrl;
log.value += '语音合成成功!\n';
ElMessage.success('语音合成成功');
} catch (error: any) {
console.error('Synthesis failed:', error);
log.value += `语音合成失败: ${error.message}\n`;
ElMessage.error(`语音合成失败: ${error.message}`);
} finally {
isGenerating.value = false;
}
};
// 停止生成 (当前 fetch 无法真正中断,主要重置 UI 状态)
const stopGeneration = () => {
isGenerating.value = false;
log.value += '用户已请求停止生成。\n';
ElMessage.info('已请求停止生成');
};
// 清空音频
const clearAudio = () => {
if (audioUrl.value) {
URL.revokeObjectURL(audioUrl.value);
audioUrl.value = null;
}
};
// 组件卸载时清理 URL 对象
onUnmounted(() => {
clearAudio();
});
</script>
<style lang="less" scoped>
.tts-app {
padding: 20px;
max-width: 1000px;
margin: 0 auto;
.box-card {
min-width: 800px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.config-section,
.character-section,
.params-section,
.controls-section,
.audio-section,
.log-section {
margin-top: 20px;
}
.controls-section {
text-align: center;
}
.audio-player {
width: 100%;
margin-top: 10px;
}
.log-content {
background-color: #f5f5f5;
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px;
height: 150px;
overflow-y: auto;
white-space: pre-wrap;
word-wrap: break-word;
}
}
</style>

View File

@@ -0,0 +1,142 @@
<template>
<el-dialog v-model="visible" :title="!dataForm.id ? '新增' : '修改'" :close-on-click-modal="false" :close-on-press-escape="false">
<el-form :model="dataForm" :rules="rules" ref="dataFormRef" @keyup.enter="dataFormSubmitHandle()" label-width="120px">
<el-form-item label=" 提供商" prop="provider">
<el-input v-model="dataForm.provider" placeholder=" 提供商:阿里/腾讯/网易/百度/"></el-input>
</el-form-item>
<el-form-item label="名称" prop="name">
<el-input v-model="dataForm.name" placeholder="名称"></el-input>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="dataForm.remark" placeholder="备注"></el-input>
</el-form-item>
<el-form-item label="续费方式" prop="renewalType">
<el-select v-model="dataForm.renewalType" placeholder="请选择续费方式" style="width: 240px">
<el-option v-for="item in renewalType" :key="item.dictValue" :label="item.dictLabel" :value="item.dictValue" />
</el-select>
</el-form-item>
<el-form-item label="续费日" prop="renewalDate">
<el-input-number v-model="dataForm.renewalDate" :min="1" :max="29" placeholder="具体续费日期 (1-29)" style="width: 100%" />
</el-form-item>
<el-form-item label="到期时间" prop="expireTime">
<el-date-picker v-model="dataForm.expireTime" type="datetime" placeholder="选择到期时间" format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss" style="width: 100%" />
</el-form-item>
<el-form-item label="其他信息" prop="otherInfo">
<el-input v-model="dataForm.otherInfo" placeholder="其他信息json形式"></el-input>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="dataFormSubmitHandle()">确定</el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { reactive, ref, computed } from "vue";
import baseService from "@/service/baseService";
import { ElMessage } from "element-plus";
import { useAppStore } from "@/store";
import { getDictDataList } from "@/utils/utils";
const store = useAppStore();
const renewalType = getDictDataList(store.state.dicts, "renewal_type");
const emit = defineEmits(["refreshDataList"]);
const visible = ref(false);
const dataFormRef = ref();
const dataForm = reactive({
id: "",
provider: "",
name: "",
remark: "",
otherInfo: "",
renewalType: "",
renewalDate: null as number | null,
expireTime: "",
createDate: "",
createUser: "",
updateDate: "",
updateUser: ""
});
const getRules = () => ({
provider: [{ required: true, message: "必填项不能为空", trigger: "blur" }],
name: [{ required: true, message: "必填项不能为空", trigger: "blur" }],
remark: [{ required: true, message: "必填项不能为空", trigger: "blur" }],
renewalType: [{ required: true, message: "必填项不能为空", trigger: "blur" }],
renewalDate:
dataForm.renewalType !== "1"
? [
{ required: true, message: "续费日不能为空", trigger: "blur" },
{ type: "number", min: 1, max: 29, message: "日期必须在1到29之间", trigger: "blur" }
]
: [{ type: "number", min: 1, max: 29, message: "日期必须在1到29之间", trigger: "blur" }],
expireTime: [{ required: true, message: "到期时间不能为空", trigger: "blur" }]
});
// 使用 computed 属性,使其响应式
const rules = computed(() => getRules());
const init = (id?: number) => {
visible.value = true;
dataForm.id = "";
// 重置表单数据
if (dataFormRef.value) {
dataFormRef.value.resetFields();
}
if (id) {
getInfo(id);
}
};
// 获取信息
const getInfo = (id: number) => {
baseService.get("/baitutools/dlrenewalremind/" + id).then((res) => {
const { data } = res; // 解构出 data 对象
// --- 新增处理逻辑 ---
if (data.hasOwnProperty("renewalDate")) {
if (typeof data.renewalDate === "string") {
const parsed = parseInt(data.renewalDate, 10);
data.renewalDate = isNaN(parsed) ? null : parsed;
} else if (typeof data.renewalDate === "number") {
data.renewalDate = data.renewalDate
} else {
data.renewalDate = null;
}
}
// 将处理后的 data 对象合并到 dataForm
Object.assign(dataForm, data);
});
};
// 表单提交
const dataFormSubmitHandle = () => {
dataFormRef.value.validate((valid: boolean) => {
if (!valid) {
return false;
}
(!dataForm.id ? baseService.post : baseService.put)("/baitutools/dlrenewalremind", dataForm).then((res) => {
ElMessage.success({
message: "成功",
duration: 500,
onClose: () => {
visible.value = false;
emit("refreshDataList");
}
});
});
});
};
defineExpose({
init
});
</script>

View File

@@ -0,0 +1,63 @@
<template>
<div class="mod-baitutools__dlrenewalremind">
<el-form :inline="true" :model="state.dataForm" @keyup.enter="state.getDataList()">
<el-form-item>
<el-input v-model="state.dataForm.keywordName" placeholder="名称" clearable></el-input>
</el-form-item>
<el-form-item>
<el-button @click="state.getDataList()">查询</el-button>
</el-form-item>
<el-form-item>
<el-button v-if="state.hasPermission('baitutools:dlrenewalremind:save')" type="primary" @click="addOrUpdateHandle()">新增</el-button>
</el-form-item>
<el-form-item>
<el-button v-if="state.hasPermission('baitutools:dlrenewalremind:delete')" type="danger" @click="state.deleteHandle()">删除</el-button>
</el-form-item>
</el-form>
<el-table v-loading="state.dataListLoading" :data="state.dataList" border @selection-change="state.dataListSelectionChangeHandle" style="width: 100%">
<el-table-column type="selection" header-align="center" align="center" width="50"></el-table-column>
<el-table-column prop="provider" label=" 提供商" header-align="center" align="center"></el-table-column>
<el-table-column prop="name" label="名称" header-align="center" align="center"></el-table-column>
<el-table-column prop="remark" label="备注" header-align="center" align="center"></el-table-column>
<el-table-column prop="otherInfo" label="其他信息" header-align="center" align="center"></el-table-column>
<el-table-column prop="renewalType" label="续费方式" header-align="center" align="center"></el-table-column>
<el-table-column prop="renewalDate" label="具体续费日期" width="130" header-align="center" align="center"></el-table-column>
<el-table-column prop="expireTime" label="到期时间" header-align="center" align="center"></el-table-column>
<el-table-column label="操作" fixed="right" header-align="center" align="center" width="150">
<template v-slot="scope">
<el-button v-if="state.hasPermission('baitutools:dlrenewalremind:update')" type="primary" link @click="addOrUpdateHandle(scope.row.id)">修改</el-button>
<el-button v-if="state.hasPermission('baitutools:dlrenewalremind:delete')" type="primary" link @click="state.deleteHandle(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination :current-page="state.page" :page-sizes="[10, 20, 50, 100]" :page-size="state.limit" :total="state.total" layout="total, sizes, prev, pager, next, jumper" @size-change="state.pageSizeChangeHandle" @current-change="state.pageCurrentChangeHandle"> </el-pagination>
<!-- 弹窗, 新增 / 修改 -->
<add-or-update ref="addOrUpdateRef" @refreshDataList="state.getDataList">确定</add-or-update>
</div>
</template>
<script lang="ts" setup>
import useView from "@/hooks/useView";
import { reactive, ref, toRefs } from "vue";
import AddOrUpdate from "./renewalremind-add-or-update.vue";
const view = reactive({
deleteIsBatch: true,
getDataListURL: "/baitutools/dlrenewalremind/page",
getDataListIsPage: true,
exportURL: "/baitutools/dlrenewalremind/export",
deleteURL: "/baitutools/dlrenewalremind",
dataForm: {
keywordName: "",
}
});
const state = reactive({ ...useView(view), ...toRefs(view) });
const addOrUpdateRef = ref();
const addOrUpdateHandle = (id?: number) => {
addOrUpdateRef.value.init(id);
};
</script>