refactor: remove unused dashboard components and views

This commit is contained in:
skytek_xinliang
2026-03-30 10:08:24 +08:00
parent 8ef6873abc
commit 742d5cafcd
33 changed files with 257 additions and 3207 deletions
+235
View File
@@ -0,0 +1,235 @@
<template>
<v-container class="pa-0" fluid>
<div class="d-flex flex-column ga-5 py-4 pr-2 pl-0">
<v-sheet
class="d-flex flex-column flex-sm-row align-start align-sm-center ga-4 pa-5 elevation-1"
color="surface"
>
<v-avatar color="primary" size="52" variant="tonal">
<span class="text-h5">👋</span>
</v-avatar>
<div>
<div class="text-h5 font-weight-bold">歡迎使用校務資訊系統</div>
<div class="text-body-2 text-medium-emphasis mt-1">
使用頂部搜尋框快速找到功能或從左側選單瀏覽所有系統模組
</div>
</div>
</v-sheet>
<section class="d-flex flex-column">
<div class="d-flex align-center ga-2 text-h6 font-weight-bold">📰 最新消息</div>
<!--
使用 v-data-iterator 保留一致的列表輸出結構
讓首頁消息之後若要加排序或分頁時不需要再重寫畫面骨架
-->
<v-data-iterator class="mt-2" item-key="id" :items="props.newsItems" :items-per-page="-1">
<!--
Vuetify 會把原始資料包進 wrapper
這裡統一解包可避免模板層散落型別判斷
-->
<template #default="{ items }">
<v-row density="compact">
<v-col v-for="wrapped in items" :key="resolveNewsItem(wrapped).id" cols="12">
<v-card
class="news-item d-flex flex-column flex-sm-row ga-4 pa-4 bg-surface"
variant="outlined"
@click="emit('news', resolveNewsItem(wrapped))"
>
<v-sheet class="news-badge">
<div class="news-badge-date">{{ resolveNewsItem(wrapped).date }}</div>
<div class="news-badge-month">{{ resolveNewsItem(wrapped).month }}</div>
</v-sheet>
<div class="flex-grow-1">
<div class="d-flex flex-wrap align-center font-weight-bold">
{{ resolveNewsItem(wrapped).title }}
<v-chip
v-if="resolveNewsItem(wrapped).isNew"
class="ml-2"
color="primary"
size="x-small"
variant="flat"
>
NEW
</v-chip>
</div>
<div class="text-body-2 text-medium-emphasis mt-2">
{{ resolveNewsItem(wrapped).desc }}
</div>
<div class="d-flex ga-4 mt-3 text-caption text-medium-emphasis">
<div class="d-flex align-center ga-1">
<v-icon size="14" :icon="mdiFolderOutline" />
<span>{{ resolveNewsItem(wrapped).dept }}</span>
</div>
<div class="d-flex align-center ga-1">
<v-icon size="14" :icon="mdiEyeOutline" />
<span>{{ resolveNewsItem(wrapped).views }} 次瀏覽</span>
</div>
</div>
</div>
</v-card>
</v-col>
</v-row>
</template>
</v-data-iterator>
</section>
<v-card
class="d-flex align-center justify-space-between ga-3 px-5 py-4"
color="secondary"
rounded="xl"
variant="tonal"
@click="emit('message-center')"
>
<div class="d-flex align-center ga-4">
<v-avatar color="secondary" size="44" variant="flat">
<span class="text-h6"></span>
</v-avatar>
<div>
<div class="text-subtitle-1 font-weight-bold">訊息中心</div>
<div class="text-body-2 font-weight-bold text-on-secondary">12 筆未讀</div>
</div>
</div>
<div class="text-body-2 font-weight-medium">查看全部 </div>
</v-card>
<section class="d-flex flex-column pb-4">
<div class="d-flex align-center ga-2 text-h6 font-weight-bold">🚀 快速存取</div>
<v-row class="mt-2" density="compact">
<v-col v-for="item in props.quickItems" :key="item.title" cols="6" md="2" sm="4">
<v-card
class="d-flex flex-column align-center ga-2 text-center py-4 px-2 quick-item"
variant="outlined"
@click="emit('quick', item)"
>
<div class="text-h5">{{ item.icon }}</div>
<div class="text-body-2 font-weight-medium">{{ item.title }}</div>
</v-card>
</v-col>
</v-row>
</section>
</div>
<!--
這個 dialog 只做消息內容呈現
開關狀態仍交給 view 管理避免頁面元件自行持有流程狀態
-->
<v-dialog
:model-value="props.isNewsDialogOpen"
max-width="640"
@update:model-value="emit('update:isNewsDialogOpen', $event)"
>
<v-card v-if="props.selectedNews">
<v-card-title class="text-h6 font-weight-bold bg-primary-variant pa-4">
{{ props.selectedNews.title }}
</v-card-title>
<v-card-subtitle class="text-body-2 pt-4 text-medium-emphasis">
{{ props.selectedNews.month }} {{ props.selectedNews.date }} ·
{{ props.selectedNews.dept }} ·
{{ props.selectedNews.views }} 次瀏覽
</v-card-subtitle>
<v-card-text class="pt-4">
{{ props.selectedNews.desc }}
</v-card-text>
<v-card-actions class="justify-end">
<v-btn color="primary" variant="text" @click="emit('update:isNewsDialogOpen', false)">
關閉
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<script setup lang="ts">
import { mdiEyeOutline, mdiFolderOutline } from '@mdi/js'
interface NewsItem {
id: number
date: string
month: string
title: string
desc: string
dept: string
views: string
isNew: boolean
}
interface QuickItem {
icon: string
title: string
}
const props = defineProps<{
newsItems: NewsItem[]
quickItems: QuickItem[]
selectedNews: NewsItem | null
isNewsDialogOpen: boolean
}>()
const emit = defineEmits<{
news: [item: NewsItem]
'message-center': []
quick: [item: QuickItem]
'update:isNewsDialogOpen': [value: boolean]
}>()
// Vuetify 的 iterator 會回傳包裝後的資料,這裡集中解包可維持模板單純。
function resolveNewsItem(wrapped: unknown): NewsItem {
if (wrapped && typeof wrapped === 'object' && 'raw' in wrapped) {
return (wrapped as { raw: NewsItem }).raw
}
return wrapped as NewsItem
}
</script>
<style scoped>
.news-item {
cursor: pointer;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.news-item:hover {
transform: translateY(-2px);
box-shadow: 0 10px 24px rgba(var(--v-theme-on-surface), 0.12);
}
.news-badge {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgb(var(--v-theme-primary));
color: rgb(var(--v-theme-on-primary));
border-radius: 12px;
padding: 10px 6px;
min-height: 64px;
min-width: 64px;
}
.news-badge-date {
font-size: 20px;
font-weight: 700;
line-height: 1;
}
.news-badge-month {
font-size: 12px;
margin-top: 4px;
}
.quick-item {
display: flex;
cursor: pointer;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.quick-item:hover {
transform: translateY(-2px);
box-shadow: 0 10px 24px rgba(var(--v-theme-on-surface), 0.12);
}
</style>
-111
View File
@@ -1,111 +0,0 @@
<template>
<v-sheet class="bg-surface" v-bind="$attrs">
<!-- Top Stats Cards -->
<v-row class="mb-4">
<v-col v-for="(stat, index) in props.stats" :key="index" cols="12" md="3" sm="6">
<AnalysisStatsCard
:color="stat.color"
:icon="stat.icon"
:label="stat.label"
:title="stat.title"
:total="stat.total"
:value="stat.value"
/>
</v-col>
</v-row>
<!-- Main Trend Chart (Sparkline Area) -->
<v-row class="mb-4">
<v-col cols="12">
<AnalysisTrendChart
:active-filter="activeFilter"
:data="props.trendData"
:filters="props.trendFilters"
:title="props.trendTitle"
@filter-change="activeFilter = $event"
/>
</v-col>
</v-row>
<!-- Bottom Charts Grid -->
<v-row>
<!-- Chart 1: Bar Chart (Proxy for Radar/Distribution) -->
<v-col cols="12" md="4">
<AnalysisBarChart :data="props.barData" :title="props.chart1Title" />
</v-col>
<!-- Chart 2: Donut Chart (Source) -->
<v-col cols="12" md="4">
<AnalysisPieChart :data="props.pie1Data" :title="props.chart2Title" />
</v-col>
<!-- Chart 3: Donut Chart (Distribution) -->
<v-col cols="12" md="4">
<AnalysisDonutChart :data="props.pie2Data" :title="props.chart3Title" />
</v-col>
</v-row>
</v-sheet>
</template>
<script setup lang="ts">
import { mdiSchool } from '@mdi/js'
import { ref } from 'vue'
import AnalysisBarChart from './base/analysis/AnalysisBarChart.vue'
import AnalysisDonutChart from './base/analysis/AnalysisDonutChart.vue'
import AnalysisPieChart from './base/analysis/AnalysisPieChart.vue'
import AnalysisStatsCard from './base/analysis/AnalysisStatsCard.vue'
import AnalysisTrendChart from './base/analysis/AnalysisTrendChart.vue'
interface StatsItem {
title: string
value: string | number
label: string
total: string | number
icon: string
color: string
}
interface BarDataItem {
label: string
value: number
color: string
}
interface PieData {
value: number
label: string
color: string
}
interface DonutData extends PieData {
icon: string
}
const props = defineProps({
// Stats Cards Data
stats: { type: Array as () => StatsItem[], default: () => [] },
// Trend Chart
trendTitle: { type: String, default: '流量趨勢' },
trendData: { type: Array as () => number[], default: () => [] },
trendFilters: { type: Array as () => string[], default: () => ['流量', '訪問量'] },
// Chart Titles
chart1Title: { type: String, default: '核心素養' },
chart2Title: { type: String, default: '訪問來源' },
chart3Title: { type: String, default: '成績分佈' },
// Data for Charts
barData: { type: Array as () => BarDataItem[], default: () => [] },
pie1Data: {
type: Object as () => PieData,
default: () => ({ value: 75, label: '直接訪問', color: 'primary' }),
},
pie2Data: {
type: Object as () => DonutData,
default: () => ({ value: 65, label: '及格率', color: 'success', icon: mdiSchool }),
},
})
const activeFilter = ref(props.trendFilters[0] || '')
</script>
-137
View File
@@ -1,137 +0,0 @@
<template>
<v-sheet class="bg-surface" v-bind="$attrs">
<!-- Header Section -->
<DashboardHeader
class="mb-4"
:greeting-title="props.greetingTitle"
:projects="props.statProjects"
:team="props.statTeam"
:todo="props.statTodo"
:user-avatar="props.userAvatar"
:weather-info="props.weatherInfo"
/>
<v-row>
<!-- Left Column (Main) -->
<v-col cols="12" md="8">
<!-- Applications Card -->
<DashboardApps
:apps="props.applications"
class="mb-4"
:title="props.appsTitle"
@app-click="$emit('app-click', $event)"
@view-all="$emit('view-all-apps')"
/>
<!-- School Announcements (Dynamic) -->
<DashboardAnnouncements
:announcements="props.announcements"
:title="props.announcementsTitle"
@item-click="$emit('announcement-click', $event)"
@view-more="$emit('view-more-announcements')"
/>
</v-col>
<!-- Right Column (Side) -->
<v-col cols="12" md="4">
<!-- Quick Nav -->
<DashboardQuickNav
class="mb-4"
:navs="props.quickNavs"
:title="props.quickNavTitle"
@nav-click="$emit('nav-click', $event)"
/>
<!-- To-Do List -->
<DashboardTodoList
class="mb-4"
:title="props.todoTitle"
:todos="props.todos"
@toggle-todo="$emit('toggle-todo', $event)"
/>
<!-- Visit Source Chart -->
<DashboardChart :title="props.chartTitle" :value="props.chartValue" />
</v-col>
</v-row>
</v-sheet>
</template>
<script setup lang="ts">
import DashboardAnnouncements from './base/dashboard/DashboardAnnouncements.vue'
import DashboardApps from './base/dashboard/DashboardApps.vue'
import DashboardChart from './base/dashboard/DashboardChart.vue'
import DashboardHeader from './base/dashboard/DashboardHeader.vue'
import DashboardQuickNav from './base/dashboard/DashboardQuickNav.vue'
import DashboardTodoList from './base/dashboard/DashboardTodoList.vue'
defineEmits([
'view-all-apps',
'app-click',
'view-more-announcements',
'announcement-click',
'nav-click',
'toggle-todo',
])
interface DashboardApp {
name: string
desc: string
icon: string
color: string
group: string
date: string
}
interface Announcement {
title: string
author: string
time: string
avatarSrc?: string | null
avatarColor?: string
}
interface QuickNav {
icon: string
title: string
color: string
}
interface Todo {
title: string
due: string
done: boolean
}
const props = defineProps({
// Header
userAvatar: {
type: String,
default:
'https://avataaars.io/?avatarStyle=Circle&topType=ShortHairShortFlat&accessoriesType=Sunglasses&hairColor=Blonde&facialHairType=Blank&clotheType=Hoodie&clotheColor=Red&eyeType=Happy&eyebrowType=Default&mouthType=Smile&skinColor=Light',
},
greetingTitle: { type: String, default: '早安,校長!開始您一天的工作吧!' },
weatherInfo: { type: String, default: '今日晴,20℃ - 32℃!' },
statTodo: { type: String, default: '2/10' },
statProjects: { type: String, default: '8' },
statTeam: { type: String, default: '300' },
// Apps
appsTitle: { type: String, default: '應用程式' },
applications: { type: Array as () => DashboardApp[], default: () => [] },
// Announcements
announcementsTitle: { type: String, default: '學校公告' },
announcements: { type: Array as () => Announcement[], default: () => [] },
// Right Side
quickNavTitle: { type: String, default: '快速導航' },
quickNavs: { type: Array as () => QuickNav[], default: () => [] },
todoTitle: { type: String, default: '待辦事項' },
todos: { type: Array as () => Todo[], default: () => [] },
chartTitle: { type: String, default: '訪問來源' },
chartValue: { type: Number, default: 75 },
})
</script>
-255
View File
@@ -1,255 +0,0 @@
<template>
<v-sheet class="bg-surface" v-bind="$attrs">
<v-card border elevation="0">
<!-- Top Action Bar -->
<SKTableActionBar
:create-btn-text="props.createBtnText"
:title="props.listTitle"
@create="emit('create')"
@refresh="emit('refresh')"
@settings="emit('settings')"
/>
<SKFormEditDialog
v-model="addSubDialogOpen"
:cancel-text="props.dialogCancelText"
:confirm-text="props.dialogConfirmText"
:item="addSubDraftItem"
:loading="props.dialogLoading"
:show-permission="false"
:status-label-text="props.dialogStatusLabelText"
:status-options="props.statusOptions"
:title-text="props.addSubDialogTitleText"
@submit="onAddSubSubmit"
>
<template #fields="{ form }">
<v-text-field
v-model="form.name"
density="comfortable"
hide-details
:label="props.dialogNameLabelText"
variant="outlined"
/>
<v-textarea
v-model="form.note"
density="comfortable"
hide-details
:label="props.dialogNoteLabelText"
rows="3"
variant="outlined"
/>
</template>
</SKFormEditDialog>
<SKFormEditDialog
v-model="editDialogOpen"
:cancel-text="props.dialogCancelText"
:confirm-text="props.dialogConfirmText"
:item="selectedItem"
:loading="props.dialogLoading"
:show-permission="false"
:status-label-text="props.dialogStatusLabelText"
:status-options="props.statusOptions"
:title-text="props.editDialogTitleText"
@submit="onEditSubmit"
>
<template #fields="{ form }">
<v-text-field
v-model="form.name"
density="comfortable"
hide-details
:label="props.dialogNameLabelText"
variant="outlined"
/>
<v-textarea
v-model="form.note"
density="comfortable"
hide-details
:label="props.dialogNoteLabelText"
rows="3"
variant="outlined"
/>
</template>
</SKFormEditDialog>
<!-- Tree Table -->
<SKTreeTable
:headers="formattedHeaders"
:items="props.items"
:loading="props.loading"
table-class="dept-table"
:tree-column-keys="['name']"
@toggle-expand="emit('toggle-expand', $event)"
>
<!-- Status Column -->
<template #[`item.status`]="{ item }">
<v-chip
:color="isEnabledStatus(item.status) ? 'success' : 'error'"
label
size="small"
variant="tonal"
>
{{ isEnabledStatus(item.status) ? props.statusEnabledText : props.statusDisabledText }}
</v-chip>
</template>
<!-- Actions Column -->
<template #[`item.actions`]="{ item }">
<v-btn class="px-1" color="primary" size="small" variant="text" @click="openAddSub(item)">
{{ props.addSubActionText }}
</v-btn>
<v-btn class="px-1" color="primary" size="small" variant="text" @click="openEdit(item)">
{{ props.editActionText }}
</v-btn>
<v-btn
class="px-1"
color="error"
size="small"
variant="text"
@click="emit('delete', item)"
>
{{ props.deleteActionText }}
</v-btn>
</template>
</SKTreeTable>
</v-card>
</v-sheet>
</template>
<script setup lang="ts">
import type { PropType } from 'vue'
import { computed, ref } from 'vue'
import SKFormEditDialog from './base/SKFormEditDialog.vue'
import SKTableActionBar from './base/SKTableActionBar.vue'
import SKTreeTable from './base/SKTreeTable.vue'
export interface DeptItem {
id: string | number
name: string
status: string | number
createTime: string
note: string
children?: DeptItem[]
[key: string]: unknown
}
const props = defineProps({
items: { type: Array as () => DeptItem[], default: () => [] },
loading: { type: Boolean, default: false },
// Text Props
listTitle: { type: String, default: '部門列表' },
createBtnText: { type: String, default: '新增部門' },
addSubActionText: { type: String, default: '新增下級' },
editActionText: { type: String, default: '修改' },
deleteActionText: { type: String, default: '刪除' },
statusEnabledText: { type: String, default: '已啟用' },
statusDisabledText: { type: String, default: '已禁用' },
// Dialog Props
addSubDialogTitleText: { type: String, default: '新增下級' },
editDialogTitleText: { type: String, default: '編輯' },
dialogStatusLabelText: { type: String, default: '狀態' },
dialogNameLabelText: { type: String, default: '部門名稱' },
dialogNoteLabelText: { type: String, default: '備註' },
dialogCancelText: { type: String, default: '取消' },
dialogConfirmText: { type: String, default: '確認' },
dialogLoading: { type: Boolean, default: false },
statusEnabledValue: { type: [String, Number] as PropType<string | number>, default: undefined },
statusOptions: {
type: Array as () => Array<string | number | { title: string; value: string | number }>,
default: () => [],
},
// Header Texts
nameHeader: { type: String, default: '部門名稱' },
statusHeader: { type: String, default: '狀態' },
createTimeHeader: { type: String, default: '創建時間' },
noteHeader: { type: String, default: '備註' },
actionsHeader: { type: String, default: '操作' },
})
const emit = defineEmits([
'create',
'add-sub',
'edit',
'delete',
'refresh',
'settings',
'toggle-expand',
])
function normalizeOptions(
options: Array<string | number | { title: string; value: 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 resolvedStatusEnabledValue = computed(() => {
if (props.statusEnabledValue !== undefined) return props.statusEnabledValue
return normalizedStatusOptions.value[0]?.value ?? 'enable'
})
const isEnabledStatus = (status: unknown) => status === resolvedStatusEnabledValue.value
const addSubDialogOpen = ref(false)
const editDialogOpen = ref(false)
const addSubParentItem = ref<DeptItem | null>(null)
const addSubDraftItem = ref<Record<string, unknown> | null>(null)
const selectedItem = ref<DeptItem | null>(null)
function openAddSub(item: DeptItem) {
addSubParentItem.value = item
addSubDraftItem.value = {
name: '',
note: '',
status: resolvedStatusEnabledValue.value,
}
addSubDialogOpen.value = true
}
function openEdit(item: DeptItem) {
selectedItem.value = item
editDialogOpen.value = true
}
function onAddSubSubmit(payload: Record<string, unknown>) {
if (!addSubParentItem.value) return
const newItem: DeptItem = {
id: Date.now(),
name: String(payload.name ?? ''),
note: String(payload.note ?? ''),
status: (payload.status as string | number | undefined) ?? resolvedStatusEnabledValue.value,
createTime: new Date().toISOString(),
children: [],
}
emit('add-sub', addSubParentItem.value, newItem)
}
function onEditSubmit(updated: Record<string, unknown>) {
emit('edit', updated)
}
// --- Table Config ---
const formattedHeaders = computed(() => [
{ title: props.nameHeader, key: 'name', align: 'start' as const, minWidth: '250px' },
{ title: props.statusHeader, key: 'status', align: 'center' as const, width: '100px' },
{ title: props.createTimeHeader, key: 'createTime', align: 'start' as const },
{ title: props.noteHeader, key: 'note', align: 'start' as const },
{
title: props.actionsHeader,
key: 'actions',
align: 'center' as const,
width: '250px',
sortable: false,
},
])
</script>
-211
View File
@@ -1,211 +0,0 @@
<template>
<v-sheet class="bg-surface" v-bind="$attrs">
<v-card border elevation="0">
<!-- Top Action Bar -->
<SKTableActionBar
:show-create="false"
@refresh="emit('refresh')"
@settings="emit('settings')"
/>
<SKFormEditDialog
v-model="editDialogOpen"
:cancel-text="props.editDialogCancelText"
:confirm-text="props.editDialogConfirmText"
:item="selectedItem"
:loading="props.editDialogLoading"
:permission-label-text="props.editDialogPermissionLabelText"
:permission-options="props.permissionOptions"
:status-label-text="props.editDialogStatusLabelText"
:status-options="props.statusOptions"
:title-text="props.editDialogTitleText"
@submit="onEditSubmit"
/>
<!-- Tree Table -->
<SKTreeTable
:headers="formattedHeaders"
:items="props.items"
:loading="props.loading"
table-class="menu-table"
:tree-column-keys="['title']"
@toggle-expand="emit('toggle-expand', $event)"
>
<!-- Title Column (Tree Indentation) -->
<template #[`tree-title`]="{ item }">
<v-chip v-if="item.isNew" class="px-1" color="primary" label size="x-small">new</v-chip>
</template>
<!-- Icon Column -->
<template #[`item.icon`]="{ item }">
<v-icon v-if="item.icon" size="small" :icon="item.icon" />
</template>
<!-- Permission Column -->
<template #[`item.permission`]="{ item }">
<v-chip
:color="getPermissionColor(item.permission)"
label
size="small"
variant="outlined"
>
{{ item.permission }}
</v-chip>
</template>
<!-- Status Column -->
<template #[`item.status`]="{ item }">
<v-chip
:color="isEnabledStatus(item.status) ? 'success' : 'error'"
label
size="small"
variant="tonal"
>
{{ isEnabledStatus(item.status) ? props.statusEnabledText : props.statusDisabledText }}
</v-chip>
</template>
<!-- Actions Column -->
<template #[`item.actions`]="{ item }">
<v-btn class="px-1" color="primary" size="small" variant="text" @click="openEdit(item)">
{{ props.editActionText }}
</v-btn>
</template>
</SKTreeTable>
</v-card>
</v-sheet>
</template>
<script setup lang="ts">
import type { PropType } from 'vue'
import { computed, ref } from 'vue'
import SKFormEditDialog from './base/SKFormEditDialog.vue'
import SKTableActionBar from './base/SKTableActionBar.vue'
import SKTreeTable from './base/SKTreeTable.vue'
export interface MenuItem {
id: string | number
title: string
icon?: string
permission: string
path?: string
component?: string
status: string | number
isNew?: boolean
children?: MenuItem[]
[key: string]: unknown
}
const props = defineProps({
items: { type: Array as () => MenuItem[], default: () => [] },
loading: { type: Boolean, default: false },
// Text Props
editActionText: { type: String, default: '修改' },
statusEnabledText: { type: String, default: '已啟用' },
statusDisabledText: { type: String, default: '已禁用' },
// Edit Dialog Props
editDialogTitleText: { type: String, default: '編輯' },
editDialogStatusLabelText: { type: String, default: '狀態' },
editDialogPermissionLabelText: { type: String, default: '權限' },
editDialogCancelText: { type: String, default: '取消' },
editDialogConfirmText: { type: String, default: '確認' },
editDialogLoading: { type: Boolean, default: false },
statusEnabledValue: {
type: [String, Number] as PropType<string | number>,
default: undefined,
},
statusDisabledValue: {
type: [String, Number] as PropType<string | number>,
default: undefined,
},
statusOptions: {
type: Array as () => Array<string | number | { title: string; value: string | number }>,
default: () => [],
},
permissionOptions: {
type: Array as () => Array<string | number | { title: string; value: string | number }>,
default: () => [],
},
// Header Texts
titleHeader: { type: String, default: '標題' },
permissionHeader: { type: String, default: '權限' },
pathHeader: { type: String, default: '路由路徑' },
componentHeader: { type: String, default: '組件路徑' },
statusHeader: { type: String, default: '狀態' },
actionsHeader: { type: String, default: '操作' },
})
const emit = defineEmits(['edit', 'refresh', 'settings', 'toggle-expand'])
const editDialogOpen = ref(false)
const selectedItem = ref<MenuItem | null>(null)
function normalizeOptions(
options: Array<string | number | { title: string; value: 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 resolvedStatusEnabledValue = computed(() => {
if (props.statusEnabledValue !== undefined) return props.statusEnabledValue
return normalizedStatusOptions.value[0]?.value ?? 'enable'
})
const isEnabledStatus = (status: unknown) => status === resolvedStatusEnabledValue.value
function openEdit(item: MenuItem) {
selectedItem.value = item
editDialogOpen.value = true
}
function onEditSubmit(updated: Record<string, unknown>) {
emit('edit', updated)
}
// --- Table Config ---
const formattedHeaders = computed(() => [
{ title: props.titleHeader, key: 'title', align: 'start' as const, minWidth: '250px' },
{ title: props.permissionHeader, key: 'permission', align: 'center' as const, width: '120px' },
{ title: props.pathHeader, key: 'path', align: 'start' as const },
{ title: props.componentHeader, key: 'component', align: 'start' as const },
{ title: props.statusHeader, key: 'status', align: 'center' as const, width: '100px' },
{
title: props.actionsHeader,
key: 'actions',
align: 'center' as const,
width: '250px',
sortable: false,
},
])
function getPermissionColor(permission: string) {
switch (permission) {
case '管理員': {
return 'primary'
}
case '一級主管': {
return 'success'
}
case '二級主管': {
return 'info'
}
case '使用者': {
return 'warning'
}
default: {
return 'grey'
}
}
}
</script>
-263
View File
@@ -1,263 +0,0 @@
<template>
<v-sheet class="bg-background" v-bind="$attrs">
<!-- Search Filter Section -->
<SKSearchFilter
v-if="showSearchFilter"
:collapse-btn-text="props.collapseBtnText"
:expand-btn-text="props.expandBtnText"
:fields="searchFields"
:reset-btn-text="props.resetBtnText"
:search-btn-text="props.searchBtnText"
:show-expand="true"
:visible-when-collapsed="['roleName', 'roleId']"
@reset="$emit('reset')"
@search="$emit('search', $event)"
/>
<!-- Data Table Section -->
<v-card class="bg-surface">
<SKTableActionBar
v-model:settings-selected-keys="visibleHeaderKeys"
:create-btn-text="props.createBtnText"
:search-visible="showSearchFilter"
:settings-items="headerSettingsItems"
:show-search-toggle="true"
:title="props.listTitle"
@create="emit('create')"
@refresh="emit('refresh')"
@settings="emit('settings')"
@toggle-search="showSearchFilter = !showSearchFilter"
/>
<v-divider></v-divider>
<v-data-table
class="role-table"
:headers="filteredHeaders"
hover
:items="props.roles"
:items-per-page="10"
:items-per-page-options="itemsPerPageOptions"
:items-per-page-text="props.itemsPerPageText"
:loading="props.loading"
>
<!-- Status Slot -->
<template #[`item.status`]="{ item }">
<v-switch
color="primary"
density="compact"
hide-details
:model-value="item.status"
@update:model-value="emit('update:status', item, $event)"
></v-switch>
</template>
<!-- Actions Slot -->
<template #[`item.actions`]="{ item }">
<v-btn
class="px-1"
color="primary"
size="small"
variant="text"
@click="emit('edit', item)"
>
{{ props.editActionText }}
</v-btn>
<v-divider
class="mx-1 d-inline-block"
style="height: 12px; vertical-align: middle"
vertical
></v-divider>
<v-btn
class="px-1"
color="error"
size="small"
variant="text"
@click="emit('delete', item)"
>
{{ props.deleteActionText }}
</v-btn>
</template>
</v-data-table>
</v-card>
</v-sheet>
</template>
<script setup lang="ts">
import { computed, ref, watchEffect } from 'vue'
import SKSearchFilter from './base/SKSearchFilter.vue'
import SKTableActionBar from './base/SKTableActionBar.vue'
export interface RoleItem {
name: string
id: string
status: boolean
note: string
createTime: string
}
const showSearchFilter = ref(true)
const props = defineProps({
// Filter Labels & Placeholders
roleNameLabel: { type: String, default: '角色名稱' },
roleNamePlaceholder: { type: String, default: '請輸入' },
roleIdLabel: { type: String, default: '角色ID' },
roleIdPlaceholder: { type: String, default: '請輸入' },
statusLabel: { type: String, default: '狀態' },
statusPlaceholder: { type: String, default: '請選擇' },
statusOptions: { type: Array, default: () => ['已啟用', '已禁用'] },
noteLabel: { type: String, default: '備註' },
notePlaceholder: { type: String, default: '請輸入' },
createTimeLabel: { type: String, default: '建立時間' },
startDateLabel: { type: String, default: '開始日期' },
endDateLabel: { type: String, default: '結束日期' },
datePlaceholder: { type: String, default: '請選擇' },
// Button Texts
searchBtnText: { type: String, default: '搜尋' },
resetBtnText: { type: String, default: '重置' },
expandBtnText: { type: String, default: '展開' },
collapseBtnText: { type: String, default: '收起' },
createBtnText: { type: String, default: '新增角色' },
// Table Texts
listTitle: { type: String, default: '權限列表' },
roleNameHeader: { type: String, default: '角色名稱' },
roleIdHeader: { type: String, default: '角色ID' },
statusHeader: { type: String, default: '狀態' },
noteHeader: { type: String, default: '備註' },
createTimeHeader: { type: String, default: '建立時間' },
actionsHeader: { type: String, default: '操作' },
editActionText: { type: String, default: '修改' },
deleteActionText: { type: String, default: '刪除' },
// Data Table Footer Texts
itemsPerPageText: { type: String, default: '每頁筆數:' },
itemsPerPageAllText: { type: String, default: '全部' },
// Data
roles: {
type: Array as () => RoleItem[],
default: () => [],
},
loading: { type: Boolean, default: false },
})
const emit = defineEmits([
'search',
'reset',
'create',
'edit',
'delete',
'update:status',
'refresh',
'settings',
])
// --- Search Fields Configuration ---
const searchFields = computed(() => [
{
key: 'roleName',
type: 'text' as const,
label: props.roleNameLabel,
placeholder: props.roleNamePlaceholder,
meta: {
cols: 12,
md: 4,
lg: 3,
},
},
{
key: 'roleId',
type: 'text' as const,
label: props.roleIdLabel,
placeholder: props.roleIdPlaceholder,
meta: {
cols: 12,
md: 4,
lg: 3,
},
},
{
key: 'status',
type: 'select' as const,
label: props.statusLabel,
placeholder: props.statusPlaceholder,
items: props.statusOptions,
meta: {
cols: 12,
md: 4,
lg: 3,
},
},
{
key: 'note',
type: 'text' as const,
label: props.noteLabel,
placeholder: props.notePlaceholder,
meta: {
cols: 12,
md: 4,
lg: 3,
},
},
{
type: 'date' as const,
key: 'startDate',
label: props.startDateLabel,
placeholder: props.datePlaceholder,
meta: {
cols: 12,
md: 4,
lg: 3,
},
},
{
type: 'date' as const,
key: 'endDate',
label: props.endDateLabel,
placeholder: props.datePlaceholder,
meta: {
cols: 12,
md: 4,
lg: 3,
},
},
])
// --- Table Config ---
const formattedHeaders = computed(() => [
{ title: props.roleNameHeader, key: 'name', align: 'start' as const },
{ title: props.roleIdHeader, key: 'id', align: 'start' as const },
{ title: props.statusHeader, key: 'status', align: 'start' as const },
{ title: props.noteHeader, key: 'note', align: 'start' as const },
{ title: props.createTimeHeader, key: 'createTime', align: 'start' as const },
{ title: props.actionsHeader, key: 'actions', align: 'end' as const, sortable: false },
])
const visibleHeaderKeys = ref<string[]>([])
watchEffect(() => {
if (visibleHeaderKeys.value.length > 0) {
return
}
visibleHeaderKeys.value = formattedHeaders.value.map((h) => String(h.key))
})
const headerSettingsItems = computed(() =>
formattedHeaders.value
.filter((h) => h.key !== 'actions')
.map((h) => ({ key: String(h.key), label: String(h.title) }))
)
const filteredHeaders = computed(() =>
formattedHeaders.value.filter((h) => visibleHeaderKeys.value.includes(String(h.key)))
)
const itemsPerPageOptions = computed(() =>
[10, 25, 50, 100, -1].map((value) => ({
value,
title: value === -1 ? props.itemsPerPageAllText : String(value),
}))
)
</script>
-197
View File
@@ -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>
-146
View File
@@ -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>
-168
View File
@@ -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>
-138
View File
@@ -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>