This commit is contained in:
fengchuanhn@gmail.com
2026-05-23 09:50:35 +08:00
parent 6b81e2a94a
commit a6335ba712
8 changed files with 224 additions and 94 deletions

12
.gitignore vendored
View File

@@ -25,10 +25,12 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
*.pyd
src-tauri/binaries/ src-tauri/binaries/
src-tauri/resources/nodejs/ src-tauri/resources/nodejs/
src-tauri/resources/resources-bundles/python-runtime/ src-tauri/resources/pythonruntime/
src-tauri/resources/resources-bundles/python-runtime/Lib/site-packages/ src-tauri/resources/pythonruntime/Lib/site-packages/
src-tauri/resources/resources-bundles/models/ src-tauri/resources/models/
src-tauri/resources/resources-bundles/python-runtimebackup/ src-tauri/resources/pythonruntimebackup/
src-tauri/resources/resources-bundles/eSpeak/ src-tauri/resources/eSpeak/

View File

@@ -16,7 +16,8 @@ const baseMenuItems = [
// { name: "design", label: "设计", to: "/design", icon: "design" }, // { name: "design", label: "设计", to: "/design", icon: "design" },
// { name: "model", label: "模型", to: "/model", icon: "model" }, // { name: "model", label: "模型", to: "/model", icon: "model" },
{ name: "local-config", label: "本地配置", to: "/local-config", icon: "settings" }, { name: "local-config", label: "本地配置", to: "/local-config", icon: "settings" },
{ name: "help", label: "帮助", to: "/help", icon: "help" }, { name: "card-activate", label: "卡密激活", to: "/card-activate", icon: "card-activate" },
// { name: "help", label: "帮助", to: "/help", icon: "help" },
]; ];
const menuItems = computed(() => { const menuItems = computed(() => {

View File

@@ -127,7 +127,17 @@ const mainChildren = [
meta: { title: "本地配置" }, meta: { title: "本地配置" },
}, },
{
path: "card-activate",
name: "card-activate",
component: () => import("../views/CardActivateView.vue"),
meta: { title: "卡密激活" },
},
{ {
path: "admin", path: "admin",

View File

View File

@@ -184,43 +184,40 @@ async function commitRenameAsr(item) {
</script> </script>
<template> <template>
<div <div class="voice-page flex h-full min-h-0 overflow-hidden text-slate-100">
class="voice-workbench flex h-full min-h-0 overflow-hidden bg-[#0a0a0a] font-sans text-white"
>
<!-- 移动端顶栏 --> <!-- 移动端顶栏 -->
<div <div
class="fixed left-0 right-0 top-0 z-50 flex h-14 items-center justify-between border-b border-white/10 bg-[#141414] px-4 md:hidden" class="fixed left-0 right-0 top-0 z-50 flex h-14 items-center justify-between border-b border-white/10 bg-bg-elevated px-4 md:hidden"
> >
<div <h1 class="gradient-text text-lg font-bold">声音</h1>
class="bg-gradient-to-r from-purple-400 to-pink-500 bg-clip-text text-lg font-bold text-transparent" <button
type="button"
class="text-slate-300 hover:text-slate-100"
@click="mobileSidebarOpen = !mobileSidebarOpen"
> >
SOUND:AI
</div>
<button type="button" class="text-white" @click="mobileSidebarOpen = !mobileSidebarOpen">
<i class="pi pi-bars text-xl" /> <i class="pi pi-bars text-xl" />
</button> </button>
</div> </div>
<!-- 左侧栏 --> <!-- 左侧栏 -->
<aside <aside
class="fixed inset-y-0 left-0 z-40 flex w-80 flex-col border-r border-white/10 bg-[#141414] transition-transform duration-300 md:relative md:translate-x-0" class="fixed inset-y-0 left-0 z-40 flex w-80 flex-col border-r border-white/10 bg-white/5 transition-transform duration-300 md:relative md:translate-x-0"
:class="mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full'" :class="mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full'"
> >
<div class="hidden h-16 shrink-0 items-center border-b border-white/10 px-6 md:flex"> <div class="hidden shrink-0 border-b border-white/10 px-6 py-4 md:block">
<span class="text-xl font-bold tracking-wider"> <h1 class="text-2xl font-bold text-slate-100">声音</h1>
SOUND<span class="text-purple-500">:AI</span> <p class="mt-1 text-sm text-slate-500">语音合成与语音识别</p>
</span>
</div> </div>
<div class="px-6 pb-2 pt-16 md:pt-6"> <div class="px-5 pb-2 pt-16 md:pt-4">
<div class="flex rounded-lg bg-[#1a1a1a] p-1"> <div class="flex rounded-lg border border-white/10 bg-white/5 p-1">
<button <button
type="button" type="button"
class="flex flex-1 items-center justify-center gap-2 rounded-md py-2 text-xs font-semibold transition-all" class="flex flex-1 items-center justify-center gap-2 rounded-md py-2 text-xs font-semibold transition-all"
:class=" :class="
sideTab === 'synthesis' sideTab === 'synthesis'
? 'bg-gradient-to-r from-purple-600 to-pink-600 text-white shadow-lg' ? 'bg-purple-500/20 text-slate-100 ring-1 ring-purple-500/40'
: 'text-gray-400 hover:text-white' : 'text-slate-500 hover:text-slate-200'
" "
@click="sideTab = 'synthesis'" @click="sideTab = 'synthesis'"
> >
@@ -232,8 +229,8 @@ async function commitRenameAsr(item) {
class="flex flex-1 items-center justify-center gap-2 rounded-md py-2 text-xs font-semibold transition-all" class="flex flex-1 items-center justify-center gap-2 rounded-md py-2 text-xs font-semibold transition-all"
:class=" :class="
sideTab === 'asr' sideTab === 'asr'
? 'bg-gradient-to-r from-blue-600 to-cyan-600 text-white shadow-lg' ? 'bg-blue-500/20 text-slate-100 ring-1 ring-blue-500/40'
: 'text-gray-400 hover:text-white' : 'text-slate-500 hover:text-slate-200'
" "
@click="sideTab = 'asr'" @click="sideTab = 'asr'"
> >
@@ -243,22 +240,23 @@ async function commitRenameAsr(item) {
</div> </div>
</div> </div>
<div class="px-6 pb-2"> <div class="px-5 pb-2">
<button <Button
type="button" label="音色管理"
class="flex w-full items-center justify-center gap-2 rounded-lg border border-purple-500/40 py-2.5 text-sm font-bold text-purple-300 transition-all hover:border-purple-400 hover:bg-purple-600/20 hover:text-white" icon="pi pi-sliders-h"
size="small"
severity="secondary"
outlined
class="w-full"
@click="voiceManageVisible = true" @click="voiceManageVisible = true"
> />
<i class="pi pi-sliders-h" />
音色管理
</button>
</div> </div>
<div class="custom-scrollbar flex-1 space-y-6 overflow-y-auto px-6 py-4"> <div class="custom-scrollbar flex-1 space-y-4 overflow-y-auto px-5 py-4">
<!-- 声音合成表单 --> <!-- 声音合成表单 -->
<div <div
v-if="sideTab === 'synthesis'" v-if="sideTab === 'synthesis'"
class="rounded-xl border border-[#333] bg-[#141414] p-4" class="rounded-xl border border-white/10 bg-white/5 p-4"
> >
<Textarea <Textarea
v-model="synthesisText" v-model="synthesisText"
@@ -268,7 +266,7 @@ async function commitRenameAsr(item) {
placeholder="输入语音内容开始合成" placeholder="输入语音内容开始合成"
/> />
<div class="mb-3 text-sm font-bold text-gray-200"> <div class="mb-3 text-sm font-medium text-slate-200">
<i class="pi pi-cog mr-1 text-slate-400" /> <i class="pi pi-cog mr-1 text-slate-400" />
语音合成模型 语音合成模型
</div> </div>
@@ -280,9 +278,9 @@ async function commitRenameAsr(item) {
size="small" size="small"
class="mb-2 w-full" class="mb-2 w-full"
/> />
<p v-if="ttsModelHint" class="mb-3 ml-1 text-xs text-gray-500">{{ ttsModelHint }}</p> <p v-if="ttsModelHint" class="mb-3 ml-1 text-xs text-slate-500">{{ ttsModelHint }}</p>
<div class="mb-3 flex items-center gap-2 text-sm text-gray-200"> <div class="mb-3 flex items-center gap-2 text-sm text-slate-200">
<i class="pi pi-user text-slate-400" /> <i class="pi pi-user text-slate-400" />
<Button <Button
:label="voicePage.selectedVoiceLabel" :label="voicePage.selectedVoiceLabel"
@@ -296,7 +294,7 @@ async function commitRenameAsr(item) {
<div class="mb-3 grid grid-cols-3 gap-2"> <div class="mb-3 grid grid-cols-3 gap-2">
<div> <div>
<label class="mb-1 block text-xs text-gray-500">情绪</label> <label class="mb-1 block text-xs text-slate-500">情绪</label>
<Select <Select
v-model="voiceEmotion" v-model="voiceEmotion"
:options="voicePage.voiceEmotions" :options="voicePage.voiceEmotions"
@@ -305,7 +303,7 @@ async function commitRenameAsr(item) {
/> />
</div> </div>
<div> <div>
<label class="mb-1 block text-xs text-gray-500">语言</label> <label class="mb-1 block text-xs text-slate-500">语言</label>
<Select <Select
v-model="voiceLanguage" v-model="voiceLanguage"
:options="voicePage.languages" :options="voicePage.languages"
@@ -314,7 +312,7 @@ async function commitRenameAsr(item) {
/> />
</div> </div>
<div> <div>
<label class="mb-1 block text-xs text-gray-500">语速</label> <label class="mb-1 block text-xs text-slate-500">语速</label>
<InputNumber <InputNumber
v-model="speechRate" v-model="speechRate"
:min="0.5" :min="0.5"
@@ -343,8 +341,8 @@ async function commitRenameAsr(item) {
</div> </div>
<!-- 语音识别表单 --> <!-- 语音识别表单 -->
<div v-else class="rounded-xl border border-[#333] bg-[#141414] p-4"> <div v-else class="rounded-xl border border-white/10 bg-white/5 p-4">
<div class="mb-3 text-sm font-bold text-gray-200"> <div class="mb-3 text-sm font-medium text-slate-200">
<i class="pi pi-file-audio mr-1 text-slate-400" /> <i class="pi pi-file-audio mr-1 text-slate-400" />
音频/视频文件 音频/视频文件
</div> </div>
@@ -373,18 +371,18 @@ async function commitRenameAsr(item) {
/> />
<!-- 右侧主区 --> <!-- 右侧主区 -->
<div class="relative z-0 flex min-w-0 flex-1 flex-col bg-[#0a0a0a] pt-14 md:pt-0"> <div class="relative z-0 flex min-w-0 flex-1 flex-col pt-14 md:pt-0">
<div <div
class="hidden h-16 shrink-0 items-center justify-between border-b border-white/10 px-8 md:flex" class="hidden shrink-0 items-center justify-between border-b border-white/10 px-6 py-3 md:flex"
> >
<div class="flex items-center gap-6"> <div class="flex items-center gap-4">
<button <button
type="button" type="button"
class="-mb-5 border-b-2 pb-5 text-sm font-medium transition-colors" class="border-b-2 pb-2 text-sm font-medium transition-colors"
:class=" :class="
workspaceTab === 'history' workspaceTab === 'history'
? 'border-purple-500 text-white' ? 'border-purple-500 text-slate-100'
: 'border-transparent text-gray-400 hover:text-white' : 'border-transparent text-slate-500 hover:text-slate-200'
" "
@click="workspaceTab = 'history'" @click="workspaceTab = 'history'"
> >
@@ -392,11 +390,11 @@ async function commitRenameAsr(item) {
</button> </button>
<button <button
type="button" type="button"
class="-mb-5 border-b-2 pb-5 text-sm font-medium transition-colors" class="border-b-2 pb-2 text-sm font-medium transition-colors"
:class=" :class="
workspaceTab === 'workspace' workspaceTab === 'workspace'
? 'border-purple-500 text-white' ? 'border-purple-500 text-slate-100'
: 'border-transparent text-gray-400 hover:text-white' : 'border-transparent text-slate-500 hover:text-slate-200'
" "
@click="workspaceTab = 'workspace'" @click="workspaceTab = 'workspace'"
> >
@@ -405,9 +403,9 @@ async function commitRenameAsr(item) {
</div> </div>
<Button <Button
icon="pi pi-trash" icon="pi pi-trash"
severity="secondary" severity="danger"
text text
class="text-gray-400 hover:text-red-400" size="small"
title="清空当前列表" title="清空当前列表"
@click="onClearHistory" @click="onClearHistory"
/> />
@@ -416,7 +414,11 @@ async function commitRenameAsr(item) {
<p <p
v-if="feedback.message" v-if="feedback.message"
class="mx-4 mt-2 text-sm md:mx-6" class="mx-4 mt-2 text-sm md:mx-6"
:class="feedback.severity === 'error' ? 'text-red-400' : 'text-emerald-400'" :class="{
'text-red-400': feedback.severity === 'error',
'text-amber-400': feedback.severity === 'warn',
'text-emerald-400': feedback.severity === 'success',
}"
> >
{{ feedback.message }} {{ feedback.message }}
</p> </p>
@@ -426,7 +428,7 @@ async function commitRenameAsr(item) {
<template v-if="sideTab === 'synthesis'"> <template v-if="sideTab === 'synthesis'">
<div <div
v-if="synthesisHistory.length" v-if="synthesisHistory.length"
class="mb-4 flex flex-wrap items-center gap-2 rounded-xl border border-[#333] bg-[#141414] p-4" class="mb-4 flex flex-wrap items-center gap-2 rounded-xl border border-white/10 bg-white/5 p-4"
> >
<Checkbox <Checkbox
:model-value="voicePage.synthesisAllChecked" :model-value="voicePage.synthesisAllChecked"
@@ -444,21 +446,21 @@ async function commitRenameAsr(item) {
:disabled="!voicePage.checkedSynthesisItems.length" :disabled="!voicePage.checkedSynthesisItems.length"
@click="voicePage.deleteCheckedSynthesis()" @click="voicePage.deleteCheckedSynthesis()"
/> />
<span class="ml-auto text-sm text-gray-500"> <span class="ml-auto text-sm text-slate-500">
{{ synthesisHistory.length }} {{ synthesisHistory.length }}
</span> </span>
</div> </div>
<p v-if="synthesisLoading" class="py-16 text-center text-sm text-gray-500"> <p v-if="synthesisLoading" class="py-16 text-center text-sm text-slate-500">
加载中... 加载中...
</p> </p>
<div <div
v-else-if="!synthesisHistory.length" v-else-if="!synthesisHistory.length"
class="flex min-h-[320px] flex-col items-center justify-center rounded-2xl border border-white/5 bg-[#111] p-12 text-center" class="flex min-h-[320px] flex-col items-center justify-center rounded-xl border border-white/10 bg-white/5 p-12 text-center"
> >
<i class="pi pi-volume-up mb-4 text-5xl text-gray-600" /> <i class="pi pi-volume-up mb-4 text-5xl text-slate-600" />
<h2 class="text-2xl font-bold text-gray-300">准备好创作了吗</h2> <h2 class="text-xl font-bold text-slate-300">准备好创作了吗</h2>
<p class="mt-2 max-w-sm text-gray-500"> <p class="mt-2 max-w-sm text-slate-500">
在左侧输入文本并选择声音开始您的创作之旅 在左侧输入文本并选择声音开始您的创作之旅
</p> </p>
</div> </div>
@@ -467,16 +469,16 @@ async function commitRenameAsr(item) {
<div <div
v-for="item in synthesisHistory" v-for="item in synthesisHistory"
:key="item.id" :key="item.id"
class="rounded-xl border border-[#333] bg-[#141414] p-4 transition-colors hover:border-[#555]" class="rounded-xl border border-white/10 bg-white/5 p-4 shadow-sm transition-colors hover:border-purple-500/40 hover:shadow-md"
> >
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<div <div
class="mr-2 inline-flex max-w-full items-start rounded-full border border-white/10 bg-white/5 px-2 py-1" class="mr-2 inline-flex max-w-full items-start rounded-full bg-blue-500/15 px-2 py-1"
> >
<Checkbox v-model="item._check" :binary="true" class="mr-2 mt-1" /> <Checkbox v-model="item._check" :binary="true" class="mr-2 mt-1" />
<div v-if="editingId !== item.id" class="min-w-0"> <div v-if="editingId !== item.id" class="min-w-0">
<span <span
class="block max-w-md cursor-pointer truncate text-sm" class="block max-w-md cursor-pointer truncate text-sm font-medium text-slate-100"
:title="item.name" :title="item.name"
@click="startRename(item)" @click="startRename(item)"
> >
@@ -495,7 +497,7 @@ async function commitRenameAsr(item) {
</div> </div>
<span <span
v-if="item.durationSec" v-if="item.durationSec"
class="rounded-full bg-white/10 px-2 py-1 text-xs" class="rounded-full bg-white/10 px-2 py-1 text-xs text-slate-400"
> >
{{ formatDuration(item.durationSec) }} {{ formatDuration(item.durationSec) }}
</span> </span>
@@ -504,7 +506,7 @@ async function commitRenameAsr(item) {
<div <div
v-if="item.sourceText" v-if="item.sourceText"
class="mt-3 rounded-lg bg-white/5 p-3 text-sm text-gray-300" class="mt-3 rounded-lg border border-white/10 bg-white/5 p-3 text-sm text-slate-300"
> >
<span>{{ truncateText(item.sourceText) }}</span> <span>{{ truncateText(item.sourceText) }}</span>
<div class="mt-2 flex gap-1"> <div class="mt-2 flex gap-1">
@@ -523,7 +525,7 @@ async function commitRenameAsr(item) {
</div> </div>
<div class="mt-4 flex items-center justify-between"> <div class="mt-4 flex items-center justify-between">
<span class="text-xs text-gray-500"> <span class="text-xs text-slate-500">
{{ formatRelativeTime(item.createdAt) }} {{ formatRelativeTime(item.createdAt) }}
</span> </span>
<div class="flex gap-1"> <div class="flex gap-1">
@@ -553,7 +555,7 @@ async function commitRenameAsr(item) {
<template v-else> <template v-else>
<div <div
v-if="asrHistory.length" v-if="asrHistory.length"
class="mb-4 flex flex-wrap items-center gap-2 rounded-xl border border-[#333] bg-[#141414] p-4" class="mb-4 flex flex-wrap items-center gap-2 rounded-xl border border-white/10 bg-white/5 p-4"
> >
<Checkbox <Checkbox
:model-value="voicePage.asrAllChecked" :model-value="voicePage.asrAllChecked"
@@ -571,17 +573,17 @@ async function commitRenameAsr(item) {
:disabled="!voicePage.checkedAsrItems.length" :disabled="!voicePage.checkedAsrItems.length"
@click="voicePage.deleteCheckedAsr()" @click="voicePage.deleteCheckedAsr()"
/> />
<span class="ml-auto text-sm text-gray-500"> {{ asrHistory.length }} </span> <span class="ml-auto text-sm text-slate-500"> {{ asrHistory.length }} </span>
</div> </div>
<p v-if="asrLoading" class="py-16 text-center text-sm text-gray-500">加载中...</p> <p v-if="asrLoading" class="py-16 text-center text-sm text-slate-500">加载中...</p>
<div <div
v-else-if="!asrHistory.length" v-else-if="!asrHistory.length"
class="flex min-h-[320px] flex-col items-center justify-center rounded-2xl border border-white/5 bg-[#111] p-12 text-center" class="flex min-h-[320px] flex-col items-center justify-center rounded-xl border border-white/10 bg-white/5 p-12 text-center"
> >
<i class="pi pi-microphone mb-4 text-5xl text-gray-600" /> <i class="pi pi-microphone mb-4 text-5xl text-slate-600" />
<h2 class="text-2xl font-bold text-gray-300">准备好识别了吗</h2> <h2 class="text-xl font-bold text-slate-300">准备好识别了吗</h2>
<p class="mt-2 max-w-sm text-gray-500"> <p class="mt-2 max-w-sm text-slate-500">
在左侧上传音频或视频文件开始您的识别之旅 在左侧上传音频或视频文件开始您的识别之旅
</p> </p>
</div> </div>
@@ -590,16 +592,16 @@ async function commitRenameAsr(item) {
<div <div
v-for="item in asrHistory" v-for="item in asrHistory"
:key="item.id" :key="item.id"
class="rounded-xl border border-[#333] bg-[#141414] p-4 transition-colors hover:border-[#555]" class="rounded-xl border border-white/10 bg-white/5 p-4 shadow-sm transition-colors hover:border-purple-500/40 hover:shadow-md"
> >
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<div <div
class="mr-2 inline-flex max-w-full items-start rounded-full border border-white/10 bg-white/5 px-2 py-1" class="mr-2 inline-flex max-w-full items-start rounded-full bg-blue-500/15 px-2 py-1"
> >
<Checkbox v-model="item._check" :binary="true" class="mr-2 mt-1" /> <Checkbox v-model="item._check" :binary="true" class="mr-2 mt-1" />
<div v-if="editingId !== item.id" class="min-w-0"> <div v-if="editingId !== item.id" class="min-w-0">
<span <span
class="block max-w-md cursor-pointer truncate text-sm" class="block max-w-md cursor-pointer truncate text-sm font-medium text-slate-100"
@click="startRename(item)" @click="startRename(item)"
> >
{{ item.name }} {{ item.name }}
@@ -616,13 +618,13 @@ async function commitRenameAsr(item) {
<div <div
v-if="item.resultText" v-if="item.resultText"
class="mt-3 rounded-lg bg-white/5 p-3 text-sm text-gray-300" class="mt-3 rounded-lg border border-white/10 bg-white/5 p-3 text-sm text-slate-300"
> >
{{ truncateText(item.resultText, 300) }} {{ truncateText(item.resultText, 300) }}
</div> </div>
<div class="mt-4 flex items-center justify-between"> <div class="mt-4 flex items-center justify-between">
<span class="text-xs text-gray-500"> <span class="text-xs text-slate-500">
{{ formatRelativeTime(item.createdAt) }} {{ formatRelativeTime(item.createdAt) }}
</span> </span>
<div class="flex gap-1"> <div class="flex gap-1">

View File

@@ -23,6 +23,11 @@ const ASSIGN_OPTIONS = [
{ label: "分配给代理", value: "agent" }, { label: "分配给代理", value: "agent" },
]; ];
const CARD_TYPE_OPTIONS = [
{ label: "时长", value: "时长" },
{ label: "点数", value: "点数" },
];
const cards = ref([]); const cards = ref([]);
const total = ref(0); const total = ref(0);
const page = ref(1); const page = ref(1);
@@ -45,7 +50,9 @@ const editingId = ref(null);
const emptyForm = () => ({ const emptyForm = () => ({
serial_number: "", serial_number: "",
card_type: "时长",
duration_days: 30, duration_days: 30,
points_amount: 100,
count: 1, count: 1,
remark: "", remark: "",
assign_type: "none", assign_type: "none",
@@ -59,6 +66,7 @@ const dialogTitle = computed(() =>
); );
const isActivated = computed(() => dialogMode.value === "edit" && !!editingActivatedAt.value); const isActivated = computed(() => dialogMode.value === "edit" && !!editingActivatedAt.value);
const isDurationType = computed(() => form.value.card_type === "时长");
const editingActivatedAt = ref(null); const editingActivatedAt = ref(null);
const ownerNameById = computed(() => { const ownerNameById = computed(() => {
@@ -74,6 +82,13 @@ const assignSelectOptions = computed(() => {
return []; return [];
}); });
function cardValueLabel(row) {
if (row.card_type === "点数") {
return row.points_amount != null ? `${row.points_amount}` : "—";
}
return `${row.duration_days}`;
}
function ownerLabel(userId) { function ownerLabel(userId) {
if (userId == null) return "—"; if (userId == null) return "—";
return ownerNameById.value.get(userId) ?? `#${userId}`; return ownerNameById.value.get(userId) ?? `#${userId}`;
@@ -103,9 +118,12 @@ async function loadFilterOptions() {
} }
} }
async function loadCards() { async function loadCards(options = {}) {
const { clearFeedback = true } = options;
loading.value = true; loading.value = true;
feedback.value = { severity: "", message: "" }; if (clearFeedback) {
feedback.value = { severity: "", message: "" };
}
const res = await listAdminCardKeysApi(auth.token, { const res = await listAdminCardKeysApi(auth.token, {
page: page.value, page: page.value,
pageSize: pageSize.value, pageSize: pageSize.value,
@@ -164,7 +182,9 @@ function openEditDialog(row) {
editingActivatedAt.value = row.activated_at; editingActivatedAt.value = row.activated_at;
form.value = { form.value = {
serial_number: row.serial_number, serial_number: row.serial_number,
card_type: row.card_type || "时长",
duration_days: row.duration_days, duration_days: row.duration_days,
points_amount: row.points_amount ?? 100,
count: 1, count: 1,
remark: row.remark || "", remark: row.remark || "",
assign_type: "none", assign_type: "none",
@@ -193,8 +213,13 @@ function buildAssignPayload() {
} }
async function saveCard() { async function saveCard() {
if (form.value.duration_days < 1) { if (isDurationType.value) {
showMessage("warn", "时长至少 1 天"); if (form.value.duration_days < 1) {
showMessage("warn", "时长至少 1 天");
return;
}
} else if (!form.value.points_amount || form.value.points_amount <= 0) {
showMessage("warn", "点数须大于 0");
return; return;
} }
@@ -209,11 +234,16 @@ async function saveCard() {
return; return;
} }
const payload = { const payload = {
duration_days: form.value.duration_days, card_type: form.value.card_type,
count: form.value.count, count: form.value.count,
remark: form.value.remark.trim() || null, remark: form.value.remark.trim() || null,
...buildAssignPayload(), ...buildAssignPayload(),
}; };
if (isDurationType.value) {
payload.duration_days = form.value.duration_days;
} else {
payload.points_amount = form.value.points_amount;
}
const serial = form.value.serial_number.trim(); const serial = form.value.serial_number.trim();
if (serial) { if (serial) {
if (form.value.count !== 1) { if (form.value.count !== 1) {
@@ -225,10 +255,18 @@ async function saveCard() {
} }
res = await createAdminCardKeysApi(auth.token, payload); res = await createAdminCardKeysApi(auth.token, payload);
} else { } else {
res = await updateAdminCardKeyApi(auth.token, editingId.value, { const payload = {
duration_days: isActivated.value ? undefined : form.value.duration_days,
remark: form.value.remark.trim() || null, remark: form.value.remark.trim() || null,
}); };
if (!isActivated.value) {
payload.card_type = form.value.card_type;
if (isDurationType.value) {
payload.duration_days = form.value.duration_days;
} else {
payload.points_amount = form.value.points_amount;
}
}
res = await updateAdminCardKeyApi(auth.token, editingId.value, payload);
} }
saving.value = false; saving.value = false;
@@ -238,9 +276,15 @@ async function saveCard() {
return; return;
} }
const wasCreate = dialogMode.value === "create";
const successMessage = res.message || (wasCreate ? "卡密已生成" : "保存成功");
dialogVisible.value = false; dialogVisible.value = false;
showMessage("success", res.message || "保存成功"); if (wasCreate) {
await loadCards(); page.value = 1;
}
await loadCards({ clearFeedback: false });
showMessage("success", successMessage);
} }
async function removeCard(row) { async function removeCard(row) {
@@ -369,7 +413,12 @@ onMounted(async () => {
</div> </div>
</template> </template>
</Column> </Column>
<Column field="duration_days" header="时长(天)" style="width: 6rem" /> <Column field="card_type" header="类型" style="width: 5rem" />
<Column header="面值" style="width: 7rem">
<template #body="{ data }">
{{ cardValueLabel(data) }}
</template>
</Column>
<Column header="OEM" style="width: 7rem"> <Column header="OEM" style="width: 7rem">
<template #body="{ data }"> <template #body="{ data }">
{{ ownerLabel(data.oem_id) }} {{ ownerLabel(data.oem_id) }}
@@ -440,9 +489,29 @@ onMounted(async () => {
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<template v-if="dialogMode === 'create'"> <template v-if="dialogMode === 'create'">
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label class="text-xs text-text-muted uppercase">类型</label>
<Select
v-model="form.card_type"
:options="CARD_TYPE_OPTIONS"
option-label="label"
option-value="value"
class="w-full"
/>
</div>
<div v-if="isDurationType" class="flex flex-col gap-2">
<label class="text-xs text-text-muted uppercase">时长</label> <label class="text-xs text-text-muted uppercase">时长</label>
<InputNumber v-model="form.duration_days" :min="1" :max="3650" class="w-full" /> <InputNumber v-model="form.duration_days" :min="1" :max="3650" class="w-full" />
</div> </div>
<div v-else class="flex flex-col gap-2">
<label class="text-xs text-text-muted uppercase">点数</label>
<InputNumber
v-model="form.points_amount"
:min="0.01"
:min-fraction-digits="0"
:max-fraction-digits="2"
class="w-full"
/>
</div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label class="text-xs text-text-muted uppercase">生成数量</label> <label class="text-xs text-text-muted uppercase">生成数量</label>
<InputNumber v-model="form.count" :min="1" :max="100" class="w-full" /> <InputNumber v-model="form.count" :min="1" :max="100" class="w-full" />
@@ -491,6 +560,17 @@ onMounted(async () => {
/> />
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label class="text-xs text-text-muted uppercase">类型</label>
<Select
v-model="form.card_type"
:options="CARD_TYPE_OPTIONS"
option-label="label"
option-value="value"
class="w-full"
:disabled="isActivated"
/>
</div>
<div v-if="isDurationType" class="flex flex-col gap-2">
<label class="text-xs text-text-muted uppercase">时长</label> <label class="text-xs text-text-muted uppercase">时长</label>
<InputNumber <InputNumber
v-model="form.duration_days" v-model="form.duration_days"
@@ -500,6 +580,17 @@ onMounted(async () => {
:disabled="isActivated" :disabled="isActivated"
/> />
</div> </div>
<div v-else class="flex flex-col gap-2">
<label class="text-xs text-text-muted uppercase">点数</label>
<InputNumber
v-model="form.points_amount"
:min="0.01"
:min-fraction-digits="0"
:max-fraction-digits="2"
class="w-full"
:disabled="isActivated"
/>
</div>
</template> </template>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">

View File

@@ -33,6 +33,13 @@ function formatDateTime(value) {
return d.toLocaleString("zh-CN", { hour12: false }); return d.toLocaleString("zh-CN", { hour12: false });
} }
function cardValueLabel(row) {
if (row.card_type === "点数") {
return row.points_amount != null ? `${row.points_amount}` : "—";
}
return `${row.duration_days}`;
}
function showMessage(severity, message) { function showMessage(severity, message) {
feedback.value = { severity, message }; feedback.value = { severity, message };
} }
@@ -189,7 +196,12 @@ onMounted(loadCards);
</div> </div>
</template> </template>
</Column> </Column>
<Column field="duration_days" header="时长(天)" style="width: 6rem" /> <Column field="card_type" header="类型" style="width: 5rem" />
<Column header="面值" style="width: 7rem">
<template #body="{ data }">
{{ cardValueLabel(data) }}
</template>
</Column>
<Column field="created_at" header="创建时间"> <Column field="created_at" header="创建时间">
<template #body="{ data }"> <template #body="{ data }">
{{ formatDateTime(data.created_at) }} {{ formatDateTime(data.created_at) }}

View File

@@ -64,6 +64,13 @@ function formatDateTime(value) {
return d.toLocaleString("zh-CN", { hour12: false }); return d.toLocaleString("zh-CN", { hour12: false });
} }
function cardValueLabel(row) {
if (row.card_type === "点数") {
return row.points_amount != null ? `${row.points_amount}` : "—";
}
return `${row.duration_days}`;
}
function isRowSelectable(row) { function isRowSelectable(row) {
return !row.activated_at; return !row.activated_at;
} }
@@ -295,7 +302,12 @@ onMounted(async () => {
</div> </div>
</template> </template>
</Column> </Column>
<Column field="duration_days" header="时长(天)" style="width: 6rem" /> <Column field="card_type" header="类型" style="width: 5rem" />
<Column header="面值" style="width: 7rem">
<template #body="{ data }">
{{ cardValueLabel(data) }}
</template>
</Column>
<Column header="代理" style="width: 7rem"> <Column header="代理" style="width: 7rem">
<template #body="{ data }"> <template #body="{ data }">
{{ ownerLabel(data.agent_id) }} {{ ownerLabel(data.agent_id) }}