@vela-studio/ui 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +152 -0
  2. package/dist/index.d.ts +696 -0
  3. package/dist/index.js +10 -0
  4. package/dist/index.js.map +1 -0
  5. package/dist/index.mjs +11786 -0
  6. package/dist/index.mjs.map +1 -0
  7. package/dist/index.umd.js +10 -0
  8. package/dist/index.umd.js.map +1 -0
  9. package/dist/style.css +1 -0
  10. package/index.ts +150 -0
  11. package/package.json +73 -0
  12. package/src/components/advanced/scripting/Scripting.vue +189 -0
  13. package/src/components/advanced/state/State.vue +231 -0
  14. package/src/components/advanced/trigger/Trigger.vue +256 -0
  15. package/src/components/basic/button/Button.vue +120 -0
  16. package/src/components/basic/container/Container.vue +22 -0
  17. package/src/components/chart/barChart/barChart.vue +176 -0
  18. package/src/components/chart/doughnutChart/doughnutChart.vue +128 -0
  19. package/src/components/chart/funnelChart/funnelChart.vue +128 -0
  20. package/src/components/chart/gaugeChart/gaugeChart.vue +144 -0
  21. package/src/components/chart/lineChart/lineChart.vue +188 -0
  22. package/src/components/chart/pieChart/pieChart.vue +114 -0
  23. package/src/components/chart/radarChart/radarChart.vue +115 -0
  24. package/src/components/chart/sankeyChart/sankeyChart.vue +144 -0
  25. package/src/components/chart/scatterChart/scatterChart.vue +162 -0
  26. package/src/components/chart/stackedBarChart/stackedBarChart.vue +184 -0
  27. package/src/components/content/html/Html.vue +104 -0
  28. package/src/components/content/iframe/Iframe.vue +111 -0
  29. package/src/components/content/markdown/Markdown.vue +174 -0
  30. package/src/components/controls/breadcrumb/Breadcrumb.vue +79 -0
  31. package/src/components/controls/buttonGroup/ButtonGroup.vue +93 -0
  32. package/src/components/controls/checkboxGroup/CheckboxGroup.vue +147 -0
  33. package/src/components/controls/dateRange/DateRange.vue +174 -0
  34. package/src/components/controls/multiSelect/MultiSelect.vue +155 -0
  35. package/src/components/controls/navButton/NavButton.vue +97 -0
  36. package/src/components/controls/pagination/Pagination.vue +94 -0
  37. package/src/components/controls/searchBox/SearchBox.vue +170 -0
  38. package/src/components/controls/select/Select.vue +134 -0
  39. package/src/components/controls/slider/Slider.vue +167 -0
  40. package/src/components/controls/switch/Switch.vue +107 -0
  41. package/src/components/data/cardGrid/CardGrid.vue +318 -0
  42. package/src/components/data/list/List.vue +282 -0
  43. package/src/components/data/pivot/Pivot.vue +270 -0
  44. package/src/components/data/table/Table.vue +150 -0
  45. package/src/components/data/timeline/Timeline.vue +315 -0
  46. package/src/components/group/Group.vue +75 -0
  47. package/src/components/kpi/box/Box.vue +98 -0
  48. package/src/components/kpi/countUp/CountUp.vue +193 -0
  49. package/src/components/kpi/progress/Progress.vue +159 -0
  50. package/src/components/kpi/stat/Stat.vue +205 -0
  51. package/src/components/kpi/text/Text.vue +74 -0
  52. package/src/components/layout/badge/Badge.vue +105 -0
  53. package/src/components/layout/col/Col.vue +114 -0
  54. package/src/components/layout/flex/Flex.vue +105 -0
  55. package/src/components/layout/grid/Grid.vue +89 -0
  56. package/src/components/layout/modal/Modal.vue +118 -0
  57. package/src/components/layout/panel/Panel.vue +162 -0
  58. package/src/components/layout/row/Row.vue +99 -0
  59. package/src/components/layout/tabs/Tabs.vue +117 -0
  60. package/src/components/media/image/Image.vue +132 -0
  61. package/src/components/media/video/Video.vue +115 -0
  62. package/src/components/v2/basic/BaseButton.vue +179 -0
  63. package/src/components/v2/kpi/KpiCard.vue +215 -0
  64. package/src/components/v2/layout/GridBox.vue +55 -0
  65. package/src/hooks/useDataSource.ts +123 -0
  66. package/src/types/gis.ts +251 -0
  67. package/src/utils/chartUtils.ts +349 -0
  68. package/src/utils/dataUtils.ts +403 -0
