| 1 | <template> |
| 2 | <n-card content-class="p-0!" :hoverable="clickable" :class="{ 'group cursor-pointer': clickable }" size="small"> |
| 3 | <div |
| 4 | class="card-header border-border flex items-center justify-between gap-4 overflow-hidden border-b px-4 py-3" |
| 5 | :class="{ 'group-hover:text-primary transition-colors': clickable }" |
| 6 | > |
| 7 | <div class="truncate text-base">{{ title }}</div> |
| 8 | <Icon v-if="clickable" :name="icon || 'carbon:arrow-up-right'" /> |
| 9 | </div> |
| 10 | |
| 11 | <div class="card-content flex flex-col gap-3"> |
| 12 | <div v-if="barValues.length" class="bars flex"> |
| 13 | <div |
| 14 | v-for="item of barValues" |
| 15 | :key="JSON.stringify(item)" |
| 16 | :class="item.status" |
| 17 | class="bar" |
| 18 | :style="{ width: `${item.percentage}%` }" |
| 19 | > |
| 20 | <div class="fill"></div> |
| 21 | </div> |
| 22 | </div> |
| 23 | <div class="list flex flex-col"> |
| 24 | <div |
| 25 | v-for="item of listValues" |
| 26 | :key="JSON.stringify(item)" |
| 27 | class="item group/item flex items-center gap-3" |
| 28 | :class="[item.status]" |
| 29 | > |
| 30 | <div |
| 31 | class="label flex items-center gap-2 truncate" |
| 32 | :class="{ 'cursor-pointer': selectable }" |
| 33 | @click="emit('select', item)" |
| 34 | > |
| 35 | <span class="badge"></span> |
| 36 | <span |
| 37 | class="truncate font-mono" |
| 38 | :class="{ 'group-hover/item:text-primary transition-colors': selectable }" |
| 39 | > |
| 40 | {{ item.label }} |
| 41 | </span> |
| 42 | <Icon |
| 43 | v-if="selectable" |
| 44 | :name="icon || 'carbon:arrow-up-right'" |
| 45 | class="group-hover/item:text-primary transition-colors" |
| 46 | /> |
| 47 | </div> |
| 48 | <div class="divider grow"></div> |
| 49 | <div class="value flex gap-3 font-mono whitespace-nowrap"> |
| 50 | <span v-if="!item.isTotal" class="opacity-50">{{ item.percentage }}%</span> |
| 51 | <strong>{{ item.value }}</strong> |
| 52 | </div> |
| 53 | </div> |
| 54 | <div v-if="!listValues.length" class="item group/item muted flex items-center gap-3"> |
| 55 | <div class="label flex items-center gap-2 truncate"> |
| 56 | <span class="badge"></span> |
| 57 | <span class="truncate font-mono">No data</span> |
| 58 | </div> |
| 59 | <div class="divider grow"></div> |
| 60 | </div> |
| 61 | </div> |
| 62 | </div> |
| 63 | </n-card> |
| 64 | </template> |
| 65 | |
| 66 | <script setup lang="ts"> |
| 67 | import _round from "lodash/round" |
| 68 | import { NCard } from "naive-ui" |
| 69 | import { computed } from "vue" |
| 70 | import Icon from "@/components/common/Icon.vue" |
| 71 | |
| 72 | export interface ItemProps { |
| 73 | value: number |
| 74 | label: string |
| 75 | isTotal?: boolean |
| 76 | status?: "success" | "warning" | "error" | "muted" | "primary" | "default" | "info" |
| 77 | } |
| 78 | |
| 79 | interface ItemPropsExt extends ItemProps { |
| 80 | percentage: number |
| 81 | } |
| 82 | |
| 83 | const { |
| 84 | title, |
| 85 | values, |
| 86 | showTotal = true, |
| 87 | showZeroItems, |
| 88 | clickable, |
| 89 | selectable, |
| 90 | icon |
| 91 | } = defineProps<{ |
| 92 | title: string |
| 93 | values: ItemProps[] |
| 94 | icon?: string |
| 95 | showTotal?: boolean |
| 96 | showZeroItems?: boolean |
| 97 | clickable?: boolean |
| 98 | selectable?: boolean |
| 99 | }>() |
| 100 | |
| 101 | const emit = defineEmits<{ |
| 102 | select: [item: ItemProps] |
| 103 | }>() |
| 104 | |
| 105 | const totItem = computed( |
| 106 | () => |
| 107 | values.find(item => item.isTotal) || { |
| 108 | value: values.reduce((acc, cur) => { |
| 109 | return acc + cur.value |
| 110 | }, 0), |
| 111 | isTotal: true, |
| 112 | label: "Total" |
| 113 | } |
| 114 | ) |
| 115 | |
| 116 | const sanitizedValues = computed<ItemPropsExt[]>(() => { |
| 117 | const list: ItemPropsExt[] = values |
| 118 | .filter(o => !o.isTotal) |
| 119 | .map(o => ({ ...o, percentage: _round((o.value / totItem.value.value) * 100, 2) })) |
| 120 | .filter(o => o.value || (!o.value && showZeroItems)) |
| 121 | |
| 122 | return [{ ...totItem.value, percentage: 100 }, ...list] |
| 123 | }) |
| 124 | |
| 125 | const listValues = computed<ItemPropsExt[]>(() => |
| 126 | sanitizedValues.value.filter(o => !o.isTotal || (showTotal && o.isTotal)) |
| 127 | ) |
| 128 | |
| 129 | const barValues = computed<ItemPropsExt[]>(() => sanitizedValues.value.filter(o => !o.isTotal && o.percentage)) |
| 130 | </script> |
| 131 | |
| 132 | <style scoped lang="scss"> |
| 133 | @use "sass:list"; |
| 134 | |
| 135 | .n-card { |
| 136 | overflow: hidden; |
| 137 | |
| 138 | .card-content { |
| 139 | padding: 12px 16px; |
| 140 | |
| 141 | .bars { |
| 142 | height: 10px; |
| 143 | |
| 144 | .bar { |
| 145 | padding: 0 2px; |
| 146 | |
| 147 | &:first-child { |
| 148 | padding-left: 0; |
| 149 | } |
| 150 | &:last-child { |
| 151 | padding-right: 0; |
| 152 | } |
| 153 | |
| 154 | .fill { |
| 155 | border-radius: var(--border-radius-small); |
| 156 | background-color: var(--fg-default-color); |
| 157 | height: 100%; |
| 158 | width: 100%; |
| 159 | } |
| 160 | |
| 161 | @for $i from 2 through 10 { |
| 162 | $opacities: (1, 0.8, 0.6, 0.4, 0.2); |
| 163 | $idx: (($i - 1) % 5) + 1; |
| 164 | &:nth-child(#{$i}) { |
| 165 | .fill { |
| 166 | opacity: list.nth($opacities, $idx); |
| 167 | } |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | &.success { |
| 172 | .fill { |
| 173 | background-color: var(--success-color); |
| 174 | opacity: 1; |
| 175 | } |
| 176 | } |
| 177 | &.warning { |
| 178 | .fill { |
| 179 | background-color: var(--warning-color); |
| 180 | opacity: 1; |
| 181 | } |
| 182 | } |
| 183 | &.error { |
| 184 | .fill { |
| 185 | background-color: var(--error-color); |
| 186 | opacity: 1; |
| 187 | } |
| 188 | } |
| 189 | &.muted { |
| 190 | .fill { |
| 191 | background-color: var(--fg-secondary-color); |
| 192 | opacity: 1; |
| 193 | } |
| 194 | } |
| 195 | &.info { |
| 196 | .fill { |
| 197 | background-color: var(--info-color); |
| 198 | opacity: 1; |
| 199 | } |
| 200 | } |
| 201 | &.primary { |
| 202 | .fill { |
| 203 | background-color: var(--primary-color); |
| 204 | opacity: 1; |
| 205 | } |
| 206 | } |
| 207 | &.default { |
| 208 | .fill { |
| 209 | background-color: var(--fg-default-color); |
| 210 | opacity: 1; |
| 211 | } |
| 212 | } |
| 213 | } |
| 214 | } |
| 215 | |
| 216 | .list { |
| 217 | font-size: 13px; |
| 218 | gap: 4px; |
| 219 | |
| 220 | .item { |
| 221 | line-height: 1; |
| 222 | padding: 3px 4px; |
| 223 | |
| 224 | .badge { |
| 225 | height: 10px; |
| 226 | width: 10px; |
| 227 | min-width: 10px; |
| 228 | border-radius: var(--border-radius-small); |
| 229 | background-color: var(--fg-default-color); |
| 230 | } |
| 231 | |
| 232 | .divider { |
| 233 | height: 1px; |
| 234 | background-color: rgba(var(--border-color-rgb) / 0.7); |
| 235 | } |
| 236 | |
| 237 | @for $i from 2 through 10 { |
| 238 | $opacities: (1, 0.8, 0.6, 0.4, 0.2); |
| 239 | $idx: (($i - 1) % 5) + 1; |
| 240 | &:nth-child(#{$i}) { |
| 241 | .badge { |
| 242 | opacity: list.nth($opacities, $idx); |
| 243 | } |
| 244 | } |
| 245 | } |
| 246 | |
| 247 | &.success { |
| 248 | .badge { |
| 249 | background-color: var(--success-color); |
| 250 | opacity: 1; |
| 251 | } |
| 252 | } |
| 253 | &.warning { |
| 254 | .badge { |
| 255 | background-color: var(--warning-color); |
| 256 | opacity: 1; |
| 257 | } |
| 258 | } |
| 259 | &.error { |
| 260 | .badge { |
| 261 | background-color: var(--error-color); |
| 262 | opacity: 1; |
| 263 | } |
| 264 | } |
| 265 | &.muted { |
| 266 | .badge { |
| 267 | background-color: var(--fg-secondary-color); |
| 268 | opacity: 1; |
| 269 | } |
| 270 | } |
| 271 | &.info { |
| 272 | .badge { |
| 273 | background-color: var(--info-color); |
| 274 | opacity: 1; |
| 275 | } |
| 276 | } |
| 277 | &.default { |
| 278 | .badge { |
| 279 | background-color: var(--fg-default-color); |
| 280 | opacity: 1; |
| 281 | } |
| 282 | } |
| 283 | &.primary { |
| 284 | .badge { |
| 285 | background-color: var(--primary-color); |
| 286 | opacity: 1; |
| 287 | } |
| 288 | } |
| 289 | } |
| 290 | } |
| 291 | } |
| 292 | |
| 293 | &.hovered { |
| 294 | &:hover { |
| 295 | border-color: var(--primary-color); |
| 296 | } |
| 297 | } |
| 298 | } |
| 299 | </style> |