UI/细节调整
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
/**
|
||||
* 破坏性 / 重要操作确认框 —— 规范 P4 的落地件。
|
||||
*
|
||||
* variant="danger" 用于不可逆动作(停止内核、删除记录、清空历史)。
|
||||
* 不要用 window.confirm:它无法承载「这次操作具体影响什么」的说明。
|
||||
*/
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
open: boolean
|
||||
title: string
|
||||
description?: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
variant?: 'default' | 'danger'
|
||||
loading?: boolean
|
||||
}>(),
|
||||
{ confirmText: '确认', cancelText: '取消', variant: 'default', loading: false }
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean]
|
||||
confirm: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="open" @update:open="emit('update:open', $event)">
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{{ title }}</DialogTitle>
|
||||
<DialogDescription v-if="description">{{ description }}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<slot />
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" :disabled="loading" @click="emit('update:open', false)">
|
||||
{{ cancelText }}
|
||||
</Button>
|
||||
<Button
|
||||
:variant="variant === 'danger' ? 'destructive' : 'default'"
|
||||
:disabled="loading"
|
||||
@click="emit('confirm')"
|
||||
>
|
||||
{{ confirmText }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
import { Check, Copy } from '@lucide/vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
/**
|
||||
* 可复制文本(路径、版本号、ID)。
|
||||
* 复制后图标短暂切换为对勾 —— 比只弹 toast 的反馈更即时。
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
value: string
|
||||
mono?: boolean
|
||||
toastText?: string
|
||||
}>(),
|
||||
{ mono: false, toastText: '已复制' }
|
||||
)
|
||||
|
||||
const copied = ref(false)
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.value)
|
||||
copied.value = true
|
||||
toast.success(props.toastText)
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(() => (copied.value = false), 1500)
|
||||
} catch {
|
||||
toast.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => clearTimeout(timer))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="group inline-flex min-w-0 max-w-full items-center gap-1.5 rounded-sm px-1.5 py-0.5 text-left transition-colors duration-150 outline-none hover:bg-hover focus-visible:ring-2 focus-visible:ring-ring/40"
|
||||
:title="value"
|
||||
@click="copy"
|
||||
>
|
||||
<span
|
||||
class="truncate text-[13px] text-foreground"
|
||||
:class="mono && 'font-mono tabular-nums'"
|
||||
>
|
||||
{{ value }}
|
||||
</span>
|
||||
<Check v-if="copied" class="size-3 shrink-0 text-success" :stroke-width="1.75" />
|
||||
<Copy
|
||||
v-else
|
||||
class="size-3 shrink-0 text-muted-foreground opacity-0 transition-opacity duration-150 group-hover:opacity-100"
|
||||
:stroke-width="1.75"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 列表行 —— 主从分栏(模式 B)左栏的条目。
|
||||
* 选中态用 primary-soft,hover 态用中性 hover,两者不要混用同一个色。
|
||||
*/
|
||||
defineProps<{
|
||||
title: string
|
||||
subtitle?: string
|
||||
active?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ select: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors duration-150 outline-none focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-inset"
|
||||
:class="active ? 'bg-primary-soft' : 'hover:bg-hover'"
|
||||
@click="emit('select')"
|
||||
>
|
||||
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span
|
||||
class="truncate text-[13px]"
|
||||
:class="active ? 'font-medium text-primary-soft-text' : 'text-foreground'"
|
||||
>
|
||||
{{ title }}
|
||||
</span>
|
||||
<span v-if="subtitle" class="truncate text-xs text-muted-foreground">{{ subtitle }}</span>
|
||||
</span>
|
||||
<span v-if="$slots.trailing" class="shrink-0">
|
||||
<slot name="trailing" />
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 信息分组 —— 概览类页面的组织单元(规范 4.4「平铺分组」)。
|
||||
*
|
||||
* 用「组标题 + 1px 分隔线 + 键值区」替代卡片:
|
||||
* - 去掉卡片外框与 16px 内边距,纵向密度提升约 40%
|
||||
* - 彻底避开「两列卡片高度不齐 → 矮的那列下方留空」的问题
|
||||
*
|
||||
* 与 StatCard 的分工:StatCard 用于「每块内容量接近、需要视觉分组」的场景;
|
||||
* InfoGroup 用于「键值快照」类概览页,密度优先。
|
||||
*/
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
/** 键值区列数:1 = 单列(默认,一行一条、值有完整宽度);2 = 双列(仅内容极短时用) */
|
||||
columns?: 1 | 2
|
||||
/** plain = 平铺无容器;card = 卡片形态(概览网格用,规范 4.4 D2 结构) */
|
||||
variant?: 'plain' | 'card'
|
||||
}>(),
|
||||
{ columns: 1, variant: 'plain' }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="flex flex-col"
|
||||
:class="variant === 'card' && 'rounded-lg border border-border bg-card p-4 shadow-sm'"
|
||||
>
|
||||
<header class="flex min-h-8 items-center justify-between gap-3">
|
||||
<h2 class="text-sm font-semibold text-foreground">{{ title }}</h2>
|
||||
<div v-if="$slots.action" class="flex shrink-0 items-center gap-1.5">
|
||||
<slot name="action" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mt-2 border-t border-divider" />
|
||||
|
||||
<div
|
||||
class="grid gap-x-10 pt-2"
|
||||
:class="columns === 2 ? 'grid-cols-1 md:grid-cols-2' : 'grid-cols-1'"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<div v-if="$slots.footer" class="pt-3">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 键值行:左侧键名(次要色),右侧值(主文字色)。
|
||||
*
|
||||
* 这是「状态 / 当前版本 / 路径 / 模式」这类信息的统一承载方式,
|
||||
* 此前各模块各自手写 flex + truncate,间距和对齐都不一致。
|
||||
*
|
||||
* 需要放徽章、开关、复制按钮等复杂内容时用默认插槽。
|
||||
*/
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
value?: string | number
|
||||
/** 值用等宽数字(版本号、ID、计数) */
|
||||
mono?: boolean
|
||||
}>(),
|
||||
{ mono: false }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-8 items-center justify-between gap-3 py-1.5">
|
||||
<span class="shrink-0 text-[13px] text-fg-secondary">{{ label }}</span>
|
||||
<span
|
||||
class="min-w-0 truncate text-right text-[13px] text-foreground"
|
||||
:class="mono && 'font-mono tabular-nums'"
|
||||
>
|
||||
<slot>{{ value }}</slot>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
/**
|
||||
* 关键数值(速率、容量、计数)。
|
||||
*
|
||||
* 数字必须 tabular-nums —— 否则高频刷新时宽度会抖动,整行跟着跳。
|
||||
* trend 只用于「有方向语义」的数值(下载/上传、收入/支出),不要拿它当状态色用。
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
value: string | number
|
||||
unit?: string
|
||||
label?: string
|
||||
trend?: 'down' | 'up'
|
||||
size?: 'md' | 'lg'
|
||||
}>(),
|
||||
{ size: 'md' }
|
||||
)
|
||||
|
||||
const valueClass = computed(() => {
|
||||
if (props.trend === 'down') return 'text-data-down'
|
||||
if (props.trend === 'up') return 'text-data-up'
|
||||
return 'text-foreground'
|
||||
})
|
||||
|
||||
const sizeClass = computed(() =>
|
||||
props.size === 'lg' ? 'text-[26px] leading-8' : 'text-[22px] leading-7'
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<span v-if="label" class="text-[11px] text-muted-foreground">{{ label }}</span>
|
||||
<span class="flex items-baseline gap-1">
|
||||
<span class="font-semibold tabular-nums" :class="[valueClass, sizeClass]">
|
||||
{{ value }}
|
||||
</span>
|
||||
<span v-if="unit" class="text-xs text-muted-foreground">{{ unit }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* 模块外壳 —— 页面框架的唯一落地件,见 UI_DESIGN_SYSTEM.md 第 4 章。
|
||||
*
|
||||
* 它固定三件事,缺一就会出现「底部贴边」或「内容被裁」:
|
||||
* 1. 高度约束链:h-full + min-h-0 一路传下去
|
||||
* 2. 滚动归属:只有内容区滚动,Tab 行固定
|
||||
* 3. padding 位置:加在滚动容器内部的 wrapper 上
|
||||
*
|
||||
* 模块自身不要再套 overflow 容器,否则会变成嵌套滚动。
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 内置 Tab 行(可选)。需要 TabsContent 这类面板切换时,改用 #header 插槽自带 */
|
||||
tabs?: { value: string; label: string; count?: number }[]
|
||||
activeTab?: string
|
||||
/** 内容区布局,见规范 4.2 */
|
||||
layout?: 'grid' | 'split' | 'workbench' | 'settings' | 'plain'
|
||||
/** 内容区是否带内边距;split 与全出血布局传 false */
|
||||
padded?: boolean
|
||||
/** 内容区最大宽度,超出居中 —— 避免大屏上卡片被拉得过宽 */
|
||||
maxWidth?: number
|
||||
}>(),
|
||||
{ layout: 'plain', padded: true, maxWidth: 0 }
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ 'update:activeTab': [value: string] }>()
|
||||
|
||||
const LAYOUT_CLASS = {
|
||||
grid: 'grid grid-cols-1 content-start gap-4 md:grid-cols-2',
|
||||
workbench: 'flex flex-col gap-3',
|
||||
settings: 'flex flex-col gap-6',
|
||||
split: 'flex h-full min-h-0',
|
||||
plain: 'flex flex-col gap-3',
|
||||
} as const
|
||||
|
||||
/** split 需要撑满高度、由内部分栏各自滚动,外壳不能再包一层滚动 */
|
||||
const isFill = computed(() => props.layout === 'split')
|
||||
|
||||
const innerClass = computed(() =>
|
||||
cn(props.padded && !isFill.value && 'p-5', LAYOUT_CLASS[props.layout])
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<!-- 固定层 #1:模块自定义头部(通常由 ModuleToolbar 填充) -->
|
||||
<slot name="toolbar" />
|
||||
|
||||
<!-- 固定层 #2:Tab 行。自定义内容走 #header,否则用内置 tabs -->
|
||||
<slot name="header">
|
||||
<nav
|
||||
v-if="tabs?.length"
|
||||
class="flex h-12 shrink-0 items-center gap-1 border-b border-border px-5"
|
||||
role="tablist"
|
||||
>
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === tab.value"
|
||||
class="tab-underline relative inline-flex shrink-0 items-center gap-1.5 rounded-md px-3 py-2 text-[13px] transition-colors duration-150 outline-none hover:bg-hover focus-visible:ring-2 focus-visible:ring-ring/40"
|
||||
:class="
|
||||
activeTab === tab.value
|
||||
? 'font-medium text-primary'
|
||||
: 'text-fg-secondary hover:text-foreground'
|
||||
"
|
||||
@click="emit('update:activeTab', tab.value)"
|
||||
>
|
||||
{{ tab.label }}
|
||||
<span v-if="tab.count" class="tabular-nums opacity-70">{{ tab.count }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
</slot>
|
||||
|
||||
<!-- split:撑满,由内部分栏自己滚 -->
|
||||
<div v-if="isFill" class="min-h-0 flex-1">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- 其余:内容区滚动,padding 在内层 wrapper 上(否则底部 padding 会被内容盖掉) -->
|
||||
<div v-else class="min-h-0 flex-1 overflow-y-auto">
|
||||
<div
|
||||
class="mx-auto w-full"
|
||||
:class="innerClass"
|
||||
:style="maxWidth ? { maxWidth: `${maxWidth}px` } : undefined"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 模块内容区顶部的操作条。
|
||||
* 统一 48px 高、20px 左右内边距、底部 1px 分隔,避免各模块自定高度和间距。
|
||||
*/
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-12 shrink-0 items-center justify-between gap-3 border-b border-border px-5">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<slot name="left" />
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-1.5">
|
||||
<slot name="right" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 区块标题(h1 = 16px)。
|
||||
* 用在内容区里做分组标题,区别于卡片内的 h2(14px)。
|
||||
*/
|
||||
defineProps<{
|
||||
title: string
|
||||
description?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<h2 class="truncate text-base font-semibold text-foreground">{{ title }}</h2>
|
||||
<p v-if="description" class="text-xs text-muted-foreground">{{ description }}</p>
|
||||
</div>
|
||||
<div v-if="$slots.action" class="shrink-0">
|
||||
<slot name="action" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -33,11 +33,11 @@ const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="modelValue === item.value"
|
||||
class="flex min-w-0 flex-1 items-center justify-center gap-1.5 rounded-[7px] px-3 py-1.5 text-[13px] transition-colors duration-150 outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
class="flex min-w-0 flex-1 items-center justify-center gap-1.5 rounded-md px-3 py-1.5 text-[13px] transition-colors duration-150 outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
||||
:class="
|
||||
modelValue === item.value
|
||||
? 'bg-background font-medium text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
? 'bg-primary-soft font-medium text-primary-soft-text'
|
||||
: 'text-fg-secondary hover:bg-hover hover:text-foreground'
|
||||
"
|
||||
@click="emit('update:modelValue', item.value)"
|
||||
>
|
||||
@@ -45,8 +45,12 @@ const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
<span class="truncate">{{ item.label }}</span>
|
||||
<span
|
||||
v-if="item.count"
|
||||
class="shrink-0 rounded-full bg-muted px-1.5 text-[10px] tabular-nums text-muted-foreground"
|
||||
:class="modelValue === item.value ? 'bg-muted' : 'bg-background/70'"
|
||||
class="shrink-0 rounded-full px-1.5 text-[10px] tabular-nums"
|
||||
:class="
|
||||
modelValue === item.value
|
||||
? 'bg-primary-soft-text/15 text-primary-soft-text'
|
||||
: 'bg-card/70 text-fg-secondary'
|
||||
"
|
||||
>
|
||||
{{ item.count }}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 设置分组:标题 + 一组 SettingRow。
|
||||
*
|
||||
* # 卡片化承载
|
||||
*
|
||||
* 组内容用项目的 `Card` + `CardContent` 承载,而不是手写
|
||||
* `rounded-lg border bg-card`:项目里「卡片」的统一口径是
|
||||
* `rounded-xl + shadow-sm`(见 UI_DESIGN_SYSTEM 与各模块横幅),手写那份
|
||||
* 既少了阴影、圆角也小一号 —— 结果是同一个设置页里,用 SettingGroup 的分组
|
||||
* 没有阴影、直接用 Card 的分组(Shell / 快捷键)有阴影,看着像两套体系。
|
||||
*
|
||||
* 行的分隔(1px divider)与内边距留在这里:Card 默认的 `py-6 gap-6`
|
||||
* 对「一叠设置行」来说过宽,用 `!py-0 !gap-0` 让行自己掌控垂直间距。
|
||||
*/
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
description?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex flex-col gap-3">
|
||||
<div class="flex min-w-0 flex-col gap-1">
|
||||
<h2 class="text-sm font-semibold text-foreground">{{ title }}</h2>
|
||||
<p v-if="description" class="text-xs text-muted-foreground">{{ description }}</p>
|
||||
</div>
|
||||
<Card class="!py-0 !gap-0">
|
||||
<CardContent class="flex flex-col divide-y divide-divider px-4">
|
||||
<slot />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 设置行:左标签(可带说明),右控件。
|
||||
* 行高 44px,分组内由 SettingGroup 用 1px divider 分隔。
|
||||
*/
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
label: string
|
||||
description?: string
|
||||
/** wide = 控件固定 200px,用于下拉/输入框对齐;auto = 自适应(开关、按钮) */
|
||||
controlWidth?: 'auto' | 'wide'
|
||||
}>(),
|
||||
{ controlWidth: 'auto' }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-11 items-center justify-between gap-4 py-2">
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<span class="text-[13px] text-foreground">{{ label }}</span>
|
||||
<span v-if="description" class="text-xs text-muted-foreground">{{ description }}</span>
|
||||
</div>
|
||||
<div class="shrink-0" :class="controlWidth === 'wide' && 'w-50'">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 键值/指标卡片 —— 概览类内容区(模式 A 卡片网格)的最小单元。
|
||||
*
|
||||
* 默认无阴影,靠 1px 描边与 --canvas 底色区分层级(规范 P7)。
|
||||
* 卡片内边距 16px、标题与内容间距 12px,不要逐处覆盖。
|
||||
*/
|
||||
defineProps<{
|
||||
title: string
|
||||
description?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="flex flex-col gap-3 rounded-lg border border-border bg-card p-4 shadow-sm">
|
||||
<header class="flex items-center justify-between gap-2">
|
||||
<h3 class="truncate text-sm font-semibold text-foreground">{{ title }}</h3>
|
||||
<div v-if="$slots.action" class="shrink-0">
|
||||
<slot name="action" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p v-if="description" class="text-xs text-muted-foreground">{{ description }}</p>
|
||||
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Inbox, Loader2, ShieldOff, TriangleAlert } from '@lucide/vue'
|
||||
|
||||
/**
|
||||
* 四态占位块 —— 规范 P3 的落地件。
|
||||
*
|
||||
* 任何数据驱动的界面都必须能从这四态里选一个渲染,不允许留白。
|
||||
* 使用方式:v-if 链上按 loading → error → empty → 内容 的顺序判断。
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
variant: 'loading' | 'empty' | 'error' | 'denied'
|
||||
title?: string
|
||||
description?: string
|
||||
/** 占位高度,默认 140;放在卡片里可传更小值 */
|
||||
minHeight?: number
|
||||
}>(),
|
||||
{ minHeight: 140 }
|
||||
)
|
||||
|
||||
const PRESET = {
|
||||
loading: { icon: Loader2, title: '加载中…', spin: true },
|
||||
empty: { icon: Inbox, title: '暂无数据', spin: false },
|
||||
error: { icon: TriangleAlert, title: '加载失败', spin: false },
|
||||
denied: { icon: ShieldOff, title: '无访问权限', spin: false },
|
||||
} as const
|
||||
|
||||
const preset = computed(() => PRESET[props.variant])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full flex-col items-center justify-center gap-2 px-6 text-center"
|
||||
:style="{ minHeight: `${minHeight}px` }"
|
||||
>
|
||||
<component
|
||||
:is="preset.icon"
|
||||
class="size-8 shrink-0"
|
||||
:class="[variant === 'error' ? 'text-danger' : 'text-fg-disabled', preset.spin && 'animate-spin']"
|
||||
:stroke-width="1.75"
|
||||
/>
|
||||
<p class="text-[13px] font-medium text-foreground">{{ title ?? preset.title }}</p>
|
||||
<p v-if="description" class="max-w-xs text-xs text-muted-foreground">{{ description }}</p>
|
||||
<div v-if="$slots.action" class="mt-1">
|
||||
<slot name="action" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* 语义徽章。
|
||||
*
|
||||
* 三件套用法:底色取 --<state>-soft,文字取 --<state>-text。
|
||||
* 禁止用实心状态色做徽章底(那是状态点的用法)。
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
variant?: 'success' | 'warning' | 'danger' | 'info' | 'neutral' | 'accent'
|
||||
size?: 'sm' | 'md'
|
||||
}>(),
|
||||
{ variant: 'neutral', size: 'md' }
|
||||
)
|
||||
|
||||
const variantClass = computed(
|
||||
() =>
|
||||
({
|
||||
success: 'bg-success-soft text-success-text',
|
||||
warning: 'bg-warning-soft text-warning-text',
|
||||
danger: 'bg-danger-soft text-danger-text',
|
||||
info: 'bg-info-soft text-info-text',
|
||||
neutral: 'bg-neutral-soft text-neutral-text',
|
||||
accent: 'bg-primary-soft text-primary-soft-text',
|
||||
})[props.variant]
|
||||
)
|
||||
|
||||
const sizeClass = computed(() =>
|
||||
props.size === 'sm' ? 'h-5 px-2 text-[11px]' : 'h-6 px-2.5 text-xs'
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="inline-flex shrink-0 items-center gap-1 rounded-sm font-medium whitespace-nowrap"
|
||||
:class="cn(variantClass, sizeClass)"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
/**
|
||||
* 状态点 + 文字。
|
||||
*
|
||||
* 状态色只表达「系统状态」,不得用在可点击元素上(规范 P1)——
|
||||
* 需要表达「可点击」时用 --primary,需要表达状态时用这里。
|
||||
*/
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
state?: 'success' | 'warning' | 'danger' | 'info' | 'neutral'
|
||||
label?: string
|
||||
/** 持续态(运行中 / 连接中)才开呼吸动效;瞬时状态不要开 */
|
||||
pulse?: boolean
|
||||
}>(),
|
||||
{ state: 'neutral', pulse: false }
|
||||
)
|
||||
|
||||
const dotClass = computed(
|
||||
() =>
|
||||
({
|
||||
success: 'bg-success',
|
||||
warning: 'bg-warning',
|
||||
danger: 'bg-danger',
|
||||
info: 'bg-info',
|
||||
neutral: 'bg-neutral',
|
||||
})[props.state]
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="inline-flex min-w-0 items-center gap-1.5">
|
||||
<span
|
||||
class="size-2 shrink-0 rounded-full"
|
||||
:class="[dotClass, pulse && 'status-dot-pulse']"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span v-if="label" class="truncate text-[13px] text-foreground">{{ label }}</span>
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 业务组件层 —— 规范见 UI_DESIGN_SYSTEM.md 第 5 章。
|
||||
*
|
||||
* 这一层负责「把 token 组装成模块能直接用的结构」。
|
||||
* 模块内不得再手写状态点、徽章、卡片标题、键值行、四态占位等结构,
|
||||
* 一律从这里取 —— 否则又会退回各写各的状态。
|
||||
*/
|
||||
|
||||
export { default as ConfirmDialog } from './ConfirmDialog.vue'
|
||||
export { default as CopyField } from './CopyField.vue'
|
||||
export { default as DataListRow } from './DataListRow.vue'
|
||||
export { default as InfoGroup } from './InfoGroup.vue'
|
||||
export { default as KeyValueRow } from './KeyValueRow.vue'
|
||||
export { default as MetricValue } from './MetricValue.vue'
|
||||
export { default as ModuleShell } from './ModuleShell.vue'
|
||||
export { default as ModuleToolbar } from './ModuleToolbar.vue'
|
||||
export { default as SectionHeader } from './SectionHeader.vue'
|
||||
export { default as SettingGroup } from './SettingGroup.vue'
|
||||
export { default as SettingRow } from './SettingRow.vue'
|
||||
export { default as StatCard } from './StatCard.vue'
|
||||
export { default as StateBlock } from './StateBlock.vue'
|
||||
export { default as StatusBadge } from './StatusBadge.vue'
|
||||
export { default as StatusDot } from './StatusDot.vue'
|
||||
|
||||
export { default as SegmentedNav } from './SegmentedNav.vue'
|
||||
Reference in New Issue
Block a user