mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
Initial commit
This commit is contained in:
412
frontend/src/components/AetherLineByLineLogo.vue
Normal file
412
frontend/src/components/AetherLineByLineLogo.vue
Normal 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>
|
||||
400
frontend/src/components/CodeHighlight.vue
Normal file
400
frontend/src/components/CodeHighlight.vue
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
|
||||
// 使用占位符保护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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
}
|
||||
|
||||
// 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>
|
||||
26
frontend/src/components/ConfirmContainer.vue
Normal file
26
frontend/src/components/ConfirmContainer.vue
Normal 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>
|
||||
403
frontend/src/components/GeminiStarCluster.vue
Normal file
403
frontend/src/components/GeminiStarCluster.vue
Normal 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>
|
||||
40
frontend/src/components/HeaderLogo.vue
Normal file
40
frontend/src/components/HeaderLogo.vue
Normal file
File diff suppressed because one or more lines are too long
310
frontend/src/components/PlatformSelect.vue
Normal file
310
frontend/src/components/PlatformSelect.vue
Normal 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>
|
||||
1239
frontend/src/components/RippleLogo.vue
Normal file
1239
frontend/src/components/RippleLogo.vue
Normal file
File diff suppressed because one or more lines are too long
70
frontend/src/components/ToastContainer.vue
Normal file
70
frontend/src/components/ToastContainer.vue
Normal 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>
|
||||
185
frontend/src/components/ToastWithProgress.vue
Normal file
185
frontend/src/components/ToastWithProgress.vue
Normal 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>
|
||||
148
frontend/src/components/charts/BarChart.vue
Normal file
148
frontend/src/components/charts/BarChart.vue
Normal 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>
|
||||
128
frontend/src/components/charts/LineChart.vue
Normal file
128
frontend/src/components/charts/LineChart.vue
Normal 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>
|
||||
165
frontend/src/components/common/AlertDialog.vue
Normal file
165
frontend/src/components/common/AlertDialog.vue
Normal 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>
|
||||
285
frontend/src/components/common/EmptyState.vue
Normal file
285
frontend/src/components/common/EmptyState.vue
Normal 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>
|
||||
69
frontend/src/components/common/LoadingState.vue
Normal file
69
frontend/src/components/common/LoadingState.vue
Normal 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>
|
||||
9
frontend/src/components/common/index.ts
Normal file
9
frontend/src/components/common/index.ts
Normal 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'
|
||||
5
frontend/src/components/icons/AetherLogo.vue
Normal file
5
frontend/src/components/icons/AetherLogo.vue
Normal file
File diff suppressed because one or more lines are too long
55
frontend/src/components/layout/AppShell.vue
Normal file
55
frontend/src/components/layout/AppShell.vue
Normal 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>
|
||||
103
frontend/src/components/layout/CardSection.vue
Normal file
103
frontend/src/components/layout/CardSection.vue
Normal 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>
|
||||
123
frontend/src/components/layout/MobileNav.vue
Normal file
123
frontend/src/components/layout/MobileNav.vue
Normal 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>
|
||||
47
frontend/src/components/layout/PageContainer.vue
Normal file
47
frontend/src/components/layout/PageContainer.vue
Normal 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>
|
||||
38
frontend/src/components/layout/PageHeader.vue
Normal file
38
frontend/src/components/layout/PageHeader.vue
Normal 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>
|
||||
54
frontend/src/components/layout/Section.vue
Normal file
54
frontend/src/components/layout/Section.vue
Normal 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>
|
||||
88
frontend/src/components/layout/SidebarNav.vue
Normal file
88
frontend/src/components/layout/SidebarNav.vue
Normal 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>
|
||||
15
frontend/src/components/layout/index.ts
Normal file
15
frontend/src/components/layout/index.ts
Normal 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'
|
||||
360
frontend/src/components/stats/ActivityHeatmap.vue
Normal file
360
frontend/src/components/stats/ActivityHeatmap.vue
Normal 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>
|
||||
24
frontend/src/components/ui/avatar-fallback.vue
Normal file
24
frontend/src/components/ui/avatar-fallback.vue
Normal 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>
|
||||
23
frontend/src/components/ui/avatar-image.vue
Normal file
23
frontend/src/components/ui/avatar-image.vue
Normal 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>
|
||||
21
frontend/src/components/ui/avatar.vue
Normal file
21
frontend/src/components/ui/avatar.vue
Normal 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>
|
||||
50
frontend/src/components/ui/badge.vue
Normal file
50
frontend/src/components/ui/badge.vue
Normal 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>
|
||||
61
frontend/src/components/ui/button.vue
Normal file
61
frontend/src/components/ui/button.vue
Normal 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>
|
||||
44
frontend/src/components/ui/card.vue
Normal file
44
frontend/src/components/ui/card.vue
Normal 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>
|
||||
47
frontend/src/components/ui/checkbox.vue
Normal file
47
frontend/src/components/ui/checkbox.vue
Normal 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>
|
||||
139
frontend/src/components/ui/dialog/Dialog.vue
Normal file
139
frontend/src/components/ui/dialog/Dialog.vue
Normal 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>
|
||||
5
frontend/src/components/ui/dialog/DialogContent.vue
Normal file
5
frontend/src/components/ui/dialog/DialogContent.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="px-6 py-5 bg-background">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
7
frontend/src/components/ui/dialog/DialogDescription.vue
Normal file
7
frontend/src/components/ui/dialog/DialogDescription.vue
Normal 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>
|
||||
5
frontend/src/components/ui/dialog/DialogFooter.vue
Normal file
5
frontend/src/components/ui/dialog/DialogFooter.vue
Normal 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>
|
||||
5
frontend/src/components/ui/dialog/DialogHeader.vue
Normal file
5
frontend/src/components/ui/dialog/DialogHeader.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="border-b border-border px-6 py-4">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
5
frontend/src/components/ui/dialog/DialogTitle.vue
Normal file
5
frontend/src/components/ui/dialog/DialogTitle.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<h3 class="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
<slot />
|
||||
</h3>
|
||||
</template>
|
||||
67
frontend/src/components/ui/index.ts
Normal file
67
frontend/src/components/ui/index.ts
Normal 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'
|
||||
39
frontend/src/components/ui/input.vue
Normal file
39
frontend/src/components/ui/input.vue
Normal 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>
|
||||
23
frontend/src/components/ui/label.vue
Normal file
23
frontend/src/components/ui/label.vue
Normal 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>
|
||||
172
frontend/src/components/ui/pagination.vue
Normal file
172
frontend/src/components/ui/pagination.vue
Normal 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>
|
||||
37
frontend/src/components/ui/refresh-button.vue
Normal file
37
frontend/src/components/ui/refresh-button.vue
Normal 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>
|
||||
52
frontend/src/components/ui/select-content.vue
Normal file
52
frontend/src/components/ui/select-content.vue
Normal 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>
|
||||
34
frontend/src/components/ui/select-item.vue
Normal file
34
frontend/src/components/ui/select-item.vue
Normal 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>
|
||||
31
frontend/src/components/ui/select-trigger.vue
Normal file
31
frontend/src/components/ui/select-trigger.vue
Normal 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>
|
||||
15
frontend/src/components/ui/select-value.vue
Normal file
15
frontend/src/components/ui/select-value.vue
Normal 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>
|
||||
94
frontend/src/components/ui/select.vue
Normal file
94
frontend/src/components/ui/select.vue
Normal 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>
|
||||
32
frontend/src/components/ui/separator.vue
Normal file
32
frontend/src/components/ui/separator.vue
Normal 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>
|
||||
18
frontend/src/components/ui/skeleton.vue
Normal file
18
frontend/src/components/ui/skeleton.vue
Normal 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>
|
||||
29
frontend/src/components/ui/switch.vue
Normal file
29
frontend/src/components/ui/switch.vue
Normal 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>
|
||||
20
frontend/src/components/ui/table-body.vue
Normal file
20
frontend/src/components/ui/table-body.vue
Normal 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>
|
||||
34
frontend/src/components/ui/table-card.vue
Normal file
34
frontend/src/components/ui/table-card.vue
Normal 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>
|
||||
20
frontend/src/components/ui/table-cell.vue
Normal file
20
frontend/src/components/ui/table-cell.vue
Normal 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>
|
||||
23
frontend/src/components/ui/table-head.vue
Normal file
23
frontend/src/components/ui/table-head.vue
Normal 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>
|
||||
20
frontend/src/components/ui/table-header.vue
Normal file
20
frontend/src/components/ui/table-header.vue
Normal 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>
|
||||
23
frontend/src/components/ui/table-row.vue
Normal file
23
frontend/src/components/ui/table-row.vue
Normal 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>
|
||||
22
frontend/src/components/ui/table.vue
Normal file
22
frontend/src/components/ui/table.vue
Normal 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>
|
||||
31
frontend/src/components/ui/tabs-content.vue
Normal file
31
frontend/src/components/ui/tabs-content.vue
Normal 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>
|
||||
205
frontend/src/components/ui/tabs-list.vue
Normal file
205
frontend/src/components/ui/tabs-list.vue
Normal 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>
|
||||
42
frontend/src/components/ui/tabs-trigger.vue
Normal file
42
frontend/src/components/ui/tabs-trigger.vue
Normal 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>
|
||||
35
frontend/src/components/ui/tabs.vue
Normal file
35
frontend/src/components/ui/tabs.vue
Normal 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>
|
||||
35
frontend/src/components/ui/textarea.vue
Normal file
35
frontend/src/components/ui/textarea.vue
Normal 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>
|
||||
32
frontend/src/components/ui/tooltip/Tooltip.vue
Normal file
32
frontend/src/components/ui/tooltip/Tooltip.vue
Normal 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>
|
||||
44
frontend/src/components/ui/tooltip/TooltipContent.vue
Normal file
44
frontend/src/components/ui/tooltip/TooltipContent.vue
Normal 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>
|
||||
25
frontend/src/components/ui/tooltip/TooltipProvider.vue
Normal file
25
frontend/src/components/ui/tooltip/TooltipProvider.vue
Normal 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>
|
||||
17
frontend/src/components/ui/tooltip/TooltipTrigger.vue
Normal file
17
frontend/src/components/ui/tooltip/TooltipTrigger.vue
Normal 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>
|
||||
4
frontend/src/components/ui/tooltip/index.ts
Normal file
4
frontend/src/components/ui/tooltip/index.ts
Normal 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'
|
||||
Reference in New Issue
Block a user