@@ -0,0 +1,132 @@
1
+ <template>
2
+ <div :style="containerStyle">
3
+ <el-image
4
+ v-if="url"
5
+ :src="url"
6
+ :alt="alt"
7
+ :style="imageStyle"
8
+ :fit="fit"
9
+ :lazy="lazy"
10
+ :preview-src-list="preview ? [url] : undefined"
11
+ :z-index="previewZIndex"
12
+ :initial-index="0"
13
+ :hide-on-click-modal="hideOnClickModal"
14
+ :preview-teleported="true"
15
+ >
16
+ <template #error>
17
+ <div class="image-error">
18
+ <el-icon><Picture /></el-icon>
19
+ <span>{{ errorText }}</span>
20
+ </div>
21
+ </template>
22
+ <template #placeholder>
23
+ <div class="image-loading">
24
+ <el-icon class="is-loading"><Loading /></el-icon>
25
+ </div>
26
+ </template>
27
+ </el-image>
28
+ <div v-else class="image-placeholder">
29
+ <el-icon><Picture /></el-icon>
30
+ <span>{{ placeholder }}</span>
31
+ </div>
32
+ </div>
33
+ </template>
34
+
35
+ <script setup lang="ts">
36
+ import { computed } from 'vue'
37
+ import type { CSSProperties } from 'vue'
38
+ import { Picture, Loading } from '@element-plus/icons-vue'
39
+ import { ElImage, ElIcon } from 'element-plus'
40
+
41
+ const props = withDefaults(
42
+ defineProps<{
43
+ url?: string
44
+ alt?: string
45
+ fit?: 'fill' | 'contain' | 'cover' | 'none' | 'scale-down'
46
+ lazy?: boolean
47
+ preview?: boolean
48
+ previewZIndex?: number
49
+ hideOnClickModal?: boolean
50
+ placeholder?: string
51
+ errorText?: string
52
+ backgroundColor?: string
53
+ borderRadius?: number | string
54
+ border?: string
55
+ objectFit?: string
56
+ opacity?: number | string
57
+ }>(),
58
+ {
59
+ url: '',
60
+ fit: 'cover',
61
+ lazy: true,
62
+ preview: false,
63
+ previewZIndex: 2000,
64
+ hideOnClickModal: true,
65
+ placeholder: '请设置图片地址',
66
+ errorText: '图片加载失败',
67
+ backgroundColor: 'transparent',
68
+ borderRadius: 0,
69
+ border: 'none',
70
+ objectFit: 'cover',
71
+ opacity: 1, // 默认为 1
72
+ },
73
+ )
74
+
75
+ // 样式
76
+ const containerStyle = computed<CSSProperties>(() => ({
77
+ width: '100%',
78
+ height: '100%',
79
+ display: 'flex',
80
+ justifyContent: props.objectFit === 'contain' ? 'center' : 'flex-start',
81
+ alignItems: props.objectFit === 'contain' ? 'center' : 'flex-start',
82
+ backgroundColor: props.backgroundColor,
83
+ borderRadius:
84
+ typeof props.borderRadius === 'number' ? `${props.borderRadius}px` : props.borderRadius,
85
+ overflow: 'hidden',
86
+ border: props.border,
87
+ opacity: typeof props.opacity === 'number' ? props.opacity : parseFloat(props.opacity || '1'),
88
+ }))
89
+
90
+ const imageStyle = computed<CSSProperties>(() => ({
91
+ width: '100%',
92
+ height: '100%',
93
+ objectFit: props.objectFit as CSSProperties['objectFit'],
94
+ }))
95
+ </script>
96
+
97
+ <style scoped>
98
+ .image-error,
99
+ .image-loading,
100
+ .image-placeholder {
101
+ display: flex;
102
+ flex-direction: column;
103
+ justify-content: center;
104
+ align-items: center;
105
+ width: 100%;
106
+ height: 100%;
107
+ color: #909399;
108
+ font-size: 14px;
109
+ gap: 8px;
110
+ background-color: #f5f7fa;
111
+ }
112
+
113
+ .image-error .el-icon,
114
+ .image-loading .el-icon,
115
+ .image-placeholder .el-icon {
116
+ font-size: 48px;
117
+ color: #c0c4cc;
118
+ }
119
+
120
+ .is-loading {
121
+ animation: rotating 2s linear infinite;
122
+ }
123
+
124
+ @keyframes rotating {
125
+ 0% {
126
+ transform: rotate(0deg);
127
+ }
128
+ 100% {
129
+ transform: rotate(360deg);
130
+ }
131
+ }
132
+ </style>
@@ -0,0 +1,115 @@
1
+ <template>
2
+ <div :style="containerStyle">
3
+ <video
4
+ v-if="url"
5
+ ref="videoRef"
6
+ :src="url"
7
+ :poster="poster"
8
+ :style="videoStyle"
9
+ :controls="controls"
10
+ :autoplay="autoplay"
11
+ :loop="loop"
12
+ :muted="muted"
13
+ :preload="preload"
14
+ :playsinline="true"
15
+ :controlslist="noDownload ? 'nodownload' : undefined"
16
+ :disablepictureinpicture="noPictureInPicture"
17
+ >
18
+ 您的浏览器不支持视频播放
19
+ </video>
20
+ <div v-else class="video-placeholder">
21
+ <el-icon><VideoPlay /></el-icon>
22
+ <span>{{ placeholder }}</span>
23
+ </div>
24
+ </div>
25
+ </template>
26
+
27
+ <script setup lang="ts">
28
+ import { computed, ref } from 'vue'
29
+ import type { CSSProperties } from 'vue'
30
+ import { VideoPlay } from '@element-plus/icons-vue'
31
+ import { ElIcon } from 'element-plus'
32
+
33
+ const props = withDefaults(
34
+ defineProps<{
35
+ url?: string
36
+ poster?: string
37
+ controls?: boolean
38
+ autoplay?: boolean
39
+ loop?: boolean
40
+ muted?: boolean
41
+ preload?: 'none' | 'metadata' | 'auto'
42
+ noDownload?: boolean
43
+ noPictureInPicture?: boolean
44
+ placeholder?: string
45
+ backgroundColor?: string
46
+ borderRadius?: number
47
+ border?: string
48
+ objectFit?: 'fill' | 'contain' | 'cover' | 'none'
49
+ opacity?: number
50
+ }>(),
51
+ {
52
+ url: '',
53
+ poster: '',
54
+ controls: true,
55
+ autoplay: false,
56
+ loop: false,
57
+ muted: false,
58
+ preload: 'metadata',
59
+ noDownload: false,
60
+ noPictureInPicture: false,
61
+ placeholder: '请设置视频地址',
62
+ backgroundColor: '#000000',
63
+ borderRadius: 0,
64
+ border: 'none',
65
+ objectFit: 'contain',
66
+ opacity: 100,
67
+ },
68
+ )
69
+
70
+ const videoRef = ref<HTMLVideoElement>()
71
+
72
+ // 样式
73
+ const containerStyle = computed<CSSProperties>(() => ({
74
+ width: '100%',
75
+ height: '100%',
76
+ backgroundColor: props.backgroundColor,
77
+ borderRadius: `${props.borderRadius}px`,
78
+ overflow: 'hidden',
79
+ border: props.border,
80
+ }))
81
+
82
+ const videoStyle = computed<CSSProperties>(() => ({
83
+ width: '100%',
84
+ height: '100%',
85
+ objectFit: props.objectFit,
86
+ opacity: props.opacity / 100,
87
+ }))
88
+
89
+ // 暴露方法供外部调用
90
+ defineExpose({
91
+ play: () => videoRef.value?.play(),
92
+ pause: () => videoRef.value?.pause(),
93
+ getVideoElement: () => videoRef.value,
94
+ })
95
+ </script>
96
+
97
+ <style scoped>
98
+ .video-placeholder {
99
+ display: flex;
100
+ flex-direction: column;
101
+ justify-content: center;
102
+ align-items: center;
103
+ width: 100%;
104
+ height: 100%;
105
+ color: #909399;
106
+ font-size: 14px;
107
+ gap: 8px;
108
+ background-color: #000000;
109
+ }
110
+
111
+ .video-placeholder .el-icon {
112
+ font-size: 64px;
113
+ color: #606266;
114
+ }
115
+ </style>
@@ -0,0 +1,179 @@
1
+ <template>
2
+ <button
3
+ class="v-button"
4
+ :class="[
5
+ `v-button--${type}`,
6
+ `v-button--${size}`,
7
+ {
8
+ 'is-disabled': disabled,
9
+ 'is-loading': loading,
10
+ 'is-plain': plain,
11
+ 'is-round': round,
12
+ 'is-circle': circle,
13
+ },
14
+ ]"
15
+ :disabled="disabled || loading"
16
+ :style="customStyle"
17
+ @click="handleClick"
18
+ >
19
+ <i v-if="loading" class="el-icon-loading" />
20
+ <i v-else-if="icon" :class="icon" />
21
+ <span v-if="$slots.default"><slot /></span>
22
+ <span v-else>{{ text }}</span>
23
+ </button>
24
+ </template>
25
+
26
+ <script setup lang="ts">
27
+ import { computed, type CSSProperties } from 'vue'
28
+
29
+ /**
30
+ * Button V2 (UI Layer)
31
+ * 纯粹的展示组件,严格遵循 Core Schema 定义
32
+ */
33
+
34
+ export interface ButtonStyleConfig {
35
+ backgroundColor?: string
36
+ textColor?: string
37
+ fontSize?: number
38
+ fontWeight?: number | string
39
+ borderRadius?: number
40
+ borderColor?: string
41
+ }
42
+
43
+ const props = withDefaults(
44
+ defineProps<{
45
+ // 1. 基础原子属性 (与 Core Schema 对应)
46
+ text?: string
47
+ type?: 'primary' | 'success' | 'warning' | 'danger' | 'info' | 'default'
48
+ size?: 'large' | 'default' | 'small'
49
+ plain?: boolean
50
+ round?: boolean
51
+ circle?: boolean
52
+ disabled?: boolean
53
+ loading?: boolean
54
+ icon?: string
55
+
56
+ // 2. 样式配置对象 (V2 特性)
57
+ styleConfig?: ButtonStyleConfig
58
+ }>(),
59
+ {
60
+ text: '',
61
+ type: 'primary',
62
+ size: 'default',
63
+ styleConfig: () => ({}),
64
+ },
65
+ )
66
+
67
+ const emit = defineEmits<{
68
+ (e: 'click', event: MouseEvent): void
69
+ }>()
70
+
71
+ // 计算动态样式
72
+ const customStyle = computed<CSSProperties>(() => {
73
+ const { styleConfig } = props
74
+ const style: CSSProperties = {}
75
+
76
+ if (styleConfig.backgroundColor) {
77
+ style.backgroundColor = styleConfig.backgroundColor
78
+ // 如果设置了背景色且不是 plain 模式,边框色默认跟随背景色
79
+ if (!props.plain) {
80
+ style.borderColor = styleConfig.backgroundColor
81
+ }
82
+ }
83
+
84
+ if (styleConfig.textColor) style.color = styleConfig.textColor
85
+ if (styleConfig.fontSize) style.fontSize = `${styleConfig.fontSize}px`
86
+ if (styleConfig.fontWeight) style.fontWeight = styleConfig.fontWeight
87
+ if (styleConfig.borderRadius !== undefined) style.borderRadius = `${styleConfig.borderRadius}px`
88
+ if (styleConfig.borderColor) style.borderColor = styleConfig.borderColor
89
+
90
+ return style
91
+ })
92
+
93
+ const handleClick = (e: MouseEvent) => {
94
+ if (!props.disabled && !props.loading) {
95
+ emit('click', e)
96
+ }
97
+ }
98
+ </script>
99
+
100
+ <style scoped>
101
+ .v-button {
102
+ display: inline-flex;
103
+ justify-content: center;
104
+ align-items: center;
105
+ line-height: 1;
106
+ height: 32px;
107
+ white-space: nowrap;
108
+ cursor: pointer;
109
+ color: #606266;
110
+ text-align: center;
111
+ box-sizing: border-box;
112
+ outline: none;
113
+ margin: 0;
114
+ transition: 0.1s;
115
+ font-weight: 500;
116
+ user-select: none;
117
+ vertical-align: middle;
118
+ background-color: #fff;
119
+ border: 1px solid #dcdfe6;
120
+ padding: 8px 15px;
121
+ font-size: 14px;
122
+ border-radius: 4px;
123
+ }
124
+
125
+ .v-button:hover,
126
+ .v-button:focus {
127
+ color: #409eff;
128
+ border-color: #c6e2ff;
129
+ background-color: #ecf5ff;
130
+ }
131
+
132
+ .v-button:active {
133
+ color: #3a8ee6;
134
+ border-color: #3a8ee6;
135
+ outline: none;
136
+ }
137
+
138
+ /* Disabled */
139
+ .v-button.is-disabled {
140
+ color: #c0c4cc;
141
+ cursor: not-allowed;
142
+ background-image: none;
143
+ background-color: #fff;
144
+ border-color: #ebeef5;
145
+ }
146
+
147
+ /* Sizes */
148
+ .v-button--large {
149
+ height: 40px;
150
+ padding: 12px 19px;
151
+ font-size: 14px;
152
+ }
153
+ .v-button--small {
154
+ height: 24px;
155
+ padding: 5px 11px;
156
+ font-size: 12px;
157
+ }
158
+
159
+ /* Types (Simplified for Demo) */
160
+ .v-button--primary {
161
+ color: #fff;
162
+ background-color: #409eff;
163
+ border-color: #409eff;
164
+ }
165
+ .v-button--primary:not(.is-disabled):hover {
166
+ background-color: #66b1ff;
167
+ border-color: #66b1ff;
168
+ }
169
+
170
+ /* Round/Circle */
171
+ .v-button.is-round {
172
+ border-radius: 20px;
173
+ }
174
+ .v-button.is-circle {
175
+ border-radius: 50%;
176
+ padding: 8px;
177
+ min-width: 32px;
178
+ }
179
+ </style>
@@ -0,0 +1,215 @@
1
+ <template>
2
+ <div class="v-stat-card" :style="containerStyle">
3
+ <!-- Header -->
4
+ <div v-if="header?.title" class="v-stat-card__header">
5
+ <span class="v-stat-card__title" :style="titleStyle">{{ header.title }}</span>
6
+ <i
7
+ v-if="header.tooltip"
8
+ class="v-stat-card__tooltip-icon el-icon-info"
9
+ :title="header.tooltip"
10
+ >ℹ️</i
11
+ >
12
+ </div>
13
+
14
+ <!-- Content -->
15
+ <div class="v-stat-card__content">
16
+ <div class="v-stat-card__value-wrapper">
17
+ <span v-if="content?.prefix" class="v-stat-card__prefix">{{ content.prefix }}</span>
18
+ <span class="v-stat-card__value" :style="valueStyle">{{ formattedValue }}</span>
19
+ <span v-if="content?.suffix" class="v-stat-card__suffix">{{ content.suffix }}</span>
20
+ </div>
21
+ </div>
22
+
23
+ <!-- Footer / Trend -->
24
+ <div v-if="footer?.trend" class="v-stat-card__footer">
25
+ <div class="v-stat-card__trend" :class="`is-${footer.trend.type || 'normal'}`">
26
+ <span v-if="footer.trend.label" class="trend-label">{{ footer.trend.label }}</span>
27
+ <span class="trend-value">{{ footer.trend.value }}</span>
28
+ <span class="trend-icon">
29
+ {{ footer.trend.type === 'up' ? '↑' : footer.trend.type === 'down' ? '↓' : '-' }}
30
+ </span>
31
+ </div>
32
+ </div>
33
+ </div>
34
+ </template>
35
+
36
+ <script setup lang="ts">
37
+ import { computed, type CSSProperties } from 'vue'
38
+
39
+ export interface StatHeader {
40
+ title?: string
41
+ tooltip?: string
42
+ }
43
+
44
+ export interface StatContent {
45
+ value?: number | string
46
+ prefix?: string
47
+ suffix?: string
48
+ precision?: number // 小数精度
49
+ separator?: boolean // 千分位
50
+ }
51
+
52
+ export interface StatTrend {
53
+ value?: string
54
+ label?: string
55
+ type?: 'up' | 'down' | 'normal'
56
+ }
57
+
58
+ export interface StatFooter {
59
+ trend?: StatTrend
60
+ }
61
+
62
+ export interface StatStyle {
63
+ background?: {
64
+ color?: string
65
+ image?: string
66
+ }
67
+ border?: {
68
+ width?: number
69
+ color?: string
70
+ radius?: number
71
+ }
72
+ padding?: number
73
+ shadow?: string
74
+
75
+ // 具体的文字样式
76
+ titleColor?: string
77
+ titleSize?: number
78
+ valueColor?: string
79
+ valueSize?: number
80
+ valueWeight?: number | string
81
+ }
82
+
83
+ const props = withDefaults(
84
+ defineProps<{
85
+ header?: StatHeader
86
+ content?: StatContent
87
+ footer?: StatFooter
88
+ styleConfig?: StatStyle
89
+ }>(),
90
+ {
91
+ header: () => ({}),
92
+ content: () => ({ value: 0 }),
93
+ footer: () => ({}),
94
+ styleConfig: () => ({}),
95
+ },
96
+ )
97
+
98
+ // 格式化数值
99
+ const formattedValue = computed(() => {
100
+ let val = props.content.value
101
+
102
+ if (typeof val === 'number') {
103
+ // 精度处理
104
+ if (props.content.precision !== undefined) {
105
+ val = val.toFixed(props.content.precision)
106
+ }
107
+ // 千分位
108
+ if (props.content.separator) {
109
+ val = Number(val).toLocaleString()
110
+ }
111
+ }
112
+
113
+ return val
114
+ })
115
+
116
+ // 容器样式
117
+ const containerStyle = computed<CSSProperties>(() => {
118
+ const { styleConfig } = props
119
+ return {
120
+ backgroundColor: styleConfig.background?.color || '#fff',
121
+ backgroundImage: styleConfig.background?.image
122
+ ? `url(${styleConfig.background.image})`
123
+ : undefined,
124
+ borderWidth:
125
+ styleConfig.border?.width !== undefined ? `${styleConfig.border.width}px` : undefined,
126
+ borderColor: styleConfig.border?.color,
127
+ borderStyle: styleConfig.border?.width ? 'solid' : undefined,
128
+ borderRadius:
129
+ styleConfig.border?.radius !== undefined ? `${styleConfig.border.radius}px` : '4px',
130
+ padding: styleConfig.padding !== undefined ? `${styleConfig.padding}px` : '16px',
131
+ boxShadow: styleConfig.shadow || '0 2px 12px 0 rgba(0,0,0,0.1)',
132
+ display: 'flex',
133
+ flexDirection: 'column',
134
+ justifyContent: 'space-between',
135
+ height: '100%',
136
+ boxSizing: 'border-box',
137
+ }
138
+ })
139
+
140
+ const titleStyle = computed<CSSProperties>(() => ({
141
+ color: props.styleConfig.titleColor || '#909399',
142
+ fontSize: props.styleConfig.titleSize ? `${props.styleConfig.titleSize}px` : '14px',
143
+ }))
144
+
145
+ const valueStyle = computed<CSSProperties>(() => ({
146
+ color: props.styleConfig.valueColor || '#303133',
147
+ fontSize: props.styleConfig.valueSize ? `${props.styleConfig.valueSize}px` : '24px',
148
+ fontWeight: props.styleConfig.valueWeight || 'bold',
149
+ fontFamily:
150
+ 'Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB, Microsoft YaHei, Arial, sans-serif',
151
+ }))
152
+ </script>
153
+
154
+ <style scoped>
155
+ .v-stat-card__header {
156
+ display: flex;
157
+ align-items: center;
158
+ margin-bottom: 8px;
159
+ }
160
+
161
+ .v-stat-card__tooltip-icon {
162
+ margin-left: 4px;
163
+ cursor: help;
164
+ font-size: 12px;
165
+ color: #909399;
166
+ }
167
+
168
+ .v-stat-card__content {
169
+ flex: 1;
170
+ display: flex;
171
+ align-items: center;
172
+ }
173
+
174
+ .v-stat-card__value-wrapper {
175
+ display: flex;
176
+ align-items: baseline;
177
+ }
178
+
179
+ .v-stat-card__prefix,
180
+ .v-stat-card__suffix {
181
+ font-size: 14px;
182
+ color: #606266;
183
+ margin: 0 4px;
184
+ }
185
+
186
+ .v-stat-card__footer {
187
+ margin-top: 12px;
188
+ padding-top: 12px;
189
+ border-top: 1px solid #ebeef5;
190
+ }
191
+
192
+ .v-stat-card__trend {
193
+ display: flex;
194
+ align-items: center;
195
+ font-size: 12px;
196
+ }
197
+
198
+ .v-stat-card__trend.is-up {
199
+ color: #f56c6c;
200
+ }
201
+ .v-stat-card__trend.is-down {
202
+ color: #67c23a;
203
+ }
204
+ .v-stat-card__trend.is-normal {
205
+ color: #909399;
206
+ }
207
+
208
+ .trend-label {
209
+ margin-right: 8px;
210
+ }
211
+ .trend-icon {
212
+ margin-left: 4px;
213
+ font-weight: bold;
214
+ }
215
+ </style>
@@ -0,0 +1,55 @@
1
+ <template>
2
+ <div class="v-grid" :style="gridStyle">
3
+ <slot v-if="$slots.default" />
4
+ </div>
5
+ </template>
6
+
7
+ <script setup lang="ts">
8
+ import { computed, type CSSProperties } from 'vue'
9
+
10
+ export interface GridLayoutConfig {
11
+ columns?: number
12
+ gap?: number
13
+ rowGap?: number
14
+ autoRows?: string // e.g. 'minmax(100px, auto)'
15
+ alignItems?: 'start' | 'end' | 'center' | 'stretch'
16
+ justifyItems?: 'start' | 'end' | 'center' | 'stretch'
17
+ }
18
+
19
+ const props = withDefaults(
20
+ defineProps<{
21
+ layout?: GridLayoutConfig
22
+ // 高度,通常由外部容器控制,但作为布局容器可能需要自适应
23
+ height?: string | number
24
+ }>(),
25
+ {
26
+ layout: () => ({ columns: 2, gap: 10 }),
27
+ height: '100%',
28
+ },
29
+ )
30
+
31
+ const gridStyle = computed<CSSProperties>(() => {
32
+ const { layout } = props
33
+
34
+ return {
35
+ display: 'grid',
36
+ gridTemplateColumns: `repeat(${layout.columns ?? 2}, 1fr)`,
37
+ gap: `${layout.gap ?? 0}px ${layout.gap ?? 0}px`,
38
+ rowGap: layout.rowGap !== undefined ? `${layout.rowGap}px` : undefined,
39
+ gridAutoRows: layout.autoRows || 'min-content',
40
+ alignItems: layout.alignItems || 'stretch',
41
+ justifyItems: layout.justifyItems || 'stretch',
42
+ height: typeof props.height === 'number' ? `${props.height}px` : props.height,
43
+ width: '100%',
44
+ boxSizing: 'border-box',
45
+ }
46
+ })
47
+ </script>
48
+
49
+ <style scoped>
50
+ .v-grid {
51
+ /* 确保网格容器自身也是干净的 */
52
+ margin: 0;
53
+ padding: 0;
54
+ }
55
+ </style>