refactor: remove unused dashboard components and views
This commit is contained in:
+11
-41
@@ -61,11 +61,6 @@
|
|||||||
|
|
||||||
目前已接近薄 view 的頁面:
|
目前已接近薄 view 的頁面:
|
||||||
|
|
||||||
- [Dashboard.vue](/home/carl/git/skt-vuetify-templates/src/views/Dashboard.vue)
|
|
||||||
- [Analysis.vue](/home/carl/git/skt-vuetify-templates/src/views/Analysis.vue)
|
|
||||||
- [RoleManagement.vue](/home/carl/git/skt-vuetify-templates/src/views/RoleManagement.vue)
|
|
||||||
- [MenuManagement.vue](/home/carl/git/skt-vuetify-templates/src/views/MenuManagement.vue)
|
|
||||||
- [DeptManagement.vue](/home/carl/git/skt-vuetify-templates/src/views/DeptManagement.vue)
|
|
||||||
- [EditableGridMnt.vue](/home/carl/git/skt-vuetify-templates/src/views/maint/EditableGridMnt.vue)
|
- [EditableGridMnt.vue](/home/carl/git/skt-vuetify-templates/src/views/maint/EditableGridMnt.vue)
|
||||||
- [Forbidden.vue](/home/carl/git/skt-vuetify-templates/src/views/errors/Forbidden.vue)
|
- [Forbidden.vue](/home/carl/git/skt-vuetify-templates/src/views/errors/Forbidden.vue)
|
||||||
- [ServerError.vue](/home/carl/git/skt-vuetify-templates/src/views/errors/ServerError.vue)
|
- [ServerError.vue](/home/carl/git/skt-vuetify-templates/src/views/errors/ServerError.vue)
|
||||||
@@ -92,18 +87,14 @@
|
|||||||
|
|
||||||
### `src/components`
|
### `src/components`
|
||||||
|
|
||||||
目前 `components` 已經分成四種角色,不能再用單一規則描述。
|
目前 `components` 已經分成三種主要角色,不能再用單一規則描述。
|
||||||
|
|
||||||
#### 1. 頁面型元件
|
#### 1. 頁面型元件
|
||||||
|
|
||||||
目前以下元件實際上扮演「page component」:
|
目前以下元件實際上扮演「page component」:
|
||||||
|
|
||||||
- [PageLogin.vue](/home/carl/git/skt-vuetify-templates/src/components/PageLogin.vue)
|
- [PageLogin.vue](/home/carl/git/skt-vuetify-templates/src/components/PageLogin.vue)
|
||||||
- [SKDashboard.vue](/home/carl/git/skt-vuetify-templates/src/components/SKDashboard.vue)
|
- [PageIndex.vue](/home/carl/git/skt-vuetify-templates/src/components/PageIndex.vue)
|
||||||
- [SKAnalysis.vue](/home/carl/git/skt-vuetify-templates/src/components/SKAnalysis.vue)
|
|
||||||
- [SKRoleManagement.vue](/home/carl/git/skt-vuetify-templates/src/components/SKRoleManagement.vue)
|
|
||||||
- [SKMenuManagement.vue](/home/carl/git/skt-vuetify-templates/src/components/SKMenuManagement.vue)
|
|
||||||
- [SKDeptManagement.vue](/home/carl/git/skt-vuetify-templates/src/components/SKDeptManagement.vue)
|
|
||||||
|
|
||||||
這些檔案目前的實際責任是:
|
這些檔案目前的實際責任是:
|
||||||
|
|
||||||
@@ -117,18 +108,8 @@
|
|||||||
|
|
||||||
文件原先寫法是「`base` 只放真正通用元件」,但目前專案並不完全符合。
|
文件原先寫法是「`base` 只放真正通用元件」,但目前專案並不完全符合。
|
||||||
|
|
||||||
現在 `components/base` 內同時存在兩類東西:
|
現在 `components/base` 內主要保留頁面家族的內部子元件:
|
||||||
|
- `base/login/*`
|
||||||
- 跨頁可重用元件:
|
|
||||||
- [SKFormEditDialog.vue](/home/carl/git/skt-vuetify-templates/src/components/base/SKFormEditDialog.vue)
|
|
||||||
- [SKSearchFilter.vue](/home/carl/git/skt-vuetify-templates/src/components/base/SKSearchFilter.vue)
|
|
||||||
- [SKTableActionBar.vue](/home/carl/git/skt-vuetify-templates/src/components/base/SKTableActionBar.vue)
|
|
||||||
- [SKTreeTable.vue](/home/carl/git/skt-vuetify-templates/src/components/base/SKTreeTable.vue)
|
|
||||||
- 頁面家族的內部子元件:
|
|
||||||
- `base/login/*`
|
|
||||||
- `base/dashboard/*`
|
|
||||||
- `base/analysis/*`
|
|
||||||
- `base/input_field/*`
|
|
||||||
|
|
||||||
因此目前對 `base` 的正確認知應該是:
|
因此目前對 `base` 的正確認知應該是:
|
||||||
|
|
||||||
@@ -138,7 +119,6 @@
|
|||||||
|
|
||||||
目前建議:
|
目前建議:
|
||||||
|
|
||||||
- 真正跨頁重用的元件,才放在 `components/base` 根層
|
|
||||||
- 若元件只服務單一頁面家族,優先放到對應資料夾或 feature/domain 資料夾
|
- 若元件只服務單一頁面家族,優先放到對應資料夾或 feature/domain 資料夾
|
||||||
|
|
||||||
#### 3. `components/layouts`
|
#### 3. `components/layouts`
|
||||||
@@ -164,7 +144,7 @@ layout 不應承擔:
|
|||||||
- 頁面專屬業務流程
|
- 頁面專屬業務流程
|
||||||
- 特定 domain 的資料規則
|
- 特定 domain 的資料規則
|
||||||
|
|
||||||
#### 4. `components/maintenance`
|
#### 3. `components/maintenance`
|
||||||
|
|
||||||
這個目錄目前已經是最接近 feature folder 的區域:
|
這個目錄目前已經是最接近 feature folder 的區域:
|
||||||
|
|
||||||
@@ -264,11 +244,7 @@ layout 不應承擔:
|
|||||||
已落地頁面:
|
已落地頁面:
|
||||||
|
|
||||||
- `Login`
|
- `Login`
|
||||||
- `Dashboard`
|
- `Home`
|
||||||
- `Analysis`
|
|
||||||
- `RoleManagement`
|
|
||||||
- `MenuManagement`
|
|
||||||
- `DeptManagement`
|
|
||||||
|
|
||||||
代表目前專案已經存在一種穩定模式:
|
代表目前專案已經存在一種穩定模式:
|
||||||
|
|
||||||
@@ -276,7 +252,7 @@ layout 不應承擔:
|
|||||||
- page component 負責頁面畫面組裝
|
- page component 負責頁面畫面組裝
|
||||||
- 較細的視覺區塊再拆到內部子元件
|
- 較細的視覺區塊再拆到內部子元件
|
||||||
|
|
||||||
這是目前除 maintenance 外最明確的頁面分層。
|
這是目前在一般展示頁面中最明確的頁面分層。
|
||||||
|
|
||||||
### 模式 2:`view -> maintenance components + maintenance composables`
|
### 模式 2:`view -> maintenance components + maintenance composables`
|
||||||
|
|
||||||
@@ -319,33 +295,27 @@ layout 不應承擔:
|
|||||||
- [MasterDetailMntC.vue](/home/carl/git/skt-vuetify-templates/src/views/maint/MasterDetailMntC.vue)
|
- [MasterDetailMntC.vue](/home/carl/git/skt-vuetify-templates/src/views/maint/MasterDetailMntC.vue)
|
||||||
- 重新整理:
|
- 重新整理:
|
||||||
- [Login.vue](/home/carl/git/skt-vuetify-templates/src/views/Login.vue)
|
- [Login.vue](/home/carl/git/skt-vuetify-templates/src/views/Login.vue)
|
||||||
- [Home.vue](/home/carl/git/skt-vuetify-templates/src/views/Home.vue)
|
|
||||||
|
|
||||||
原因:
|
原因:
|
||||||
|
|
||||||
- 這些頁面仍保留大量模板、資料轉換或頁面內對話框協調邏輯
|
- 這些頁面仍保留大量資料轉換或頁面內對話框協調邏輯
|
||||||
|
|
||||||
### 中優先度
|
### 中優先度
|
||||||
|
|
||||||
- 重新命名或重新安置頁面型元件:
|
- 重新命名或重新安置頁面型元件:
|
||||||
- [PageLogin.vue](/home/carl/git/skt-vuetify-templates/src/components/PageLogin.vue)
|
- [PageLogin.vue](/home/carl/git/skt-vuetify-templates/src/components/PageLogin.vue)
|
||||||
- [SKDashboard.vue](/home/carl/git/skt-vuetify-templates/src/components/SKDashboard.vue)
|
- [PageIndex.vue](/home/carl/git/skt-vuetify-templates/src/components/PageIndex.vue)
|
||||||
- [SKAnalysis.vue](/home/carl/git/skt-vuetify-templates/src/components/SKAnalysis.vue)
|
|
||||||
- [SKRoleManagement.vue](/home/carl/git/skt-vuetify-templates/src/components/SKRoleManagement.vue)
|
|
||||||
- [SKMenuManagement.vue](/home/carl/git/skt-vuetify-templates/src/components/SKMenuManagement.vue)
|
|
||||||
- [SKDeptManagement.vue](/home/carl/git/skt-vuetify-templates/src/components/SKDeptManagement.vue)
|
|
||||||
|
|
||||||
原因:
|
原因:
|
||||||
|
|
||||||
- 它們語意上已是頁面型元件,但仍放在 `components` 根目錄
|
- 它們仍是頁面型元件,但目前放在 `components` 根目錄
|
||||||
- 目前仍可接受,但新功能不應沿用這種命名與放置方式擴散
|
- 若後續繼續保留 page component 分層,建議改成更清楚的命名與歸位方式
|
||||||
|
|
||||||
### 中低優先度
|
### 中低優先度
|
||||||
|
|
||||||
- 清理 `src/stores/stores/*` 重複結構
|
- 清理 `src/stores/stores/*` 重複結構
|
||||||
- 檢查 `components/base/*` 是否要把頁面家族子元件搬到更明確的資料夾
|
- 檢查 `components/base/*` 是否要把頁面家族子元件搬到更明確的資料夾
|
||||||
- 檢查空資料夾 `src/components/base/management` 是否仍需要保留
|
- 檢查空資料夾 `src/components/base/management` 是否仍需要保留
|
||||||
- 重新評估 [SKSearchFilter.vue](/home/carl/git/skt-vuetify-templates/src/components/base/SKSearchFilter.vue)、[SKFormEditDialog.vue](/home/carl/git/skt-vuetify-templates/src/components/base/SKFormEditDialog.vue)、[SKTreeTable.vue](/home/carl/git/skt-vuetify-templates/src/components/base/SKTreeTable.vue) 是否仍保持跨頁通用
|
|
||||||
|
|
||||||
## 新增或修改檔案時的判斷準則
|
## 新增或修改檔案時的判斷準則
|
||||||
|
|
||||||
|
|||||||
-12
@@ -170,23 +170,18 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
mdiAccountGroup,
|
|
||||||
mdiBellOutline,
|
mdiBellOutline,
|
||||||
mdiCalendarOutline,
|
mdiCalendarOutline,
|
||||||
mdiChartBoxOutline,
|
|
||||||
mdiClose,
|
mdiClose,
|
||||||
mdiCloseCircle,
|
mdiCloseCircle,
|
||||||
mdiCog,
|
mdiCog,
|
||||||
mdiDomain,
|
|
||||||
mdiFileDocumentOutline,
|
mdiFileDocumentOutline,
|
||||||
mdiFileTreeOutline,
|
mdiFileTreeOutline,
|
||||||
mdiHome,
|
mdiHome,
|
||||||
mdiHomeCityOutline,
|
mdiHomeCityOutline,
|
||||||
mdiMenu,
|
|
||||||
mdiPlusCircle,
|
mdiPlusCircle,
|
||||||
mdiSchoolOutline,
|
mdiSchoolOutline,
|
||||||
mdiTableEdit,
|
mdiTableEdit,
|
||||||
mdiViewDashboardVariant,
|
|
||||||
} from '@mdi/js'
|
} from '@mdi/js'
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
@@ -230,18 +225,11 @@ const _fixedMenuItems = [
|
|||||||
// 範例選單(用於 tab 顯示名稱的保底資料)
|
// 範例選單(用於 tab 顯示名稱的保底資料)
|
||||||
const _menuItemsExample = [
|
const _menuItemsExample = [
|
||||||
{ title: '首頁', icon: mdiHome, path: '/' },
|
{ title: '首頁', icon: mdiHome, path: '/' },
|
||||||
{ title: '工作台', icon: mdiViewDashboardVariant, path: '/dashboard' },
|
|
||||||
{ title: '分析頁', icon: mdiChartBoxOutline, path: '/analysis' },
|
|
||||||
{
|
{
|
||||||
title: '設定',
|
title: '設定',
|
||||||
icon: mdiCog,
|
icon: mdiCog,
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
navigable: false,
|
navigable: false,
|
||||||
subItems: [
|
|
||||||
{ title: '角色管理', icon: mdiAccountGroup, path: '/role-management' },
|
|
||||||
{ title: '選單管理', icon: mdiMenu, path: '/menu-management' },
|
|
||||||
{ title: '部門管理', icon: mdiDomain, path: '/dept-management' },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
..._fixedMenuItems,
|
..._fixedMenuItems,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -19,30 +19,6 @@ export const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/Login.vue'),
|
component: () => import('@/views/Login.vue'),
|
||||||
meta: { layout: 'none', guestOnly: true },
|
meta: { layout: 'none', guestOnly: true },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/role-management',
|
|
||||||
name: 'role-management',
|
|
||||||
component: () => import('@/views/RoleManagement.vue'),
|
|
||||||
meta: { layout: 'default' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/menu-management',
|
|
||||||
name: 'menu-management',
|
|
||||||
component: () => import('@/views/MenuManagement.vue'),
|
|
||||||
meta: { layout: 'default' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/dept-management',
|
|
||||||
name: 'dept-management',
|
|
||||||
component: () => import('@/views/DeptManagement.vue'),
|
|
||||||
meta: { layout: 'default' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/analysis',
|
|
||||||
name: 'analysis',
|
|
||||||
component: () => import('@/views/Analysis.vue'),
|
|
||||||
meta: { layout: 'default' },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/single-record-maintenance',
|
path: '/single-record-maintenance',
|
||||||
name: 'single-record-maintenance',
|
name: 'single-record-maintenance',
|
||||||
@@ -73,12 +49,6 @@ export const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import('@/views/maint/EditableGridMnt.vue'),
|
component: () => import('@/views/maint/EditableGridMnt.vue'),
|
||||||
meta: { layout: 'default' },
|
meta: { layout: 'default' },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/dashboard',
|
|
||||||
name: 'dashboard',
|
|
||||||
component: () => import('@/views/Dashboard.vue'),
|
|
||||||
meta: { layout: 'default' },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/:fncId([0-9A-Z]{5,6})',
|
path: '/:fncId([0-9A-Z]{5,6})',
|
||||||
name: 'fnc-page',
|
name: 'fnc-page',
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
<template>
|
|
||||||
<SKAnalysis
|
|
||||||
:bar-data="barData"
|
|
||||||
:chart1-title="chart1Title"
|
|
||||||
:chart2-title="chart2Title"
|
|
||||||
:chart3-title="chart3Title"
|
|
||||||
:pie1-data="pie1Data"
|
|
||||||
:pie2-data="pie2Data"
|
|
||||||
:stats="stats"
|
|
||||||
:trend-data="trendData"
|
|
||||||
:trend-title="trendTitle"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {
|
|
||||||
mdiAccountSchool,
|
|
||||||
mdiBookOpenPageVariant,
|
|
||||||
mdiChartPie,
|
|
||||||
mdiCheckDecagram,
|
|
||||||
mdiCloudDownload,
|
|
||||||
} from '@mdi/js'
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import SKAnalysis from '@/components/SKAnalysis.vue'
|
|
||||||
|
|
||||||
// Mock Data
|
|
||||||
const stats = ref([
|
|
||||||
{
|
|
||||||
title: '學生總數',
|
|
||||||
value: '2,580',
|
|
||||||
icon: mdiAccountSchool,
|
|
||||||
color: 'primary',
|
|
||||||
label: '總學籍人數',
|
|
||||||
total: '120,000',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '平台訪問',
|
|
||||||
value: '20,000',
|
|
||||||
icon: mdiChartPie,
|
|
||||||
color: 'error',
|
|
||||||
label: '今日訪問量',
|
|
||||||
total: '500,000',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '教材下載',
|
|
||||||
value: '8,000',
|
|
||||||
icon: mdiCloudDownload,
|
|
||||||
color: 'warning',
|
|
||||||
label: '本月下載次數',
|
|
||||||
total: '120,000',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '圖書館借閱',
|
|
||||||
value: '5,000',
|
|
||||||
icon: mdiBookOpenPageVariant,
|
|
||||||
color: 'success',
|
|
||||||
label: '總借閱量',
|
|
||||||
total: '50,000',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
const trendTitle = ref('數位學習平台流量')
|
|
||||||
const trendData = ref([0, 2, 5, 9, 5, 10, 3, 5, 0, 0, 1, 8, 2, 9, 0])
|
|
||||||
|
|
||||||
const chart1Title = ref('學生核心素養指標 (平均)')
|
|
||||||
const barData = ref([
|
|
||||||
{ label: '道德實踐 (Moral)', value: 85, color: 'info' },
|
|
||||||
{ label: '智力發展 (Intellectual)', value: 72, color: 'success' },
|
|
||||||
{ label: '體育健康 (Physical)', value: 90, color: 'warning' },
|
|
||||||
{ label: '群育合作 (Social)', value: 65, color: 'error' },
|
|
||||||
{ label: '美感教育 (Aesthetic)', value: 80, color: 'primary' },
|
|
||||||
])
|
|
||||||
|
|
||||||
const chart2Title = ref('訪問裝置來源')
|
|
||||||
const pie1Data = ref({
|
|
||||||
value: 75,
|
|
||||||
label: '行動裝置',
|
|
||||||
color: 'purple-accent-2',
|
|
||||||
})
|
|
||||||
|
|
||||||
const chart3Title = ref('本學期及格率')
|
|
||||||
const pie2Data = ref({
|
|
||||||
value: 92,
|
|
||||||
label: '全校平均',
|
|
||||||
color: 'teal-lighten-1',
|
|
||||||
icon: mdiCheckDecagram,
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
<template>
|
|
||||||
<SKDashboard
|
|
||||||
:announcements="announcements"
|
|
||||||
:applications="applications"
|
|
||||||
:greeting-title="greetingTitle"
|
|
||||||
:quick-navs="quickNavs"
|
|
||||||
:todos="todos"
|
|
||||||
:user-avatar="userAvatar"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import {
|
|
||||||
mdiAccountGroup,
|
|
||||||
mdiBookOpenVariant,
|
|
||||||
mdiCalendarCheck,
|
|
||||||
mdiChartBar,
|
|
||||||
mdiCog,
|
|
||||||
mdiHammerWrench,
|
|
||||||
mdiHome,
|
|
||||||
mdiLayers,
|
|
||||||
mdiLock,
|
|
||||||
mdiMonitorShimmer,
|
|
||||||
mdiSchool,
|
|
||||||
mdiViewDashboard,
|
|
||||||
} from '@mdi/js'
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import SKDashboard from '@/components/SKDashboard.vue'
|
|
||||||
|
|
||||||
const userAvatar = ref(
|
|
||||||
'https://avataaars.io/?avatarStyle=Circle&topType=ShortHairShortFlat&accessoriesType=Sunglasses&hairColor=Black&facialHairType=Blank&clotheType=BlazerShirt&clotheColor=Blue01&eyeType=Happy&eyebrowType=Default&mouthType=Smile&skinColor=Light'
|
|
||||||
)
|
|
||||||
const greetingTitle = ref('早安,王校長,開始您一天的工作吧!')
|
|
||||||
|
|
||||||
const applications = ref([
|
|
||||||
{
|
|
||||||
name: '校務行政系統',
|
|
||||||
icon: mdiSchool,
|
|
||||||
desc: '全校教職員工生學籍資料、人事資料、財產管理等核心系統入口。',
|
|
||||||
group: '行政組',
|
|
||||||
date: '2025-01-05',
|
|
||||||
color: 'primary',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '數位學習平台',
|
|
||||||
icon: mdiMonitorShimmer,
|
|
||||||
desc: '提供線上課程、作業繳交、測驗評量與師生互動討論功能。',
|
|
||||||
group: '教學組',
|
|
||||||
date: '2025-01-02',
|
|
||||||
color: 'success',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '圖書館系統',
|
|
||||||
icon: mdiBookOpenVariant,
|
|
||||||
desc: '館藏查詢、圖書借閱、還書預約與電子書資源整合平台。',
|
|
||||||
group: '圖書館',
|
|
||||||
date: '2024-12-28',
|
|
||||||
color: 'warning',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '學生請假系統',
|
|
||||||
icon: mdiCalendarCheck,
|
|
||||||
desc: '學生線上請假申請、導師審核、生輔組備查流程電子化。',
|
|
||||||
group: '學務處',
|
|
||||||
date: '2024-12-25',
|
|
||||||
color: 'error',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '報修系統',
|
|
||||||
icon: mdiHammerWrench,
|
|
||||||
desc: '校園設施設備故障通報、維修進度查詢與滿意度調查。',
|
|
||||||
group: '總務處',
|
|
||||||
date: '2024-12-20',
|
|
||||||
color: 'purple',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '會議室預約',
|
|
||||||
icon: mdiAccountGroup,
|
|
||||||
desc: '校內各大型會議室、視聽教室場地查詢與線上預約登記。',
|
|
||||||
group: '總務處',
|
|
||||||
date: '2024-12-15',
|
|
||||||
color: 'teal',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
const announcements = ref([
|
|
||||||
{
|
|
||||||
title: '發布全校停課通知 (凱米颱風)',
|
|
||||||
author: '教務處',
|
|
||||||
time: '1 小時前',
|
|
||||||
avatarColor: 'error',
|
|
||||||
avatarSrc: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '發布 113 學年度行事曆',
|
|
||||||
author: '王校長',
|
|
||||||
time: '2 天前',
|
|
||||||
avatarColor: 'primary',
|
|
||||||
avatarSrc: userAvatar.value,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '回覆關於營養午餐的建議',
|
|
||||||
author: '總務主任',
|
|
||||||
time: '3 天前',
|
|
||||||
avatarColor: 'warning',
|
|
||||||
avatarSrc: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '更新校園防疫規定',
|
|
||||||
author: '衛生組長',
|
|
||||||
time: '1 週前',
|
|
||||||
avatarColor: 'success',
|
|
||||||
avatarSrc: null,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
const quickNavs = ref([
|
|
||||||
{ title: '首頁', icon: mdiHome, color: 'primary' },
|
|
||||||
{ title: '控制台', icon: mdiViewDashboard, color: 'error' },
|
|
||||||
{ title: '組件', icon: mdiLayers, color: 'warning' },
|
|
||||||
{ title: '系統管理', icon: mdiCog, color: 'success' },
|
|
||||||
{ title: '權限', icon: mdiLock, color: 'purple' },
|
|
||||||
{ title: '圖表', icon: mdiChartBar, color: 'info' },
|
|
||||||
])
|
|
||||||
|
|
||||||
const todos = ref([
|
|
||||||
{ title: '審查期末考題', due: '今天 11:00', done: false },
|
|
||||||
{ title: '簽核採購申請單', due: '今天 14:00', done: false },
|
|
||||||
{ title: '校務會議', due: '明天 09:00', done: false },
|
|
||||||
{ title: '教學巡堂', due: '週五 10:00', done: true },
|
|
||||||
])
|
|
||||||
</script>
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
<template>
|
|
||||||
<SKDeptManagement
|
|
||||||
:items="deptItems"
|
|
||||||
:loading="loading"
|
|
||||||
:status-options="statusOptions"
|
|
||||||
@add-sub="onAddSub"
|
|
||||||
@create="onCreate"
|
|
||||||
@delete="onDelete"
|
|
||||||
@edit="onEdit"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { DeptItem } from '@/components/SKDeptManagement.vue'
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import SKDeptManagement from '@/components/SKDeptManagement.vue'
|
|
||||||
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
const statusOptions = [
|
|
||||||
{ title: '已啟用', value: 1 },
|
|
||||||
{ title: '已禁用', value: 0 },
|
|
||||||
]
|
|
||||||
|
|
||||||
const deptItems = ref([
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: '校長室',
|
|
||||||
status: 1,
|
|
||||||
createTime: '2023/08/01 09:00:00',
|
|
||||||
note: '負責統籌全校校務發展',
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: '教務處',
|
|
||||||
status: 1,
|
|
||||||
createTime: '2023/08/01 09:00:00',
|
|
||||||
note: '負責課程教學與學籍管理',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
id: 21,
|
|
||||||
name: '教學組',
|
|
||||||
status: 1,
|
|
||||||
createTime: '2023/08/01 09:00:00',
|
|
||||||
note: '課程編排、教師授課與教學評鑑',
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 22,
|
|
||||||
name: '註冊組',
|
|
||||||
status: 1,
|
|
||||||
createTime: '2023/08/01 09:00:00',
|
|
||||||
note: '學生學籍管理、成績處理與升學輔導',
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 23,
|
|
||||||
name: '設備組',
|
|
||||||
status: 1,
|
|
||||||
createTime: '2023/08/01 09:00:00',
|
|
||||||
note: '教學設備採購與維護',
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: '學務處',
|
|
||||||
status: 1,
|
|
||||||
createTime: '2023/08/01 09:00:00',
|
|
||||||
note: '負責學生生活輔導與活動規劃',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
id: 31,
|
|
||||||
name: '訓育組',
|
|
||||||
status: 1,
|
|
||||||
createTime: '2023/08/01 09:00:00',
|
|
||||||
note: '學生自治活動、社團活動與校慶規劃',
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 32,
|
|
||||||
name: '生輔組',
|
|
||||||
status: 1,
|
|
||||||
createTime: '2023/08/01 09:00:00',
|
|
||||||
note: '學生生活常規、出缺席管理與校園安全',
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 33,
|
|
||||||
name: '衛生組',
|
|
||||||
status: 1,
|
|
||||||
createTime: '2023/08/01 09:00:00',
|
|
||||||
note: '校園環境衛生、傳染病防治與健康促進',
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
name: '總務處',
|
|
||||||
status: 1,
|
|
||||||
createTime: '2023/08/01 09:00:00',
|
|
||||||
note: '負責校園營繕、財產管理與經費出納',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
id: 41,
|
|
||||||
name: '庶務組',
|
|
||||||
status: 1,
|
|
||||||
createTime: '2023/08/01 09:00:00',
|
|
||||||
note: '校舍修繕、物品採購與工友管理',
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 42,
|
|
||||||
name: '出納組',
|
|
||||||
status: 1,
|
|
||||||
createTime: '2023/08/01 09:00:00',
|
|
||||||
note: '現金出納、薪津發放與學費收繳',
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
name: '輔導室',
|
|
||||||
status: 1,
|
|
||||||
createTime: '2023/08/01 09:00:00',
|
|
||||||
note: '負責學生心理輔導與生涯規劃',
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
function addChildById(items: DeptItem[], parentId: string | number, child: DeptItem): boolean {
|
|
||||||
for (const current of items) {
|
|
||||||
if (!current) continue
|
|
||||||
|
|
||||||
if (current.id === parentId) {
|
|
||||||
if (!current.children) current.children = []
|
|
||||||
current.children.push(child)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current.children && current.children.length > 0) {
|
|
||||||
const found = addChildById(current.children, parentId, child)
|
|
||||||
if (found) return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateTreeItemById(items: DeptItem[], updated: DeptItem): boolean {
|
|
||||||
for (let i = 0; i < items.length; i += 1) {
|
|
||||||
const current = items[i]
|
|
||||||
if (!current) continue
|
|
||||||
|
|
||||||
if (current.id === updated.id) {
|
|
||||||
items[i] = {
|
|
||||||
...current,
|
|
||||||
name: updated.name,
|
|
||||||
note: updated.note,
|
|
||||||
status: updated.status,
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current.children && current.children.length > 0) {
|
|
||||||
const found = updateTreeItemById(current.children, updated)
|
|
||||||
if (found) return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const onCreate = () => alert('Create Dept')
|
|
||||||
function onAddSub(parent: DeptItem, newItem: DeptItem) {
|
|
||||||
addChildById(deptItems.value, parent.id, newItem)
|
|
||||||
}
|
|
||||||
function onEdit(item: DeptItem) {
|
|
||||||
updateTreeItemById(deptItems.value, item)
|
|
||||||
}
|
|
||||||
function onDelete(item: DeptItem) {
|
|
||||||
if (confirm(`Delete ${item.name}?`)) {
|
|
||||||
alert('Deleted')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
+11
-193
@@ -1,141 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-container class="pa-0" fluid>
|
<PageIndex
|
||||||
<div class="d-flex flex-column ga-5 py-4 pr-2 pl-0">
|
:is-news-dialog-open="isNewsDialogOpen"
|
||||||
<v-sheet
|
:news-items="newsItems"
|
||||||
class="d-flex flex-column flex-sm-row align-start align-sm-center ga-4 pa-5 elevation-1"
|
:quick-items="quickItems"
|
||||||
color="surface"
|
:selected-news="selectedNews"
|
||||||
>
|
@message-center="handleMessageCenter"
|
||||||
<v-avatar color="primary" size="52" variant="tonal">
|
@news="handleNews"
|
||||||
<span class="text-h5">👋</span>
|
@quick="handleQuick"
|
||||||
</v-avatar>
|
@update:is-news-dialog-open="isNewsDialogOpen = $event"
|
||||||
<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="newsItems" :items-per-page="-1">
|
|
||||||
<!--
|
|
||||||
v-data-iterator 的 default slot 會提供包裝後的 items,
|
|
||||||
這裡透過 resolveNewsItem 抽出原始資料,再沿用原本的卡片排版。
|
|
||||||
-->
|
|
||||||
<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="handleNews(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="handleMessageCenter"
|
|
||||||
>
|
|
||||||
<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 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="handleQuick(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>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
點擊消息後顯示的對話框。
|
|
||||||
僅負責呈現內容,不做任何延伸操作,確保互動行為單純可預期。
|
|
||||||
-->
|
|
||||||
<v-dialog v-model="isNewsDialogOpen" max-width="640">
|
|
||||||
<v-card v-if="selectedNews">
|
|
||||||
<v-card-title class="text-h6 font-weight-bold bg-primary-variant pa-4">
|
|
||||||
{{ selectedNews.title }}
|
|
||||||
</v-card-title>
|
|
||||||
<v-card-subtitle class="text-body-2 pt-4 text-medium-emphasis">
|
|
||||||
{{ selectedNews.month }} {{ selectedNews.date }} · {{ selectedNews.dept }} ·
|
|
||||||
{{ selectedNews.views }} 次瀏覽
|
|
||||||
</v-card-subtitle>
|
|
||||||
<v-card-text class="pt-4">
|
|
||||||
{{ selectedNews.desc }}
|
|
||||||
</v-card-text>
|
|
||||||
<v-card-actions class="justify-end">
|
|
||||||
<v-btn color="primary" variant="text" @click="isNewsDialogOpen = false">關閉</v-btn>
|
|
||||||
</v-card-actions>
|
|
||||||
</v-card>
|
|
||||||
</v-dialog>
|
|
||||||
</v-container>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { mdiEyeOutline, mdiFolderOutline } from '@mdi/js'
|
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import PageIndex from '@/components/PageIndex.vue'
|
||||||
import { useMessageStore } from '@/stores/messages'
|
import { useMessageStore } from '@/stores/messages'
|
||||||
import { useSnackbarStore } from '@/stores/snackbar'
|
import { useSnackbarStore } from '@/stores/snackbar'
|
||||||
|
|
||||||
@@ -189,15 +67,6 @@ const quickItems = [
|
|||||||
const selectedNews = ref<NewsItem | null>(null)
|
const selectedNews = ref<NewsItem | null>(null)
|
||||||
const isNewsDialogOpen = ref(false)
|
const isNewsDialogOpen = ref(false)
|
||||||
|
|
||||||
// v-data-iterator 會包裝 items,這個方法用來安全地取回原始資料結構。
|
|
||||||
function resolveNewsItem(wrapped: unknown): NewsItem {
|
|
||||||
if (wrapped && typeof wrapped === 'object' && 'raw' in wrapped) {
|
|
||||||
return (wrapped as { raw: NewsItem }).raw
|
|
||||||
}
|
|
||||||
|
|
||||||
return wrapped as NewsItem
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleNews(item: NewsItem) {
|
function handleNews(item: NewsItem) {
|
||||||
selectedNews.value = item
|
selectedNews.value = item
|
||||||
isNewsDialogOpen.value = true
|
isNewsDialogOpen.value = true
|
||||||
@@ -212,54 +81,3 @@ function handleQuick(item: (typeof quickItems)[number]) {
|
|||||||
snackbar.show({ message: `前往:${item.title}`, color: 'info' })
|
snackbar.show({ message: `前往:${item.title}`, color: 'info' })
|
||||||
}
|
}
|
||||||
</script>
|
</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>
|
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
<template>
|
|
||||||
<SKMenuManagement
|
|
||||||
:items="menuItems"
|
|
||||||
:loading="loading"
|
|
||||||
:permission-options="permissionOptions"
|
|
||||||
:status-options="statusOptions"
|
|
||||||
@edit="onEdit"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { MenuItem } from '@/components/SKMenuManagement.vue'
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import SKMenuManagement from '@/components/SKMenuManagement.vue'
|
|
||||||
|
|
||||||
const loading = ref(false)
|
|
||||||
const statusOptions = [
|
|
||||||
{ title: '已啟用', value: 1 },
|
|
||||||
{ title: '已禁用', value: 0 },
|
|
||||||
]
|
|
||||||
const permissionOptions = ['管理員', '一級主管', '二級主管', '使用者']
|
|
||||||
const menuItems = ref([
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
title: '工作台',
|
|
||||||
permission: '使用者',
|
|
||||||
path: '/workspace',
|
|
||||||
component: '/dashboard/workspace/index',
|
|
||||||
status: 1,
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
title: '系統管理',
|
|
||||||
|
|
||||||
permission: '管理員',
|
|
||||||
path: '/system',
|
|
||||||
component: 'LAYOUT',
|
|
||||||
status: 1,
|
|
||||||
isNew: true,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
id: 21,
|
|
||||||
title: '菜單管理',
|
|
||||||
|
|
||||||
permission: '管理員',
|
|
||||||
path: '/system/menu',
|
|
||||||
component: '/system/menu/list',
|
|
||||||
status: 1,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
id: 211,
|
|
||||||
title: '新增',
|
|
||||||
permission: '管理員',
|
|
||||||
status: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 212,
|
|
||||||
title: '修改',
|
|
||||||
permission: '管理員',
|
|
||||||
status: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 213,
|
|
||||||
title: '刪除',
|
|
||||||
permission: '管理員',
|
|
||||||
status: 1,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 22,
|
|
||||||
title: '部門管理',
|
|
||||||
permission: '一級主管',
|
|
||||||
path: '/system/dept',
|
|
||||||
component: '/system/dept/list',
|
|
||||||
status: 1,
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
title: '項目',
|
|
||||||
permission: '二級主管',
|
|
||||||
path: '/vben-admin',
|
|
||||||
component: 'LAYOUT',
|
|
||||||
status: 1,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
id: 31,
|
|
||||||
title: '文檔',
|
|
||||||
permission: '使用者',
|
|
||||||
path: '/vben-admin/document',
|
|
||||||
component: 'https://doc.vben.pro',
|
|
||||||
status: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 32,
|
|
||||||
title: 'Ant Design Vue 版本',
|
|
||||||
permission: '使用者',
|
|
||||||
path: '/vben-admin/antdv',
|
|
||||||
component: 'https://ant.vben.pro',
|
|
||||||
status: 0,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
title: '關於',
|
|
||||||
permission: '使用者',
|
|
||||||
path: '/about',
|
|
||||||
component: '_core/about/index',
|
|
||||||
status: 1,
|
|
||||||
children: [],
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
function updateTreeItemById(items: MenuItem[], updated: MenuItem): boolean {
|
|
||||||
for (let i = 0; i < items.length; i += 1) {
|
|
||||||
const current = items[i]
|
|
||||||
if (!current) continue
|
|
||||||
|
|
||||||
if (current.id === updated.id) {
|
|
||||||
items[i] = {
|
|
||||||
...current,
|
|
||||||
status: updated.status,
|
|
||||||
permission: updated.permission,
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current.children && current.children.length > 0) {
|
|
||||||
const found = updateTreeItemById(current.children, updated)
|
|
||||||
if (found) return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
function onEdit(item: MenuItem) {
|
|
||||||
updateTreeItemById(menuItems.value, item)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
<template>
|
|
||||||
<SKRoleManagement
|
|
||||||
:loading="loading"
|
|
||||||
:roles="roles"
|
|
||||||
@create="onCreate"
|
|
||||||
@delete="onDelete"
|
|
||||||
@edit="onEdit"
|
|
||||||
@reset="onReset"
|
|
||||||
@search="onSearch"
|
|
||||||
@update:status="onStatusUpdate"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { RoleItem } from '@/components/SKRoleManagement.vue'
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import SKRoleManagement from '@/components/SKRoleManagement.vue'
|
|
||||||
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
const roles = ref([
|
|
||||||
{
|
|
||||||
name: 'Car',
|
|
||||||
id: '2216c0ab-9d0e-4f6e-a7d6-12...',
|
|
||||||
status: false,
|
|
||||||
note: 'Somnus careo ultio caste vix adversus textilis vaco defero coniuratio.',
|
|
||||||
createTime: '2022/05/13 01:03:31',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Chair',
|
|
||||||
id: '2625810b-639f-4213-944e-ae...',
|
|
||||||
status: false,
|
|
||||||
note: 'Ulterius tristis voluptatum demergo.',
|
|
||||||
createTime: '2024/05/30 21:04:49',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Shoes',
|
|
||||||
id: 'c19c2b27-f9c9-46b8-93db-11f...',
|
|
||||||
status: true,
|
|
||||||
note: 'Illum delinquo texo tumultus perferendis debilito.',
|
|
||||||
createTime: '2024/07/18 06:21:55',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Cheese',
|
|
||||||
id: '7d1053ac-acc1-405f-8c66-235...',
|
|
||||||
status: true,
|
|
||||||
note: 'Acidus vobis coruscus.',
|
|
||||||
createTime: '2024/07/03 07:44:10',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Gloves',
|
|
||||||
id: '9bb9ed44-dfcd-11b8-bcb1-d0...',
|
|
||||||
status: true,
|
|
||||||
note: 'Vivo caelum quo caveo valetudo.',
|
|
||||||
createTime: '2024/10/05 12:00:17',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Shoes',
|
|
||||||
id: 'cdb0c47a-284e-4ca3-b434-e8...',
|
|
||||||
status: true,
|
|
||||||
note: 'Veritas adflicto temperantia.',
|
|
||||||
createTime: '2024/11/20 16:35:09',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Computer',
|
|
||||||
id: 'db9d1509-7247-4a38-9bd9-b...',
|
|
||||||
status: true,
|
|
||||||
note: 'Viridis titulus somnus voluptate voluptate aspicio sophismata ansor th...',
|
|
||||||
createTime: '2023/02/21 06:16:25',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Cheese',
|
|
||||||
id: '1beb5ce6-11f2-490f-9549-101...',
|
|
||||||
status: true,
|
|
||||||
note: 'Eveniet amicitia bestia supra.',
|
|
||||||
createTime: '2022/02/19 12:51:41',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Towels',
|
|
||||||
id: 'bd9c74bb-fc3a-4a7a-8b3f-55f...',
|
|
||||||
status: true,
|
|
||||||
note: 'Dapifer aliquam amoveo vitium ubi repellendus tactus bardus crepusc...',
|
|
||||||
createTime: '2023/02/16 20:09:27',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
function onSearch(params: Record<string, unknown>) {
|
|
||||||
console.log('Search:', params)
|
|
||||||
loading.value = true
|
|
||||||
setTimeout(() => {
|
|
||||||
loading.value = false
|
|
||||||
alert('Search Triggered! Check Console.')
|
|
||||||
}, 500)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onReset() {
|
|
||||||
console.log('Reset')
|
|
||||||
}
|
|
||||||
|
|
||||||
function onCreate() {
|
|
||||||
console.log('Create Role')
|
|
||||||
alert('Create Role Clicked')
|
|
||||||
}
|
|
||||||
|
|
||||||
function onEdit(item: RoleItem) {
|
|
||||||
console.log('Edit:', item)
|
|
||||||
alert(`Edit ${item.name}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDelete(item: RoleItem) {
|
|
||||||
console.log('Delete:', item)
|
|
||||||
if (confirm(`Are you sure you want to delete ${item.name}?`)) {
|
|
||||||
roles.value = roles.value.filter((r) => r.id !== item.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onStatusUpdate(item: RoleItem, newVal: boolean) {
|
|
||||||
// In a real app, API call here
|
|
||||||
item.status = newVal
|
|
||||||
console.log('Status Update:', item.name, newVal)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
Reference in New Issue
Block a user