refactor: remove unused dashboard components and views
This commit is contained in:
@@ -1,197 +0,0 @@
|
||||
<template>
|
||||
<v-dialog v-model="dialogModel" max-width="480" v-bind="$attrs">
|
||||
<v-card>
|
||||
<v-card-title class="text-subtitle-1 font-weight-medium">
|
||||
<slot name="title">
|
||||
{{ props.titleText }}
|
||||
</slot>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="pt-2">
|
||||
<slot :form="form" name="content" :permission="formPermission" :status="formStatus">
|
||||
<div class="d-flex flex-column ga-4">
|
||||
<v-select
|
||||
v-if="props.showStatus"
|
||||
v-model="formStatus"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
:items="normalizedStatusOptions"
|
||||
:label="props.statusLabelText"
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<v-select
|
||||
v-if="props.showPermission"
|
||||
v-model="formPermission"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
:items="normalizedPermissionOptions"
|
||||
:label="props.permissionLabelText"
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<slot :form="form" name="fields"></slot>
|
||||
</div>
|
||||
</slot>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="px-4 pb-4">
|
||||
<slot :cancel="handleCancel" name="actions" :submit="handleSubmit">
|
||||
<v-spacer />
|
||||
<v-btn :disabled="props.loading" variant="text" @click="handleCancel">
|
||||
{{ props.cancelText }}
|
||||
</v-btn>
|
||||
<v-btn color="primary" :loading="props.loading" @click="handleSubmit">
|
||||
{{ props.confirmText }}
|
||||
</v-btn>
|
||||
</slot>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
|
||||
type OptionValue = string | number
|
||||
type Option = { title: string; value: OptionValue }
|
||||
|
||||
type GenericRecord = Record<string, unknown>
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
item: GenericRecord | null
|
||||
statusKey?: string
|
||||
permissionKey?: string
|
||||
showStatus?: boolean
|
||||
showPermission?: boolean
|
||||
statusOptions?: Array<Option | string | number>
|
||||
permissionOptions?: Array<Option | string | number>
|
||||
titleText?: string
|
||||
statusLabelText?: string
|
||||
permissionLabelText?: string
|
||||
cancelText?: string
|
||||
confirmText?: string
|
||||
loading?: boolean
|
||||
closeOnSubmit?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
statusKey: 'status',
|
||||
permissionKey: 'permission',
|
||||
showStatus: true,
|
||||
showPermission: true,
|
||||
statusOptions: () => [],
|
||||
permissionOptions: () => [],
|
||||
titleText: '編輯',
|
||||
statusLabelText: '狀態',
|
||||
permissionLabelText: '權限',
|
||||
cancelText: '取消',
|
||||
confirmText: '確認',
|
||||
loading: false,
|
||||
closeOnSubmit: true,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'submit', value: GenericRecord): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
const dialogModel = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v: boolean) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
function normalizeOptions(options: Array<Option | string | number>) {
|
||||
return options.map((o) => {
|
||||
if (typeof o === 'string' || typeof o === 'number') {
|
||||
return { title: String(o), value: o }
|
||||
}
|
||||
return o
|
||||
})
|
||||
}
|
||||
|
||||
const normalizedStatusOptions = computed(() => normalizeOptions(props.statusOptions))
|
||||
const normalizedPermissionOptions = computed(() => normalizeOptions(props.permissionOptions))
|
||||
|
||||
const form = reactive<GenericRecord>({})
|
||||
|
||||
function resetForm(next: GenericRecord) {
|
||||
for (const key of Object.keys(form)) {
|
||||
delete form[key]
|
||||
}
|
||||
Object.assign(form, next)
|
||||
}
|
||||
|
||||
const getDefaultStatus = (): OptionValue | '' => normalizedStatusOptions.value[0]?.value ?? ''
|
||||
function getDefaultPermission(): OptionValue | '' {
|
||||
return normalizedPermissionOptions.value[0]?.value ?? ''
|
||||
}
|
||||
|
||||
const formStatus = computed<OptionValue | ''>({
|
||||
get: () => {
|
||||
const current = form[props.statusKey] as OptionValue | undefined
|
||||
return current ?? getDefaultStatus()
|
||||
},
|
||||
set: (v) => {
|
||||
form[props.statusKey] = v
|
||||
},
|
||||
})
|
||||
|
||||
const formPermission = computed<OptionValue | ''>({
|
||||
get: () => {
|
||||
const current = form[props.permissionKey] as OptionValue | undefined
|
||||
return current ?? getDefaultPermission()
|
||||
},
|
||||
set: (v) => {
|
||||
form[props.permissionKey] = v
|
||||
},
|
||||
})
|
||||
|
||||
function syncFromItem() {
|
||||
const item = props.item ?? {}
|
||||
resetForm({ ...item })
|
||||
|
||||
if (props.showStatus) {
|
||||
const status = item[props.statusKey] as OptionValue | undefined
|
||||
form[props.statusKey] = status ?? getDefaultStatus()
|
||||
}
|
||||
|
||||
if (props.showPermission) {
|
||||
const permission = item[props.permissionKey] as OptionValue | undefined
|
||||
form[props.permissionKey] = permission ?? getDefaultPermission()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (open) syncFromItem()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.item,
|
||||
() => {
|
||||
if (props.modelValue) syncFromItem()
|
||||
}
|
||||
)
|
||||
|
||||
function handleCancel() {
|
||||
emit('cancel')
|
||||
dialogModel.value = false
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
emit('submit', { ...form })
|
||||
|
||||
if (props.closeOnSubmit) {
|
||||
dialogModel.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,146 +0,0 @@
|
||||
<template>
|
||||
<v-card class="bg-surface mb-4" v-bind="$attrs">
|
||||
<v-card-text>
|
||||
<v-row density="compact">
|
||||
<!-- Dynamic Search Fields -->
|
||||
<v-col
|
||||
v-for="field in visibleFields"
|
||||
:key="field.key"
|
||||
:cols="field.meta?.cols || field.cols || 12"
|
||||
:lg="field.meta?.lg || field.lg"
|
||||
:md="field.meta?.md || field.md"
|
||||
>
|
||||
<v-row class="ma-0" density="compact">
|
||||
<v-col class="d-flex align-center justify-start justify-md-end" cols="12" md="4">
|
||||
<span class="text-body-1">{{ field.label }}</span>
|
||||
</v-col>
|
||||
|
||||
<v-col class="py-0" cols="12" md="8">
|
||||
<SKTextField
|
||||
v-if="field.type === 'text'"
|
||||
:model-value="searchState[field.key]"
|
||||
:placeholder="field.placeholder"
|
||||
@update:model-value="searchState[field.key] = $event"
|
||||
/>
|
||||
|
||||
<SKSelectField
|
||||
v-else-if="field.type === 'select'"
|
||||
:items="field.items"
|
||||
:model-value="searchState[field.key]"
|
||||
:placeholder="field.placeholder"
|
||||
@update:model-value="searchState[field.key] = $event"
|
||||
/>
|
||||
|
||||
<SKDatePicker
|
||||
v-else-if="field.type === 'date'"
|
||||
:model-value="searchState[field.key]"
|
||||
:placeholder="field.placeholder"
|
||||
@update:model-value="searchState[field.key] = $event"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-col>
|
||||
|
||||
<!-- Actions -->
|
||||
<v-col class="d-flex justify-end align-center flex-md-grow-1" cols="12" md="auto">
|
||||
<v-btn class="mr-2" variant="outlined" @click="handleReset">
|
||||
{{ resetBtnText }}
|
||||
</v-btn>
|
||||
<v-btn color="primary" @click="handleSearch">
|
||||
{{ searchBtnText }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="showExpand"
|
||||
class="ml-2"
|
||||
color="primary"
|
||||
variant="text"
|
||||
@click="expand = !expand"
|
||||
>
|
||||
{{ expand ? collapseBtnText : expandBtnText }}
|
||||
<v-icon end :icon="expand ? mdiChevronUp : mdiChevronDown"></v-icon>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { mdiChevronDown, mdiChevronUp } from '@mdi/js'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import SKDatePicker from './input_field/SKDatePicker.vue'
|
||||
import SKSelectField from './input_field/SKSelectField.vue'
|
||||
import SKTextField from './input_field/SKTextField.vue'
|
||||
|
||||
interface Field {
|
||||
key: string
|
||||
type: 'text' | 'select' | 'date'
|
||||
label: string
|
||||
placeholder?: string
|
||||
meta?: {
|
||||
cols?: number
|
||||
md?: number
|
||||
lg?: number
|
||||
}
|
||||
cols?: number
|
||||
md?: number
|
||||
lg?: number
|
||||
items?: unknown[]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
fields: Field[]
|
||||
visibleWhenCollapsed?: string[]
|
||||
searchBtnText?: string
|
||||
resetBtnText?: string
|
||||
expandBtnText?: string
|
||||
collapseBtnText?: string
|
||||
showExpand?: boolean
|
||||
actionCols?: number
|
||||
actionMd?: number
|
||||
actionLg?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
searchBtnText: '搜尋',
|
||||
resetBtnText: '重置',
|
||||
expandBtnText: '展開',
|
||||
collapseBtnText: '收起',
|
||||
showExpand: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['search', 'reset'])
|
||||
|
||||
const expand = ref(false)
|
||||
|
||||
// Compute visible fields based on expand state
|
||||
const visibleFields = computed(() => {
|
||||
if (expand.value) {
|
||||
return props.fields
|
||||
}
|
||||
if (props.visibleWhenCollapsed && props.visibleWhenCollapsed.length > 0) {
|
||||
return props.fields.filter((field) => props.visibleWhenCollapsed?.includes(field.key))
|
||||
}
|
||||
return props.fields
|
||||
})
|
||||
|
||||
// Initialize search state
|
||||
const searchState = reactive<Record<string, unknown>>({})
|
||||
|
||||
// Initialize search state based on fields
|
||||
for (const field of props.fields) {
|
||||
searchState[field.key] = field.type === 'select' ? null : ''
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
emit('search', { ...searchState })
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
// Reset all fields
|
||||
for (const field of props.fields) {
|
||||
searchState[field.key] = field.type === 'select' ? null : ''
|
||||
}
|
||||
emit('reset')
|
||||
}
|
||||
</script>
|
||||
@@ -1,168 +0,0 @@
|
||||
<template>
|
||||
<v-row align="center" class="pa-4" no-gutters v-bind="$attrs">
|
||||
<span v-if="title">{{ title }}</span>
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<v-btn
|
||||
v-if="showCreate"
|
||||
class="mr-4"
|
||||
color="primary"
|
||||
:prepend-icon="mdiPlus"
|
||||
@click="$emit('create')"
|
||||
>
|
||||
{{ createBtnText }}
|
||||
</v-btn>
|
||||
|
||||
<v-tooltip :disabled="!searchToggleTooltipText" location="top" :text="searchToggleTooltipText">
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-btn
|
||||
v-if="showSearchToggle"
|
||||
v-bind="activatorProps"
|
||||
density="comfortable"
|
||||
icon
|
||||
variant="text"
|
||||
@click="$emit('toggle-search')"
|
||||
>
|
||||
<v-icon :color="searchVisible ? 'primary-variant' : undefined" :icon="mdiMagnify" />
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<v-tooltip :disabled="!refreshTooltipText" location="top" :text="refreshTooltipText">
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-btn
|
||||
v-bind="activatorProps"
|
||||
density="comfortable"
|
||||
icon
|
||||
variant="text"
|
||||
@click="$emit('refresh')"
|
||||
>
|
||||
<v-icon :icon="mdiRefresh" />
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<v-menu v-if="settingsItems && settingsItems.length > 0">
|
||||
<template #activator="{ props: menuProps }">
|
||||
<v-tooltip :disabled="!settingsTooltipText" location="top" :text="settingsTooltipText">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<v-btn
|
||||
v-bind="{ ...menuProps, ...tooltipProps }"
|
||||
density="comfortable"
|
||||
icon
|
||||
variant="text"
|
||||
@click="$emit('settings')"
|
||||
>
|
||||
<v-icon :icon="mdiCog" />
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
|
||||
<v-list density="compact">
|
||||
<v-list-item class="py-0">
|
||||
<v-checkbox
|
||||
color="primary"
|
||||
density="compact"
|
||||
:disabled="selectAllChecked"
|
||||
hide-details
|
||||
:indeterminate="selectAllIndeterminate"
|
||||
label="全選"
|
||||
:model-value="selectAllChecked"
|
||||
@update:model-value="toggleSelectAll"
|
||||
/>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item v-for="item in settingsItems" :key="item.key" class="py-0">
|
||||
<v-checkbox
|
||||
color="primary"
|
||||
density="compact"
|
||||
hide-details
|
||||
:label="item.label"
|
||||
:model-value="settingsSelectedKeys"
|
||||
:value="item.key"
|
||||
@update:model-value="updateSettingsSelectedKeys"
|
||||
/>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</v-row>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { mdiCog, mdiMagnify, mdiPlus, mdiRefresh } from '@mdi/js'
|
||||
import { computed, toRefs } from 'vue'
|
||||
|
||||
interface SettingsItem {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title?: string
|
||||
createBtnText?: string
|
||||
showCreate?: boolean
|
||||
showSearchToggle?: boolean
|
||||
searchVisible?: boolean
|
||||
searchToggleTooltipText?: string
|
||||
refreshTooltipText?: string
|
||||
settingsTooltipText?: string
|
||||
settingsItems?: SettingsItem[]
|
||||
settingsSelectedKeys?: string[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
createBtnText: '新增',
|
||||
showCreate: true,
|
||||
showSearchToggle: false,
|
||||
searchVisible: true,
|
||||
searchToggleTooltipText: '顯示/隱藏搜尋條件',
|
||||
refreshTooltipText: '更新',
|
||||
settingsTooltipText: '欄位設定',
|
||||
settingsItems: () => [],
|
||||
settingsSelectedKeys: () => [],
|
||||
})
|
||||
|
||||
const { settingsItems, settingsSelectedKeys } = toRefs(props)
|
||||
|
||||
const emit = defineEmits([
|
||||
'create',
|
||||
'refresh',
|
||||
'settings',
|
||||
'toggle-search',
|
||||
'update:settingsSelectedKeys',
|
||||
])
|
||||
|
||||
const allSettingsKeys = computed(() => settingsItems.value.map((i) => i.key))
|
||||
|
||||
const selectAllChecked = computed(() => {
|
||||
if (allSettingsKeys.value.length === 0) {
|
||||
return false
|
||||
}
|
||||
return allSettingsKeys.value.every((k) => settingsSelectedKeys.value.includes(k))
|
||||
})
|
||||
|
||||
const selectAllIndeterminate = computed(() => {
|
||||
if (allSettingsKeys.value.length === 0) {
|
||||
return false
|
||||
}
|
||||
const selectedCount = allSettingsKeys.value.filter((k) =>
|
||||
settingsSelectedKeys.value.includes(k)
|
||||
).length
|
||||
return selectedCount > 0 && selectedCount < allSettingsKeys.value.length
|
||||
})
|
||||
|
||||
function toggleSelectAll(checked: unknown) {
|
||||
const current = Array.isArray(settingsSelectedKeys.value) ? settingsSelectedKeys.value : []
|
||||
const nonSettingsKeys = current.filter((k) => !allSettingsKeys.value.includes(k))
|
||||
|
||||
emit(
|
||||
'update:settingsSelectedKeys',
|
||||
checked ? [...nonSettingsKeys, ...allSettingsKeys.value] : nonSettingsKeys
|
||||
)
|
||||
}
|
||||
|
||||
function updateSettingsSelectedKeys(value: unknown) {
|
||||
emit('update:settingsSelectedKeys', Array.isArray(value) ? value : [])
|
||||
}
|
||||
</script>
|
||||
@@ -1,138 +0,0 @@
|
||||
<template>
|
||||
<v-data-table
|
||||
:class="`${tableClass} tree-table`"
|
||||
:headers="formattedHeaders"
|
||||
hide-default-footer
|
||||
hover
|
||||
:items="flattenedItems"
|
||||
:items-per-page="-1"
|
||||
:loading="loading"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<!-- Tree Column Slot -->
|
||||
<template v-for="header in treeHeaders" :key="header.key" #[`item.${header.key}`]="{ item }">
|
||||
<div class="d-flex align-center" :style="{ paddingLeft: `${(item.level as number) * 16}px` }">
|
||||
<!-- Expand Toggle -->
|
||||
<v-btn
|
||||
v-if="item.hasChildren"
|
||||
class="mr-1"
|
||||
density="compact"
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="toggleExpand(item.id)"
|
||||
>
|
||||
<v-icon :icon="isExpanded(item.id) ? mdiChevronDown : mdiChevronRight" />
|
||||
</v-btn>
|
||||
<div v-else style="width: 20px"></div>
|
||||
|
||||
<span class="mr-2 text-body-2">{{ item[header.key] }}</span>
|
||||
<slot :item="item" :name="`tree-${header.key}`"></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Custom Slots -->
|
||||
<template v-for="(_, name) in $slots" #[name]="slotData">
|
||||
<slot :name="name" v-bind="slotData"></slot>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { mdiChevronDown, mdiChevronRight } from '@mdi/js'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
interface Props {
|
||||
headers: TableHeader[]
|
||||
items: TreeNode[]
|
||||
loading?: boolean
|
||||
treeColumnKeys?: string[]
|
||||
tableClass?: string
|
||||
}
|
||||
|
||||
interface TableHeader {
|
||||
title: string
|
||||
key: string
|
||||
align?: 'start' | 'end' | 'center'
|
||||
width?: string
|
||||
minWidth?: string
|
||||
sortable?: boolean
|
||||
}
|
||||
|
||||
interface TreeNode {
|
||||
id: string | number
|
||||
children?: TreeNode[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
treeColumnKeys: () => ['name', 'title'],
|
||||
tableClass: '',
|
||||
})
|
||||
|
||||
const emit = defineEmits(['toggle-expand'])
|
||||
|
||||
const expandedIds = ref<Set<string | number>>(new Set())
|
||||
|
||||
watch(
|
||||
() => props.items,
|
||||
(newVal) => {
|
||||
if (newVal && newVal.length > 0) {
|
||||
for (const item of newVal) expandedIds.value.add(item.id)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function toggleExpand(id: string | number) {
|
||||
if (expandedIds.value.has(id)) {
|
||||
expandedIds.value.delete(id)
|
||||
} else {
|
||||
expandedIds.value.add(id)
|
||||
}
|
||||
emit('toggle-expand', id, expandedIds.value.has(id))
|
||||
}
|
||||
|
||||
const isExpanded = (id: string | number) => expandedIds.value.has(id)
|
||||
|
||||
const treeHeaders = computed(() =>
|
||||
props.headers.filter((h: TableHeader) => props.treeColumnKeys.includes(h.key))
|
||||
)
|
||||
|
||||
const flattenedItems = computed(() => {
|
||||
const result: TreeNode[] = []
|
||||
|
||||
const traverse = (nodes: TreeNode[], level = 0) => {
|
||||
for (const node of nodes) {
|
||||
const hasChildren = node.children && node.children.length > 0
|
||||
|
||||
result.push({
|
||||
...node,
|
||||
level,
|
||||
hasChildren,
|
||||
})
|
||||
|
||||
if (hasChildren && expandedIds.value.has(node.id)) {
|
||||
traverse(node.children as TreeNode[], level + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traverse(props.items)
|
||||
return result
|
||||
})
|
||||
|
||||
const formattedHeaders = computed(() => props.headers)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tree-table :deep(th) {
|
||||
font-weight: 600 !important;
|
||||
color: #666;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.tree-table :deep(td) {
|
||||
height: 54px !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,38 +0,0 @@
|
||||
<template>
|
||||
<v-card class="rounded-lg h-100" elevation="2" v-bind="$attrs">
|
||||
<v-card-title class="text-subtitle-1 font-weight-bold py-4">
|
||||
{{ title }}
|
||||
</v-card-title>
|
||||
<v-divider></v-divider>
|
||||
<v-card-text class="d-flex align-center justify-center pt-8">
|
||||
<div class="w-100">
|
||||
<div v-for="(item, i) in data" :key="i" class="mb-4">
|
||||
<div class="d-flex justify-space-between text-caption mb-1">
|
||||
<span>{{ item.label }}</span>
|
||||
<span>{{ item.value }}%</span>
|
||||
</div>
|
||||
<v-progress-linear
|
||||
:color="item.color"
|
||||
height="8"
|
||||
:model-value="item.value"
|
||||
rounded
|
||||
striped
|
||||
></v-progress-linear>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
title: string
|
||||
data: Array<{
|
||||
label: string
|
||||
value: number
|
||||
color: string
|
||||
}>
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
@@ -1,42 +0,0 @@
|
||||
<template>
|
||||
<v-card class="rounded-lg h-100" elevation="2" v-bind="$attrs">
|
||||
<v-card-title class="text-subtitle-1 font-weight-bold py-4">
|
||||
{{ title }}
|
||||
</v-card-title>
|
||||
<v-divider></v-divider>
|
||||
<v-card-text class="d-flex flex-column align-center justify-center pt-8">
|
||||
<div
|
||||
class="d-flex align-center justify-center"
|
||||
style="position: relative; width: 200px; height: 200px"
|
||||
>
|
||||
<v-progress-circular
|
||||
bg-color="grey-lighten-4"
|
||||
:color="data.color"
|
||||
:model-value="data.value"
|
||||
:size="180"
|
||||
:width="25"
|
||||
>
|
||||
<v-icon :color="data.color" size="40" :icon="data.icon" />
|
||||
</v-progress-circular>
|
||||
</div>
|
||||
<div class="mt-6 text-center">
|
||||
<div class="text-h6">{{ data.label }}</div>
|
||||
<div class="text-body-2 text-grey">佔比 {{ data.value }}%</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
title: string
|
||||
data: {
|
||||
value: number
|
||||
label: string
|
||||
color: string
|
||||
icon: string
|
||||
}
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
@@ -1,56 +0,0 @@
|
||||
<template>
|
||||
<v-card class="rounded-lg h-100" elevation="2" v-bind="$attrs">
|
||||
<v-card-title class="text-subtitle-1 font-weight-bold py-4">
|
||||
{{ title }}
|
||||
</v-card-title>
|
||||
<v-divider></v-divider>
|
||||
<v-card-text class="d-flex flex-column align-center justify-center pt-8">
|
||||
<div
|
||||
class="d-flex align-center justify-center"
|
||||
style="position: relative; width: 200px; height: 200px"
|
||||
>
|
||||
<v-progress-circular
|
||||
class="position-absolute"
|
||||
color="grey-lighten-3"
|
||||
:model-value="100"
|
||||
:size="180"
|
||||
:width="25"
|
||||
></v-progress-circular>
|
||||
|
||||
<v-progress-circular
|
||||
class="position-absolute"
|
||||
:color="data.color"
|
||||
:model-value="data.value"
|
||||
rotate="270"
|
||||
:size="180"
|
||||
:width="25"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="text-h5 font-weight-bold">{{ data.value }}%</div>
|
||||
<div class="text-caption text-grey">{{ data.label }}</div>
|
||||
</div>
|
||||
</v-progress-circular>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 d-flex flex-wrap justify-center gap-2">
|
||||
<v-chip class="mr-2" :color="data.color" label size="small" variant="flat">
|
||||
{{ data.label }}
|
||||
</v-chip>
|
||||
<v-chip color="grey-lighten-3" label size="small" variant="flat"> 其他 </v-chip>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
title: string
|
||||
data: {
|
||||
value: number
|
||||
label: string
|
||||
color: string
|
||||
}
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
@@ -1,33 +0,0 @@
|
||||
<template>
|
||||
<v-card class="rounded-lg h-100" elevation="2" v-bind="$attrs">
|
||||
<v-card-text class="d-flex flex-column justify-space-between h-100">
|
||||
<div class="d-flex justify-space-between align-start mb-4">
|
||||
<div>
|
||||
<div class="text-subtitle-1 font-weight-bold text-grey-darken-1 mb-1">
|
||||
{{ title }}
|
||||
</div>
|
||||
<div class="text-h4 font-weight-bold">{{ value }}</div>
|
||||
</div>
|
||||
<v-icon class="opacity-80" :color="color" size="x-large" :icon="icon" />
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-space-between align-center border-t pt-3">
|
||||
<span class="text-body-2 text-grey">{{ label }}</span>
|
||||
<span class="text-body-2 font-weight-medium">{{ total }}</span>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
title: string
|
||||
value: string | number
|
||||
label: string
|
||||
total: string | number
|
||||
icon: string
|
||||
color: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
@@ -1,69 +0,0 @@
|
||||
<template>
|
||||
<v-card class="rounded-lg" elevation="2" v-bind="$attrs">
|
||||
<v-card-title class="d-flex align-center py-4 px-4">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon class="mr-2" color="primary" :icon="mdiChartTimelineVariant"></v-icon>
|
||||
<span>{{ title }}</span>
|
||||
</div>
|
||||
<v-spacer></v-spacer>
|
||||
<div class="d-flex">
|
||||
<v-btn
|
||||
v-for="filter in filters"
|
||||
:key="filter"
|
||||
:color="activeFilter === filter ? 'primary' : 'grey'"
|
||||
density="compact"
|
||||
variant="text"
|
||||
@click="$emit('filter-change', filter)"
|
||||
>
|
||||
{{ filter }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="pt-6 pb-2">
|
||||
<div class="chart-container" style="height: 300px; position: relative">
|
||||
<v-sparkline
|
||||
auto-draw
|
||||
fill
|
||||
:gradient="['#1890ff', '#e6f7ff']"
|
||||
gradient-direction="top"
|
||||
height="100"
|
||||
:line-width="2"
|
||||
:model-value="data"
|
||||
:padding="8"
|
||||
:smooth="10"
|
||||
stroke-linecap="round"
|
||||
>
|
||||
<template #label="item">
|
||||
{{ item.value }}
|
||||
</template>
|
||||
</v-sparkline>
|
||||
<slot name="x-axis">
|
||||
<div class="d-flex justify-space-between mt-2 px-2 text-caption text-grey">
|
||||
<span v-for="i in 12" :key="i">{{ 6 + i }}:00</span>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { mdiChartTimelineVariant } from '@mdi/js'
|
||||
interface Props {
|
||||
title: string
|
||||
data: number[]
|
||||
filters: string[]
|
||||
activeFilter: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
defineEmits(['filter-change'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -1,55 +0,0 @@
|
||||
<template>
|
||||
<v-card class="rounded-lg" elevation="2" v-bind="$attrs">
|
||||
<v-card-title class="d-flex align-center py-4 px-4 border-b">
|
||||
<span class="font-weight-bold">{{ title }}</span>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="primary" density="compact" variant="text" @click="$emit('view-more')">
|
||||
{{ viewMoreText }}
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
<v-list class="pa-0" lines="two">
|
||||
<v-list-item
|
||||
v-for="(item, index) in announcements"
|
||||
:key="index"
|
||||
class="border-b"
|
||||
@click="$emit('item-click', item)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-avatar :color="item.avatarColor || 'primary'" size="40" variant="tonal">
|
||||
<span v-if="!item.avatarSrc" class="text-h6">{{ item.author[0] }}</span>
|
||||
<v-img v-else :src="item.avatarSrc"></v-img>
|
||||
</v-avatar>
|
||||
</template>
|
||||
|
||||
<v-list-item-title class="font-weight-medium mb-1">
|
||||
{{ item.title }}
|
||||
</v-list-item-title>
|
||||
|
||||
<v-list-item-subtitle>
|
||||
<span class="text-caption text-grey mr-2">{{ item.author }}</span>
|
||||
<span class="text-caption text-grey">{{ item.time }}</span>
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
title: string
|
||||
announcements: Array<{
|
||||
title: string
|
||||
author: string
|
||||
time: string
|
||||
avatarSrc?: string | null
|
||||
avatarColor?: string
|
||||
}>
|
||||
viewMoreText?: string
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
viewMoreText: '更多',
|
||||
})
|
||||
|
||||
defineEmits(['view-more', 'item-click'])
|
||||
</script>
|
||||
@@ -1,80 +0,0 @@
|
||||
<template>
|
||||
<v-card class="rounded-lg" elevation="2" v-bind="$attrs">
|
||||
<v-card-title class="d-flex align-center py-4 px-4 border-b">
|
||||
<span class="font-weight-bold">{{ title }}</span>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="primary" density="compact" variant="text" @click="$emit('view-all')">
|
||||
{{ viewAllText }}
|
||||
</v-btn>
|
||||
</v-card-title>
|
||||
<v-card-text class="pa-0">
|
||||
<v-row no-gutters>
|
||||
<v-col
|
||||
v-for="(app, index) in apps"
|
||||
:key="index"
|
||||
class="border-e border-b app-item"
|
||||
cols="12"
|
||||
sm="4"
|
||||
>
|
||||
<div class="pa-4 h-100 hover-bg" @click="$emit('app-click', app)">
|
||||
<div class="d-flex align-center mb-3">
|
||||
<v-icon class="mr-3" :color="app.color" size="large" :icon="app.icon" />
|
||||
<span class="text-subtitle-1 font-weight-medium">{{ app.name }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="text-body-2 text-grey mb-4"
|
||||
style="
|
||||
height: 40px;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
"
|
||||
>
|
||||
{{ app.desc }}
|
||||
</div>
|
||||
<div class="d-flex justify-space-between text-caption text-grey-lighten-1">
|
||||
<span>{{ app.group }}</span>
|
||||
<span>{{ app.date }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
title: string
|
||||
apps: Array<{
|
||||
name: string
|
||||
desc: string
|
||||
icon: string
|
||||
color: string
|
||||
group: string
|
||||
date: string
|
||||
}>
|
||||
viewAllText?: string
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
viewAllText: '全部',
|
||||
})
|
||||
|
||||
defineEmits(['view-all', 'app-click'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hover-bg {
|
||||
transition: background-color 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.hover-bg:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.app-item:last-child {
|
||||
border-right: none !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,53 +0,0 @@
|
||||
<template>
|
||||
<v-card class="rounded-lg" elevation="2" v-bind="$attrs">
|
||||
<v-card-title class="text-subtitle-1 font-weight-bold py-4 border-b">
|
||||
{{ title }}
|
||||
</v-card-title>
|
||||
<v-card-text class="d-flex flex-column align-center justify-center pt-6 pb-6">
|
||||
<div
|
||||
class="d-flex align-center justify-center"
|
||||
style="position: relative; width: 180px; height: 180px"
|
||||
>
|
||||
<v-progress-circular
|
||||
bg-color="grey-lighten-3"
|
||||
color="primary"
|
||||
:model-value="value"
|
||||
:size="160"
|
||||
:width="20"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="text-h6 font-weight-bold">{{ value }}%</div>
|
||||
<div class="text-caption text-grey">{{ subtitle }}</div>
|
||||
</div>
|
||||
</v-progress-circular>
|
||||
</div>
|
||||
<div class="mt-4 d-flex justify-center gap-4 w-100">
|
||||
<div class="d-flex align-center mr-4">
|
||||
<v-icon class="mr-1" color="primary" size="small" :icon="mdiCircle" />
|
||||
<span class="text-caption">{{ primaryLabel }}</span>
|
||||
</div>
|
||||
<div class="d-flex align-center">
|
||||
<v-icon class="mr-1" color="grey-lighten-3" size="small" :icon="mdiCircle" />
|
||||
<span class="text-caption">{{ secondaryLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { mdiCircle } from '@mdi/js'
|
||||
interface Props {
|
||||
title: string
|
||||
value: number
|
||||
subtitle?: string
|
||||
primaryLabel?: string
|
||||
secondaryLabel?: string
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
subtitle: '來源佔比',
|
||||
primaryLabel: '校內',
|
||||
secondaryLabel: '校外',
|
||||
})
|
||||
</script>
|
||||
@@ -1,56 +0,0 @@
|
||||
<template>
|
||||
<v-card class="rounded-lg pa-4 white-bg" elevation="2" v-bind="$attrs">
|
||||
<div class="d-flex flex-column flex-md-row align-center">
|
||||
<!-- Avatar -->
|
||||
<v-avatar class="mr-md-6 mb-4 mb-md-0" size="72">
|
||||
<v-img alt="Avatar" cover :src="userAvatar"></v-img>
|
||||
</v-avatar>
|
||||
|
||||
<!-- Greeting -->
|
||||
<div class="flex-grow-1 text-center text-md-left mb-4 mb-md-0">
|
||||
<h2 class="text-h5 font-weight-bold text-grey-darken-3 mb-2">
|
||||
{{ greetingTitle }}
|
||||
</h2>
|
||||
<div class="text-body-1 text-grey">
|
||||
{{ weatherInfo }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header Stats -->
|
||||
<div class="d-flex justify-center justify-md-end gap-6 px-4" style="gap: 24px">
|
||||
<div class="text-right">
|
||||
<div class="text-caption text-grey mb-1">{{ todoLabel }}</div>
|
||||
<div class="text-h5 font-weight-bold">{{ todo }}</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-caption text-grey mb-1">{{ projectsLabel }}</div>
|
||||
<div class="text-h5 font-weight-bold">{{ projects }}</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-caption text-grey mb-1">{{ teamLabel }}</div>
|
||||
<div class="text-h5 font-weight-bold">{{ team }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
userAvatar: string
|
||||
greetingTitle: string
|
||||
weatherInfo: string
|
||||
todo: string
|
||||
projects: string
|
||||
team: string
|
||||
todoLabel?: string
|
||||
projectsLabel?: string
|
||||
teamLabel?: string
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
todoLabel: '代辦事項',
|
||||
projectsLabel: '專案項目',
|
||||
teamLabel: '團隊成員',
|
||||
})
|
||||
</script>
|
||||
@@ -1,38 +0,0 @@
|
||||
<template>
|
||||
<v-card class="rounded-lg" elevation="2" v-bind="$attrs">
|
||||
<v-card-title class="text-subtitle-1 font-weight-bold py-4 border-b">
|
||||
{{ title }}
|
||||
</v-card-title>
|
||||
<v-card-text class="pa-4">
|
||||
<v-row density="compact">
|
||||
<v-col v-for="(nav, i) in navs" :key="i" class="text-center mb-2" cols="4">
|
||||
<v-btn
|
||||
class="mb-1"
|
||||
:color="nav.color"
|
||||
icon
|
||||
variant="text"
|
||||
@click="$emit('nav-click', nav)"
|
||||
>
|
||||
<v-icon size="24" :icon="nav.icon" />
|
||||
</v-btn>
|
||||
<div class="text-caption text-grey-darken-1">{{ nav.title }}</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
title: string
|
||||
navs: Array<{
|
||||
icon: string
|
||||
title: string
|
||||
color: string
|
||||
}>
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
defineEmits(['nav-click'])
|
||||
</script>
|
||||
@@ -1,40 +0,0 @@
|
||||
<template>
|
||||
<v-card class="rounded-lg" elevation="2" v-bind="$attrs">
|
||||
<v-card-title class="d-flex justify-space-between align-center py-4 border-b">
|
||||
<span class="text-subtitle-1 font-weight-bold">{{ title }}</span>
|
||||
</v-card-title>
|
||||
<v-list class="pa-0" density="compact">
|
||||
<v-list-item v-for="(todo, i) in todos" :key="i" class="py-2">
|
||||
<template #prepend>
|
||||
<v-checkbox-btn
|
||||
v-model="todo.done"
|
||||
class="mr-2"
|
||||
density="compact"
|
||||
@update:model-value="$emit('toggle-todo', todo, $event)"
|
||||
></v-checkbox-btn>
|
||||
</template>
|
||||
<v-list-item-title :class="{ 'text-decoration-line-through text-grey': todo.done }">
|
||||
{{ todo.title }}
|
||||
</v-list-item-title>
|
||||
<template #append>
|
||||
<span class="text-caption text-grey">{{ todo.due }}</span>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
title: string
|
||||
todos: Array<{
|
||||
title: string
|
||||
due: string
|
||||
done: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
defineEmits(['toggle-todo'])
|
||||
</script>
|
||||
@@ -1,23 +0,0 @@
|
||||
<template>
|
||||
<v-text-field
|
||||
color="primary"
|
||||
density="compact"
|
||||
hide-details
|
||||
:label="undefined"
|
||||
:model-value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
variant="outlined"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
modelValue: unknown
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: unknown]
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,25 +0,0 @@
|
||||
<template>
|
||||
<v-select
|
||||
color="primary"
|
||||
density="compact"
|
||||
hide-details
|
||||
:items="items"
|
||||
:label="undefined"
|
||||
:model-value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
variant="outlined"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
modelValue: unknown
|
||||
placeholder?: string
|
||||
items?: unknown[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: unknown]
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,23 +0,0 @@
|
||||
<template>
|
||||
<v-text-field
|
||||
color="primary"
|
||||
density="compact"
|
||||
hide-details
|
||||
:label="undefined"
|
||||
:model-value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
variant="outlined"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
modelValue: unknown
|
||||
placeholder?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: unknown]
|
||||
}>()
|
||||
</script>
|
||||
Reference in New Issue
Block a user