235 lines
7.5 KiB
Vue
235 lines
7.5 KiB
Vue
<template>
|
||
<v-container class="pa-0" fluid>
|
||
<div class="d-flex flex-column ga-5 pt-1 pb-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>
|