UI/细节调整

This commit is contained in:
zhongluofeng
2026-09-22 19:10:16 +08:00
parent b018abd922
commit bccd21fde5
135 changed files with 11393 additions and 5104 deletions
+61
View File
@@ -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>
+57
View File
@@ -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>
+35
View File
@@ -0,0 +1,35 @@
<script setup lang="ts">
/**
* 列表行 —— 主从分栏(模式 B)左栏的条目。
* 选中态用 primary-softhover 态用中性 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>
+49
View File
@@ -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>
+31
View File
@@ -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>
+42
View File
@@ -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>
+96
View File
@@ -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" />
<!-- 固定层 #2Tab 自定义内容走 #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>
+17
View File
@@ -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>
+22
View File
@@ -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>
+9 -5
View File
@@ -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>
+36
View File
@@ -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>
+27
View File
@@ -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>
+29
View File
@@ -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>
+49
View File
@@ -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>
+43
View File
@@ -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>
+41
View File
@@ -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>
+25
View File
@@ -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'