Initial commit

This commit is contained in:
fawney19
2025-12-10 20:52:44 +08:00
commit f784106826
485 changed files with 110993 additions and 0 deletions

View File

@@ -0,0 +1,412 @@
<template>
<div class="line-by-line-logo" :style="{ width: `${size}px`, height: `${size}px` }">
<svg
:viewBox="viewBox"
class="logo-svg"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<!-- Metallic gradient -->
<linearGradient :id="gradientId" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" :stop-color="metallicColors.dark" />
<stop offset="25%" :stop-color="metallicColors.base" />
<stop offset="50%" :stop-color="metallicColors.light" />
<stop offset="75%" :stop-color="metallicColors.base" />
<stop offset="100%" :stop-color="metallicColors.dark" />
</linearGradient>
</defs>
<!-- Layer 0: Ghost Tracks (Always visible, faint) -->
<g class="ghost-layer">
<path
v-for="(path, index) in linePaths"
:key="`ghost-${index}`"
:d="path"
class="ghost-path"
fill="none"
:stroke="currentColors.primary"
:stroke-width="strokeWidth"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<!-- Layer 1: Fill (fade in/out) -->
<path
class="fill-path"
:d="fullPath"
:fill="`url(#${gradientId})`"
fill-rule="evenodd"
:style="fillStyle"
/>
<!-- Layer 2: Animated lines -->
<g class="lines-layer">
<path
v-for="(path, index) in linePaths"
:key="`line-${index}`"
:ref="(el) => setPathRef(el as SVGPathElement, index)"
:d="path"
class="line-path"
fill="none"
:stroke="currentColors.primary"
:stroke-width="strokeWidth"
:style="getLineStyle(index)"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
</svg>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
import { AETHER_SVG_VIEWBOX, AETHER_LINE_PATHS, AETHER_FULL_PATH } from '@/constants/logoPaths'
// Animation phases
type AnimationPhase = 'idle' | 'drawOutline' | 'fillFadeIn' | 'hold' | 'fillFadeOut' | 'eraseOutline'
// Color scheme type
interface ColorScheme {
primary: string
secondary: string
}
// Constants
const LINE_COUNT = AETHER_LINE_PATHS.length
const DEFAULT_PATH_LENGTH = 3000
// Light mode color schemes
const LIGHT_MODE_SCHEMES: ColorScheme[] = [
{ primary: '#9a5a42', secondary: '#c4866a' },
{ primary: '#8b4557', secondary: '#b87a8a' },
{ primary: '#996b2e', secondary: '#c49a5c' },
{ primary: '#7a5c3a', secondary: '#a8896a' },
{ primary: '#6b4d82', secondary: '#9a7eb5' },
{ primary: '#2d6a7a', secondary: '#5a9aaa' },
{ primary: '#4a6b3a', secondary: '#7a9a6a' },
{ primary: '#8a5a5a', secondary: '#b88a8a' },
{ primary: '#5a6a7a', secondary: '#8a9aaa' },
{ primary: '#6a5a4a', secondary: '#9a8a7a' },
{ primary: '#7a4a5a', secondary: '#aa7a8a' },
{ primary: '#4a5a6a', secondary: '#7a8a9a' },
]
// Dark mode color schemes
const DARK_MODE_SCHEMES: ColorScheme[] = [
{ primary: '#f59e0b', secondary: '#fcd34d' },
{ primary: '#ec4899', secondary: '#f9a8d4' },
{ primary: '#22d3ee', secondary: '#a5f3fc' },
{ primary: '#a855f7', secondary: '#d8b4fe' },
{ primary: '#4ade80', secondary: '#bbf7d0' },
{ primary: '#f472b6', secondary: '#fbcfe8' },
{ primary: '#38bdf8', secondary: '#bae6fd' },
{ primary: '#fb923c', secondary: '#fed7aa' },
{ primary: '#a78bfa', secondary: '#ddd6fe' },
{ primary: '#2dd4bf', secondary: '#99f6e4' },
{ primary: '#facc15', secondary: '#fef08a' },
{ primary: '#e879f9', secondary: '#f5d0fe' },
]
const props = withDefaults(
defineProps<{
size?: number
lineDelay?: number
strokeDuration?: number
fillDuration?: number
autoStart?: boolean
loop?: boolean
loopPause?: number
outlineColor?: string
gradientColor?: string
strokeWidth?: number
cycleColors?: boolean
isDark?: boolean
}>(),
{
size: 400,
lineDelay: 60,
strokeDuration: 1200,
fillDuration: 800,
autoStart: true,
loop: true,
loopPause: 600,
outlineColor: '#cc785c',
gradientColor: '#e8a882',
strokeWidth: 2.5,
cycleColors: false,
isDark: false
}
)
const emit = defineEmits<{
(e: 'animationComplete'): void
(e: 'phaseChange', phase: AnimationPhase): void
(e: 'colorChange', colors: ColorScheme): void
}>()
// Unique ID for gradient
const gradientId = `aether-gradient-${Math.random().toString(36).slice(2, 9)}`
const viewBox = AETHER_SVG_VIEWBOX
const linePaths = AETHER_LINE_PATHS
const fullPath = AETHER_FULL_PATH
// Path refs and lengths
const pathRefs = ref<(SVGPathElement | null)[]>(new Array(LINE_COUNT).fill(null))
const pathLengths = ref<number[]>(new Array(LINE_COUNT).fill(DEFAULT_PATH_LENGTH))
// Animation states
const lineDrawn = ref<boolean[]>(new Array(LINE_COUNT).fill(false))
const isFilled = ref(false)
const currentPhase = ref<AnimationPhase>('idle')
const isAnimating = ref(false)
// Timer cleanup
let animationAborted = false
let startTimeoutId: ReturnType<typeof setTimeout> | null = null
let hasStartedOnce = false
// Color cycling state
const colorIndex = ref(0)
// Computed
const activeSchemes = computed(() => props.isDark ? DARK_MODE_SCHEMES : LIGHT_MODE_SCHEMES)
const currentColors = computed<ColorScheme>(() => {
if (props.cycleColors) {
return activeSchemes.value[colorIndex.value % activeSchemes.value.length]
}
return { primary: props.outlineColor, secondary: props.gradientColor }
})
const metallicColors = computed(() => ({
dark: adjustColor(currentColors.value.primary, -20),
base: currentColors.value.primary,
light: currentColors.value.secondary,
highlight: adjustColor(currentColors.value.secondary, 30)
}))
// Fill style with fade transition
const fillStyle = computed(() => ({
opacity: isFilled.value ? 0.85 : 0,
transition: `opacity ${props.fillDuration}ms ease-in-out`
}))
// Helper functions
function adjustColor(hex: string, amount: number): string {
const num = parseInt(hex.replace('#', ''), 16)
const r = Math.min(255, Math.max(0, (num >> 16) + amount))
const g = Math.min(255, Math.max(0, ((num >> 8) & 0x00FF) + amount))
const b = Math.min(255, Math.max(0, (num & 0x0000FF) + amount))
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`
}
const setPathRef = (el: SVGPathElement | null, index: number) => {
pathRefs.value[index] = el
}
const calculatePathLengths = () => {
pathRefs.value.forEach((path, index) => {
if (path) {
try {
pathLengths.value[index] = path.getTotalLength()
} catch {
pathLengths.value[index] = DEFAULT_PATH_LENGTH
}
}
})
}
// Line style with stroke drawing animation
const getLineStyle = (index: number) => {
const pathLength = pathLengths.value[index]
const isDrawn = lineDrawn.value[index]
const phase = currentPhase.value
// Only enable transition during actual draw/erase phases
let transition = 'none'
if (phase === 'drawOutline' || phase === 'eraseOutline') {
transition = `stroke-dashoffset ${props.strokeDuration}ms cubic-bezier(0.4, 0, 0.2, 1)`
}
return {
strokeDasharray: pathLength,
strokeDashoffset: isDrawn ? 0 : pathLength,
transition
}
}
// Abortable wait
const wait = (ms: number) => new Promise<void>((resolve, reject) => {
if (animationAborted) {
reject(new Error('Animation aborted'))
return
}
const timeoutId = setTimeout(() => {
if (animationAborted) {
reject(new Error('Animation aborted'))
} else {
resolve()
}
}, ms)
if (animationAborted) {
clearTimeout(timeoutId)
reject(new Error('Animation aborted'))
}
})
const nextColor = () => {
colorIndex.value = (colorIndex.value + 1) % activeSchemes.value.length
emit('colorChange', currentColors.value)
}
// Animation instance counter to prevent multiple concurrent animations
let animationInstanceId = 0
// Main animation sequence
const startAnimation = async () => {
if (isAnimating.value) return
const currentInstanceId = ++animationInstanceId
isAnimating.value = true
animationAborted = false
try {
// Reset states
lineDrawn.value = new Array(LINE_COUNT).fill(false)
isFilled.value = false
currentPhase.value = 'idle'
await nextTick()
calculatePathLengths()
await nextTick()
// Phase 1: Draw outlines (line by line)
currentPhase.value = 'drawOutline'
emit('phaseChange', 'drawOutline')
for (let i = 0; i < LINE_COUNT; i++) {
lineDrawn.value[i] = true
if (i < LINE_COUNT - 1) await wait(props.lineDelay)
}
await wait(props.strokeDuration)
// Phase 2: Fill fade in
currentPhase.value = 'fillFadeIn'
emit('phaseChange', 'fillFadeIn')
isFilled.value = true
await wait(props.fillDuration)
// Hold
currentPhase.value = 'hold'
await wait(props.loopPause / 2)
// Phase 3: Fill fade out
currentPhase.value = 'fillFadeOut'
emit('phaseChange', 'fillFadeOut')
isFilled.value = false
await wait(props.fillDuration)
// Phase 4: Erase outlines (line by line)
currentPhase.value = 'eraseOutline'
emit('phaseChange', 'eraseOutline')
for (let i = 0; i < LINE_COUNT; i++) {
lineDrawn.value[i] = false
if (i < LINE_COUNT - 1) await wait(props.lineDelay)
}
await wait(props.strokeDuration)
currentPhase.value = 'idle'
isAnimating.value = false
emit('animationComplete')
// Check if this animation instance is still valid before looping
if (props.loop && !animationAborted && currentInstanceId === animationInstanceId) {
if (props.cycleColors) nextColor()
await wait(props.loopPause / 2)
// Double check before recursing
if (!animationAborted && currentInstanceId === animationInstanceId) {
startAnimation()
}
}
} catch {
isAnimating.value = false
currentPhase.value = 'idle'
}
}
const reset = () => {
animationAborted = true
lineDrawn.value = new Array(LINE_COUNT).fill(false)
isFilled.value = false
currentPhase.value = 'idle'
isAnimating.value = false
}
const stop = () => {
animationAborted = true
}
watch(() => props.isDark, () => {
colorIndex.value = 0
})
defineExpose({ startAnimation, reset, stop, isAnimating, currentPhase, nextColor, colorIndex })
onMounted(async () => {
await nextTick()
calculatePathLengths()
if (props.autoStart && !hasStartedOnce) {
hasStartedOnce = true
startTimeoutId = setTimeout(startAnimation, 300)
}
})
onUnmounted(() => {
animationAborted = true
if (startTimeoutId) {
clearTimeout(startTimeoutId)
startTimeoutId = null
}
})
watch(() => props.autoStart, (newVal) => {
if (newVal && !isAnimating.value && !hasStartedOnce) {
hasStartedOnce = true
startAnimation()
}
})
</script>
<style scoped>
.line-by-line-logo {
display: flex;
align-items: center;
justify-content: center;
}
.logo-svg {
width: 100%;
height: 100%;
overflow: visible;
transform: translateZ(0);
backface-visibility: hidden;
}
.line-path {
will-change: stroke-dashoffset;
}
.fill-path {
will-change: opacity;
pointer-events: none;
}
.ghost-path {
opacity: 0.06;
}
</style>

View File

@@ -0,0 +1,400 @@
<template>
<div :class="wrapperClass">
<pre><code :class="`language-${language}`" v-html="highlightedCode"></code></pre>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import hljs from 'highlight.js/lib/core'
import bash from 'highlight.js/lib/languages/bash'
import json from 'highlight.js/lib/languages/json'
import ini from 'highlight.js/lib/languages/ini'
import javascript from 'highlight.js/lib/languages/javascript'
// 注册需要的语言
hljs.registerLanguage('bash', bash)
hljs.registerLanguage('sh', bash)
hljs.registerLanguage('json', json)
hljs.registerLanguage('toml', ini)
hljs.registerLanguage('ini', ini)
hljs.registerLanguage('javascript', javascript)
const props = defineProps<{
code: string
language: string
dense?: boolean
}>()
const wrapperClass = computed(() =>
['code-highlight', props.dense ? 'code-highlight--dense' : '']
.filter(Boolean)
.join(' ')
)
// 自定义 bash 高亮增强
function enhanceBashHighlight(html: string, code: string): string {
// 如果 highlight.js 已经识别了 token直接返回
if (html.includes('hljs-')) {
return html
}
// 手动添加高亮
const escaped = code
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
// 使用占位符保护URL
const urlPlaceholders: string[] = []
let result = escaped.replace(/(https?:\/\/[^\s]+)/g, (match) => {
const index = urlPlaceholders.length
urlPlaceholders.push(match)
return `__URL_PLACEHOLDER_${index}__`
})
// 高亮命令关键字
result = result
.replace(/\b(curl|npm|npx|git|bash|sh|powershell|iex|irm|wget|apt|yum|brew|pip|python|node|docker|kubectl)\b/g, '<span class="hljs-built_in">$1</span>')
.replace(/(^|\s)(-[a-zA-Z]+)/gm, '$1<span class="hljs-meta">$2</span>')
.replace(/(\|)/g, '<span class="hljs-keyword">$1</span>')
// 恢复URL并添加高亮
result = result.replace(/__URL_PLACEHOLDER_(\d+)__/g, (_, index) => {
return `<span class="hljs-string">${urlPlaceholders[parseInt(index)]}</span>`
})
return result
}
// 自定义 dotenv 高亮
function highlightDotenv(code: string): string {
const escaped = code
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
return escaped
// 注释行
.replace(/^(#.*)$/gm, '<span class="hljs-comment">$1</span>')
// 环境变量 KEY=VALUE
.replace(/^([A-Z_][A-Z0-9_]*)(=)(.*)$/gm, (_, key, eq, value) => {
return `<span class="hljs-attr">${key}</span>${eq}<span class="hljs-string">${value}</span>`
})
}
const highlightedCode = computed(() => {
const lang = props.language.trim().toLowerCase()
const code = props.code ?? ''
let result: string
try {
if (lang === 'bash' || lang === 'sh') {
const highlighted = hljs.highlight(code, { language: 'bash' }).value
result = enhanceBashHighlight(highlighted, code)
} else if (lang === 'dotenv' || lang === 'env') {
result = highlightDotenv(code)
} else {
const language = hljs.getLanguage(lang) ? lang : 'plaintext'
result = hljs.highlight(code, { language }).value
}
} catch (e) {
console.error('Highlight error:', e)
result = code
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
// Highlight placeholders that need user modification (e.g., your-api-key, latest-model-name)
result = highlightPlaceholders(result)
return result
})
// Highlight placeholder values that users need to modify
function highlightPlaceholders(html: string): string {
// Match common placeholder patterns (already HTML-escaped)
const placeholderPatterns = [
/your-api-key/gi,
/latest-model-name/gi,
/your-[a-z-]+/gi,
]
for (const pattern of placeholderPatterns) {
html = html.replace(pattern, (match) => {
// Avoid double-wrapping if already in a span
return `<span class="hljs-placeholder">${match}</span>`
})
}
return html
}
</script>
<style scoped>
.code-highlight {
width: 100%;
}
.code-highlight pre {
margin: 0;
padding: 0.9rem 1.1rem;
border-radius: 0.85rem;
border: 1px solid var(--color-border);
background-color: var(--color-code-background);
font-family: var(--font-mono, 'Cascadia Code', monospace);
font-size: 0.875rem;
line-height: 1.6;
color: var(--color-code-text);
overflow-x: auto;
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
letter-spacing: 0.01em;
}
.code-highlight code {
font-family: inherit;
font-size: inherit;
font-weight: 400;
}
.code-highlight--dense pre {
padding: 0.6rem 0.9rem;
font-size: 0.9375rem;
border-radius: 0.75rem;
line-height: 1.5;
/* Dense mode: transparent background for embedding in panels */
background-color: transparent;
border: 1px solid var(--color-border);
}
/* Highlight.js 浅色主题 */
.code-highlight :deep(.hljs-string) {
color: #24292e;
font-weight: 450;
}
.code-highlight :deep(.hljs-attr),
.code-highlight :deep(.hljs-attribute) {
color: #c96442;
font-weight: 500;
}
.code-highlight :deep(.hljs-keyword),
.code-highlight :deep(.hljs-selector-tag),
.code-highlight :deep(.hljs-built_in) {
color: #0066cc;
font-weight: 500;
}
.code-highlight :deep(.hljs-comment) {
color: #6a737d;
font-style: italic;
opacity: 0.8;
}
.code-highlight :deep(.hljs-function),
.code-highlight :deep(.hljs-title) {
color: #6f42c1;
font-weight: 500;
}
.code-highlight :deep(.hljs-number) {
color: #005cc5;
}
.code-highlight :deep(.hljs-literal),
.code-highlight :deep(.language-json .hljs-literal),
.code-highlight :deep(.language-json .hljs-literal .hljs-keyword) {
color: #24292e;
font-weight: normal;
}
.code-highlight :deep(.hljs-variable),
.code-highlight :deep(.hljs-property) {
color: #cc785c;
}
.code-highlight :deep(.hljs-punctuation) {
color: #24292e;
opacity: 0.7;
}
/* Placeholder values that need user modification */
.code-highlight :deep(.hljs-placeholder) {
color: #d73a49;
font-weight: 500;
font-style: italic;
}
/* Bash 命令样式 - 浅色模式 */
.code-highlight :deep(.hljs-built_in),
.code-highlight :deep(.hljs-name),
.code-highlight :deep(.language-bash .hljs-built_in),
.code-highlight :deep(.language-bash .hljs-name) {
color: #d73a49;
font-weight: 500;
}
.code-highlight :deep(.hljs-meta),
.code-highlight :deep(.language-bash .hljs-meta) {
color: #d73a49;
font-weight: 500;
}
.code-highlight :deep(.hljs-params),
.code-highlight :deep(.language-bash .hljs-params) {
color: #0366d6;
font-weight: 500;
}
.code-highlight :deep(.language-bash .hljs-keyword),
.code-highlight :deep(.language-bash .hljs-literal) {
color: #0366d6;
font-weight: 500;
}
.code-highlight :deep(.language-bash) {
color: #24292e;
}
.code-highlight :deep(.language-bash .hljs-string) {
color: #24292e;
font-weight: 400;
}
.code-highlight :deep(pre code.language-bash) {
color: #24292e;
}
.code-highlight :deep(pre code.language-bash .hljs-subst) {
color: #24292e;
}
/* Highlight.js 深色主题 */
.dark .code-highlight :deep(.hljs-string),
body[theme-mode='dark'] .code-highlight :deep(.hljs-string) {
color: #f1ead8;
font-weight: 450;
}
.dark .code-highlight :deep(.hljs-attr),
.dark .code-highlight :deep(.hljs-attribute),
body[theme-mode='dark'] .code-highlight :deep(.hljs-attr),
body[theme-mode='dark'] .code-highlight :deep(.hljs-attribute) {
color: #d4a27f;
font-weight: 500;
}
.dark .code-highlight :deep(.hljs-keyword),
.dark .code-highlight :deep(.hljs-selector-tag),
.dark .code-highlight :deep(.hljs-built_in),
body[theme-mode='dark'] .code-highlight :deep(.hljs-keyword),
body[theme-mode='dark'] .code-highlight :deep(.hljs-selector-tag),
body[theme-mode='dark'] .code-highlight :deep(.hljs-built_in) {
color: #569cd6;
font-weight: 500;
}
.dark .code-highlight :deep(.hljs-comment),
body[theme-mode='dark'] .code-highlight :deep(.hljs-comment) {
color: #6a9955;
font-style: italic;
opacity: 0.85;
}
.dark .code-highlight :deep(.hljs-function),
.dark .code-highlight :deep(.hljs-title),
body[theme-mode='dark'] .code-highlight :deep(.hljs-function),
body[theme-mode='dark'] .code-highlight :deep(.hljs-title) {
color: #dcdcaa;
font-weight: 500;
}
.dark .code-highlight :deep(.hljs-number),
body[theme-mode='dark'] .code-highlight :deep(.hljs-number) {
color: #b5cea8;
}
.dark .code-highlight :deep(.hljs-literal),
.dark .code-highlight :deep(.language-json .hljs-literal),
.dark .code-highlight :deep(.language-json .hljs-literal .hljs-keyword),
body[theme-mode='dark'] .code-highlight :deep(.hljs-literal),
body[theme-mode='dark'] .code-highlight :deep(.language-json .hljs-literal),
body[theme-mode='dark'] .code-highlight :deep(.language-json .hljs-literal .hljs-keyword) {
color: #e1e4e8;
font-weight: normal;
}
.dark .code-highlight :deep(.hljs-variable),
.dark .code-highlight :deep(.hljs-property),
body[theme-mode='dark'] .code-highlight :deep(.hljs-variable),
body[theme-mode='dark'] .code-highlight :deep(.hljs-property) {
color: #9cdcfe;
}
.dark .code-highlight :deep(.hljs-punctuation),
body[theme-mode='dark'] .code-highlight :deep(.hljs-punctuation) {
color: #d4d4d4;
opacity: 0.7;
}
/* Placeholder values - dark mode */
.dark .code-highlight :deep(.hljs-placeholder),
body[theme-mode='dark'] .code-highlight :deep(.hljs-placeholder) {
color: #f97583;
font-weight: 500;
font-style: italic;
}
/* Bash 深色主题 */
.dark .code-highlight :deep(.hljs-built_in),
.dark .code-highlight :deep(.hljs-name),
.dark .code-highlight :deep(.language-bash .hljs-built_in),
.dark .code-highlight :deep(.language-bash .hljs-name),
body[theme-mode='dark'] .code-highlight :deep(.hljs-built_in),
body[theme-mode='dark'] .code-highlight :deep(.hljs-name),
body[theme-mode='dark'] .code-highlight :deep(.language-bash .hljs-built_in),
body[theme-mode='dark'] .code-highlight :deep(.language-bash .hljs-name) {
color: #e67764;
font-weight: 500;
}
.dark .code-highlight :deep(.hljs-meta),
.dark .code-highlight :deep(.language-bash .hljs-meta),
body[theme-mode='dark'] .code-highlight :deep(.hljs-meta),
body[theme-mode='dark'] .code-highlight :deep(.language-bash .hljs-meta) {
color: #e67764;
}
.dark .code-highlight :deep(.hljs-params),
.dark .code-highlight :deep(.language-bash .hljs-params),
body[theme-mode='dark'] .code-highlight :deep(.hljs-params),
body[theme-mode='dark'] .code-highlight :deep(.language-bash .hljs-params) {
color: #6fa9e6;
font-weight: 500;
}
.dark .code-highlight :deep(.language-bash .hljs-keyword),
.dark .code-highlight :deep(.language-bash .hljs-literal),
body[theme-mode='dark'] .code-highlight :deep(.language-bash .hljs-keyword),
body[theme-mode='dark'] .code-highlight :deep(.language-bash .hljs-literal) {
color: #6fa9e6;
font-weight: 500;
}
.dark .code-highlight :deep(.language-bash),
body[theme-mode='dark'] .code-highlight :deep(.language-bash) {
color: #e1e4e8;
}
.dark .code-highlight :deep(.language-bash .hljs-string),
body[theme-mode='dark'] .code-highlight :deep(.language-bash .hljs-string) {
color: #e1e4e8;
font-weight: 400;
}
</style>

View File

@@ -0,0 +1,26 @@
<template>
<AlertDialog
:model-value="state.isOpen"
:title="state.title || '确认操作'"
:description="state.message"
:confirm-text="state.confirmText || '确认'"
:cancel-text="state.cancelText || '取消'"
:type="state.variant || 'question'"
@update:model-value="handleClose"
@confirm="handleConfirm"
@cancel="handleCancel"
/>
</template>
<script setup lang="ts">
import AlertDialog from './common/AlertDialog.vue'
import { useConfirm } from '@/composables/useConfirm'
const { state, handleConfirm, handleCancel } = useConfirm()
function handleClose(value: boolean) {
if (!value) {
handleCancel()
}
}
</script>

View File

@@ -0,0 +1,403 @@
<template>
<div
v-show="!isFullyHidden"
class="gemini-star-cluster absolute inset-0 overflow-hidden pointer-events-none"
:class="{ 'scattering': isScattering, 'fading-out': isFadingOut }"
>
<!-- SVG Defs for the Gemini multi-color gradient -->
<svg class="absolute w-0 h-0 overflow-hidden" aria-hidden="true">
<defs>
<!-- Main Gemini gradient (blue base with color overlays) -->
<linearGradient id="gemini-base" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#1A73E8" />
<stop offset="50%" stop-color="#4285F4" />
<stop offset="100%" stop-color="#669DF6" />
</linearGradient>
<!-- Red accent overlay - from top -->
<linearGradient id="gemini-red-overlay" x1="50%" y1="0%" x2="50%" y2="50%">
<stop offset="0%" stop-color="#EA4335" />
<stop offset="100%" stop-color="#EA4335" stop-opacity="0" />
</linearGradient>
<!-- Yellow accent overlay - from left -->
<linearGradient id="gemini-yellow-overlay" x1="0%" y1="50%" x2="50%" y2="50%">
<stop offset="0%" stop-color="#FBBC04" />
<stop offset="100%" stop-color="#FBBC04" stop-opacity="0" />
</linearGradient>
<!-- Green accent overlay - from bottom -->
<linearGradient id="gemini-green-overlay" x1="50%" y1="100%" x2="50%" y2="50%">
<stop offset="0%" stop-color="#34A853" />
<stop offset="100%" stop-color="#34A853" stop-opacity="0" />
</linearGradient>
<!-- Glow filter -->
<filter id="star-glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="2" result="blur" />
<feFlood flood-color="#4285F4" flood-opacity="0.3" />
<feComposite in2="blur" operator="in" />
<feMerge>
<feMergeNode />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
</svg>
<!-- Layer 1: Far background stars (smallest, lowest z-index) -->
<div class="stars-layer far-layer">
<div
v-for="star in farStars"
:key="`far-${star.id}`"
class="star-wrapper"
:class="{ 'star-visible': star.visible && hasScattered }"
:style="getStarStyle(star)"
>
<svg viewBox="0 0 24 24" class="star-svg">
<path :d="starPath" fill="url(#gemini-base)" />
<path :d="starPath" fill="url(#gemini-red-overlay)" />
<path :d="starPath" fill="url(#gemini-yellow-overlay)" />
<path :d="starPath" fill="url(#gemini-green-overlay)" />
</svg>
</div>
</div>
<!-- Layer 2: Mid-distance stars -->
<div class="stars-layer mid-layer">
<div
v-for="star in midStars"
:key="`mid-${star.id}`"
class="star-wrapper"
:class="{ 'star-visible': star.visible && hasScattered }"
:style="getStarStyle(star)"
>
<svg viewBox="0 0 24 24" class="star-svg" style="filter: url(#star-glow)">
<path :d="starPath" fill="url(#gemini-base)" />
<path :d="starPath" fill="url(#gemini-red-overlay)" />
<path :d="starPath" fill="url(#gemini-yellow-overlay)" />
<path :d="starPath" fill="url(#gemini-green-overlay)" />
</svg>
</div>
</div>
<!-- Layer 3: Near stars (largest, highest z-index - in front, not occluded by small stars) -->
<div class="stars-layer near-layer">
<div
v-for="star in nearStars"
:key="`near-${star.id}`"
class="star-wrapper"
:class="{ 'star-visible': star.visible && hasScattered }"
:style="getStarStyle(star)"
>
<svg viewBox="0 0 24 24" class="star-svg" style="filter: url(#star-glow)">
<path :d="starPath" fill="url(#gemini-base)" />
<path :d="starPath" fill="url(#gemini-red-overlay)" />
<path :d="starPath" fill="url(#gemini-yellow-overlay)" />
<path :d="starPath" fill="url(#gemini-green-overlay)" />
</svg>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, type CSSProperties } from 'vue'
// Props for transition control
const props = withDefaults(defineProps<{
isVisible?: boolean
}>(), {
isVisible: true
})
// Gemini star SVG path (4-point star) - viewBox 0 0 24 24
const starPath = 'M12 1.5c.2 3.4 1.4 6.4 3.8 8.8 2.4 2.4 5.4 3.6 8.8 3.8-3.4.2-6.4 1.4-8.8 3.8-2.4 2.4-3.6 5.4-3.8 8.8-.2-3.4-1.4-6.4-3.8-8.8-2.4-2.4-5.4-3.6-8.8-3.8 3.4-.2 6.4-1.4 8.8-3.8 2.4-2.4 3.6-5.4 3.8-8.8z'
interface Star {
id: number
x: number
y: number
targetX: number
targetY: number
size: number
baseOpacity: number
visible: boolean
zIndex: number
// Animation props
twinkleDuration: number
twinkleDelay: number
}
const farStars = ref<Star[]>([])
const midStars = ref<Star[]>([])
const nearStars = ref<Star[]>([])
const hasScattered = ref(false)
const isScattering = ref(false)
const isFadingOut = ref(false)
const isFullyHidden = ref(false)
const CENTER_X = 50
const CENTER_Y = 50
// Generate constrained position to keep stars within bounds
const getConstrainedPosition = (size: number): { x: number; y: number } => {
// Calculate padding based on star size (in percentage)
// Assuming container is roughly 400-600px wide, use a safe estimate
const paddingPercent = Math.max(3, (size / 5))
const minPos = paddingPercent
const maxPos = 100 - paddingPercent
return {
x: minPos + Math.random() * (maxPos - minPos),
y: minPos + Math.random() * (maxPos - minPos)
}
}
const createStar = (id: number, sizeRange: [number, number, number, number, number, number], opacityBase: number, opacityRange: number, visibleThreshold: number, zIndexBase: number): Star => {
const sizeVariant = Math.random()
let size: number
if (sizeVariant < 0.4) {
size = sizeRange[0] + Math.random() * sizeRange[1]
} else if (sizeVariant < 0.7) {
size = sizeRange[2] + Math.random() * sizeRange[3]
} else {
size = sizeRange[4] + Math.random() * sizeRange[5]
}
const { x: targetX, y: targetY } = getConstrainedPosition(size)
return {
id,
x: CENTER_X,
y: CENTER_Y,
targetX,
targetY,
size,
baseOpacity: opacityBase + Math.random() * opacityRange,
visible: true,
zIndex: zIndexBase + Math.round(size),
twinkleDuration: 3 + Math.random() * 4, // 3-7s duration
twinkleDelay: Math.random() * 5 // 0-5s delay
}
}
const createStarLayers = () => {
const far: Star[] = []
for (let i = 0; i < 30; i++) {
far.push(createStar(i, [6, 4, 10, 6, 14, 6], 0.2, 0.25, 0.35, 0))
}
farStars.value = far
const mid: Star[] = []
for (let i = 0; i < 18; i++) {
mid.push(createStar(i, [18, 8, 24, 10, 32, 10], 0.35, 0.35, 0.45, 30))
}
midStars.value = mid
const near: Star[] = []
for (let i = 0; i < 10; i++) {
near.push(createStar(i, [40, 15, 55, 20, 70, 25], 0.5, 0.5, 0.55, 100))
}
nearStars.value = near
}
// Removed: handleAnimationIteration was causing stars to "teleport" visibly
// Stars now stay in place and only twinkle without changing position
// Scatter stars from center to their target positions
const scatterStars = () => {
isScattering.value = true
hasScattered.value = true
const allStars = [...farStars.value, ...midStars.value, ...nearStars.value]
allStars.forEach(star => {
star.x = star.targetX
star.y = star.targetY
})
setTimeout(() => {
isScattering.value = false
}, 2000)
}
// Reset stars to center
const resetStarsToCenter = () => {
hasScattered.value = false
isScattering.value = false
const allStars = [...farStars.value, ...midStars.value, ...nearStars.value]
allStars.forEach(star => {
star.x = CENTER_X
star.y = CENTER_Y
const { x, y } = getConstrainedPosition(star.size)
star.targetX = x
star.targetY = y
})
}
const getStarStyle = (star: Star): CSSProperties => {
return {
left: `${star.x}%`,
top: `${star.y}%`,
width: `${star.size}px`,
height: `${star.size}px`,
zIndex: star.zIndex,
'--base-opacity': star.baseOpacity,
'--twinkle-duration': `${star.twinkleDuration}s`,
'--twinkle-delay': `${star.twinkleDelay}s`
} as CSSProperties
}
// Watch for visibility changes
watch(() => props.isVisible, (newVal, oldVal) => {
if (newVal && !oldVal) {
// Entering: recreate stars if needed, reset to center then scatter
isFadingOut.value = false
isFullyHidden.value = false
// Recreate stars if they were cleared
if (farStars.value.length === 0) {
createStarLayers()
} else {
resetStarsToCenter()
}
setTimeout(() => {
scatterStars()
}, 50)
} else if (!newVal && oldVal) {
// Leaving: immediately stop animation and hide to release GPU resources
hasScattered.value = false
isScattering.value = false // Force stop scattering transition immediately
isFadingOut.value = true
// Wait for fade-out transition (150ms) to complete before cleanup
// Use a single timeout matching the CSS transition duration
setTimeout(() => {
if (!props.isVisible) {
isFullyHidden.value = true
isFadingOut.value = false
// Clear star arrays to fully release memory
farStars.value = []
midStars.value = []
nearStars.value = []
}
}, 180) // Slightly longer than CSS transition (150ms) to ensure smooth fade
}
}, { flush: 'post' }) // Change to post flush to ensure DOM is updated before our cleanup logic runs
onMounted(() => {
createStarLayers()
if (props.isVisible) {
isFullyHidden.value = false
setTimeout(() => {
scatterStars()
}, 100)
} else {
// Start hidden if not visible
isFullyHidden.value = true
}
})
onUnmounted(() => {
// No explicit cleanup needed for CSS animations
})
</script>
<style scoped>
.gemini-star-cluster {
perspective: 800px;
}
.stars-layer {
position: absolute;
inset: 0;
pointer-events: none;
}
.far-layer {
z-index: 1;
}
.mid-layer {
z-index: 2;
}
.near-layer {
z-index: 3;
}
.star-svg {
width: 100%;
height: 100%;
overflow: visible;
}
.star-wrapper {
position: absolute;
opacity: 0;
transform: scale(0.3) translate(-50%, -50%);
transition:
opacity 0.8s ease-out,
transform 0.8s ease-out;
/* Avoid persistent will-change to reduce GPU memory usage */
}
.star-wrapper.star-visible {
opacity: 0; /* Default to invisible, animation handles opacity */
transform: scale(1) translate(-50%, -50%);
animation: twinkle var(--twinkle-duration) ease-in-out infinite;
animation-delay: var(--twinkle-delay);
}
@keyframes twinkle {
0% {
opacity: 0;
transform: scale(0.5) translate(-50%, -50%);
}
50% {
opacity: var(--base-opacity);
transform: scale(1) translate(-50%, -50%);
filter: brightness(1.2);
}
100% {
opacity: 0;
transform: scale(0.5) translate(-50%, -50%);
}
}
/* Scatter animation - stars fly outward from center (2s duration) */
.scattering .star-wrapper {
transition:
opacity 0.8s ease-out,
transform 0.8s ease-out,
left 2s cubic-bezier(0.16, 1, 0.3, 1),
top 2s cubic-bezier(0.16, 1, 0.3, 1);
/* Only use will-change during active scatter animation */
will-change: opacity, transform, left, top;
}
/* Fade out animation - quick fade and disable child transitions */
.fading-out {
opacity: 0;
transition: opacity 0.15s ease-out;
pointer-events: none;
/* Force GPU layer removal */
will-change: auto;
}
.fading-out .star-wrapper {
transition: none !important;
will-change: auto !important;
animation: none !important;
opacity: 0 !important;
}
/* Depth blur effect */
.far-layer .star-wrapper {
filter: blur(0.5px);
}
.mid-layer .star-wrapper {
filter: blur(0.2px);
}
.near-layer .star-wrapper {
filter: none;
}
</style>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,310 @@
<template>
<div
ref="rootEl"
class="platform-select"
:class="[`platform-select--${sizeClass}` , { 'platform-select--open': isOpen }]"
tabindex="0"
@click="handleRootClick"
@keydown.enter.prevent="toggleDropdown"
@keydown.space.prevent="toggleDropdown"
@keydown.escape.stop="closeDropdown"
>
<div class="platform-select__current">
<component :is="currentOption.icon" class="platform-select__icon" />
<div class="platform-select__text">
<p class="platform-select__label">{{ currentOption.label }}</p>
<p class="platform-select__hint">{{ currentOption.hint }}</p>
</div>
</div>
<ChevronDown class="platform-select__chevron" />
<transition name="platform-select-fade">
<ul v-if="isOpen" class="platform-select__dropdown">
<li
v-for="option in resolvedOptions"
:key="option.value"
class="platform-select__option"
:class="{ 'platform-select__option--active': option.value === modelValue }"
@click.stop="selectOption(option.value)"
>
<component :is="option.icon" class="platform-select__option-icon" />
<div class="platform-select__option-copy">
<p class="platform-select__option-label">{{ option.label }}</p>
<p class="platform-select__option-hint">{{ option.hint }}</p>
</div>
<Check class="platform-select__option-check" v-if="option.value === modelValue" />
</li>
</ul>
</transition>
</div>
</template>
<script lang="ts">
import { Apple, Box, Monitor, Terminal } from 'lucide-vue-next'
import type { Component } from 'vue'
export interface PlatformOption {
value: string
label: string
hint: string
icon: Component
command: string
}
// Default options for backward compatibility
export const defaultPlatformOptions: PlatformOption[] = [
{ value: 'mac', label: 'Mac / Linux', hint: 'Terminal', icon: Terminal, command: '' },
{ value: 'windows', label: 'Windows', hint: 'PowerShell', icon: Monitor, command: '' }
]
// Preset configuration for each tool
export const platformPresets = {
default: {
options: defaultPlatformOptions,
defaultValue: 'mac'
},
claude: {
options: [
{ value: 'mac', label: 'Mac / Linux', hint: 'Terminal', icon: Terminal, command: 'curl -fsSL https://claude.ai/install.sh | bash' },
{ value: 'windows', label: 'Windows', hint: 'PowerShell', icon: Monitor, command: 'irm https://claude.ai/install.ps1 | iex' },
{ value: 'nodejs', label: 'Node.js', hint: 'npm', icon: Box, command: 'npm install -g @anthropic-ai/claude-code' },
{ value: 'homebrew', label: 'Mac', hint: 'Homebrew', icon: Apple, command: 'brew install --cask claude-code' }
] as PlatformOption[],
defaultValue: 'mac'
},
codex: {
options: [
{ value: 'nodejs', label: 'Node.js', hint: 'npm', icon: Box, command: 'npm install -g @openai/codex' },
{ value: 'homebrew', label: 'Mac', hint: 'Homebrew', icon: Apple, command: 'brew install --cask codex' }
] as PlatformOption[],
defaultValue: 'nodejs'
},
gemini: {
options: [
{ value: 'nodejs', label: 'Node.js', hint: 'npm', icon: Box, command: 'npm install -g @google/gemini-cli' },
{ value: 'homebrew', label: 'Mac', hint: 'Homebrew', icon: Apple, command: 'brew install gemini-cli' }
] as PlatformOption[],
defaultValue: 'nodejs'
}
}
// Helper to get command by platform value
export function getCommand(preset: keyof typeof platformPresets, value: string): string {
const config = platformPresets[preset]
return config.options.find((opt: PlatformOption) => opt.value === value)?.command ?? ''
}
</script>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { Check, ChevronDown } from 'lucide-vue-next'
const props = defineProps<{
modelValue: string
size?: 'md' | 'lg'
options?: PlatformOption[]
}>()
const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
}>()
const rootEl = ref<HTMLElement | null>(null)
const isOpen = ref(false)
const sizeClass = computed(() => props.size ?? 'md')
const resolvedOptions = computed(() => props.options ?? defaultPlatformOptions)
const currentOption = computed(() => resolvedOptions.value.find((option: PlatformOption) => option.value === props.modelValue) ?? resolvedOptions.value[0])
function toggleDropdown() {
isOpen.value = !isOpen.value
}
function closeDropdown() {
isOpen.value = false
}
function selectOption(value: string) {
if (value !== props.modelValue) {
emit('update:modelValue', value)
}
closeDropdown()
}
function handleRootClick(event: MouseEvent) {
const dropdown = rootEl.value?.querySelector('.platform-select__dropdown')
if (dropdown?.contains(event.target as Node)) {
return
}
toggleDropdown()
}
function handleClickOutside(event: MouseEvent) {
if (!rootEl.value) {
return
}
if (!rootEl.value.contains(event.target as Node)) {
closeDropdown()
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>
<style scoped>
.platform-select {
position: relative;
width: 11rem;
border: 1px solid var(--color-border);
border-radius: 0.9rem;
background-color: var(--color-background);
padding: 0.55rem 0.85rem;
cursor: pointer;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.12);
}
.dark .platform-select {
background-color: var(--color-background);
}
.platform-select--lg {
width: 13rem;
}
.platform-select:focus-visible,
.platform-select--open {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(204, 120, 92, 0.2);
}
.platform-select__current {
display: flex;
align-items: center;
gap: 0.65rem;
}
.platform-select__icon {
width: 1.1rem;
height: 1.1rem;
color: var(--color-primary);
}
.platform-select__text {
display: flex;
flex-direction: column;
line-height: 1.1;
}
.platform-select__label {
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
}
.platform-select__hint {
font-size: 0.7rem;
color: #91918d;
white-space: nowrap;
}
.dark .platform-select__hint {
color: #a8a29e;
}
.platform-select__chevron {
width: 0.9rem;
height: 0.9rem;
color: var(--color-border-soft);
}
.platform-select__dropdown {
position: absolute;
top: calc(100% + 0.45rem);
left: 0;
right: 0;
padding: 0.35rem;
border-radius: 1rem;
border: 1px solid var(--color-border);
background-color: var(--color-background);
box-shadow: 0 25px 55px rgba(0, 0, 0, 0.25);
z-index: 30;
backdrop-filter: blur(16px);
}
.platform-select__option {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.55rem 0.6rem;
border-radius: 0.75rem;
transition: background 0.2s ease, color 0.2s ease;
}
.platform-select__option:hover {
background: rgba(204, 120, 92, 0.1);
}
.platform-select__option--active {
background: rgba(204, 120, 92, 0.18);
}
.platform-select__option-icon {
width: 1rem;
height: 1rem;
color: var(--color-primary);
}
.platform-select__option-copy {
display: flex;
flex-direction: column;
line-height: 1.1;
}
.platform-select__option-label {
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
}
.platform-select__option-hint {
font-size: 0.7rem;
color: #91918d;
white-space: nowrap;
}
.dark .platform-select__option-hint {
color: #a8a29e;
}
.platform-select__option-check {
margin-left: auto;
width: 0.85rem;
height: 0.85rem;
color: var(--color-primary);
}
.platform-select-fade-enter-active,
.platform-select-fade-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.platform-select-fade-enter-from,
.platform-select-fade-leave-to {
opacity: 0;
transform: translateY(-6px);
}
</style>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,70 @@
<template>
<div class="fixed top-4 left-1/2 -translate-x-1/2 z-[100] flex flex-col items-center gap-2">
<TransitionGroup
name="toast"
tag="div"
class="flex flex-col items-center gap-2"
>
<ToastWithProgress
v-for="toast in toasts"
:key="toast.id"
:toast="toast"
@remove="removeToast(toast.id)"
/>
</TransitionGroup>
</div>
</template>
<script setup lang="ts">
import ToastWithProgress from './ToastWithProgress.vue'
import { useToast } from '@/composables/useToast'
const { toasts, removeToast } = useToast()
</script>
<style scoped>
/* 进入动画 - 从上方弹入 */
.toast-enter-active {
transition: all 0.4s cubic-bezier(0.2, 0.9, 0.3, 1);
}
.toast-enter-from {
transform: translateY(-20px) scale(0.95);
opacity: 0;
}
.toast-enter-to {
transform: translateY(0) scale(1);
opacity: 1;
}
/* 弹出动画 - 向上消失 */
.toast-leave-active {
transition: all 0.2s ease-out;
}
.toast-leave-from {
transform: translateY(0) scale(1);
opacity: 1;
}
.toast-leave-to {
transform: translateY(-20px) scale(0.95);
opacity: 0;
}
/* 移动动画 */
.toast-move {
transition: all 0.4s cubic-bezier(0.2, 0.9, 0.3, 1);
}
/* 响应式调整 */
@media (max-width: 640px) {
div.fixed {
top: 1rem;
left: 1rem;
right: 1rem;
transform: none;
}
}
</style>

View File

@@ -0,0 +1,185 @@
<template>
<div
role="alert"
class="flex items-start gap-4 px-6 py-3 rounded-lg border shadow-sm max-w-md"
:class="variantClasses"
>
<!-- 图标带圆形进度环 -->
<div class="relative shrink-0 w-8 h-8">
<!-- 进度环背景 -->
<svg
v-if="toast.duration && toast.duration > 0"
class="absolute inset-0 w-8 h-8 -rotate-90"
>
<circle
cx="16"
cy="16"
r="14"
fill="none"
stroke="currentColor"
stroke-width="2"
class="opacity-15"
/>
<circle
cx="16"
cy="16"
r="14"
fill="none"
stroke="currentColor"
stroke-width="2"
:stroke-dasharray="circumference"
:stroke-dashoffset="strokeDashoffset"
stroke-linecap="round"
class="transition-[stroke-dashoffset] duration-75"
:class="progressColorClass"
/>
</svg>
<!-- 图标 -->
<div class="absolute inset-0 flex items-center justify-center" :class="iconClasses">
<component :is="icon" class="w-4 h-4" />
</div>
</div>
<!-- 内容 -->
<div class="flex-1 min-w-0">
<p v-if="toast.title" class="text-sm font-medium" :class="titleClasses">
{{ toast.title }}
</p>
<p v-if="toast.message" class="text-sm" :class="messageClasses">
{{ toast.message }}
</p>
</div>
<!-- 关闭按钮 -->
<button
@click="$emit('remove')"
class="shrink-0 p-1 rounded transition-colors opacity-40 hover:opacity-100"
:class="closeClasses"
type="button"
aria-label="关闭"
>
<X class="w-3.5 h-3.5" />
</button>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { CheckCircle2, XCircle, AlertTriangle, Info, X } from 'lucide-vue-next'
interface Toast {
id: string
title?: string
message?: string
variant?: 'success' | 'error' | 'warning' | 'info'
duration?: number
}
const props = defineProps<{
toast: Toast
}>()
const emit = defineEmits<{
remove: []
}>()
const progress = ref(100)
let startTime = 0
let rafId: number | null = null
let timeoutId: ReturnType<typeof setTimeout> | null = null
// 圆形进度环参数
const circumference = 2 * Math.PI * 14 // r=14
const strokeDashoffset = computed(() => {
return circumference * (1 - progress.value / 100)
})
const updateProgress = () => {
if (!props.toast.duration || props.toast.duration <= 0) return
const elapsed = Date.now() - startTime
const remaining = Math.max(0, 100 - (elapsed / props.toast.duration) * 100)
progress.value = remaining
if (remaining <= 0) {
emit('remove')
} else {
rafId = requestAnimationFrame(updateProgress)
}
}
onMounted(() => {
if (props.toast.duration && props.toast.duration > 0) {
startTime = Date.now()
rafId = requestAnimationFrame(updateProgress)
// 保底 timeout确保即使在后台也能移除
timeoutId = setTimeout(() => {
emit('remove')
}, props.toast.duration + 100)
}
})
onUnmounted(() => {
if (rafId) cancelAnimationFrame(rafId)
if (timeoutId) clearTimeout(timeoutId)
})
const icons = {
success: CheckCircle2,
error: XCircle,
warning: AlertTriangle,
info: Info
}
const icon = computed(() => icons[props.toast.variant || 'info'])
const variantClasses = computed(() => {
const variant = props.toast.variant || 'info'
const classes: Record<string, string> = {
success: 'border-[#5F8D4E]/30 bg-white dark:bg-[var(--slate-dark)]',
error: 'border-[var(--error)]/30 bg-white dark:bg-[var(--slate-dark)]',
warning: 'border-[var(--book-cloth)]/30 bg-white dark:bg-[var(--slate-dark)]',
info: 'border-[var(--slate-medium)]/20 bg-white dark:bg-[var(--slate-dark)]'
}
return classes[variant]
})
const iconClasses = computed(() => {
const variant = props.toast.variant || 'info'
const classes: Record<string, string> = {
success: 'text-[#5F8D4E]',
error: 'text-[var(--error)]',
warning: 'text-[var(--book-cloth)]',
info: 'text-[var(--slate-medium)] dark:text-[var(--cloud-medium)]'
}
return classes[variant]
})
const progressColorClass = computed(() => {
const variant = props.toast.variant || 'info'
const classes: Record<string, string> = {
success: 'stroke-[#5F8D4E]',
error: 'stroke-[var(--error)]',
warning: 'stroke-[var(--book-cloth)]',
info: 'stroke-[var(--slate-medium)]'
}
return classes[variant]
})
const titleClasses = computed(() => {
return 'text-[var(--color-text)]'
})
const messageClasses = computed(() => {
return 'text-[var(--slate-medium)] dark:text-[var(--cloud-medium)]'
})
const closeClasses = computed(() => {
return 'text-[var(--slate-medium)] hover:bg-[var(--color-border-soft)] dark:text-[var(--cloud-medium)]'
})
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,148 @@
<template>
<div class="w-full h-full">
<canvas ref="chartRef"></canvas>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
BarController,
Title,
Tooltip,
Legend,
type ChartData,
type ChartOptions
} from 'chart.js'
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
BarController,
Title,
Tooltip,
Legend
)
interface Props {
data: ChartData<'bar'>
options?: ChartOptions<'bar'>
height?: number
stacked?: boolean
}
const props = withDefaults(defineProps<Props>(), {
height: 300,
stacked: true
})
const chartRef = ref<HTMLCanvasElement>()
let chart: ChartJS<'bar'> | null = null
const defaultOptions: ChartOptions<'bar'> = {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
scales: {
x: {
stacked: true,
grid: {
color: 'rgba(156, 163, 175, 0.1)'
},
ticks: {
color: 'rgb(107, 114, 128)'
}
},
y: {
stacked: true,
grid: {
color: 'rgba(156, 163, 175, 0.1)'
},
ticks: {
color: 'rgb(107, 114, 128)'
}
}
},
plugins: {
legend: {
position: 'top',
labels: {
color: 'rgb(107, 114, 128)',
usePointStyle: true,
padding: 16
}
},
tooltip: {
backgroundColor: 'rgb(31, 41, 55)',
titleColor: 'rgb(243, 244, 246)',
bodyColor: 'rgb(243, 244, 246)',
borderColor: 'rgb(75, 85, 99)',
borderWidth: 1
}
}
}
function createChart() {
if (!chartRef.value) return
const stackedOptions = props.stacked ? {
scales: {
x: { ...defaultOptions.scales?.x, stacked: true },
y: { ...defaultOptions.scales?.y, stacked: true }
}
} : {
scales: {
x: { ...defaultOptions.scales?.x, stacked: false },
y: { ...defaultOptions.scales?.y, stacked: false }
}
}
chart = new ChartJS(chartRef.value, {
type: 'bar',
data: props.data,
options: {
...defaultOptions,
...stackedOptions,
...props.options
}
})
}
function updateChart() {
if (chart) {
chart.data = props.data
chart.update('none')
}
}
onMounted(async () => {
await nextTick()
createChart()
})
onUnmounted(() => {
if (chart) {
chart.destroy()
chart = null
}
})
watch(() => props.data, updateChart, { deep: true })
watch(() => props.options, () => {
if (chart) {
chart.options = {
...defaultOptions,
...props.options
}
chart.update()
}
}, { deep: true })
</script>

View File

@@ -0,0 +1,128 @@
<template>
<div class="w-full h-full">
<canvas ref="chartRef"></canvas>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
LineController,
Title,
Tooltip,
Legend,
type ChartData,
type ChartOptions
} from 'chart.js'
// 注册 Chart.js 组件
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
LineController,
Title,
Tooltip,
Legend
)
interface Props {
data: ChartData<'line'>
options?: ChartOptions<'line'>
height?: number
}
const props = withDefaults(defineProps<Props>(), {
height: 300
})
const chartRef = ref<HTMLCanvasElement>()
let chart: ChartJS<'line'> | null = null
const defaultOptions: ChartOptions<'line'> = {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
grid: {
color: 'rgba(156, 163, 175, 0.1)' // gray-400 with opacity
},
ticks: {
color: 'rgb(107, 114, 128)' // gray-500
}
},
y: {
grid: {
color: 'rgba(156, 163, 175, 0.1)' // gray-400 with opacity
},
ticks: {
color: 'rgb(107, 114, 128)' // gray-500
}
}
},
plugins: {
legend: {
labels: {
color: 'rgb(107, 114, 128)' // gray-500
}
},
tooltip: {
backgroundColor: 'rgb(31, 41, 55)', // gray-800
titleColor: 'rgb(243, 244, 246)', // gray-100
bodyColor: 'rgb(243, 244, 246)', // gray-100
borderColor: 'rgb(75, 85, 99)', // gray-600
borderWidth: 1
}
}
}
function createChart() {
if (!chartRef.value) return
chart = new ChartJS(chartRef.value, {
type: 'line',
data: props.data,
options: {
...defaultOptions,
...props.options
}
})
}
function updateChart() {
if (chart) {
chart.data = props.data
chart.update('none') // 禁用动画以提高性能
}
}
onMounted(async () => {
await nextTick()
createChart()
})
onUnmounted(() => {
if (chart) {
chart.destroy()
chart = null
}
})
// 监听数据变化
watch(() => props.data, updateChart, { deep: true })
watch(() => props.options, () => {
if (chart) {
chart.options = {
...defaultOptions,
...props.options
}
chart.update()
}
}, { deep: true })
</script>

View File

@@ -0,0 +1,165 @@
<template>
<Dialog :modelValue="modelValue" @update:modelValue="handleClose" :zIndex="80">
<template #header>
<div class="border-b border-border px-6 py-4">
<div class="flex items-center gap-3">
<component :is="icon" class="h-5 w-5 flex-shrink-0" :class="iconColorClass" />
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-foreground leading-tight">{{ title }}</h3>
</div>
</div>
</div>
</template>
<template #default>
<!-- 描述 -->
<div class="space-y-3">
<p v-for="(line, index) in descriptionLines" :key="index" :class="getLineClass(index)">
{{ line }}
</p>
</div>
<!-- 自定义内容插槽 -->
<slot></slot>
</template>
<template #footer>
<!-- 取消按钮 -->
<Button
variant="outline"
@click="handleCancel"
:disabled="loading"
class="h-10 px-5"
>
{{ cancelText }}
</Button>
<!-- 确认按钮 -->
<Button
:variant="confirmVariant"
@click="handleConfirm"
:disabled="loading"
class="h-10 px-5"
>
<Loader2 v-if="loading" class="animate-spin h-4 w-4 mr-2" />
{{ confirmText }}
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Dialog } from '@/components/ui'
import Button from '@/components/ui/button.vue'
import { AlertTriangle, AlertCircle, Info, Trash2, HelpCircle, Loader2 } from 'lucide-vue-next'
export type AlertType = 'danger' | 'destructive' | 'warning' | 'info' | 'question'
interface Props {
modelValue: boolean
title: string
description: string
type?: AlertType
confirmText?: string
cancelText?: string
loading?: boolean
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'confirm'): void
(e: 'cancel'): void
}
const props = withDefaults(defineProps<Props>(), {
type: 'warning',
confirmText: '确认',
cancelText: '取消',
loading: false
})
const emit = defineEmits<Emits>()
// 解析描述文本为多行
const descriptionLines = computed(() => {
return props.description.split('\n').filter(line => line.trim())
})
// 根据行索引获取样式(中间行高亮)
function getLineClass(index: number): string {
const total = descriptionLines.value.length
if (total <= 1) {
return 'text-sm text-muted-foreground'
}
// 中间行(不是第一行也不是最后一行)使用高亮样式
if (index > 0 && index < total - 1) {
return 'text-sm font-mono font-medium text-foreground bg-muted/50 px-3 py-2 rounded-md'
}
return 'text-sm text-muted-foreground'
}
// 根据类型获取图标
const icon = computed(() => {
switch (props.type) {
case 'danger':
case 'destructive':
return Trash2
case 'warning':
return AlertTriangle
case 'info':
return Info
case 'question':
return HelpCircle
default:
return AlertCircle
}
})
// 根据类型获取图标颜色样式
const iconColorClass = computed(() => {
switch (props.type) {
case 'danger':
case 'destructive':
return 'text-rose-600 dark:text-rose-400'
case 'warning':
return 'text-amber-600 dark:text-amber-400'
case 'info':
return 'text-primary'
case 'question':
return 'text-gray-600 dark:text-muted-foreground'
default:
return 'text-primary'
}
})
// 根据类型获取确认按钮样式
const confirmVariant = computed(() => {
switch (props.type) {
case 'danger':
case 'destructive':
return 'destructive' as const
case 'warning':
case 'info':
case 'question':
default:
return 'default' as const
}
})
function handleConfirm() {
emit('confirm')
}
function handleCancel() {
emit('cancel')
emit('update:modelValue', false)
}
function handleClose(value: boolean) {
if (!value && !props.loading) {
emit('update:modelValue', value)
emit('cancel')
}
}
</script>

View File

@@ -0,0 +1,285 @@
<template>
<div :class="containerClasses">
<!-- 图标 -->
<div :class="iconContainerClasses">
<component
v-if="icon"
:is="icon"
:class="iconClasses"
/>
<component
v-else
:is="defaultIcon"
:class="iconClasses"
/>
</div>
<!-- 标题 -->
<h3 v-if="title" :class="titleClasses">
{{ title }}
</h3>
<!-- 描述 -->
<p v-if="description" :class="descriptionClasses">
{{ description }}
</p>
<!-- 自定义内容插槽 -->
<div v-if="$slots.default" class="mt-4">
<slot />
</div>
<!-- 操作按钮 -->
<div v-if="$slots.actions || actionText" class="mt-6 flex flex-wrap items-center justify-center gap-3">
<slot name="actions">
<Button
v-if="actionText"
@click="handleAction"
:variant="actionVariant"
:size="actionSize"
>
<component v-if="actionIcon" :is="actionIcon" class="mr-2 h-4 w-4" />
{{ actionText }}
</Button>
</slot>
</div>
<!-- 次要操作 -->
<div v-if="$slots.secondary" class="mt-3">
<slot name="secondary" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Button from '@/components/ui/button.vue'
import {
FileQuestion,
Search,
Inbox,
AlertCircle,
PackageOpen,
FolderOpen,
Database,
Filter
} from 'lucide-vue-next'
import type { Component } from 'vue'
type EmptyStateType = 'default' | 'search' | 'filter' | 'error' | 'empty' | 'notFound'
type ButtonVariant = 'default' | 'outline' | 'secondary' | 'ghost' | 'link' | 'destructive'
type ButtonSize = 'sm' | 'default' | 'lg' | 'icon'
interface Props {
/** 空状态类型 */
type?: EmptyStateType
/** 自定义图标组件 */
icon?: Component
/** 标题 */
title?: string
/** 描述文本 */
description?: string
/** 操作按钮文本 */
actionText?: string
/** 操作按钮图标 */
actionIcon?: Component
/** 操作按钮变体 */
actionVariant?: ButtonVariant
/** 操作按钮大小 */
actionSize?: ButtonSize
/** 大小 */
size?: 'sm' | 'md' | 'lg'
/** 对齐方式 */
align?: 'left' | 'center' | 'right'
}
interface Emits {
(e: 'action'): void
}
const props = withDefaults(defineProps<Props>(), {
type: 'default',
actionVariant: 'default',
actionSize: 'default',
size: 'md',
align: 'center'
})
const emit = defineEmits<Emits>()
// 根据类型获取默认配置
const typeConfig = computed(() => {
const configs = {
default: {
icon: Inbox,
title: '暂无数据',
description: '当前没有可显示的内容'
},
search: {
icon: Search,
title: '未找到结果',
description: '尝试使用不同的关键词搜索'
},
filter: {
icon: Filter,
title: '无匹配结果',
description: '没有符合当前筛选条件的数据'
},
error: {
icon: AlertCircle,
title: '加载失败',
description: '数据加载过程中出现错误'
},
empty: {
icon: PackageOpen,
title: '这里空空如也',
description: '还没有任何内容'
},
notFound: {
icon: FileQuestion,
title: '未找到',
description: '请求的资源不存在'
}
}
return configs[props.type]
})
// 默认图标
const defaultIcon = computed(() => typeConfig.value.icon)
// 容器样式
const containerClasses = computed(() => {
const classes = ['empty-state']
// 大小
if (props.size === 'sm') {
classes.push('empty-state-sm', 'py-6')
} else if (props.size === 'lg') {
classes.push('empty-state-lg', 'py-16')
} else {
classes.push('empty-state-md', 'py-12')
}
// 对齐
if (props.align === 'left') {
classes.push('text-left')
} else if (props.align === 'right') {
classes.push('text-right')
} else {
classes.push('text-center')
}
return classes.join(' ')
})
// 图标容器样式
const iconContainerClasses = computed(() => {
const classes = [
'empty-state-icon-container',
'rounded-full',
'inline-flex',
'items-center',
'justify-center',
'mb-4'
]
// 大小和颜色
if (props.type === 'error') {
classes.push('bg-red-100', 'dark:bg-red-900/30')
} else if (props.type === 'search' || props.type === 'filter') {
classes.push('bg-blue-100', 'dark:bg-blue-900/30')
} else {
classes.push('bg-muted')
}
// 尺寸
if (props.size === 'sm') {
classes.push('w-12', 'h-12')
} else if (props.size === 'lg') {
classes.push('w-20', 'h-20')
} else {
classes.push('w-16', 'h-16')
}
return classes.join(' ')
})
// 图标样式
const iconClasses = computed(() => {
const classes = []
// 颜色
if (props.type === 'error') {
classes.push('text-red-600', 'dark:text-red-400')
} else if (props.type === 'search' || props.type === 'filter') {
classes.push('text-blue-600', 'dark:text-blue-400')
} else {
classes.push('text-muted-foreground')
}
// 尺寸
if (props.size === 'sm') {
classes.push('w-6', 'h-6')
} else if (props.size === 'lg') {
classes.push('w-10', 'h-10')
} else {
classes.push('w-8', 'h-8')
}
return classes.join(' ')
})
// 标题样式
const titleClasses = computed(() => {
const classes = ['font-semibold', 'text-foreground', 'mb-2']
if (props.size === 'sm') {
classes.push('text-base')
} else if (props.size === 'lg') {
classes.push('text-2xl')
} else {
classes.push('text-lg')
}
return classes.join(' ')
})
// 描述样式
const descriptionClasses = computed(() => {
const classes = ['text-muted-foreground', 'max-w-md']
if (props.align === 'center') {
classes.push('mx-auto')
}
if (props.size === 'sm') {
classes.push('text-xs')
} else if (props.size === 'lg') {
classes.push('text-base')
} else {
classes.push('text-sm')
}
return classes.join(' ')
})
// 处理操作
function handleAction() {
emit('action')
}
</script>
<style scoped>
.empty-state {
@apply flex flex-col items-center justify-center;
}
.empty-state-icon-container {
@apply transition-transform duration-200;
}
.empty-state:hover .empty-state-icon-container {
@apply scale-105;
}
</style>

View File

@@ -0,0 +1,69 @@
<template>
<div :class="containerClasses">
<div class="flex flex-col items-center gap-4">
<Skeleton v-if="variant === 'skeleton'" :class="skeletonClasses" />
<div v-else-if="variant === 'spinner'" class="relative">
<div class="h-12 w-12 animate-spin rounded-full border-4 border-muted border-t-primary"></div>
</div>
<div v-else-if="variant === 'pulse'" class="flex gap-2">
<div
v-for="i in 3"
:key="i"
class="h-3 w-3 animate-pulse rounded-full bg-primary"
:style="{ animationDelay: `${i * 150}ms` }"
></div>
</div>
<div v-if="message" class="text-sm text-muted-foreground">
{{ message }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Skeleton from '@/components/ui/skeleton.vue'
interface Props {
variant?: 'skeleton' | 'spinner' | 'pulse'
message?: string
size?: 'sm' | 'md' | 'lg'
fullHeight?: boolean
}
const props = withDefaults(defineProps<Props>(), {
variant: 'spinner',
size: 'md',
fullHeight: false,
})
const containerClasses = computed(() => {
const classes = ['flex items-center justify-center']
if (props.fullHeight) {
classes.push('min-h-[400px]')
} else {
const sizeMap = {
sm: 'py-8',
md: 'py-12',
lg: 'py-16',
}
classes.push(sizeMap[props.size])
}
return classes.join(' ')
})
const skeletonClasses = computed(() => {
const sizeMap = {
sm: 'h-24 w-full',
md: 'h-48 w-full',
lg: 'h-64 w-full',
}
return sizeMap[props.size]
})
</script>

View File

@@ -0,0 +1,9 @@
/**
* Common Components
* 常用的自定义业务组件
*/
// 状态和反馈组件
export { default as EmptyState } from './EmptyState.vue'
export { default as AlertDialog } from './AlertDialog.vue'
export { default as LoadingState } from './LoadingState.vue'

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,55 @@
<template>
<div class="app-shell" :class="{ 'pt-24': showNotice }">
<div
v-if="showNotice"
class="fixed top-6 left-0 right-0 z-50 flex justify-center px-4"
>
<slot name="notice" />
</div>
<div class="app-shell__backdrop">
<div class="app-shell__gradient app-shell__gradient--primary"></div>
<div class="app-shell__gradient app-shell__gradient--accent"></div>
</div>
<div class="app-shell__body">
<aside
v-if="$slots.sidebar"
class="app-shell__sidebar"
:class="sidebarClass"
>
<slot name="sidebar" />
</aside>
<div class="app-shell__content" :class="contentClass">
<slot name="header" />
<main :class="mainClass">
<slot />
</main>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(defineProps<{
showNotice?: boolean
contentClass?: string
mainClass?: string
sidebarClass?: string
}>(), {
showNotice: false,
contentClass: '',
mainClass: '',
sidebarClass: '',
})
const showNotice = computed(() => props.showNotice)
// contentClass and mainClass are now just the props, base classes are in template
const contentClass = computed(() => props.contentClass)
const mainClass = computed(() => ['app-shell__main', props.mainClass].filter(Boolean).join(' '))
const sidebarClass = computed(() => props.sidebarClass)
</script>

View File

@@ -0,0 +1,103 @@
<template>
<Card :class="cardClasses">
<div v-if="title || description || $slots.header" :class="headerClasses">
<slot name="header">
<div class="flex items-center justify-between">
<div>
<h3 v-if="title" class="text-lg font-medium leading-6 text-foreground">
{{ title }}
</h3>
<p v-if="description" class="mt-1 text-sm text-muted-foreground">
{{ description }}
</p>
</div>
<div v-if="$slots.actions">
<slot name="actions" />
</div>
</div>
</slot>
</div>
<div :class="contentClasses">
<slot />
</div>
<div v-if="$slots.footer" :class="footerClasses">
<slot name="footer" />
</div>
</Card>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import Card from '@/components/ui/card.vue'
interface Props {
title?: string
description?: string
variant?: 'default' | 'elevated' | 'glass'
padding?: 'none' | 'sm' | 'md' | 'lg'
}
const props = withDefaults(defineProps<Props>(), {
variant: 'default',
padding: 'md',
})
const cardClasses = computed(() => {
const classes = []
if (props.variant === 'elevated') {
classes.push('shadow-md')
} else if (props.variant === 'glass') {
classes.push('surface-glass')
}
return classes.join(' ')
})
const headerClasses = computed(() => {
const paddingMap = {
none: '',
sm: 'px-3 py-3',
md: 'px-4 py-5 sm:p-6',
lg: 'px-6 py-6 sm:p-8',
}
const classes = [paddingMap[props.padding]]
if (props.padding !== 'none') {
classes.push('border-b border-border')
}
return classes.join(' ')
})
const contentClasses = computed(() => {
const paddingMap = {
none: '',
sm: 'px-3 py-3',
md: 'px-4 py-5 sm:p-6',
lg: 'px-6 py-6 sm:p-8',
}
return paddingMap[props.padding]
})
const footerClasses = computed(() => {
const paddingMap = {
none: '',
sm: 'px-3 py-3',
md: 'px-4 py-5 sm:p-6',
lg: 'px-6 py-6 sm:p-8',
}
const classes = [paddingMap[props.padding]]
if (props.padding !== 'none') {
classes.push('border-t border-border')
}
return classes.join(' ')
})
</script>

View File

@@ -0,0 +1,123 @@
<template>
<div class="lg:hidden">
<div class="sticky top-0 z-40 space-y-4 pb-4">
<!-- Logo头部 - 移动端优化 -->
<div class="flex items-center gap-3 rounded-2xl bg-card/90 px-4 py-3 shadow-lg shadow-primary/20 ring-1 ring-border backdrop-blur">
<div class="flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-2xl bg-background shadow-md shadow-primary/20 flex-shrink-0">
<img src="/aether_adaptive.svg" alt="Logo" class="h-8 w-8 sm:h-10 sm:w-10" />
</div>
<!-- 文字部分 - 小屏隐藏 -->
<div class="hidden sm:block">
<p class="text-sm font-semibold text-foreground">
Aether
</p>
<p class="text-xs text-muted-foreground">
AI 控制中心
</p>
</div>
</div>
<button
type="button"
class="flex w-full items-center gap-3 rounded-2xl bg-card/80 px-4 py-3 shadow-lg shadow-primary/20 ring-1 ring-border backdrop-blur transition hover:ring-primary/30"
@click="toggleMenu"
>
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10 text-primary flex-shrink-0">
<Menu class="h-5 w-5" />
</div>
<div class="flex flex-1 flex-col text-left min-w-0">
<span class="text-sm font-semibold text-foreground truncate">
快速导航
</span>
<span class="text-xs text-muted-foreground truncate">
{{ activeItem ? `当前:${activeItem.name}` : '选择功能页面' }}
</span>
</div>
<ChevronDown
class="h-4 w-4 text-muted-foreground transition-transform duration-200 flex-shrink-0"
:class="{ 'rotate-180': isOpen }"
/>
</button>
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0 -translate-y-2 scale-95"
enter-to-class="opacity-100 translate-y-0 scale-100"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100 translate-y-0 scale-100"
leave-to-class="opacity-0 -translate-y-1 scale-95"
>
<div
v-if="isOpen"
class="space-y-3 rounded-3xl bg-card/95 p-4 shadow-2xl ring-1 ring-border backdrop-blur-xl"
>
<SidebarNav
:items="props.items"
:is-active="isLinkActive"
:active-path="props.activePath"
list-class="space-y-2"
@navigate="handleNavigate"
/>
</div>
</Transition>
</div>
</div>
</template>
<script setup lang="ts">
import type { Component } from 'vue'
import { computed, ref, watch } from 'vue'
import { ChevronDown, Menu } from 'lucide-vue-next'
import SidebarNav from '@/components/layout/SidebarNav.vue'
export interface NavigationItem {
name: string
href: string
icon: Component
description?: string
}
export interface NavigationGroup {
title?: string
items: NavigationItem[]
}
const props = defineProps<{
items: NavigationGroup[]
activePath?: string
isActive?: (href: string) => boolean
isDark?: boolean
}>()
const isOpen = ref(false)
const activeItem = computed(() => {
for (const group of props.items) {
const found = group.items.find(item => isLinkActive(item.href))
if (found) return found
}
return null
})
function isLinkActive(href: string) {
if (props.isActive) {
return props.isActive(href)
}
if (props.activePath) {
return props.activePath === href || props.activePath.startsWith(`${href}/`)
}
return false
}
function toggleMenu() {
isOpen.value = !isOpen.value
}
function handleNavigate() {
isOpen.value = false
}
watch(() => props.activePath, () => {
isOpen.value = false
})
</script>

View File

@@ -0,0 +1,47 @@
<template>
<div :class="containerClasses">
<slot />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full'
padding?: 'none' | 'sm' | 'md' | 'lg'
}
const props = withDefaults(defineProps<Props>(), {
maxWidth: '2xl',
padding: 'md',
})
const containerClasses = computed(() => {
const classes = ['w-full mx-auto']
// Max width
const maxWidthMap = {
sm: 'max-w-screen-sm',
md: 'max-w-screen-md',
lg: 'max-w-screen-lg',
xl: 'max-w-screen-xl',
'2xl': 'max-w-screen-2xl',
full: 'max-w-full',
}
classes.push(maxWidthMap[props.maxWidth])
// Padding
const paddingMap = {
none: '',
sm: 'px-4 py-4',
md: 'px-4 py-6 sm:px-6 lg:px-8',
lg: 'px-6 py-8 sm:px-8 lg:px-12',
}
if (props.padding !== 'none') {
classes.push(paddingMap[props.padding])
}
return classes.join(' ')
})
</script>

View File

@@ -0,0 +1,38 @@
<template>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex-1">
<div class="flex items-center gap-3">
<slot name="icon">
<div v-if="icon" class="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10">
<component :is="icon" class="h-5 w-5 text-primary" />
</div>
</slot>
<div>
<h1 class="text-2xl font-semibold text-foreground sm:text-3xl">
{{ title }}
</h1>
<p v-if="description" class="mt-1 text-sm text-muted-foreground">
{{ description }}
</p>
</div>
</div>
</div>
<div v-if="$slots.actions" class="flex items-center gap-2">
<slot name="actions" />
</div>
</div>
</template>
<script setup lang="ts">
import type { Component } from 'vue'
interface Props {
title: string
description?: string
icon?: Component
}
defineProps<Props>()
</script>

View File

@@ -0,0 +1,54 @@
<template>
<section :class="sectionClasses">
<div v-if="title || description || $slots.header" class="mb-6">
<slot name="header">
<div class="flex items-center justify-between">
<div>
<h2 v-if="title" class="text-lg font-medium text-foreground">
{{ title }}
</h2>
<p v-if="description" class="mt-1 text-sm text-muted-foreground">
{{ description }}
</p>
</div>
<div v-if="$slots.actions">
<slot name="actions" />
</div>
</div>
</slot>
</div>
<slot />
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
title?: string
description?: string
spacing?: 'none' | 'sm' | 'md' | 'lg'
}
const props = withDefaults(defineProps<Props>(), {
spacing: 'md',
})
const sectionClasses = computed(() => {
const classes = []
const spacingMap = {
none: '',
sm: 'mb-4',
md: 'mb-6',
lg: 'mb-8',
}
if (props.spacing !== 'none') {
classes.push(spacingMap[props.spacing])
}
return classes.join(' ')
})
</script>

View File

@@ -0,0 +1,88 @@
<template>
<nav class="sidebar-nav w-full px-3">
<div v-for="(group, index) in items" :key="index" class="space-y-1 mb-5">
<!-- Section Header -->
<div v-if="group.title" class="px-2.5 pb-1 flex items-center gap-2" :class="index > 0 ? 'pt-1' : ''">
<span class="text-[10px] font-medium text-muted-foreground/50 font-mono tabular-nums">{{ String(index + 1).padStart(2, '0') }}</span>
<span class="text-[10px] font-semibold text-muted-foreground/70 uppercase tracking-[0.1em]">{{ group.title }}</span>
</div>
<!-- Links -->
<div class="space-y-0.5">
<template v-for="item in group.items" :key="item.href">
<RouterLink
:to="item.href"
class="group relative flex items-center justify-between px-2.5 py-2 rounded-lg transition-all duration-200"
:class="[
isItemActive(item.href)
? 'bg-primary/10 text-primary font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
]"
@click="handleNavigate(item.href)"
>
<div class="flex items-center gap-2.5">
<component
:is="item.icon"
class="h-4 w-4 transition-colors duration-200"
:class="isItemActive(item.href) ? 'text-primary' : 'text-muted-foreground/70 group-hover:text-foreground'"
:stroke-width="isItemActive(item.href) ? 2 : 1.75"
/>
<span class="text-[13px] tracking-tight">{{ item.name }}</span>
</div>
<!-- Active Indicator -->
<div
v-if="isItemActive(item.href)"
class="w-1 h-1 rounded-full bg-primary"
></div>
</RouterLink>
</template>
</div>
</div>
</nav>
</template>
<script setup lang="ts">
import type { Component } from 'vue'
export interface NavigationItem {
name: string
href: string
icon: Component
description?: string
}
export interface NavigationGroup {
title?: string
items: NavigationItem[]
}
const props = defineProps<{
items: NavigationGroup[]
activePath?: string
isActive?: (href: string) => boolean
}>()
const emit = defineEmits<{
(e: 'navigate', href: string): void
}>()
function isItemActive(href: string) {
if (props.isActive) {
return props.isActive(href)
}
if (props.activePath) {
return props.activePath === href || props.activePath.startsWith(`${href}/`)
}
return false
}
function handleNavigate(href: string) {
emit('navigate', href)
}
</script>
<style scoped>
/* Navigation styles handled by Tailwind */
</style>

View File

@@ -0,0 +1,15 @@
/**
* Layout Components Library
* 基于 shadcn/ui 的自定义布局组件库
*/
// 页面布局组件
export { default as PageHeader } from './PageHeader.vue'
export { default as PageContainer } from './PageContainer.vue'
export { default as Section } from './Section.vue'
export { default as CardSection } from './CardSection.vue'
// 应用外壳组件
export { default as AppShell } from './AppShell.vue'
export { default as MobileNav } from './MobileNav.vue'
export { default as SidebarNav } from './SidebarNav.vue'

View File

@@ -0,0 +1,360 @@
<template>
<div class="space-y-4 w-full overflow-visible">
<div v-if="showHeader" class="flex items-center justify-between gap-4">
<div class="flex-shrink-0">
<p class="text-sm font-semibold">{{ title }}</p>
<p v-if="subtitle" class="text-xs text-muted-foreground">{{ subtitle }}</p>
</div>
<div v-if="weekColumns.length > 0" class="flex items-center gap-1 text-[11px] text-muted-foreground flex-shrink-0">
<span class="flex-shrink-0"></span>
<div
v-for="(level, index) in legendLevels"
:key="index"
class="w-3 h-3 rounded-[3px] flex-shrink-0"
:style="getLegendStyle(level)"
></div>
<span class="flex-shrink-0"></span>
</div>
</div>
<div v-if="weekColumns.length > 0" class="flex w-full gap-3 overflow-visible">
<div class="flex flex-col text-[10px] text-muted-foreground flex-shrink-0" :style="verticalGapStyle">
<!-- Placeholder to align with month markers -->
<div class="text-[10px] mb-3 invisible">M</div>
<span :style="dayLabelStyle" class="flex items-center invisible">周日</span>
<span :style="dayLabelStyle" class="flex items-center"></span>
<span :style="dayLabelStyle" class="flex items-center invisible">周二</span>
<span :style="dayLabelStyle" class="flex items-center"></span>
<span :style="dayLabelStyle" class="flex items-center invisible">周四</span>
<span :style="dayLabelStyle" class="flex items-center"></span>
<span :style="dayLabelStyle" class="flex items-center invisible">周六</span>
</div>
<div class="flex-1 min-w-[240px] overflow-visible">
<div ref="heatmapWrapper" class="relative block w-full">
<div
v-if="tooltip.visible && tooltip.day"
class="fixed z-10 rounded-lg border border-border/70 bg-background px-3 py-2 text-xs shadow-lg backdrop-blur pointer-events-none"
:style="tooltipStyle"
>
<p class="font-medium">{{ tooltip.day.date }}</p>
<p class="mt-0.5">{{ tooltip.day.requests }} 次请求 · {{ formatTokens(tooltip.day.total_tokens) }}</p>
<p class="text-[11px] text-muted-foreground">成本 {{ formatCurrency(tooltip.day.total_cost) }}</p>
</div>
<div class="flex text-[10px] text-muted-foreground/80 mb-3" :style="horizontalGapStyle">
<div
v-for="(week, weekIndex) in weekColumns"
:key="`month-${weekIndex}`"
:style="monthCellStyle"
class="text-center"
>
<span v-if="monthMarkers[weekIndex]">{{ monthMarkers[weekIndex] }}</span>
</div>
</div>
<div class="flex" :style="horizontalGapStyle">
<div v-for="(week, weekIndex) in weekColumns" :key="weekIndex" class="flex flex-col" :style="verticalGapStyle">
<div v-for="(day, dayIndex) in week" :key="dayIndex" class="relative group">
<div
v-if="day"
class="rounded-[4px] transition-all duration-200 hover:shadow-lg cursor-pointer cell-emerge"
:style="[cellSquareStyle, getCellStyle(day.requests), getCellAnimationDelay(weekIndex, dayIndex)]"
:title="buildTooltip(day)"
@mouseenter="handleHover(day, $event)"
@mouseleave="clearHover"
></div>
<div v-else :style="cellSquareStyle" class="rounded-[4px] bg-transparent"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<p v-else class="text-xs text-muted-foreground">暂无活跃数据</p>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { ActivityHeatmap, ActivityHeatmapDay } from '@/types/activity'
import { formatCurrency, formatTokens } from '@/utils/format'
const props = withDefaults(defineProps<{
data?: ActivityHeatmap | null
title?: string
subtitle?: string
showHeader?: boolean
}>(), {
showHeader: true
})
const legendLevels = [0.08, 0.25, 0.45, 0.65, 0.85]
type DayWithMeta = ActivityHeatmapDay & { dateObj: Date }
const heatmapWrapper = ref<HTMLElement | null>(null)
const heatmapWidth = ref(0)
const cellSize = ref(10)
const cellGap = ref(4)
const tooltip = ref<{ day: ActivityHeatmapDay | null; x: number; y: number; visible: boolean; below: boolean }>({
day: null,
x: 0,
y: 0,
visible: false,
below: false,
})
const tooltipStyle = computed(() => ({
top: `${tooltip.value.y}px`,
left: `${tooltip.value.x}px`,
transform: tooltip.value.below ? 'translate(-50%, 0)' : 'translate(-50%, -100%)',
}))
const cellSquareStyle = computed(() => ({
width: `${cellSize.value}px`,
height: `${cellSize.value}px`,
}))
const dayLabelStyle = computed(() => ({
height: `${cellSize.value}px`,
lineHeight: `${cellSize.value}px`,
}))
const monthCellStyle = computed(() => ({
width: `${cellSize.value}px`,
}))
const horizontalGapStyle = computed(() => ({
gap: `${cellGap.value}px`,
}))
const verticalGapStyle = computed(() => ({
rowGap: `${cellGap.value}px`,
}))
const weekColumns = computed(() => {
if (!props.data || !props.data.days || props.data.days.length === 0) {
return []
}
const dayEntries: DayWithMeta[] = props.data.days.map(day => ({
...day,
dateObj: new Date(`${day.date}T00:00:00Z`)
}))
const firstDay = dayEntries[0]?.dateObj
const padding: (DayWithMeta | null)[] = []
if (firstDay) {
const weekday = firstDay.getUTCDay() // 周日=0, 周一=1, ..., 周六=6
for (let i = 0; i < weekday; i++) {
padding.push(null)
}
}
const paddedDays: (DayWithMeta | null)[] = [...padding, ...dayEntries]
const remainder = paddedDays.length % 7
if (remainder !== 0) {
for (let i = remainder; i < 7; i++) {
paddedDays.push(null)
}
}
const chunked: (DayWithMeta | null)[][] = []
for (let i = 0; i < paddedDays.length; i += 7) {
chunked.push(paddedDays.slice(i, i + 7))
}
// Trim trailing empty weeks (weeks with all null cells)
let lastIndex = chunked.length - 1
while (lastIndex >= 0) {
const week = chunked[lastIndex]
const hasAnyDay = week.some(day => day !== null)
if (hasAnyDay) {
break
}
lastIndex--
}
return chunked.slice(0, lastIndex + 1)
})
const monthMarkers = computed(() => {
const markers: Record<number, string> = {}
const columns = weekColumns.value
let lastMonth: number | null = null
columns.forEach((week, index) => {
const firstValid = week.find((day): day is DayWithMeta => day !== null)
if (!firstValid) {
return
}
const month = firstValid.dateObj.getUTCMonth()
if (month === lastMonth) {
return
}
markers[index] = String(month + 1)
lastMonth = month
})
return markers
})
let resizeObserver: ResizeObserver | null = null
let mediaQuery: MediaQueryList | null = null
let mediaQueryHandler: ((event?: MediaQueryListEvent) => void) | null = null
const recalcCellSize = () => {
const columnCount = weekColumns.value.length
if (!columnCount || !heatmapWidth.value) {
return
}
const totalGap = Math.max(columnCount - 1, 0) * cellGap.value
const availableSpace = Math.max(heatmapWidth.value - totalGap, 0)
const rawSize = availableSpace / columnCount
// 不设置上限,让格子填满可用空间
cellSize.value = rawSize > 0 ? rawSize : 8
}
watch(
[() => heatmapWidth.value, () => weekColumns.value.length, () => cellGap.value],
() => {
recalcCellSize()
},
{ immediate: true }
)
watch(
() => heatmapWrapper.value,
el => {
resizeObserver?.disconnect()
if (el && typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(entries => {
if (!entries.length) {
return
}
heatmapWidth.value = entries[0].contentRect.width
recalcCellSize()
})
resizeObserver.observe(el)
} else {
heatmapWidth.value = 0
}
},
{ immediate: true }
)
onMounted(() => {
if (typeof window === 'undefined') {
return
}
mediaQuery = window.matchMedia('(min-width: 640px)')
const updateGap = () => {
cellGap.value = mediaQuery && mediaQuery.matches ? 4 : 2
recalcCellSize()
}
mediaQueryHandler = () => updateGap()
updateGap()
mediaQuery?.addEventListener('change', mediaQueryHandler)
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
if (mediaQuery && mediaQueryHandler) {
mediaQuery.removeEventListener('change', mediaQueryHandler)
}
})
function handleHover(day: ActivityHeatmapDay, event: MouseEvent) {
const cellRect = (event.currentTarget as HTMLElement).getBoundingClientRect()
const tooltipWidth = 200
const tooltipHeight = 72
// Calculate horizontal position (centered on cell)
let left = cellRect.left + cellRect.width / 2
const minLeft = tooltipWidth / 2 + 8
const maxLeft = window.innerWidth - tooltipWidth / 2 - 8
left = Math.min(Math.max(left, minLeft), maxLeft)
// Calculate vertical position
let top = cellRect.top - 12
let below = false
// If tooltip would go above viewport, show it below the cell
if (top - tooltipHeight < 0) {
top = cellRect.bottom + 12
below = true
}
tooltip.value = {
day,
x: left,
y: top,
visible: true,
below,
}
}
function clearHover() {
tooltip.value.visible = false
}
function getLegendStyle(alpha: number) {
return {
backgroundColor: `rgba(var(--color-primary-rgb), ${alpha})`
}
}
function getCellStyle(requests: number) {
const max = props.data?.max_requests || 1
if (!requests || max === 0) {
return {
backgroundColor: `rgba(var(--color-primary-rgb), 0.08)`
}
}
const ratio = Math.min(1, requests / max)
const minAlpha = 0.2
const maxAlpha = 0.95
const alpha = minAlpha + (maxAlpha - minAlpha) * ratio
return {
backgroundColor: `rgba(var(--color-primary-rgb), ${alpha})`
}
}
function buildTooltip(day: ActivityHeatmapDay): string {
const dateLabel = day.date
const costLabel = formatCurrency(day.total_cost || 0)
const parts = [`${dateLabel}`, `${day.requests} 次请求`, `${formatTokens(day.total_tokens)} tokens`, costLabel]
if (day.actual_total_cost !== undefined) {
parts.push(`倍率: ${formatCurrency(day.actual_total_cost)}`)
}
return parts.join(' · ')
}
// 生成完全随机的动画延迟,每个格子独立随机弹出
const cellDelayMap = new Map<string, number>()
function getCellAnimationDelay(weekIndex: number, dayIndex: number) {
const key = `${weekIndex}-${dayIndex}`
if (!cellDelayMap.has(key)) {
// 完全随机延迟,范围 0-800ms
cellDelayMap.set(key, Math.random() * 800)
}
return {
animationDelay: `${cellDelayMap.get(key)}ms`
}
}
</script>
<style scoped>
.cell-emerge {
opacity: 0;
animation: cellEmerge 0.35s ease-out forwards;
}
@keyframes cellEmerge {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import { AvatarFallback as AvatarFallbackPrimitive } from 'radix-vue'
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
}
const props = defineProps<Props>()
const fallbackClass = computed(() =>
cn(
'flex h-full w-full items-center justify-center rounded-full bg-muted',
props.class
)
)
</script>
<template>
<AvatarFallbackPrimitive :class="fallbackClass">
<slot />
</AvatarFallbackPrimitive>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import { AvatarImage as AvatarImagePrimitive } from 'radix-vue'
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
src?: string
alt: string
}
const props = withDefaults(defineProps<Props>(), {
alt: ''
})
const imageClass = computed(() =>
cn('aspect-square h-full w-full', props.class)
)
</script>
<template>
<AvatarImagePrimitive :class="imageClass" :src="src || ''" :alt="alt" />
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import { AvatarRoot } from 'radix-vue'
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
}
const props = defineProps<Props>()
const avatarClass = computed(() =>
cn('relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full', props.class)
)
</script>
<template>
<AvatarRoot :class="avatarClass">
<slot />
</AvatarRoot>
</template>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import { cva } from 'class-variance-authority'
import { cn } from '@/lib/utils'
import { computed } from 'vue'
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground border-border bg-card/50',
success:
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
warning:
'border-transparent bg-yellow-500 text-white hover:bg-yellow-600',
dark:
'border-transparent bg-foreground text-background hover:bg-foreground/80',
},
},
defaultVariants: {
variant: 'default',
},
}
)
interface Props {
variant?: 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'warning' | 'dark'
class?: string
}
const props = withDefaults(defineProps<Props>(), {
variant: 'default',
})
const badgeClass = computed(() =>
cn(badgeVariants({ variant: props.variant }), props.class)
)
</script>
<template>
<div :class="badgeClass">
<slot />
</div>
</template>

View File

@@ -0,0 +1,61 @@
<template>
<button
:type="props.type"
:class="buttonClass"
:disabled="disabled"
v-bind="$attrs"
>
<slot />
</button>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'
size?: 'default' | 'sm' | 'lg' | 'icon'
disabled?: boolean
class?: string
type?: 'button' | 'submit' | 'reset'
}
const props = withDefaults(defineProps<Props>(), {
variant: 'default',
size: 'default',
disabled: false,
type: 'button'
})
const buttonClass = computed(() => {
const baseClass =
'inline-flex items-center justify-center rounded-xl text-sm font-semibold transition-all duration-200 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98]'
const variantClasses = {
default:
'bg-primary text-white shadow-[0_20px_35px_rgba(204,120,92,0.35)] hover:bg-primary/90 hover:shadow-[0_25px_45px_rgba(204,120,92,0.45)]',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/85 shadow-sm',
outline:
'border border-border/60 bg-card/60 text-foreground hover:border-primary/60 hover:text-primary hover:bg-primary/10 shadow-sm backdrop-blur transition-all',
secondary:
'bg-secondary text-secondary-foreground shadow-inner hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
}
const sizeClasses = {
default: 'h-11 px-5',
sm: 'h-9 rounded-lg px-3',
lg: 'h-12 rounded-xl px-8 text-base',
icon: 'h-11 w-11 rounded-2xl',
}
return cn(
baseClass,
variantClasses[props.variant],
sizeClasses[props.size],
props.class
)
})
</script>

View File

@@ -0,0 +1,44 @@
<template>
<div :class="cardClass">
<slot />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
variant?: 'default' | 'glass' | 'elevated' | 'interactive' | 'subtle' | 'inset'
class?: string
}
const props = withDefaults(defineProps<Props>(), {
variant: 'default'
})
// 标准卡片变体定义
const variants = {
// 默认卡片 - 纯色背景,标准边框,用于主要内容容器
default: 'rounded-2xl border border-border bg-card text-card-foreground shadow-sm',
// 玻璃态卡片 - 半透明背景+模糊效果,用于嵌套内容/次要层级
glass: 'rounded-2xl border border-border bg-card/50 text-card-foreground shadow-sm backdrop-blur-sm',
// 提升卡片 - 更强阴影效果,用于模态对话框/强调内容
elevated: 'rounded-2xl border border-border bg-card text-card-foreground shadow-lg',
// 交互卡片 - 带hover效果,用于可点击列表项
interactive: 'rounded-2xl border border-border bg-card text-card-foreground shadow-sm transition-all duration-200 hover:shadow-md hover:border-primary/30 hover:-translate-y-0.5',
// 轻量卡片 - 更淡的边框,用于辅助信息区域
subtle: 'rounded-2xl border border-border/50 bg-card text-card-foreground shadow-sm',
// 嵌入式卡片 - 极淡背景,用于卡片内的子卡片(保持层级关系)
inset: 'rounded-xl border border-border/40 bg-card/40 text-card-foreground'
}
const cardClass = computed(() =>
cn(variants[props.variant], props.class)
)
</script>

View File

@@ -0,0 +1,47 @@
<template>
<input
type="checkbox"
:class="checkboxClass"
:checked="isChecked"
v-bind="$attrs"
@change="handleChange"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
modelValue?: boolean
checked?: boolean
class?: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'update:checked': [value: boolean]
}>()
const checkboxClass = computed(() =>
cn(
'h-4 w-4 rounded border-border/60 bg-card/80 text-primary shadow-sm focus:ring-2 focus:ring-primary/40 focus:ring-offset-1 accent-primary',
props.class
)
)
const isChecked = computed<boolean>(() => {
if (typeof props.checked === 'boolean') {
return props.checked
}
return props.modelValue ?? false
})
function handleChange(event: Event) {
const target = event.target as HTMLInputElement
const value = target.checked
emit('update:modelValue', value)
emit('update:checked', value)
}
</script>

View File

@@ -0,0 +1,139 @@
<template>
<Teleport to="body">
<div v-if="isOpen" class="fixed inset-0 overflow-y-auto" :style="{ zIndex: containerZIndex }">
<!-- 背景遮罩 -->
<Transition
enter-active-class="duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="isOpen"
class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity"
:style="{ zIndex: backdropZIndex }"
@click="handleClose"
/>
</Transition>
<div class="relative flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<!-- 对话框内容 -->
<Transition
enter-active-class="duration-300 ease-out"
enter-from-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enter-to-class="opacity-100 translate-y-0 sm:scale-100"
leave-active-class="duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0 sm:scale-100"
leave-to-class="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<div
v-if="isOpen"
@click.stop
class="relative transform rounded-lg bg-background text-left shadow-2xl transition-all sm:my-8 sm:w-full border border-border"
:style="{ zIndex: contentZIndex }"
:class="maxWidthClass"
>
<!-- Header 区域优先使用 slot否则使用 title prop -->
<slot name="header">
<div v-if="title" class="border-b border-border px-6 py-4">
<div class="flex items-center gap-3">
<div v-if="icon" class="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 flex-shrink-0" :class="iconClass">
<component :is="icon" class="h-5 w-5 text-primary" />
</div>
<div class="flex-1 min-w-0">
<h3 class="text-lg font-semibold text-foreground leading-tight">{{ title }}</h3>
<p v-if="description" class="text-xs text-muted-foreground">{{ description }}</p>
</div>
</div>
</div>
</slot>
<!-- 内容区域统一添加 padding -->
<div class="px-6 py-3">
<slot />
</div>
<!-- Footer 区域如果有 footer 插槽自动添加样式 -->
<div
v-if="slots.footer"
class="border-t border-border px-6 py-4 bg-muted/10 flex flex-row-reverse gap-3"
>
<slot name="footer" />
</div>
</div>
</Transition>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, useSlots, type Component } from 'vue'
// Props 定义
const props = defineProps<{
open?: boolean
modelValue?: boolean
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | '7xl'
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | '7xl'
title?: string
description?: string
icon?: Component // Lucide icon component
iconClass?: string // Custom icon color class
zIndex?: number // Custom z-index for nested dialogs (default: 60)
}>()
// Emits 定义
const emit = defineEmits<{
'update:open': [value: boolean]
'update:modelValue': [value: boolean]
}>()
// 获取 slots 以便在模板中使用
const slots = useSlots()
// 统一处理 open 状态
const isOpen = computed(() => {
if (props.modelValue === true) {
return true
}
if (props.open === true) {
return true
}
return false
})
// 统一处理关闭事件
function handleClose() {
if (props.open !== undefined) {
emit('update:open', false)
}
if (props.modelValue !== undefined) {
emit('update:modelValue', false)
}
}
const maxWidthClass = computed(() => {
const sizeValue = props.maxWidth || props.size || 'md'
const sizes = {
sm: 'sm:max-w-sm',
md: 'sm:max-w-md',
lg: 'sm:max-w-lg',
xl: 'sm:max-w-xl',
'2xl': 'sm:max-w-2xl',
'3xl': 'sm:max-w-3xl',
'4xl': 'sm:max-w-4xl',
'5xl': 'sm:max-w-5xl',
'6xl': 'sm:max-w-6xl',
'7xl': 'sm:max-w-7xl'
}
return sizes[sizeValue]
})
// Z-index computed values for nested dialogs support
const containerZIndex = computed(() => props.zIndex || 60)
const backdropZIndex = computed(() => props.zIndex || 60)
const contentZIndex = computed(() => (props.zIndex || 60) + 10)
</script>

View File

@@ -0,0 +1,5 @@
<template>
<div class="px-6 py-5 bg-background">
<slot />
</div>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<div class="mt-2">
<p class="text-sm text-gray-500 dark:text-muted-foreground">
<slot />
</p>
</div>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<div class="border-t border-border px-6 py-4 bg-muted/10 flex flex-row-reverse gap-3">
<slot />
</div>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<div class="border-b border-border px-6 py-4">
<slot />
</div>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<h3 class="text-lg font-semibold text-foreground flex items-center gap-2">
<slot />
</h3>
</template>

View File

@@ -0,0 +1,67 @@
/**
* shadcn/ui Components
* 统一导出所有 shadcn UI 组件,简化导入
*
* 使用方式:
* import { Button, Input, Card } from '@/components/ui'
*/
// 布局组件
export { default as Card } from './card.vue'
export { default as Separator } from './separator.vue'
// Tabs 选项卡系列
export { default as Tabs } from './tabs.vue'
export { default as TabsContent } from './tabs-content.vue'
export { default as TabsList } from './tabs-list.vue'
export { default as TabsTrigger } from './tabs-trigger.vue'
// 表单组件
export { default as Button } from './button.vue'
export { default as Input } from './input.vue'
export { default as Textarea } from './textarea.vue'
export { default as Label } from './label.vue'
export { default as Checkbox } from './checkbox.vue'
export { default as Switch } from './switch.vue'
// Select 选择器系列
export { default as Select } from './select.vue'
export { default as SelectTrigger } from './select-trigger.vue'
export { default as SelectValue } from './select-value.vue'
export { default as SelectContent } from './select-content.vue'
export { default as SelectItem } from './select-item.vue'
// 反馈组件
export { default as Badge } from './badge.vue'
export { default as Skeleton } from './skeleton.vue'
// Dialog 对话框系列
export { default as Dialog } from './dialog/Dialog.vue'
export { default as DialogContent } from './dialog/DialogContent.vue'
export { default as DialogHeader } from './dialog/DialogHeader.vue'
export { default as DialogTitle } from './dialog/DialogTitle.vue'
export { default as DialogDescription } from './dialog/DialogDescription.vue'
export { default as DialogFooter } from './dialog/DialogFooter.vue'
// Table 表格系列
export { default as Table } from './table.vue'
export { default as TableBody } from './table-body.vue'
export { default as TableCell } from './table-cell.vue'
export { default as TableHead } from './table-head.vue'
export { default as TableHeader } from './table-header.vue'
export { default as TableRow } from './table-row.vue'
export { default as TableCard } from './table-card.vue'
// Avatar 头像系列
export { default as Avatar } from './avatar.vue'
export { default as AvatarFallback } from './avatar-fallback.vue'
export { default as AvatarImage } from './avatar-image.vue'
// 分页组件
export { default as Pagination } from './pagination.vue'
// 操作按钮
export { default as RefreshButton } from './refresh-button.vue'
// Tooltip 提示系列
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip'

View File

@@ -0,0 +1,39 @@
<template>
<input
:class="inputClass"
:value="modelValue"
:autocomplete="autocompleteAttr"
v-bind="$attrs"
@input="handleInput"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
modelValue?: string | number
class?: string
autocomplete?: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const autocompleteAttr = computed(() => props.autocomplete ?? 'off')
const inputClass = computed(() =>
cn(
'flex h-11 w-full rounded-2xl border border-border/60 bg-card/80 px-4 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:border-primary/60 text-foreground backdrop-blur transition-all',
props.class
)
)
function handleInput(event: Event) {
const target = event.target as HTMLInputElement
emit('update:modelValue', target.value)
}
</script>

View File

@@ -0,0 +1,23 @@
<template>
<label :class="labelClass">
<slot />
</label>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: string
}
const props = defineProps<Props>()
const labelClass = computed(() =>
cn(
'text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
props.class
)
)
</script>

View File

@@ -0,0 +1,172 @@
<template>
<div class="flex flex-col sm:flex-row gap-4 border-t border-border/60 px-6 py-4 bg-muted/20">
<!-- 左侧记录范围和每页数量 -->
<div class="flex flex-col sm:flex-row items-start sm:items-center gap-3 text-sm text-muted-foreground">
<span class="font-medium">
显示 <span class="text-foreground font-semibold">{{ recordRange.start }}-{{ recordRange.end }}</span> <span class="text-foreground font-semibold">{{ total }}</span>
</span>
<Select
v-if="showPageSizeSelector"
v-model:open="pageSizeSelectOpen"
:model-value="String(pageSize)"
@update:model-value="handlePageSizeChange"
>
<SelectTrigger class="w-36 h-9 border-border/60">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="size in pageSizeOptions"
:key="size"
:value="String(size)"
>
{{ size }} /
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- 右侧分页按钮 -->
<div class="flex flex-wrap items-center gap-2 sm:ml-auto">
<Button
variant="outline"
size="sm"
class="h-9 px-3"
:disabled="current === 1"
@click="handlePageChange(1)"
>
首页
</Button>
<Button
variant="outline"
size="sm"
class="h-9 px-3"
:disabled="current === 1"
@click="handlePageChange(current - 1)"
>
上一页
</Button>
<!-- 页码按钮智能省略 -->
<template v-for="page in pageNumbers" :key="page">
<Button
v-if="typeof page === 'number'"
:variant="page === current ? 'default' : 'outline'"
size="sm"
class="h-9 min-w-[36px] px-2"
:class="page === current ? 'shadow-sm' : ''"
@click="handlePageChange(page)"
>
{{ page }}
</Button>
<span v-else class="px-2 text-muted-foreground select-none">{{ page }}</span>
</template>
<Button
variant="outline"
size="sm"
class="h-9 px-3"
:disabled="current === totalPages"
@click="handlePageChange(current + 1)"
>
下一页
</Button>
<Button
variant="outline"
size="sm"
class="h-9 px-3"
:disabled="current === totalPages"
@click="handlePageChange(totalPages)"
>
末页
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Button, Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui'
interface Props {
current: number
total: number
pageSize?: number
pageSizeOptions?: number[]
showPageSizeSelector?: boolean
}
interface Emits {
(e: 'update:current', value: number): void
(e: 'update:pageSize', value: number): void
}
const props = withDefaults(defineProps<Props>(), {
pageSize: 20,
pageSizeOptions: () => [10, 20, 50, 100],
showPageSizeSelector: true
})
const emit = defineEmits<Emits>()
const pageSizeSelectOpen = ref(false)
const totalPages = computed(() => Math.ceil(props.total / props.pageSize))
const recordRange = computed(() => {
const start = (props.current - 1) * props.pageSize + 1
const end = Math.min(props.current * props.pageSize, props.total)
return { start, end }
})
const pageNumbers = computed(() => {
const pages: (number | string)[] = []
const total = totalPages.value
const current = props.current
if (total <= 7) {
// 总页数 <= 7全部显示
for (let i = 1; i <= total; i++) {
pages.push(i)
}
} else {
// 总页数 > 7智能省略
if (current <= 3) {
// 当前页在前 3 页:[1, 2, 3, 4, 5, ..., total]
for (let i = 1; i <= 5; i++) pages.push(i)
pages.push('...')
pages.push(total)
} else if (current >= total - 2) {
// 当前页在后 3 页:[1, ..., total-4, total-3, total-2, total-1, total]
pages.push(1)
pages.push('...')
for (let i = total - 4; i <= total; i++) pages.push(i)
} else {
// 当前页在中间:[1, ..., current-1, current, current+1, ..., total]
pages.push(1)
pages.push('...')
for (let i = current - 1; i <= current + 1; i++) pages.push(i)
pages.push('...')
pages.push(total)
}
}
return pages
})
function handlePageChange(page: number) {
if (page < 1 || page > totalPages.value || page === props.current) {
return
}
emit('update:current', page)
}
function handlePageSizeChange(value: string) {
const newSize = parseInt(value)
if (newSize !== props.pageSize) {
emit('update:pageSize', newSize)
// 切换每页数量时,重置到第一页
emit('update:current', 1)
}
}
</script>

View File

@@ -0,0 +1,37 @@
<template>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
:disabled="loading"
:title="title"
@click="handleClick"
>
<RefreshCcw class="w-3.5 h-3.5" :class="loading ? 'animate-spin' : ''" />
</Button>
</template>
<script setup lang="ts">
import { Button } from '@/components/ui'
import { RefreshCcw } from 'lucide-vue-next'
interface Props {
loading?: boolean
title?: string
}
interface Emits {
(e: 'click'): void
}
withDefaults(defineProps<Props>(), {
loading: false,
title: '刷新'
})
const emit = defineEmits<Emits>()
function handleClick() {
emit('click')
}
</script>

View File

@@ -0,0 +1,52 @@
<template>
<SelectPortal>
<SelectContentPrimitive
v-bind="$attrs"
:class="contentClass"
:position="position"
:side="side"
:side-offset="sideOffset"
:align="align"
:align-offset="alignOffset"
>
<SelectViewport :class="viewportClass">
<slot />
</SelectViewport>
</SelectContentPrimitive>
</SelectPortal>
</template>
<script setup lang="ts">
import {
SelectContent as SelectContentPrimitive,
SelectPortal,
SelectViewport,
} from 'radix-vue'
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
position?: 'item-aligned' | 'popper'
side?: 'top' | 'right' | 'bottom' | 'left'
sideOffset?: number
align?: 'start' | 'center' | 'end'
alignOffset?: number
}
const props = withDefaults(defineProps<Props>(), {
position: 'popper',
sideOffset: 4,
})
const contentClass = computed(() =>
cn(
'z-[100] max-h-96 min-w-[8rem] overflow-hidden rounded-2xl border border-border bg-card text-foreground shadow-2xl backdrop-blur-xl pointer-events-auto',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
props.class
)
)
const viewportClass = 'p-1 max-h-[var(--radix-select-content-available-height)]'
</script>

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
import { SelectItem as SelectItemPrimitive, SelectItemIndicator, SelectItemText } from 'radix-vue'
import { Check } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
value: string
disabled?: boolean
}
const props = defineProps<Props>()
const itemClass = computed(() =>
cn(
'relative flex w-full cursor-pointer select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm outline-none hover:bg-primary/10 focus:bg-primary/15 text-foreground transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
props.class
)
)
</script>
<template>
<SelectItemPrimitive :class="itemClass" :value="value" :disabled="disabled">
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectItemIndicator>
<Check class="h-4 w-4" />
</SelectItemIndicator>
</span>
<SelectItemText>
<slot />
</SelectItemText>
</SelectItemPrimitive>
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import { SelectTrigger as SelectTriggerPrimitive } from 'radix-vue'
import { ChevronDown } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
disabled?: boolean
}
const props = defineProps<Props>()
const triggerClass = computed(() =>
cn(
'flex h-11 w-full items-center justify-between rounded-2xl border border-border/60 bg-card/80 px-4 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary/60 disabled:cursor-not-allowed disabled:opacity-50 text-foreground cursor-pointer backdrop-blur transition-all',
props.class
)
)
</script>
<template>
<SelectTriggerPrimitive
v-bind="$attrs"
:class="triggerClass"
:disabled="disabled"
>
<slot />
<ChevronDown class="h-4 w-4 opacity-50 pointer-events-none" />
</SelectTriggerPrimitive>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import { SelectValue as SelectValuePrimitive } from 'radix-vue'
interface Props {
placeholder?: string
}
const props = defineProps<Props>()
</script>
<template>
<SelectValuePrimitive :placeholder="placeholder">
<slot />
</SelectValuePrimitive>
</template>

View File

@@ -0,0 +1,94 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { SelectRoot as SelectRootPrimitive } from 'radix-vue'
interface Props {
defaultValue?: string
modelValue?: string
open?: boolean
defaultOpen?: boolean
dir?: 'ltr' | 'rtl'
name?: string
autocomplete?: string
disabled?: boolean
required?: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: string]
'update:open': [value: boolean]
}>()
const internalValue = ref<string | undefined>(
props.modelValue ?? props.defaultValue
)
const isModelControlled = computed(() => props.modelValue !== undefined)
watch(
() => props.modelValue,
value => {
if (isModelControlled.value) {
internalValue.value = value
}
}
)
const modelValueState = computed({
get: () => (isModelControlled.value ? props.modelValue : internalValue.value),
set: (value: string | undefined) => {
if (!isModelControlled.value) {
internalValue.value = value
}
// Cast to string for the emit signature when value exists
if (value !== undefined) {
emit('update:modelValue', value)
}
}
})
const internalOpen = ref<boolean>(
props.open ?? props.defaultOpen ?? false
)
const isOpenControlled = computed(() => props.open !== undefined)
watch(
() => props.open,
value => {
if (isOpenControlled.value && value !== undefined) {
internalOpen.value = value
}
}
)
const openState = computed({
get: () => (isOpenControlled.value ? props.open : internalOpen.value),
set: (value: boolean) => {
if (!isOpenControlled.value) {
internalOpen.value = value
}
emit('update:open', value)
}
})
</script>
<template>
<SelectRootPrimitive
:default-value="defaultValue"
:model-value="modelValueState"
:open="openState"
:default-open="defaultOpen"
:dir="dir"
:name="name"
:autocomplete="autocomplete"
:disabled="disabled"
:required="required"
@update:model-value="modelValueState = $event"
@update:open="openState = $event"
>
<slot />
</SelectRootPrimitive>
</template>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import { Separator as SeparatorPrimitive } from 'radix-vue'
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
orientation?: 'horizontal' | 'vertical'
decorative?: boolean
}
const props = withDefaults(defineProps<Props>(), {
orientation: 'horizontal',
decorative: true,
})
const separatorClass = computed(() =>
cn(
'shrink-0 bg-border',
props.orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
props.class
)
)
</script>
<template>
<SeparatorPrimitive
:class="separatorClass"
:orientation="orientation"
:decorative="decorative"
/>
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
}
const props = defineProps<Props>()
const skeletonClass = computed(() =>
cn('animate-pulse rounded-md bg-muted', props.class)
)
</script>
<template>
<div :class="skeletonClass" />
</template>

View File

@@ -0,0 +1,29 @@
<template>
<button
type="button"
role="switch"
:aria-checked="modelValue"
:class="[
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
modelValue ? 'bg-primary' : 'bg-muted'
]"
@click="$emit('update:modelValue', !modelValue)"
>
<span
:class="[
'inline-block h-4 w-4 transform rounded-full bg-white transition-transform',
modelValue ? 'translate-x-6' : 'translate-x-1'
]"
/>
</button>
</template>
<script setup lang="ts">
defineProps<{
modelValue: boolean
}>()
defineEmits<{
'update:modelValue': [value: boolean]
}>()
</script>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
}
const props = defineProps<Props>()
const bodyClass = computed(() =>
cn('[&_tr:last-child]:border-0', props.class)
)
</script>
<template>
<tbody :class="bodyClass">
<slot />
</tbody>
</template>

View File

@@ -0,0 +1,34 @@
<template>
<Card class="overflow-hidden">
<!-- 标题和操作栏 -->
<div v-if="$slots.header || title" class="px-6 py-3.5 border-b border-border/60">
<slot name="header">
<div class="flex items-center justify-between gap-4">
<!-- 左侧标题 -->
<h3 class="text-base font-semibold">{{ title }}</h3>
<!-- 右侧操作区 -->
<div v-if="$slots.actions" class="flex items-center gap-2">
<slot name="actions" />
</div>
</div>
</slot>
</div>
<!-- 表格内容 -->
<slot />
<!-- 分页 -->
<slot name="pagination" />
</Card>
</template>
<script setup lang="ts">
import { Card } from '@/components/ui'
interface Props {
title?: string
}
defineProps<Props>()
</script>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
}
const props = defineProps<Props>()
const cellClass = computed(() =>
cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', props.class)
)
</script>
<template>
<td :class="cellClass">
<slot />
</td>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
}
const props = defineProps<Props>()
const headClass = computed(() =>
cn(
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
props.class
)
)
</script>
<template>
<th :class="headClass">
<slot />
</th>
</template>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
}
const props = defineProps<Props>()
const headerClass = computed(() =>
cn('[&_tr]:border-b', props.class)
)
</script>
<template>
<thead :class="headerClass">
<slot />
</thead>
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
}
const props = defineProps<Props>()
const rowClass = computed(() =>
cn(
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
props.class
)
)
</script>
<template>
<tr :class="rowClass">
<slot />
</tr>
</template>

View File

@@ -0,0 +1,22 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
import { computed } from 'vue'
interface Props {
class?: string
}
const props = defineProps<Props>()
const tableClass = computed(() =>
cn('w-full caption-bottom text-sm', props.class)
)
</script>
<template>
<div class="relative w-full overflow-auto">
<table :class="tableClass">
<slot />
</table>
</div>
</template>

View File

@@ -0,0 +1,31 @@
<template>
<div
v-show="isActive"
:class="contentClass"
>
<slot />
</div>
</template>
<script setup lang="ts">
import { computed, inject, type Ref } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
value: string
class?: string
}
const props = defineProps<Props>()
const activeTab = inject<Ref<string>>('activeTab')
const isActive = computed(() => activeTab?.value === props.value)
const contentClass = computed(() => {
return cn(
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
props.class
)
})
</script>

View File

@@ -0,0 +1,205 @@
<template>
<div :class="listClass" ref="listRef">
<!-- 滑动指示器 - 放在按钮前面 -->
<div
class="tabs-indicator"
:style="indicatorStyle"
/>
<slot />
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch, onMounted, onUnmounted, nextTick, inject, type Ref } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
class?: string
}
const props = defineProps<Props>()
const listRef = ref<HTMLElement | null>(null)
const indicatorStyle = ref<Record<string, string>>({
transform: 'translateX(0)',
width: '0px',
opacity: '0',
transition: 'none'
})
// 标记是否已完成首次定位(首次无动画)
const hasInitialized = ref(false)
// 记录当前激活的 tab 索引,用于计算相对位置
const activeIndex = ref(-1)
const activeTab = inject<Ref<string>>('activeTab')
// 检查是否有 grid 类(由外部传入)
const hasGridClass = computed(() => {
return props.class?.includes('grid')
})
const listClass = computed(() => {
return cn(
'tabs-list',
// 如果外部传入了 grid 类,就不使用默认的 inline-flex
!hasGridClass.value && 'inline-flex',
props.class
)
})
// 更新指示器位置
const updateIndicator = () => {
if (!listRef.value) return
const buttons = Array.from(
listRef.value.querySelectorAll<HTMLButtonElement>('button[data-value]')
)
// 确保所有 button 都已渲染且有 data-value
if (buttons.length === 0) return
const newIndex = buttons.findIndex(
(button) => button.dataset.value === activeTab?.value
)
if (newIndex === -1) {
indicatorStyle.value = {
transform: 'translateX(0)',
width: '0px',
opacity: '0',
transition: 'none'
}
return
}
const activeButton = buttons[newIndex]
const buttonRect = activeButton.getBoundingClientRect()
// 确保按钮已渲染
if (buttonRect.width === 0) return
// 计算相对位置:累加前面所有按钮的宽度
let offsetLeft = 0
for (let i = 0; i < newIndex; i++) {
offsetLeft += buttons[i].getBoundingClientRect().width
}
// 判断是否需要动画:
// 1. 首次初始化不需要动画
// 2. 索引变化(用户切换 tab需要动画
const isTabChange = hasInitialized.value && activeIndex.value !== newIndex && activeIndex.value !== -1
// 更新状态
activeIndex.value = newIndex
if (!hasInitialized.value) {
hasInitialized.value = true
}
indicatorStyle.value = {
transform: `translateX(${offsetLeft}px)`,
width: `${buttonRect.width}px`,
opacity: '1',
transition: isTabChange
? 'transform 0.2s cubic-bezier(0.4, 0, 0.2, 1), width 0.2s cubic-bezier(0.4, 0, 0.2, 1)'
: 'none'
}
}
let rafId: number | null = null
const scheduleIndicatorUpdate = () => {
if (rafId !== null) {
cancelAnimationFrame(rafId)
}
rafId = requestAnimationFrame(() => {
updateIndicator()
rafId = null
})
}
// 监听 activeTab 变化
watch(
() => activeTab?.value,
() => {
nextTick(() => {
scheduleIndicatorUpdate()
})
},
{ immediate: true, flush: 'post' }
)
// DOM 初始化/重新挂载时重新计算
watch(
() => listRef.value,
(el) => {
if (el) {
nextTick(() => {
scheduleIndicatorUpdate()
})
}
}
)
onMounted(() => {
// 重置状态
hasInitialized.value = false
activeIndex.value = -1
// 立即尝试更新
nextTick(() => {
scheduleIndicatorUpdate()
})
window.addEventListener('resize', scheduleIndicatorUpdate)
})
onUnmounted(() => {
if (rafId !== null) {
cancelAnimationFrame(rafId)
}
window.removeEventListener('resize', scheduleIndicatorUpdate)
})
</script>
<style scoped>
.tabs-list {
position: relative;
height: 2.5rem;
align-items: center;
justify-content: center;
border-radius: 0.5rem;
background-color: hsl(var(--muted) / 0.3);
padding: 0.25rem;
color: hsl(var(--muted-foreground));
border: 1px solid hsl(var(--border) / 0.6);
}
.tabs-indicator {
position: absolute;
z-index: 0;
top: 0.25rem;
bottom: 0.25rem;
left: 0;
border-radius: 0.375rem;
background: linear-gradient(
180deg,
hsl(var(--background)),
hsl(var(--background) / 0.95)
);
border: 1px solid hsl(var(--border));
box-shadow:
0 1px 3px 0 rgb(0 0 0 / 0.1),
0 1px 2px -1px rgb(0 0 0 / 0.1);
pointer-events: none;
}
@media (prefers-color-scheme: dark) {
.tabs-indicator {
background: linear-gradient(
180deg,
hsl(var(--accent)),
hsl(var(--accent) / 0.95)
);
border-color: hsl(var(--border) / 0.8);
}
}
</style>

View File

@@ -0,0 +1,42 @@
<template>
<button
:class="triggerClass"
:data-state="isActive ? 'active' : 'inactive'"
:data-value="props.value"
@click="handleClick"
type="button"
>
<slot />
</button>
</template>
<script setup lang="ts">
import { computed, inject, type Ref } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
value: string
class?: string
}
const props = defineProps<Props>()
const activeTab = inject<Ref<string>>('activeTab')
const setActiveTab = inject<(value: string) => void>('setActiveTab')
const isActive = computed(() => activeTab?.value === props.value)
const handleClick = () => {
setActiveTab?.(props.value)
}
const triggerClass = computed(() => {
return cn(
'relative z-10 inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
isActive.value
? 'text-foreground font-semibold'
: 'text-muted-foreground hover:text-foreground',
props.class
)
})
</script>

View File

@@ -0,0 +1,35 @@
<template>
<div class="tabs-root">
<slot />
</div>
</template>
<script setup lang="ts">
import { provide, ref, watch } from 'vue'
interface Props {
defaultValue?: string
modelValue?: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const activeTab = ref(props.modelValue || props.defaultValue || '')
watch(() => props.modelValue, (newValue) => {
if (newValue !== undefined) {
activeTab.value = newValue
}
})
const setActiveTab = (value: string) => {
activeTab.value = value
emit('update:modelValue', value)
}
provide('activeTab', activeTab)
provide('setActiveTab', setActiveTab)
</script>

View File

@@ -0,0 +1,35 @@
<template>
<textarea
:class="textareaClass"
:value="modelValue"
v-bind="$attrs"
@input="handleInput"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/lib/utils'
interface Props {
modelValue?: string
class?: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const textareaClass = computed(() =>
cn(
'flex min-h-[80px] w-full rounded-2xl border border-border/60 bg-card/80 px-4 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:border-primary/60 text-foreground backdrop-blur transition-all resize-none',
props.class
)
)
function handleInput(event: Event) {
const target = event.target as HTMLTextAreaElement
emit('update:modelValue', target.value)
}
</script>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import { TooltipRoot } from 'radix-vue'
interface Props {
defaultOpen?: boolean
open?: boolean
delayDuration?: number
disableHoverableContent?: boolean
}
const props = withDefaults(defineProps<Props>(), {
defaultOpen: false,
delayDuration: 200,
disableHoverableContent: false
})
const emit = defineEmits<{
'update:open': [value: boolean]
}>()
</script>
<template>
<TooltipRoot
:default-open="props.defaultOpen"
:open="props.open"
:delay-duration="props.delayDuration"
:disable-hoverable-content="props.disableHoverableContent"
@update:open="emit('update:open', $event)"
>
<slot />
</TooltipRoot>
</template>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import { computed } from 'vue'
import { TooltipContent as TooltipContentPrimitive, TooltipPortal } from 'radix-vue'
import { cn } from '@/lib/utils'
interface Props {
class?: string
side?: 'top' | 'right' | 'bottom' | 'left'
sideOffset?: number
align?: 'start' | 'center' | 'end'
alignOffset?: number
avoidCollisions?: boolean
}
const props = withDefaults(defineProps<Props>(), {
side: 'top',
sideOffset: 4,
align: 'center',
alignOffset: 0,
avoidCollisions: true
})
const contentClass = computed(() =>
cn(
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
props.class
)
)
</script>
<template>
<TooltipPortal>
<TooltipContentPrimitive
:class="contentClass"
:side="side"
:side-offset="sideOffset"
:align="align"
:align-offset="alignOffset"
:avoid-collisions="avoidCollisions"
>
<slot />
</TooltipContentPrimitive>
</TooltipPortal>
</template>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import { TooltipProvider as TooltipProviderPrimitive } from 'radix-vue'
interface Props {
delayDuration?: number
skipDelayDuration?: number
disableHoverableContent?: boolean
}
withDefaults(defineProps<Props>(), {
delayDuration: 200,
skipDelayDuration: 300,
disableHoverableContent: false
})
</script>
<template>
<TooltipProviderPrimitive
:delay-duration="delayDuration"
:skip-delay-duration="skipDelayDuration"
:disable-hoverable-content="disableHoverableContent"
>
<slot />
</TooltipProviderPrimitive>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
import { TooltipTrigger as TooltipTriggerPrimitive } from 'radix-vue'
interface Props {
asChild?: boolean
}
withDefaults(defineProps<Props>(), {
asChild: false
})
</script>
<template>
<TooltipTriggerPrimitive :as-child="asChild">
<slot />
</TooltipTriggerPrimitive>
</template>

View File

@@ -0,0 +1,4 @@
export { default as Tooltip } from './Tooltip.vue'
export { default as TooltipContent } from './TooltipContent.vue'
export { default as TooltipProvider } from './TooltipProvider.vue'
export { default as TooltipTrigger } from './TooltipTrigger.vue'