refactor(ui): 重构 PointsTag 组件并优化 AI 聊天抽屉样式

- 将 PointsTag.vue 从 Less 迁移至 Tailwind CSS,移除冗余样式和 showIcon 属性
- 优化 ChatDrawer 组件视觉设计,包括背景、间距和渐变效果
- 统一 ChatDrawer 子组件(Header、Footer、Empty、Result)的样式和交互细节
- 使用 Skeleton 组件改进加载状态,增强视觉一致性
- 调整按钮尺寸、图标大小和文本样式以符合设计系统规范
This commit is contained in:
2026-03-20 18:21:26 +08:00
parent 9ff10e6769
commit 4e26c248a6
6 changed files with 94 additions and 137 deletions

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>