refactor(ui): 重构 PointsTag 组件并优化 AI 聊天抽屉样式
- 将 PointsTag.vue 从 Less 迁移至 Tailwind CSS,移除冗余样式和 showIcon 属性 - 优化 ChatDrawer 组件视觉设计,包括背景、间距和渐变效果 - 统一 ChatDrawer 子组件(Header、Footer、Empty、Result)的样式和交互细节 - 使用 Skeleton 组件改进加载状态,增强视觉一致性 - 调整按钮尺寸、图标大小和文本样式以符合设计系统规范
This commit is contained in:
@@ -1,100 +1,45 @@
|
||||
<template>
|
||||
<span v-if="displayText" :class="['points-tag', `points-tag--${size}`, { 'points-tag--free': isFree }]">
|
||||
<span v-if="showIcon" class="points-tag__icon">⚡</span>
|
||||
<span class="points-tag__text">{{ displayText }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { usePointsConfigStore } from '@/stores/pointsConfig'
|
||||
|
||||
const props = defineProps({
|
||||
// 模型代码
|
||||
modelCode: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
// 直接传入积分数(优先级高于 modelCode)
|
||||
points: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
// 尺寸
|
||||
size: {
|
||||
type: String,
|
||||
default: 'default', // small, default, large
|
||||
validator: (value) => ['small', 'default', 'large'].includes(value)
|
||||
},
|
||||
// 是否显示图标
|
||||
showIcon: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
modelCode: { type: String, default: '' },
|
||||
points: { type: Number, default: null },
|
||||
size: { type: String, default: 'default' }
|
||||
})
|
||||
|
||||
const pointsConfigStore = usePointsConfigStore()
|
||||
|
||||
// 获取积分数
|
||||
const consumePoints = computed(() => {
|
||||
if (props.points !== null) {
|
||||
return props.points
|
||||
}
|
||||
if (props.modelCode) {
|
||||
return pointsConfigStore.getConsumePoints(props.modelCode)
|
||||
}
|
||||
if (props.points !== null) return props.points
|
||||
if (props.modelCode) return pointsConfigStore.getConsumePoints(props.modelCode)
|
||||
return null
|
||||
})
|
||||
|
||||
// 是否免费
|
||||
const isFree = computed(() => consumePoints.value === 0)
|
||||
|
||||
// 显示文本
|
||||
const displayText = computed(() => {
|
||||
if (consumePoints.value === null) {
|
||||
return ''
|
||||
}
|
||||
if (consumePoints.value === null) return ''
|
||||
return pointsConfigStore.formatPoints(consumePoints.value)
|
||||
})
|
||||
|
||||
const sizeClass = computed(() => {
|
||||
const map = { small: 'px-1 py-0.5 text-[10px]', default: 'px-1.5 py-0.5 text-xs', large: 'px-2 py-1 text-sm' }
|
||||
return map[props.size] || map.default
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.points-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
border-radius: var(--radius-tag);
|
||||
font-weight: 500;
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
white-space: nowrap;
|
||||
|
||||
&--small {
|
||||
padding: 1px 6px;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
&--default {
|
||||
padding: 2px var(--space-2);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
&--large {
|
||||
padding: var(--space-1) var(--space-3);
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
&--free {
|
||||
background: var(--success);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
&__text {
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<span
|
||||
v-if="displayText"
|
||||
:class="[
|
||||
'inline-flex items-center rounded border font-medium whitespace-nowrap',
|
||||
sizeClass,
|
||||
isFree
|
||||
? 'border-success/50 text-success'
|
||||
: 'border-current/30 text-current opacity-80'
|
||||
]"
|
||||
>
|
||||
{{ displayText }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -179,9 +179,9 @@ watch(() => props.visible, (val) => {
|
||||
|
||||
<template>
|
||||
<Sheet :open="visible" @update:open="handleClose">
|
||||
<SheetContent side="right" class="w-[560px] sm:max-w-full p-0 flex flex-col">
|
||||
<SheetContent side="right" class="w-[560px] sm:max-w-full p-0 flex flex-col bg-background">
|
||||
<!-- Header -->
|
||||
<SheetHeader>
|
||||
<SheetHeader class="shrink-0">
|
||||
<ChatDrawerHeader :agent="agent" @history="openHistory" />
|
||||
<SheetDescription class="sr-only">AI 对话面板</SheetDescription>
|
||||
</SheetHeader>
|
||||
@@ -190,7 +190,7 @@ watch(() => props.visible, (val) => {
|
||||
<div class="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<!-- Empty State -->
|
||||
<ChatDrawerEmpty v-if="!generatedContent && !isGenerating" />
|
||||
|
||||
|
||||
<!-- Result Area -->
|
||||
<ChatDrawerResult
|
||||
v-else
|
||||
|
||||
@@ -4,25 +4,24 @@ import { Icon } from '@iconify/vue'
|
||||
|
||||
<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 class="relative w-20 h-20 mb-5">
|
||||
<div class="absolute inset-0 rounded-full bg-gradient-to-br from-primary/20 to-violet-500/20 animate-pulse"></div>
|
||||
<div class="absolute inset-2 rounded-full bg-gradient-to-br from-primary/30 to-violet-500/30 animate-pulse" style="animation-delay: 0.3s"></div>
|
||||
<div class="absolute inset-4 rounded-full bg-gradient-to-br from-primary to-violet-500 flex items-center justify-center shadow-lg shadow-primary/25">
|
||||
<Icon icon="lucide:sparkles" class="text-white text-2xl" />
|
||||
</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]">
|
||||
<h3 class="text-base font-semibold text-foreground mb-1.5">准备好为你生成内容</h3>
|
||||
<p class="text-sm text-muted-foreground mb-5 max-w-[260px] leading-relaxed">
|
||||
在下方输入框描述你的需求,AI 将立即开始创作
|
||||
</p>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Icon icon="lucide:zap" class="text-primary" />
|
||||
<Icon icon="lucide:zap" class="size-4 text-primary" />
|
||||
<span>深度模式支持复杂任务</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Icon icon="lucide:pencil" class="text-primary" />
|
||||
<Icon icon="lucide:pencil" class="size-4 text-primary" />
|
||||
<span>可随时重新生成调整</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,16 +25,22 @@ const handleGenerate = () => emit('generate')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shrink-0 px-5 py-4 border-t border-border bg-background/95 backdrop-blur-sm">
|
||||
<div class="shrink-0 px-4 py-3 border-t border-border/50 bg-gradient-to-t from-muted/20 to-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">
|
||||
<Tabs v-model="modelMode" class="mb-2.5">
|
||||
<TabsList class="w-full h-8 p-0.5 bg-muted/50">
|
||||
<TabsTrigger
|
||||
value="standard"
|
||||
class="flex-1 h-7 gap-1 text-xs data-[state=active]:bg-primary/10 data-[state=active]:text-primary data-[state=active]:shadow-none"
|
||||
>
|
||||
标准
|
||||
<PointsTag :points="10" size="small" />
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="pro" class="flex-1 gap-1.5">
|
||||
<Icon icon="lucide:zap" class="size-4" />
|
||||
<TabsTrigger
|
||||
value="pro"
|
||||
class="flex-1 h-7 gap-1 text-xs data-[state=active]:bg-gradient-to-r data-[state=active]:from-primary data-[state=active]:to-violet-500 data-[state=active]:text-white data-[state=active]:shadow-sm data-[state=active]:shadow-primary/20"
|
||||
>
|
||||
<Icon icon="lucide:zap" class="size-3" />
|
||||
深度
|
||||
<PointsTag :points="50" size="small" />
|
||||
</TabsTrigger>
|
||||
@@ -46,12 +52,12 @@ const handleGenerate = () => emit('generate')
|
||||
<Textarea
|
||||
v-model="inputText"
|
||||
placeholder="输入你的需求..."
|
||||
class="pr-12 min-h-[52px] max-h-[120px] resize-none"
|
||||
class="pr-11 min-h-[48px] max-h-[100px] resize-none text-sm"
|
||||
@keydown="handleKeyDown"
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
class="absolute right-2 bottom-2 size-9"
|
||||
class="absolute right-1.5 bottom-1.5 size-8"
|
||||
:disabled="!inputText.trim() || isGenerating"
|
||||
@click="handleGenerate"
|
||||
>
|
||||
@@ -60,7 +66,7 @@ const handleGenerate = () => emit('generate')
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p class="text-center text-xs text-muted-foreground mt-2 mb-0">
|
||||
<p class="text-center text-[11px] text-muted-foreground/70 mt-1.5 mb-0">
|
||||
按 Enter 发送,Shift + Enter 换行
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
defineProps({
|
||||
agent: { type: Object, default: null }
|
||||
@@ -11,28 +12,30 @@ 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 justify-between px-4 py-3 border-b border-border/50 shrink-0 bg-gradient-to-r from-background to-muted/30">
|
||||
<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"
|
||||
<div class="size-10 rounded-xl bg-gradient-to-br from-primary to-violet-500 flex items-center justify-center overflow-hidden shadow-sm shadow-primary/20">
|
||||
<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 class="font-semibold text-foreground text-sm">{{ agent?.name || 'AI 助手' }}</div>
|
||||
<div class="text-xs 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"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-9"
|
||||
@click="openHistory"
|
||||
title="历史记录"
|
||||
>
|
||||
<Icon icon="lucide:history" class="size-5" />
|
||||
</button>
|
||||
<Icon icon="lucide:history" class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup>
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
currentInput: { type: String, default: '' },
|
||||
generatedContent: { type: String, default: '' },
|
||||
isGenerating: { type: Boolean, default: false }
|
||||
@@ -11,11 +13,11 @@ defineProps({
|
||||
|
||||
const emit = defineEmits(['reset', 'copy', 'regenerate'])
|
||||
|
||||
const handleReset = () => emit('reset')
|
||||
const handleCopy = () => emit('copy', props.generatedContent)
|
||||
const handleRegenerate = () => emit('regenerate')
|
||||
const scrollAreaRef = ref(null)
|
||||
|
||||
const scrollAreaRef = defineExpose({ scrollAreaRef })
|
||||
const handleReset = () => emit('reset')
|
||||
const handleCopy = () => emit('copy')
|
||||
const handleRegenerate = () => emit('regenerate')
|
||||
|
||||
const scrollToBottom = () => {
|
||||
nextTick(() => {
|
||||
@@ -32,15 +34,16 @@ defineExpose({ scrollToBottom })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 flex flex-col min-h-0 p-5 gap-4">
|
||||
<div class="flex-1 flex flex-col min-h-0 p-4 gap-3">
|
||||
<!-- User Prompt Display -->
|
||||
<div class="flex items-start gap-2 p-3 bg-muted rounded-lg">
|
||||
<div class="flex items-start gap-2 p-3 bg-gradient-to-r from-primary/5 to-accent/5 rounded-lg border border-primary/10">
|
||||
<Icon icon="lucide:user" class="size-4 text-primary mt-0.5 shrink-0" />
|
||||
<p class="flex-1 text-sm text-foreground leading-relaxed m-0">{{ currentInput }}</p>
|
||||
<Button
|
||||
<Button
|
||||
v-if="generatedContent && !isGenerating"
|
||||
variant="ghost"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-7 shrink-0"
|
||||
class="size-7 shrink-0 hover:bg-primary/10"
|
||||
@click="handleReset"
|
||||
>
|
||||
<Icon icon="lucide:pencil" class="size-4" />
|
||||
@@ -49,34 +52,35 @@ defineExpose({ scrollToBottom })
|
||||
|
||||
<!-- Result Content -->
|
||||
<ScrollArea ref="scrollAreaRef" class="flex-1 min-h-0">
|
||||
<div class="p-4 bg-card border border-border rounded-lg">
|
||||
<div class="p-4 bg-card/50 backdrop-blur-sm border border-border/50 rounded-lg shadow-sm">
|
||||
<!-- 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>
|
||||
<Skeleton class="h-4 w-full" />
|
||||
<Skeleton class="h-4 w-4/5" />
|
||||
<Skeleton class="h-4 w-3/5" />
|
||||
<Skeleton class="h-4 w-2/5" />
|
||||
</div>
|
||||
|
||||
<!-- Actual Content -->
|
||||
<div
|
||||
v-else
|
||||
<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>
|
||||
<span v-if="isGenerating" class="inline-block w-0.5 h-4 bg-primary ml-0.5 animate-pulse rounded-full"></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" />
|
||||
复制
|
||||
<div v-if="generatedContent && !isGenerating" class="flex gap-2">
|
||||
<Button class="flex-1 h-9" @click="handleCopy">
|
||||
<Icon icon="lucide:copy" class="size-4 mr-1.5" />
|
||||
复制内容
|
||||
</Button>
|
||||
<Button variant="outline" class="flex-1" @click="handleRegenerate">
|
||||
<Icon icon="lucide:refresh-cw" class="mr-2" />
|
||||
<Button variant="outline" class="flex-1 h-9" @click="handleRegenerate">
|
||||
<Icon icon="lucide:refresh-cw" class="size-4 mr-1.5" />
|
||||
重新生成
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user