feat: add SingleRecordMnt component for student record maintenance with search, add, edit, view, and delete functionalities

This commit is contained in:
skytek_xinliang
2026-03-26 11:24:37 +08:00
parent 507afcc99c
commit 069141794e
116 changed files with 15247 additions and 107 deletions
+197
View File
@@ -0,0 +1,197 @@
<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>
+145
View File
@@ -0,0 +1,145 @@
<template>
<v-card class="bg-surface mb-4" v-bind="$attrs">
<v-card-text>
<v-row dense>
<!-- 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" dense>
<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 ? 'mdi-chevron-up' : 'mdi-chevron-down'"></v-icon>
</v-btn>
</v-col>
</v-row>
</v-card-text>
</v-card>
</template>
<script setup lang="ts">
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>
+161
View File
@@ -0,0 +1,161 @@
<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="mdi-plus"
@click="$emit('create')"
>
{{ createBtnText }}
</v-btn>
<v-tooltip :disabled="!searchToggleTooltipText" location="top" :text="searchToggleTooltipText">
<template #activator="{ props }">
<v-btn
v-if="showSearchToggle"
v-bind="props"
density="comfortable"
icon
variant="text"
@click="$emit('toggle-search')"
>
<v-icon :color="searchVisible ? 'primary-variant' : undefined"> mdi-magnify </v-icon>
</v-btn>
</template>
</v-tooltip>
<v-tooltip :disabled="!refreshTooltipText" location="top" :text="refreshTooltipText">
<template #activator="{ props }">
<v-btn v-bind="props" density="comfortable" icon variant="text" @click="$emit('refresh')">
<v-icon>mdi-refresh</v-icon>
</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>mdi-cog</v-icon>
</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 { 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>
+137
View File
@@ -0,0 +1,137 @@
<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>{{ isExpanded(item.id) ? 'mdi-chevron-down' : 'mdi-chevron-right' }}</v-icon>
</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 { 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>
@@ -0,0 +1,38 @@
<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>
@@ -0,0 +1,42 @@
<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">{{ data.icon }}</v-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>
@@ -0,0 +1,56 @@
<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>
@@ -0,0 +1,35 @@
<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 }}
</v-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>
@@ -0,0 +1,68 @@
<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="mdi-chart-timeline-variant"></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">
interface Props {
title: string
data: number[]
filters: string[]
activeFilter: string
}
defineProps<Props>()
defineEmits(['filter-change'])
</script>
<style scoped>
.chart-container {
overflow: hidden;
}
</style>
@@ -0,0 +1,55 @@
<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>
@@ -0,0 +1,80 @@
<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">{{ app.icon }}</v-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>
@@ -0,0 +1,52 @@
<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">mdi-circle</v-icon>
<span class="text-caption">{{ primaryLabel }}</span>
</div>
<div class="d-flex align-center">
<v-icon class="mr-1" color="grey-lighten-3" size="small">mdi-circle</v-icon>
<span class="text-caption">{{ secondaryLabel }}</span>
</div>
</div>
</v-card-text>
</v-card>
</template>
<script setup lang="ts">
interface Props {
title: string
value: number
subtitle?: string
primaryLabel?: string
secondaryLabel?: string
}
withDefaults(defineProps<Props>(), {
subtitle: '來源佔比',
primaryLabel: '校內',
secondaryLabel: '校外',
})
</script>
@@ -0,0 +1,56 @@
<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>
@@ -0,0 +1,38 @@
<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 dense>
<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">{{ nav.icon }}</v-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>
@@ -0,0 +1,40 @@
<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>
@@ -0,0 +1,23 @@
<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>
@@ -0,0 +1,25 @@
<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>
@@ -0,0 +1,23 @@
<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>
@@ -0,0 +1,45 @@
<template>
<div class="text-center w-100">
<div class="text-body-2">
{{ props.promptText }}
<a
class="text-primary text-decoration-none font-weight-bold ml-1"
:href="props.href || '#'"
:target="props.target"
@click="handleClick"
>
{{ props.linkText }}
</a>
</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps({
promptText: {
type: String,
default: '還沒有帳號?',
},
linkText: {
type: String,
default: '註冊帳號',
},
href: {
type: String,
default: '',
},
target: {
type: String,
default: undefined,
},
})
const emit = defineEmits(['click'])
function handleClick (e: MouseEvent) {
emit('click', e)
if (!props.href) {
e.preventDefault()
}
}
</script>
@@ -0,0 +1,187 @@
<template>
<v-card class="w-100 h-100 d-flex flex-column bg-transparent pa-2 pa-lg-4" elevation="3">
<v-card-title class="text-h6 text-lg-h5 font-weight-bold text-accent mb-4">{{ title }}</v-card-title>
<v-tabs v-model="activeTab" class="mb-3" color="primary" density="comfortable">
<v-tab v-for="tab in normalizedTabs" :key="tab.value" :value="tab.value">
{{ tab.label }}
</v-tab>
</v-tabs>
<div class="announcement-content mb-3">
<v-table v-if="!isSystemTab" density="comfortable" fixed-header height="300">
<thead>
<tr>
<th class="text-left">{{ dateHeader }}</th>
<th class="text-left">{{ schoolHeader }}</th>
<th class="text-left">{{ titleHeader }}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in pageItems" :key="item.id">
<td class="text-no-wrap">{{ item.date }}</td>
<td class="text-no-wrap">{{ item.school }}</td>
<td>
<v-btn
class="px-0 text-none justify-start" color="primary" variant="text"
@click="emit('select-announcement', item)">
{{ item.title }}
</v-btn>
</td>
</tr>
<tr v-if="pageItems.length === 0">
<td class="text-center text-medium-emphasis py-6" :colspan="3">{{ emptyText }}</td>
</tr>
</tbody>
</v-table>
<v-list v-else class="rounded border overflow-y-auto h-100" density="comfortable" lines="two">
<v-list-item v-for="item in systemPageItems" :key="item.id" border="b">
<v-list-item-title class="text-h6 mb-2">
{{ item.content }}
</v-list-item-title>
<v-list-item-subtitle v-if="item.title || item.createdAt">
{{ item.title }}<span v-if="item.title && item.createdAt"> </span>{{ item.createdAt }}
</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="systemPageItems.length === 0" class="h-100">
<v-list-item-title class="text-center text-medium-emphasis">{{ emptyText }}</v-list-item-title>
</v-list-item>
</v-list>
</div>
<div class="d-flex justify-space-between align-center mt-auto pt-3">
<span class="text-caption text-medium-emphasis">
{{ paginationLabel }} {{ totalItems }}
</span>
<v-pagination v-model="page" density="comfortable" :length="pageCount" rounded="circle" />
</div>
</v-card>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
interface AnnouncementTab {
label: string
value: string
}
interface AnnouncementItem {
id: string | number
date: string
school: string
title: string
tab?: string
}
interface SystemAnnouncementItem {
id: string | number
content: string
title?: string
createdAt?: string
}
interface Props {
title?: string
tabs?: AnnouncementTab[]
items?: AnnouncementItem[]
systemAnnouncements?: SystemAnnouncementItem[]
allTabLabel?: string
itemsPerPage?: number
dateHeader?: string
schoolHeader?: string
titleHeader?: string
emptyText?: string
paginationLabel?: string
}
const props = withDefaults(defineProps<Props>(), {
title: '學校甄選簡章公告區',
tabs: () => [{ label: '全部', value: '__all__' }],
items: () => [],
systemAnnouncements: () => [],
allTabLabel: '全部',
itemsPerPage: 5,
dateHeader: '公告時間',
schoolHeader: '公告學校',
titleHeader: '公告標題',
emptyText: '目前沒有公告資料',
paginationLabel: '總筆數:',
})
const emit = defineEmits<{
(event: 'select-announcement', item: AnnouncementItem): void
}>()
const allTabValue = '__all__'
const systemTabValue = '__system__'
const systemTab = computed<AnnouncementTab>(() => ({
label: '系統公告',
value: systemTabValue,
}))
const normalizedTabs = computed<AnnouncementTab[]>(() => {
const baseTabs = props.tabs.length > 0 ? props.tabs : [{ label: props.allTabLabel, value: allTabValue }]
if (baseTabs.some((tab) => tab.value === systemTabValue)) return baseTabs
return [...baseTabs, systemTab.value]
})
const activeTab = ref(normalizedTabs.value[0]?.value ?? allTabValue)
const page = ref(1)
const isSystemTab = computed(() => activeTab.value === systemTabValue)
const filteredItems = computed(() => {
if (activeTab.value === allTabValue) return props.items
return props.items.filter((item) => item.tab === activeTab.value)
})
const totalItems = computed(() => {
if (isSystemTab.value) return props.systemAnnouncements.length
return filteredItems.value.length
})
const pageCount = computed(() => {
const size = Math.max(1, props.itemsPerPage)
return Math.max(1, Math.ceil(totalItems.value / size))
})
const pageItems = computed(() => {
const size = Math.max(1, props.itemsPerPage)
const start = (page.value - 1) * size
return filteredItems.value.slice(start, start + size)
})
const systemPageItems = computed<SystemAnnouncementItem[]>(() => {
const size = Math.max(1, props.itemsPerPage)
const start = (page.value - 1) * size
return props.systemAnnouncements.slice(start, start + size)
})
watch(
normalizedTabs,
(tabs) => {
if (tabs.some((tab) => tab.value === activeTab.value)) return
activeTab.value = tabs[0]?.value ?? allTabValue
},
{ immediate: true }
)
watch(activeTab, () => {
page.value = 1
})
watch(pageCount, (count) => {
if (page.value <= count) return
page.value = count
})
</script>
<style scoped>
.announcement-content {
height: 300px;
}
</style>
+14
View File
@@ -0,0 +1,14 @@
<template>
<div class="d-flex align-center">
<span class="text-h5 font-weight-bold text-primary">{{ title }}</span>
</div>
</template>
<script setup lang="ts">
defineProps({
title: {
type: String,
default: 'Login PageS',
},
})
</script>
+121
View File
@@ -0,0 +1,121 @@
<template>
<v-form @submit.prevent="$emit('submit', { username, password, rememberMe })">
<v-text-field
v-model="username" bg-color="surface" class="mb-6 mb-md-4" color="primary"
density="comfortable" hide-details :placeholder="props.accPlaceholder" variant="outlined"></v-text-field>
<v-text-field
v-model="password" :append-inner-icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'" bg-color="surface"
class="mb-6 mb-md-4" color="primary" density="comfortable" hide-details :placeholder="props.passwPlaceholder" :type="showPassword ? 'text' : 'password'"
variant="outlined"
@click:append-inner="showPassword = !showPassword"></v-text-field>
<slot name="verify"></slot>
<div class="d-flex align-center justify-space-between mb-6 mb-md-4">
<v-checkbox
v-model="rememberMe" color="primary" density="compact" hide-details
:label="props.rememberMeLabel"></v-checkbox>
<a
class="text-body-2 text-primary text-decoration-none" :href="props.forgotPasswordHref || '#'"
:target="props.forgotPasswordTarget" @click="handleForgotPasswordClick">
{{ props.forgotPasswordText }}
</a>
</div>
<v-btn block class="mb-6 font-weight-bold" color="primary" elevation="0" height="48" size="large" type="submit">
{{ props.submitText }}
</v-btn>
</v-form>
</template>
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
const username = ref('')
const password = ref('')
const showPassword = ref(false)
const rememberMe = ref(false)
const props = defineProps({
passwPlaceholder: {
type: String,
default: '請輸入6位數密碼',
},
accPlaceholder: {
type: String,
default: '請輸入帳號',
},
rememberMeLabel: {
type: String,
default: '記住帳號',
},
forgotPasswordText: {
type: String,
default: '忘記密碼?',
},
forgotPasswordHref: {
type: String,
default: '',
},
forgotPasswordTarget: {
type: String,
default: undefined,
},
submitText: {
type: String,
default: '登入',
},
rememberStorageKey: {
type: String,
default: 'sklogin.remember.username',
},
})
const emit = defineEmits(['submit', 'forgot-password'])
onMounted(() => {
const saved = localStorage.getItem(props.rememberStorageKey)
if (saved) {
username.value = saved
rememberMe.value = true
}
})
watch([rememberMe, username], ([nextRemember, nextUsername]) => {
if (!nextRemember) {
localStorage.removeItem(props.rememberStorageKey)
return
}
if (!nextUsername) {
localStorage.removeItem(props.rememberStorageKey)
return
}
localStorage.setItem(props.rememberStorageKey, nextUsername)
})
function handleForgotPasswordClick (e: MouseEvent) {
emit('forgot-password', e)
if (!props.forgotPasswordHref) {
e.preventDefault()
}
}
</script>
<style scoped>
:deep(.v-field--variant-outlined) {
border-radius: 8px;
}
:deep(.v-btn) {
text-transform: none;
border-radius: 8px;
letter-spacing: 0;
}
:deep(.v-checkbox .v-label) {
font-size: 14px;
opacity: 1;
}
</style>
+24
View File
@@ -0,0 +1,24 @@
<template>
<div class="login-header-wrapper">
<h2 class="text-h5 text-primary font-weight-bold mb-2">{{ props.welcomeText }}</h2>
<p class="text-subtitle-1 text-secondary">{{ props.welcomeDescription }}</p>
</div>
</template>
<script setup lang="ts">
const props = defineProps({
welcomeText: {
type: String,
default: '歡迎回來 👋🏻',
},
welcomeDescription: {
type: String,
default: '請輸入您的帳號密碼進行登入',
},
})
</script>
<style scoped>
.login-header-wrapper {
height: 140px;
}
</style>
@@ -0,0 +1,44 @@
<template>
<div
class="illustration-container d-flex flex-column align-center justify-center fill-height px-8"
>
<div class="illustration-wrapper mb-8 w-100 d-flex justify-center">
<v-img
v-if="image"
aspect-ratio="16/9"
contain
max-width="600"
:src="image"
width="100%"
></v-img>
</div>
<div class="text-center">
<h1 class="text-h4 font-weight-bold text-secondary mb-4">{{ title }}</h1>
<p class="text-body-1 text-secondary">{{ description }}</p>
</div>
</div>
</template>
<script setup lang="ts">
defineProps({
image: {
type: String,
default: null,
},
title: {
type: String,
default: '這是一個標題',
},
description: {
type: String,
default: '這是一個副標題',
},
})
</script>
<style scoped>
.illustration-container {
width: 100%;
}
</style>
@@ -0,0 +1,72 @@
<template>
<div class="d-flex justify-end py-0 py-sm-2">
<v-btn
class="d-none d-md-block" color="grey-darken-1" icon="mdi-palette-outline" size="small" variant="text"
@click="toggleTheme"></v-btn>
<!-- <v-btn icon="mdi-dock-window" variant="text" size="small" color="grey-darken-1" @click="handleToggleLayout"></v-btn> -->
<v-menu location="bottom end">
<template #activator="{ props: menuActivatorProps }">
<v-btn
v-bind="menuActivatorProps" color="grey-darken-1" icon="mdi-translate" size="small"
variant="text"></v-btn>
</template>
<v-list density="compact">
<v-list-item
v-for="locale in localeOptions" :key="locale" :active="locale === props.locale"
@click="handleSelectLocale(locale)">
<v-list-item-title>{{ localeLabels[locale] ?? locale }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<!-- <v-btn icon="mdi-weather-night" variant="text" size="small" color="grey-darken-1"></v-btn> -->
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useTheme } from 'vuetify'
import { getNextThemeName } from '@/utils/theme'
interface Props {
locale?: string
locales?: string[]
localeLabels?: Record<string, string>
}
const props = withDefaults(defineProps<Props>(), {
locale: 'zh-TW',
locales: () => ['zh-TW', 'en-US'],
localeLabels: () => ({
'en-US': 'English',
'zh-TW': '中文',
}),
})
const emit = defineEmits(['change-locale', 'toggle-layout'])
const theme = useTheme()
const availableThemeNames = computed(() =>
Object.keys(theme.themes.value ?? {}).filter((name) => name.startsWith('theme'))
)
function toggleTheme () {
const names = availableThemeNames.value
if (names.length === 0) return
const current = theme.global.name.value
const next = getNextThemeName(names, current)
if (!next) return
theme.change(next)
}
const localeOptions = computed(() =>
props.locales.length > 0 ? props.locales : ['zh-TW', 'en-US']
)
function handleSelectLocale (locale: string) {
if (locale === props.locale) return
emit('change-locale', locale)
}
</script>
+106
View File
@@ -0,0 +1,106 @@
<template>
<v-sheet v-bind="$attrs" class="verify-container mb-6 mb-md-4" color="transparent">
<div v-if="loading && !captchaImage" class="d-flex justify-center align-center py-4">
<v-progress-circular color="primary" indeterminate></v-progress-circular>
</div>
<div v-else class="d-flex align-center gap-2">
<!-- Captcha Image and Refresh -->
<div
class="captcha-wrapper d-flex align-center cursor-pointer me-1 me-md-2" :title="props.refreshTitle"
@click="handleRefresh">
<img v-if="captchaImage" alt="Captcha" class="captcha-img" :src="captchaImage" />
<v-icon class="ms-2" color="grey" icon="mdi-refresh"></v-icon>
</div>
<!-- Input and Verify -->
<v-text-field
v-model="inputCode" :append-inner-icon="props.verified ? 'mdi-check-circle' : ''" bg-color="surface" class="flex-grow-1"
color="primary" density="compact" :disabled="props.verified" :error="!!errorMsg" hide-details
:placeholder="props.captchaPlaceholder" variant="outlined">
<template v-if="props.verified" #append-inner>
<v-icon color="success">mdi-check-circle</v-icon>
</template>
</v-text-field>
</div>
<div v-if="errorMsg" class="text-caption text-error mt-1">
{{ errorMsg }}
</div>
</v-sheet>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface CaptchaPayload {
imgUrl?: string
id?: string
tokenValue?: string
}
interface Props {
captcha?: CaptchaPayload
modelValue?: string
loading?: boolean
errorMessage?: string
verified?: boolean
verifyText?: string
captchaPlaceholder?: string
refreshTitle?: string
}
const props = withDefaults(defineProps<Props>(), {
captcha: undefined,
modelValue: '',
loading: false,
errorMessage: '',
verified: false,
verifyText: '驗證',
captchaPlaceholder: '驗證碼',
refreshTitle: '點擊刷新驗證碼',
})
const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
(event: 'refresh'): void
}>()
const captchaImage = computed(() => props.captcha?.imgUrl ?? '')
const inputCode = computed({
get: () => props.modelValue,
set: (val: string) => emit('update:modelValue', val),
})
const errorMsg = computed(() => props.errorMessage)
const loading = computed(() => props.loading)
function handleRefresh () {
if (props.verified) return
emit('refresh')
}
</script>
<style scoped>
.verify-container {
width: 100%;
}
.captcha-wrapper {
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
border-radius: 4px;
padding: 0 8px;
height: 40px;
background: rgb(var(--v-theme-surface));
display: flex;
align-items: center;
}
.captcha-img {
height: 100%;
width: auto;
display: block;
}
</style>