优化
This commit is contained in:
15
frontend/app/web-gold/src/api/redeemCode.js
Normal file
15
frontend/app/web-gold/src/api/redeemCode.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import http from './http'
|
||||||
|
import { API_BASE } from '@gold/config/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 兑换码 API
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 兑换码兑换
|
||||||
|
* @param {string} code - 兑换码
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
export function redeemCode(code) {
|
||||||
|
return http.post(`${API_BASE.APP_TIK}/redeem-code/redeem`, { code })
|
||||||
|
}
|
||||||
@@ -15,16 +15,13 @@ const shouldShowUser = computed(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header
|
<header
|
||||||
class="fixed top-0 left-0 right-0
|
class="fixed top-0 left-0 right-0 flex items-center px-6 h-[70px] bg-background/95 backdrop-blur-sm border-b border-border z-50"
|
||||||
flex items-center px-[30px]
|
|
||||||
bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-800 shadow-sm
|
|
||||||
text-slate-900 dark:text-slate-50"
|
|
||||||
:style="{ height: 'var(--header-height)', zIndex: 'var(--z-header)' }"
|
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-md flex-1">
|
<div class="flex items-center gap-4 flex-1">
|
||||||
<BrandLogo :size="40" />
|
<BrandLogo :size="36" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-md pr-1">
|
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
<UserDropdown v-if="shouldShowUser" />
|
<UserDropdown v-if="shouldShowUser" />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<Dialog v-model:open="visible" @update:open="$emit('update:open', $event)">
|
<Dialog v-model:open="visible" @update:open="$emit('update:open', $event)">
|
||||||
<DialogContent class="sm:max-w-[900px] p-0">
|
<DialogContent class="sm:max-w-[900px] p-6">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{{ modalTitle }}</DialogTitle>
|
<DialogTitle>{{ modalTitle }}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -324,13 +324,14 @@ watch(() => props.open, (isOpen) => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 搜索栏样式 */
|
/* 搜索栏样式 */
|
||||||
.search-bar {
|
.search-bar {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
background: var(--color-gray-50);
|
background: var(--muted);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-icon {
|
.search-icon {
|
||||||
@@ -338,7 +339,7 @@ watch(() => props.open, (isOpen) => {
|
|||||||
left: 12px;
|
left: 12px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
color: var(--color-gray-400);
|
color: var(--muted-foreground);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,20 +362,20 @@ watch(() => props.open, (isOpen) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.video-grid::-webkit-scrollbar-track {
|
.video-grid::-webkit-scrollbar-track {
|
||||||
background: var(--color-gray-100);
|
background: var(--muted);
|
||||||
border-radius: 3px;
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-grid::-webkit-scrollbar-thumb {
|
.video-grid::-webkit-scrollbar-thumb {
|
||||||
background: var(--color-gray-300);
|
background: var(--border);
|
||||||
border-radius: 3px;
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 视频卡片样式 */
|
/* 视频卡片样式 */
|
||||||
.video-card {
|
.video-card {
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius-xl);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all var(--duration-fast);
|
transition: all var(--duration-fast);
|
||||||
@@ -399,7 +400,7 @@ watch(() => props.open, (isOpen) => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 112px;
|
height: 112px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--color-gray-100);
|
background: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-thumbnail img {
|
.video-thumbnail img {
|
||||||
@@ -493,7 +494,7 @@ watch(() => props.open, (isOpen) => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 60px 20px;
|
padding: 60px 20px;
|
||||||
color: var(--color-gray-500);
|
color: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-illustration {
|
.empty-illustration {
|
||||||
@@ -504,7 +505,7 @@ watch(() => props.open, (isOpen) => {
|
|||||||
width: 80px;
|
width: 80px;
|
||||||
height: 80px;
|
height: 80px;
|
||||||
margin: 0 auto 16px;
|
margin: 0 auto 16px;
|
||||||
background: var(--color-gray-100);
|
background: var(--muted);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -518,13 +519,13 @@ watch(() => props.open, (isOpen) => {
|
|||||||
.empty-title {
|
.empty-title {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-gray-900);
|
color: var(--foreground);
|
||||||
margin: 0 0 8px 0;
|
margin: 0 0 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-description {
|
.empty-description {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--color-gray-500);
|
color: var(--muted-foreground);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,14 +554,14 @@ watch(() => props.open, (isOpen) => {
|
|||||||
.video-card-skeleton {
|
.video-card-skeleton {
|
||||||
background: white;
|
background: white;
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
border-radius: 8px;
|
border-radius: var(--radius-lg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeleton-thumbnail {
|
.skeleton-thumbnail {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 112px;
|
height: 112px;
|
||||||
background: linear-gradient(90deg, var(--color-gray-100) 25%, var(--color-gray-200) 50%, var(--color-gray-100) 75%);
|
background: linear-gradient(90deg, var(--muted) 25%, var(--border) 50%, var(--muted) 75%);
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
animation: shimmer 1.5s infinite;
|
animation: shimmer 1.5s infinite;
|
||||||
}
|
}
|
||||||
@@ -572,20 +573,20 @@ watch(() => props.open, (isOpen) => {
|
|||||||
.skeleton-title {
|
.skeleton-title {
|
||||||
height: 16px;
|
height: 16px;
|
||||||
width: 70%;
|
width: 70%;
|
||||||
background: linear-gradient(90deg, var(--color-gray-100) 25%, var(--color-gray-200) 50%, var(--color-gray-100) 75%);
|
background: linear-gradient(90deg, var(--muted) 25%, var(--border) 50%, var(--muted) 75%);
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
animation: shimmer 1.5s infinite;
|
animation: shimmer 1.5s infinite;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeleton-meta {
|
.skeleton-meta {
|
||||||
height: 12px;
|
height: 12px;
|
||||||
width: 40%;
|
width: 40%;
|
||||||
background: linear-gradient(90deg, var(--color-gray-100) 25%, var(--color-gray-200) 50%, var(--color-gray-100) 75%);
|
background: linear-gradient(90deg, var(--muted) 25%, var(--border) 50%, var(--muted) 75%);
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
animation: shimmer 1.5s infinite;
|
animation: shimmer 1.5s infinite;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes shimmer {
|
@keyframes shimmer {
|
||||||
|
|||||||
@@ -385,8 +385,8 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.synthesize-btn {
|
.synthesize-btn {
|
||||||
height: 34px;
|
height: 36px;
|
||||||
padding: 0 var(--space-3-5);
|
padding: var(--space-2) var(--space-4);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
border: none;
|
border: none;
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
@@ -413,7 +413,7 @@ onBeforeUnmount(() => {
|
|||||||
.voice-grid {
|
.voice-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
gap: 12px;
|
gap: 16px;
|
||||||
|
|
||||||
&.has-many {
|
&.has-many {
|
||||||
max-height: 280px;
|
max-height: 280px;
|
||||||
@@ -588,7 +588,7 @@ onBeforeUnmount(() => {
|
|||||||
.download-btn {
|
.download-btn {
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
font-size: var(--font-size-xs);
|
font-size: var(--font-size-xs);
|
||||||
padding: var(--space-1) var(--space-2-5);
|
padding: var(--space-2) var(--space-3);
|
||||||
height: auto;
|
height: auto;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
transition: all var(--duration-fast);
|
transition: all var(--duration-fast);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
|||||||
|
<script setup>
|
||||||
|
import { Icon } from '@iconify/vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex-1 flex flex-col items-center justify-center p-8 text-center">
|
||||||
|
<div class="relative w-24 h-24 mb-6">
|
||||||
|
<div class="absolute inset-0 rounded-full border-2 border-primary/20 animate-pulse"></div>
|
||||||
|
<div class="absolute inset-1 rounded-full border-2 border-primary/15 animate-pulse" style="animation-delay: 0.5s"></div>
|
||||||
|
<div class="absolute inset-2 rounded-full border-2 border-primary/10 animate-pulse" style="animation-delay: 1s"></div>
|
||||||
|
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-12 h-12 rounded-full bg-gradient-to-br from-primary to-violet-500 flex items-center justify-center shadow-lg shadow-primary/30">
|
||||||
|
<Icon icon="lucide:bot" class="text-white text-xl" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold text-foreground mb-2">准备好为你生成内容</h3>
|
||||||
|
<p class="text-sm text-muted-foreground mb-6 max-w-[280px]">
|
||||||
|
在下方输入框描述你的需求,AI 将立即开始创作
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Icon icon="lucide:zap" class="text-primary" />
|
||||||
|
<span>深度模式支持复杂任务</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Icon icon="lucide:pencil" class="text-primary" />
|
||||||
|
<span>可随时重新生成调整</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<script setup>
|
||||||
|
import { Icon } from '@iconify/vue'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import PointsTag from '@/components/PointsTag.vue'
|
||||||
|
|
||||||
|
const modelMode = defineModel('modelMode', { default: 'pro' })
|
||||||
|
const inputText = defineModel('inputText', { default: '' })
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
isGenerating: { type: Boolean, default: false }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['generate'])
|
||||||
|
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
emit('generate')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGenerate = () => emit('generate')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="shrink-0 px-5 py-4 border-t border-border bg-background/95 backdrop-blur-sm">
|
||||||
|
<!-- Model Selector -->
|
||||||
|
<Tabs v-model="modelMode" class="mb-3">
|
||||||
|
<TabsList class="w-full">
|
||||||
|
<TabsTrigger value="standard" class="flex-1 gap-1.5">
|
||||||
|
标准
|
||||||
|
<PointsTag :points="10" size="small" />
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="pro" class="flex-1 gap-1.5">
|
||||||
|
<Icon icon="lucide:zap" class="size-4" />
|
||||||
|
深度
|
||||||
|
<PointsTag :points="50" size="small" />
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<!-- Input Box -->
|
||||||
|
<div class="relative">
|
||||||
|
<Textarea
|
||||||
|
v-model="inputText"
|
||||||
|
placeholder="输入你的需求..."
|
||||||
|
class="pr-12 min-h-[52px] max-h-[120px] resize-none"
|
||||||
|
@keydown="handleKeyDown"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
class="absolute right-2 bottom-2 size-9"
|
||||||
|
:disabled="!inputText.trim() || isGenerating"
|
||||||
|
@click="handleGenerate"
|
||||||
|
>
|
||||||
|
<Icon v-if="isGenerating" icon="lucide:loader-2" class="size-4 animate-spin" />
|
||||||
|
<Icon v-else icon="lucide:send" class="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-center text-xs text-muted-foreground mt-2 mb-0">
|
||||||
|
按 Enter 发送,Shift + Enter 换行
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup>
|
||||||
|
import { Icon } from '@iconify/vue'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
agent: { type: Object, default: null }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['history'])
|
||||||
|
|
||||||
|
const openHistory = () => emit('history')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center justify-between px-5 py-4 border-b border-border shrink-0">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 rounded-lg bg-gradient-to-br from-primary to-violet-500 flex items-center justify-center overflow-hidden">
|
||||||
|
<img
|
||||||
|
v-if="agent?.avatar"
|
||||||
|
:src="agent.avatar"
|
||||||
|
:alt="agent.name"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
<Icon v-else icon="lucide:bot" class="text-white text-xl" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-semibold text-foreground">{{ agent?.name || 'AI 助手' }}</div>
|
||||||
|
<div class="text-sm text-muted-foreground">{{ agent?.categoryName || '通用' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center justify-center rounded-md text-sm font-medium h-9 w-9 hover:bg-accent hover:text-accent-foreground"
|
||||||
|
@click="openHistory"
|
||||||
|
title="历史记录"
|
||||||
|
>
|
||||||
|
<Icon icon="lucide:history" class="size-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<script setup>
|
||||||
|
import { Icon } from '@iconify/vue'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
currentInput: { type: String, default: '' },
|
||||||
|
generatedContent: { type: String, default: '' },
|
||||||
|
isGenerating: { type: Boolean, default: false }
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['reset', 'copy', 'regenerate'])
|
||||||
|
|
||||||
|
const handleReset = () => emit('reset')
|
||||||
|
const handleCopy = () => emit('copy', props.generatedContent)
|
||||||
|
const handleRegenerate = () => emit('regenerate')
|
||||||
|
|
||||||
|
const scrollAreaRef = defineExpose({ scrollAreaRef })
|
||||||
|
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
nextTick(() => {
|
||||||
|
if (scrollAreaRef.value) {
|
||||||
|
const viewport = scrollAreaRef.value.$el?.querySelector('[data-reka-scroll-area-viewport]')
|
||||||
|
if (viewport) {
|
||||||
|
viewport.scrollTop = viewport.scrollHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ scrollToBottom })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex-1 flex flex-col min-h-0 p-5 gap-4">
|
||||||
|
<!-- User Prompt Display -->
|
||||||
|
<div class="flex items-start gap-2 p-3 bg-muted rounded-lg">
|
||||||
|
<p class="flex-1 text-sm text-foreground leading-relaxed m-0">{{ currentInput }}</p>
|
||||||
|
<Button
|
||||||
|
v-if="generatedContent && !isGenerating"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="size-7 shrink-0"
|
||||||
|
@click="handleReset"
|
||||||
|
>
|
||||||
|
<Icon icon="lucide:pencil" class="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Result Content -->
|
||||||
|
<ScrollArea ref="scrollAreaRef" class="flex-1 min-h-0">
|
||||||
|
<div class="p-4 bg-card border border-border rounded-lg">
|
||||||
|
<!-- Generating Skeleton -->
|
||||||
|
<div v-if="isGenerating && !generatedContent" class="space-y-3">
|
||||||
|
<div class="h-4 bg-muted rounded animate-pulse"></div>
|
||||||
|
<div class="h-4 bg-muted rounded animate-pulse w-3/4"></div>
|
||||||
|
<div class="h-4 bg-muted rounded animate-pulse w-1/2"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actual Content -->
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="text-sm text-foreground leading-relaxed whitespace-pre-wrap"
|
||||||
|
:class="{ 'opacity-90': isGenerating }"
|
||||||
|
>
|
||||||
|
{{ generatedContent }}
|
||||||
|
<span v-if="isGenerating" class="inline-block w-0.5 h-4 bg-primary ml-0.5 animate-pulse"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<!-- Action Bar -->
|
||||||
|
<div v-if="generatedContent && !isGenerating" class="flex gap-3">
|
||||||
|
<Button class="flex-1" @click="handleCopy">
|
||||||
|
<Icon icon="lucide:copy" class="mr-2" />
|
||||||
|
复制
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" class="flex-1" @click="handleRegenerate">
|
||||||
|
<Icon icon="lucide:refresh-cw" class="mr-2" />
|
||||||
|
重新生成
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -66,6 +66,28 @@ function handleReset() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 排序方式 -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label class="text-sm font-medium text-gray-700">排序方式</Label>
|
||||||
|
<RadioGroup v-model="form.sort_type" class="flex gap-2">
|
||||||
|
<div class="inline-flex items-center justify-center px-4 h-9 text-sm font-medium rounded-lg cursor-pointer transition-all"
|
||||||
|
:class="form.sort_type === 0 ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:bg-muted/80'">
|
||||||
|
<RadioGroupItem :value="0" id="sort-default" class="hidden" />
|
||||||
|
<label for="sort-default" class="cursor-pointer">综合排序</label>
|
||||||
|
</div>
|
||||||
|
<div class="inline-flex items-center justify-center px-4 h-9 text-sm font-medium rounded-lg cursor-pointer transition-all"
|
||||||
|
:class="form.sort_type === 1 ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:bg-muted/80'">
|
||||||
|
<RadioGroupItem :value="1" id="sort-likes" class="hidden" />
|
||||||
|
<label for="sort-likes" class="cursor-pointer">最多点赞</label>
|
||||||
|
</div>
|
||||||
|
<div class="inline-flex items-center justify-center px-4 h-9 text-sm font-medium rounded-lg cursor-pointer transition-all"
|
||||||
|
:class="form.sort_type === 2 ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:bg-muted/80'">
|
||||||
|
<RadioGroupItem :value="2" id="sort-latest" class="hidden" />
|
||||||
|
<label for="sort-latest" class="cursor-pointer">最新发布</label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 分析数量 -->
|
<!-- 分析数量 -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label class="text-sm font-medium text-gray-700">分析数量</Label>
|
<Label class="text-sm font-medium text-gray-700">分析数量</Label>
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ function handleSort(key) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 选择切换
|
// 选择切换
|
||||||
function handleSelectAll() {
|
function handleSelectAll(checked) {
|
||||||
if (isAllSelected.value) {
|
if (isAllSelected.value) {
|
||||||
emit('update:selectedRowKeys', [])
|
emit('update:selectedRowKeys', [])
|
||||||
} else {
|
} else {
|
||||||
@@ -129,12 +129,15 @@ function formatNumber(value) {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead class="w-[40px]">
|
<TableHead class="w-[60px]">
|
||||||
<Checkbox
|
<div class="flex items-center gap-2">
|
||||||
:checked="isAllSelected"
|
<Checkbox
|
||||||
@update:checked="handleSelectAll"
|
:checked="isAllSelected"
|
||||||
class="scale-85"
|
@update:checked="handleSelectAll"
|
||||||
/>
|
class="scale-110"
|
||||||
|
/>
|
||||||
|
<span class="text-xs text-muted-foreground">全选</span>
|
||||||
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead
|
<TableHead
|
||||||
v-for="col in columns"
|
v-for="col in columns"
|
||||||
@@ -171,11 +174,11 @@ function formatNumber(value) {
|
|||||||
:key="record.id"
|
:key="record.id"
|
||||||
class="hover:bg-gray-50 transition-colors"
|
class="hover:bg-gray-50 transition-colors"
|
||||||
>
|
>
|
||||||
<TableCell class="w-[40px]">
|
<TableCell class="w-[60px]">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
:checked="selectedRowKeys.includes(String(record.id))"
|
:checked="selectedRowKeys.includes(String(record.id))"
|
||||||
@update:checked="handleSelect(record.id)"
|
@update:checked="handleSelect(record.id)"
|
||||||
class="scale-85"
|
class="scale-110"
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
|||||||
@@ -374,7 +374,6 @@ onMounted(() => loadVoiceList())
|
|||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<div class="toolbar-left">
|
<div class="toolbar-left">
|
||||||
<Button @click="handleCreate">
|
<Button @click="handleCreate">
|
||||||
<Icon icon="lucide:plus" class="mr-1.5 size-4" />
|
|
||||||
新建配音
|
新建配音
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -358,10 +358,10 @@ onMounted(async () => {
|
|||||||
@border-light: rgba(59, 130, 246, 0.1);
|
@border-light: rgba(59, 130, 246, 0.1);
|
||||||
@border-medium: rgba(59, 130, 246, 0.2);
|
@border-medium: rgba(59, 130, 246, 0.2);
|
||||||
|
|
||||||
// 蓝紫渐变主题色 (与 VoiceSelector 一致)
|
// 主色 - 使用设计系统变量
|
||||||
@accent-blue: #3b82f6;
|
@accent-blue: #3b82f6;
|
||||||
@accent-purple: #8b5cf6;
|
@accent-purple: #8b5cf6;
|
||||||
@accent-gradient: linear-gradient(135deg, @accent-blue 0%, @accent-purple 100%);
|
@accent-gradient: var(--primary);
|
||||||
@accent-green: #10b981;
|
@accent-green: #10b981;
|
||||||
@accent-red: #ef4444;
|
@accent-red: #ef4444;
|
||||||
@accent-orange: #f59e0b;
|
@accent-orange: #f59e0b;
|
||||||
@@ -914,7 +914,7 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.disabled {
|
&.disabled {
|
||||||
background: linear-gradient(135deg, #cbd5e1 0%, #94a3b8 100%);
|
background: var(--muted);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
box-shadow: 0 2px 8px rgba(148, 163, 184, 0.25);
|
box-shadow: 0 2px 8px rgba(148, 163, 184, 0.25);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,20 +19,20 @@
|
|||||||
<div
|
<div
|
||||||
v-for="group in groupList"
|
v-for="group in groupList"
|
||||||
:key="group.id"
|
:key="group.id"
|
||||||
class="group flex items-center justify-between px-3 py-2 cursor-pointer rounded-lg mb-0.5 transition-all duration-150"
|
class="group flex items-center justify-between px-4 py-2.5 cursor-pointer rounded-full mb-1 transition-all duration-200"
|
||||||
:class="selectedGroupId === group.id
|
:class="selectedGroupId === group.id
|
||||||
? 'bg-primary/10 text-primary'
|
? 'bg-primary/10 text-primary shadow-sm'
|
||||||
: 'hover:bg-muted'"
|
: 'hover:bg-muted'"
|
||||||
@click="handleSelectGroup(group)"
|
@click="handleSelectGroup(group)"
|
||||||
>
|
>
|
||||||
<div class="flex items-center min-w-0 flex-1">
|
<div class="flex items-center min-w-0 flex-1">
|
||||||
<Icon
|
<Icon
|
||||||
icon="lucide:folder"
|
icon="lucide:folder"
|
||||||
class="mr-2 text-base shrink-0 transition-colors"
|
class="mr-2.5 text-base shrink-0"
|
||||||
:class="selectedGroupId === group.id ? 'text-primary' : 'text-muted-foreground'"
|
:class="selectedGroupId === group.id ? 'text-primary' : 'text-muted-foreground'"
|
||||||
/>
|
/>
|
||||||
<template v-if="editingGroupId !== group.id">
|
<template v-if="editingGroupId !== group.id">
|
||||||
<span class="text-sm font-medium truncate">{{ group.name }}</span>
|
<span class="text-sm font-medium truncate" :class="selectedGroupId === group.id ? 'text-primary' : 'text-foreground'">{{ group.name }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<Input
|
<Input
|
||||||
@@ -46,17 +46,19 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-0.5">
|
<div class="flex items-center gap-1">
|
||||||
<template v-if="editingGroupId !== group.id">
|
<template v-if="editingGroupId !== group.id">
|
||||||
<span class="text-xs text-muted-foreground mr-2">{{ group.fileCount }}</span>
|
<span class="text-xs mr-1" :class="selectedGroupId === group.id ? 'text-primary/80' : 'text-muted-foreground'">{{ group.fileCount }}</span>
|
||||||
<Icon
|
<Icon
|
||||||
icon="lucide:pencil"
|
icon="lucide:pencil"
|
||||||
class="opacity-0 group-hover:opacity-100 p-1 cursor-pointer text-muted-foreground hover:text-primary text-xs transition-all"
|
class="opacity-0 group-hover:opacity-100 p-1 cursor-pointer text-xs transition-all"
|
||||||
|
:class="selectedGroupId === group.id ? 'text-primary/70 hover:text-primary' : 'text-muted-foreground hover:text-primary'"
|
||||||
@click.stop="handleEditGroup(group, $event)"
|
@click.stop="handleEditGroup(group, $event)"
|
||||||
/>
|
/>
|
||||||
<Icon
|
<Icon
|
||||||
icon="lucide:trash-2"
|
icon="lucide:trash-2"
|
||||||
class="opacity-0 group-hover:opacity-100 p-1 cursor-pointer text-muted-foreground hover:text-destructive text-xs transition-all"
|
class="opacity-0 group-hover:opacity-100 p-1 cursor-pointer text-xs transition-all"
|
||||||
|
:class="selectedGroupId === group.id ? 'text-primary/70 hover:text-destructive' : 'text-muted-foreground hover:text-destructive'"
|
||||||
@click.stop="handleDeleteGroup(group, $event)"
|
@click.stop="handleDeleteGroup(group, $event)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -72,25 +74,25 @@
|
|||||||
<!-- 顶部工具栏 -->
|
<!-- 顶部工具栏 -->
|
||||||
<div class="flex items-center justify-between px-6 py-4 bg-card border-b border-border gap-6">
|
<div class="flex items-center justify-between px-6 py-4 bg-card border-b border-border gap-6">
|
||||||
<!-- 分类切换器 - 胶囊式设计 -->
|
<!-- 分类切换器 - 胶囊式设计 -->
|
||||||
<div class="flex bg-muted rounded-lg p-1 gap-1">
|
<div class="flex bg-muted/50 rounded-full p-1 gap-1">
|
||||||
<button
|
<button
|
||||||
class="flex items-center gap-2 px-5 py-2 rounded-lg cursor-pointer text-sm font-medium transition-all duration-200"
|
class="flex items-center gap-2 px-6 py-2 rounded-full cursor-pointer text-sm font-medium transition-all duration-200"
|
||||||
:class="activeCategory === 'MIX'
|
:class="activeCategory === 'MIX'
|
||||||
? 'bg-card text-primary shadow-sm'
|
? 'bg-primary text-primary-foreground shadow-sm'
|
||||||
: 'text-muted-foreground hover:text-foreground'"
|
: 'text-muted-foreground hover:text-foreground hover:bg-muted'"
|
||||||
@click="handleCategoryChange('MIX')"
|
@click="handleCategoryChange('MIX')"
|
||||||
>
|
>
|
||||||
<Icon icon="lucide:video" class="text-base" :class="activeCategory === 'MIX' ? 'text-primary' : 'text-muted-foreground'" />
|
<Icon icon="lucide:video" class="text-base" />
|
||||||
<span>混剪素材</span>
|
<span>混剪素材</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="flex items-center gap-2 px-5 py-2 rounded-lg cursor-pointer text-sm font-medium transition-all duration-200"
|
class="flex items-center gap-2 px-6 py-2 rounded-full cursor-pointer text-sm font-medium transition-all duration-200"
|
||||||
:class="activeCategory === 'DIGITAL_HUMAN'
|
:class="activeCategory === 'DIGITAL_HUMAN'
|
||||||
? 'bg-card text-primary shadow-sm'
|
? 'bg-primary text-primary-foreground shadow-sm'
|
||||||
: 'text-muted-foreground hover:text-foreground'"
|
: 'text-muted-foreground hover:text-foreground hover:bg-muted'"
|
||||||
@click="handleCategoryChange('DIGITAL_HUMAN')"
|
@click="handleCategoryChange('DIGITAL_HUMAN')"
|
||||||
>
|
>
|
||||||
<Icon icon="lucide:user" class="text-base" :class="activeCategory === 'DIGITAL_HUMAN' ? 'text-primary' : 'text-muted-foreground'" />
|
<Icon icon="lucide:user" class="text-base" />
|
||||||
<span>数字人素材</span>
|
<span>数字人素材</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -165,7 +167,7 @@
|
|||||||
v-for="file in fileList"
|
v-for="file in fileList"
|
||||||
:key="file.id"
|
:key="file.id"
|
||||||
:data-file-id="file.id"
|
:data-file-id="file.id"
|
||||||
class="group relative bg-card border border-border rounded-xl overflow-hidden cursor-pointer transition-all duration-300 hover:border-primary hover:shadow-md hover:-translate-y-0.5"
|
class="group relative bg-card border border-border rounded-2xl overflow-hidden cursor-pointer transition-all duration-300 hover:border-primary hover:shadow-lg hover:-translate-y-1"
|
||||||
:class="{ 'border-primary shadow-[0_0_0_2px_rgba(59,130,246,0.2),var(--shadow-md)]': selectedFileIds.includes(file.id) }"
|
:class="{ 'border-primary shadow-[0_0_0_2px_rgba(59,130,246,0.2),var(--shadow-md)]': selectedFileIds.includes(file.id) }"
|
||||||
@click="handleFileClick(file)"
|
@click="handleFileClick(file)"
|
||||||
>
|
>
|
||||||
@@ -256,7 +258,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 底部操作栏 -->
|
<!-- 底部操作栏 -->
|
||||||
<div class="flex items-center justify-between px-6 py-3 bg-card border-t border-border">
|
<div class="flex items-center justify-between px-6 py-3 border-t border-border">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
:checked="selectedFileIds.length === fileList.length && fileList.length > 0"
|
:checked="selectedFileIds.length === fileList.length && fileList.length > 0"
|
||||||
@@ -828,9 +830,9 @@ onMounted(async () => {
|
|||||||
.material-list-container {
|
.material-list-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: var(--color-gray-50);
|
background: var(--background);
|
||||||
padding: var(--space-5);
|
padding: var(--space-4);
|
||||||
gap: var(--space-5);
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|||||||
@@ -388,7 +388,7 @@ onMounted(() => {
|
|||||||
<div class="filter-item">
|
<div class="filter-item">
|
||||||
<span class="filter-label">排序</span>
|
<span class="filter-label">排序</span>
|
||||||
<Select v-model="searchParams.sort_type">
|
<Select v-model="searchParams.sort_type">
|
||||||
<SelectTrigger class="h-[26px] w-[88px]">
|
<SelectTrigger class="h-[26px] w-[160px]">
|
||||||
<SelectValue placeholder="综合" />
|
<SelectValue placeholder="综合" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -401,7 +401,7 @@ onMounted(() => {
|
|||||||
<div class="filter-item">
|
<div class="filter-item">
|
||||||
<span class="filter-label">时间</span>
|
<span class="filter-label">时间</span>
|
||||||
<Select v-model="searchParams.publish_time">
|
<Select v-model="searchParams.publish_time">
|
||||||
<SelectTrigger class="h-[26px] w-[88px]">
|
<SelectTrigger class="h-[26px] w-[160px]">
|
||||||
<SelectValue placeholder="不限" />
|
<SelectValue placeholder="不限" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -415,7 +415,7 @@ onMounted(() => {
|
|||||||
<div class="filter-item">
|
<div class="filter-item">
|
||||||
<span class="filter-label">时长</span>
|
<span class="filter-label">时长</span>
|
||||||
<Select v-model="searchParams.filter_duration">
|
<Select v-model="searchParams.filter_duration">
|
||||||
<SelectTrigger class="h-[26px] w-[88px]">
|
<SelectTrigger class="h-[26px] w-[160px]">
|
||||||
<SelectValue placeholder="不限" />
|
<SelectValue placeholder="不限" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -677,7 +677,7 @@ onMounted(() => {
|
|||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--color-primary-500);
|
border-color: var(--color-primary-500);
|
||||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
box-shadow: 0 0 0 3px oklch(from var(--primary) l c h / 0.1);
|
||||||
}
|
}
|
||||||
&:disabled { opacity: 0.5; }
|
&:disabled { opacity: 0.5; }
|
||||||
}
|
}
|
||||||
@@ -687,10 +687,10 @@ onMounted(() => {
|
|||||||
height: 38px;
|
height: 38px;
|
||||||
font-size: var(--font-size-base);
|
font-size: var(--font-size-base);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #fff;
|
color: var(--primary-foreground);
|
||||||
background: var(--color-primary-500);
|
background: var(--color-primary-500);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-base);
|
border-radius: 9999px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -705,13 +705,13 @@ onMounted(() => {
|
|||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
border: 2px solid rgba(255,255,255,0.3);
|
border: 2px solid rgba(255,255,255,0.3);
|
||||||
border-top-color: #fff;
|
border-top-color: var(--primary-foreground);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 0.6s linear infinite;
|
animation: spin 0.6s linear infinite;
|
||||||
|
|
||||||
&.light {
|
&.light {
|
||||||
border-color: rgba(255,255,255,0.2);
|
border-color: rgba(255,255,255,0.2);
|
||||||
border-top-color: #fff;
|
border-top-color: var(--primary-foreground);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -741,7 +741,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
&::-webkit-scrollbar { width: 4px; }
|
&::-webkit-scrollbar { width: 4px; }
|
||||||
&::-webkit-scrollbar-track { background: transparent; }
|
&::-webkit-scrollbar-track { background: transparent; }
|
||||||
&::-webkit-scrollbar-thumb { background: var(--color-gray-300); border-radius: 2px; }
|
&::-webkit-scrollbar-thumb { background: var(--color-gray-300); border-radius: var(--radius-sm); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 空状态
|
// 空状态
|
||||||
@@ -814,7 +814,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.selected {
|
&.selected {
|
||||||
background: rgba(59, 130, 246, 0.08);
|
background: oklch(from var(--primary) l c h / 0.08);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -854,9 +854,9 @@ onMounted(() => {
|
|||||||
padding: 0 var(--space-1);
|
padding: 0 var(--space-1);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #fff;
|
color: var(--primary-foreground);
|
||||||
background: rgba(0, 0, 0, 0.6);
|
background: var(--muted);
|
||||||
border-radius: 3px;
|
border-radius: var(--radius-sm);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -928,7 +928,7 @@ onMounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: var(--color-primary-500);
|
color: var(--color-primary-500);
|
||||||
background: rgba(59, 130, 246, 0.1);
|
background: oklch(from var(--primary) l c h / 0.1);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-base);
|
border-radius: var(--radius-base);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -938,7 +938,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--color-primary-500);
|
background: var(--color-primary-500);
|
||||||
color: #fff;
|
color: var(--primary-foreground);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -953,7 +953,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
&::-webkit-scrollbar { width: 4px; }
|
&::-webkit-scrollbar { width: 4px; }
|
||||||
&::-webkit-scrollbar-track { background: transparent; }
|
&::-webkit-scrollbar-track { background: transparent; }
|
||||||
&::-webkit-scrollbar-thumb { background: var(--color-gray-300); border-radius: 2px; }
|
&::-webkit-scrollbar-thumb { background: var(--color-gray-300); border-radius: var(--radius-sm); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-block {
|
.form-block {
|
||||||
@@ -1031,13 +1031,13 @@ onMounted(() => {
|
|||||||
color: var(--color-gray-600);
|
color: var(--color-gray-600);
|
||||||
background: var(--color-gray-50);
|
background: var(--color-gray-50);
|
||||||
border: 1px solid var(--color-gray-300);
|
border: 1px solid var(--color-gray-300);
|
||||||
border-radius: var(--radius-base);
|
border-radius: 9999px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all var(--duration-fast);
|
transition: all var(--duration-fast);
|
||||||
|
|
||||||
&:hover { border-color: var(--color-gray-200); color: var(--color-gray-900); }
|
&:hover { border-color: var(--color-gray-200); color: var(--color-gray-900); }
|
||||||
&.active {
|
&.active {
|
||||||
color: #fff;
|
color: var(--primary-foreground);
|
||||||
background: var(--color-primary-500);
|
background: var(--color-primary-500);
|
||||||
border-color: var(--color-primary-500);
|
border-color: var(--color-primary-500);
|
||||||
}
|
}
|
||||||
@@ -1056,7 +1056,7 @@ onMounted(() => {
|
|||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
background: var(--color-gray-50);
|
background: var(--color-gray-50);
|
||||||
border-radius: 2px;
|
border-radius: var(--radius-sm);
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|
||||||
&::-webkit-slider-thumb {
|
&::-webkit-slider-thumb {
|
||||||
@@ -1070,7 +1070,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: scale(1.15);
|
transform: scale(1.15);
|
||||||
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.15);
|
box-shadow: 0 0 0 4px oklch(from var(--primary) l c h / 0.15);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1081,10 +1081,10 @@ onMounted(() => {
|
|||||||
height: 44px;
|
height: 44px;
|
||||||
font-size: var(--font-size-base);
|
font-size: var(--font-size-base);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #fff;
|
color: var(--primary-foreground);
|
||||||
background: var(--color-primary-500);
|
background: var(--color-primary-500);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-md);
|
border-radius: 9999px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1098,7 +1098,7 @@ onMounted(() => {
|
|||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
background: var(--color-primary-400);
|
background: var(--color-primary-400);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.15);
|
box-shadow: 0 6px 20px oklch(from var(--primary) l c h / 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
@@ -1172,7 +1172,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
&::-webkit-scrollbar { width: 4px; }
|
&::-webkit-scrollbar { width: 4px; }
|
||||||
&::-webkit-scrollbar-track { background: transparent; }
|
&::-webkit-scrollbar-track { background: transparent; }
|
||||||
&::-webkit-scrollbar-thumb { background: var(--color-gray-300); border-radius: 2px; }
|
&::-webkit-scrollbar-thumb { background: var(--color-gray-300); border-radius: var(--radius-sm); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 过渡动画
|
// 过渡动画
|
||||||
|
|||||||
@@ -1,13 +1,29 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref, reactive } from 'vue'
|
import { computed, onMounted, ref, reactive } from 'vue'
|
||||||
import { Icon } from '@iconify/vue'
|
import { Icon } from '@iconify/vue'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
import { useUserStore } from '@/stores/user'
|
import { useUserStore } from '@/stores/user'
|
||||||
import { getPointRecordPage } from '@/api/pointRecord'
|
import { getPointRecordPage } from '@/api/pointRecord'
|
||||||
|
import { redeemCode as redeemCodeApi } from '@/api/redeemCode'
|
||||||
import { Progress } from '@/components/ui/progress'
|
import { Progress } from '@/components/ui/progress'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
DialogDescription
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
// 兑换码相关
|
||||||
|
const showRedeemDialog = ref(false)
|
||||||
|
const redeemCodeInput = ref('')
|
||||||
|
const redeeming = ref(false)
|
||||||
|
|
||||||
// 积分记录数据
|
// 积分记录数据
|
||||||
const pointRecords = ref([])
|
const pointRecords = ref([])
|
||||||
const recordsLoading = ref(false)
|
const recordsLoading = ref(false)
|
||||||
@@ -129,6 +145,31 @@ function getStatusInfo(status) {
|
|||||||
return statusMap[status] || { text: status, color: 'default' }
|
return statusMap[status] || { text: status, color: 'default' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 兑换码兑换
|
||||||
|
async function handleRedeem() {
|
||||||
|
const code = redeemCodeInput.value.trim()
|
||||||
|
if (!code) {
|
||||||
|
toast.error('请输入兑换码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
redeeming.value = true
|
||||||
|
try {
|
||||||
|
await redeemCodeApi(code)
|
||||||
|
toast.success('兑换成功,积分已到账')
|
||||||
|
showRedeemDialog.value = false
|
||||||
|
redeemCodeInput.value = ''
|
||||||
|
// 刷新用户信息和积分记录
|
||||||
|
await userStore.fetchUserProfile()
|
||||||
|
await fetchPointRecords()
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e?.response?.data?.msg || e?.message || '兑换失败'
|
||||||
|
toast.error(msg)
|
||||||
|
} finally {
|
||||||
|
redeeming.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (userStore.isLoggedIn) {
|
if (userStore.isLoggedIn) {
|
||||||
// 获取用户基本信息和档案信息
|
// 获取用户基本信息和档案信息
|
||||||
@@ -186,7 +227,7 @@ onMounted(async () => {
|
|||||||
<!-- 右侧:资源统计与活动 -->
|
<!-- 右侧:资源统计与活动 -->
|
||||||
<div class="lg:col-span-8">
|
<div class="lg:col-span-8">
|
||||||
<!-- 资源概览卡片 -->
|
<!-- 资源概览卡片 -->
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
<!-- 存储空间 -->
|
<!-- 存储空间 -->
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-icon-wrapper blue">
|
<div class="stat-icon-wrapper blue">
|
||||||
@@ -222,6 +263,26 @@ onMounted(async () => {
|
|||||||
<div class="stat-desc">累计充值金额</div>
|
<div class="stat-desc">累计充值金额</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 兑换码充值 -->
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon-wrapper green">
|
||||||
|
<Icon icon="lucide:gift" class="text-2xl" />
|
||||||
|
</div>
|
||||||
|
<div class="stat-content">
|
||||||
|
<div class="stat-label">兑换码充值</div>
|
||||||
|
<div class="stat-value" style="font-size: 16px;">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
@click="showRedeemDialog = true"
|
||||||
|
>
|
||||||
|
输入兑换码
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="stat-desc">使用兑换码获取积分</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 积分记录 -->
|
<!-- 积分记录 -->
|
||||||
@@ -291,6 +352,34 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 兑换码弹窗 -->
|
||||||
|
<Dialog v-model:open="showRedeemDialog">
|
||||||
|
<DialogContent class="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>兑换码充值</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
输入您的兑换码,积分将立即到账
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div class="py-4">
|
||||||
|
<Input
|
||||||
|
v-model="redeemCodeInput"
|
||||||
|
placeholder="请输入兑换码"
|
||||||
|
class="w-full"
|
||||||
|
@keyup.enter="handleRedeem"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" @click="showRedeemDialog = false">
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button @click="handleRedeem" :disabled="redeeming">
|
||||||
|
{{ redeeming ? '兑换中...' : '立即兑换' }}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -430,6 +519,11 @@ onMounted(async () => {
|
|||||||
color: #faad14;
|
color: #faad14;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stat-icon-wrapper.green {
|
||||||
|
background: rgba(82, 196, 26, 0.1);
|
||||||
|
color: #52c41a;
|
||||||
|
}
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import org.springframework.validation.annotation.Validated;
|
|||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.io.PrintWriter;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import static cn.iocoder.yudao.framework.apilog.core.enums.OperateTypeEnum.EXPORT;
|
import static cn.iocoder.yudao.framework.apilog.core.enums.OperateTypeEnum.EXPORT;
|
||||||
@@ -108,4 +109,29 @@ public class RedeemCodeController {
|
|||||||
BeanUtils.toBean(list, RedeemCodeRespVO.class));
|
BeanUtils.toBean(list, RedeemCodeRespVO.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/export-plain")
|
||||||
|
@Operation(summary = "导出兑换码纯文本(按批次号,用于自动发货软件)")
|
||||||
|
@Parameter(name = "batchNo", description = "批次号", required = true)
|
||||||
|
@PreAuthorize("@ss.hasPermission('muye:redeem-code:export')")
|
||||||
|
public void exportRedeemCodePlain(@RequestParam("batchNo") String batchNo,
|
||||||
|
HttpServletResponse response) throws IOException {
|
||||||
|
List<RedeemCodeDO> list = redeemCodeService.getRedeemCodeListByBatchNo(batchNo);
|
||||||
|
|
||||||
|
// 只导出未使用的码
|
||||||
|
List<String> codes = list.stream()
|
||||||
|
.filter(c -> c.getStatus() == RedeemCodeDO.STATUS_ENABLED)
|
||||||
|
.map(RedeemCodeDO::getCode)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
response.setContentType("text/plain; charset=UTF-8");
|
||||||
|
response.setHeader("Content-Disposition",
|
||||||
|
"attachment; filename=codes_" + batchNo + ".txt");
|
||||||
|
|
||||||
|
PrintWriter writer = response.getWriter();
|
||||||
|
for (String code : codes) {
|
||||||
|
writer.println(code);
|
||||||
|
}
|
||||||
|
writer.flush();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,5 +99,11 @@ public class RedeemCodeDO extends BaseDO {
|
|||||||
public static final String SOURCE_PAY = "pay";
|
public static final String SOURCE_PAY = "pay";
|
||||||
/** 来源:礼包赠送 */
|
/** 来源:礼包赠送 */
|
||||||
public static final String SOURCE_GIFT = "gift";
|
public static final String SOURCE_GIFT = "gift";
|
||||||
|
/** 来源:淘宝 */
|
||||||
|
public static final String SOURCE_TAOBAO = "taobao";
|
||||||
|
/** 来源:闲鱼 */
|
||||||
|
public static final String SOURCE_XIANYU = "xianyu";
|
||||||
|
/** 来源:活动发放 */
|
||||||
|
public static final String SOURCE_ACTIVITY = "activity";
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import org.apache.ibatis.annotations.Mapper;
|
|||||||
import org.apache.ibatis.annotations.Param;
|
import org.apache.ibatis.annotations.Param;
|
||||||
import org.apache.ibatis.annotations.Select;
|
import org.apache.ibatis.annotations.Select;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 兑换码 Mapper
|
* 兑换码 Mapper
|
||||||
*
|
*
|
||||||
@@ -47,4 +49,13 @@ public interface RedeemCodeMapper extends BaseMapperX<RedeemCodeDO> {
|
|||||||
.orderByDesc(RedeemCodeDO::getId));
|
.orderByDesc(RedeemCodeDO::getId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据批次号查询列表
|
||||||
|
*/
|
||||||
|
default List<RedeemCodeDO> selectListByBatchNo(String batchNo) {
|
||||||
|
return selectList(new LambdaQueryWrapperX<RedeemCodeDO>()
|
||||||
|
.eq(RedeemCodeDO::getBatchNo, batchNo)
|
||||||
|
.orderByAsc(RedeemCodeDO::getId));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,4 +94,12 @@ public interface RedeemCodeService {
|
|||||||
*/
|
*/
|
||||||
String issueCodeAfterPayment(Long userId, Integer points, String sourceId);
|
String issueCodeAfterPayment(Long userId, Integer points, String sourceId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据批次号获取兑换码列表
|
||||||
|
*
|
||||||
|
* @param batchNo 批次号
|
||||||
|
* @return 兑换码列表
|
||||||
|
*/
|
||||||
|
List<RedeemCodeDO> getRedeemCodeListByBatchNo(String batchNo);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ public class RedeemCodeServiceImpl implements RedeemCodeService {
|
|||||||
.maxUseCount(generateReqVO.getMaxUseCount() != null ? generateReqVO.getMaxUseCount() : 1)
|
.maxUseCount(generateReqVO.getMaxUseCount() != null ? generateReqVO.getMaxUseCount() : 1)
|
||||||
.usedCount(0)
|
.usedCount(0)
|
||||||
.batchNo(batchNo)
|
.batchNo(batchNo)
|
||||||
.source(RedeemCodeDO.SOURCE_ADMIN)
|
.source(generateReqVO.getSource() != null ? generateReqVO.getSource() : RedeemCodeDO.SOURCE_ADMIN)
|
||||||
.expireTime(generateReqVO.getExpireTime())
|
.expireTime(generateReqVO.getExpireTime())
|
||||||
.status(RedeemCodeDO.STATUS_ENABLED)
|
.status(RedeemCodeDO.STATUS_ENABLED)
|
||||||
.remark(generateReqVO.getRemark())
|
.remark(generateReqVO.getRemark())
|
||||||
@@ -250,6 +250,11 @@ public class RedeemCodeServiceImpl implements RedeemCodeService {
|
|||||||
return code;
|
return code;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<RedeemCodeDO> getRedeemCodeListByBatchNo(String batchNo) {
|
||||||
|
return redeemCodeMapper.selectListByBatchNo(batchNo);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成唯一兑换码
|
* 生成唯一兑换码
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -38,4 +38,7 @@ public class RedeemCodeGenerateReqVO {
|
|||||||
@Schema(description = "操作人账号", example = "admin")
|
@Schema(description = "操作人账号", example = "admin")
|
||||||
private String operatorName;
|
private String operatorName;
|
||||||
|
|
||||||
|
@Schema(description = "来源", example = "taobao")
|
||||||
|
private String source;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,3 +39,8 @@ export const generateRedeemCodes = async (data) => {
|
|||||||
export const exportRedeemCode = async (params) => {
|
export const exportRedeemCode = async (params) => {
|
||||||
return await request.download({ url: `/admin-api/muye/redeem-code/export-excel`, params })
|
return await request.download({ url: `/admin-api/muye/redeem-code/export-excel`, params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 导出兑换码纯文本(按批次号)
|
||||||
|
export const exportRedeemCodePlain = async (batchNo: string) => {
|
||||||
|
return await request.download({ url: `/admin-api/muye/redeem-code/export-plain`, params: { batchNo } })
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ const download = {
|
|||||||
json: (data: Blob, fileName: string) => {
|
json: (data: Blob, fileName: string) => {
|
||||||
download0(data, fileName, 'application/json')
|
download0(data, fileName, 'application/json')
|
||||||
},
|
},
|
||||||
|
// 下载纯文本方法
|
||||||
|
txt: (data: Blob, fileName: string) => {
|
||||||
|
download0(data, fileName, 'text/plain')
|
||||||
|
},
|
||||||
// 下载图片(允许跨域)
|
// 下载图片(允许跨域)
|
||||||
image: ({
|
image: ({
|
||||||
url,
|
url,
|
||||||
|
|||||||
@@ -67,11 +67,16 @@
|
|||||||
|
|
||||||
<el-form-item label="来源" prop="source">
|
<el-form-item label="来源" prop="source">
|
||||||
<el-select v-model="formData.source" placeholder="请选择来源" class="!w-full">
|
<el-select v-model="formData.source" placeholder="请选择来源" class="!w-full">
|
||||||
|
<el-option label="淘宝" value="taobao" />
|
||||||
|
<el-option label="闲鱼" value="xianyu" />
|
||||||
<el-option label="后台生成" value="admin" />
|
<el-option label="后台生成" value="admin" />
|
||||||
<el-option label="活动发放" value="activity" />
|
<el-option label="活动发放" value="activity" />
|
||||||
<el-option label="支付发放" value="payment" />
|
<el-option label="支付发放" value="payment" />
|
||||||
<el-option label="其他" value="other" />
|
<el-option label="其他" value="other" />
|
||||||
</el-select>
|
</el-select>
|
||||||
|
<div class="text-gray-400 text-12px mt-5px">
|
||||||
|
用于区分不同销售渠道
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,16 @@
|
|||||||
:loading="exportLoading"
|
:loading="exportLoading"
|
||||||
v-hasPermi="['muye:redeem-code:export']"
|
v-hasPermi="['muye:redeem-code:export']"
|
||||||
>
|
>
|
||||||
<Icon icon="ep:download" class="mr-5px" /> 导出
|
<Icon icon="ep:download" class="mr-5px" /> 导出Excel
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
type="warning"
|
||||||
|
plain
|
||||||
|
@click="handleExportPlain"
|
||||||
|
:disabled="!queryParams.batchNo"
|
||||||
|
v-hasPermi="['muye:redeem-code:export']"
|
||||||
|
>
|
||||||
|
<Icon icon="ep:document" class="mr-5px" /> 导出纯文本
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
type="danger"
|
type="danger"
|
||||||
@@ -246,6 +255,19 @@ const handleExport = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 导出纯文本按钮操作 */
|
||||||
|
const handleExportPlain = async () => {
|
||||||
|
if (!queryParams.batchNo) {
|
||||||
|
message.warning('请先输入批次号')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await message.confirm('确定导出该批次未使用的兑换码吗?')
|
||||||
|
const data = await RedeemCodeApi.exportRedeemCodePlain(queryParams.batchNo)
|
||||||
|
download.txt(data, `codes_${queryParams.batchNo}.txt`)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
/** 初始化 **/
|
/** 初始化 **/
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
getList()
|
getList()
|
||||||
|
|||||||
Reference in New Issue
Block a user