Refactor MasterDetailMntC.vue for improved readability and consistency
This commit is contained in:
@@ -16,7 +16,7 @@ The following example assumes a component located at `src/components/MyComponent
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
//
|
||||
//
|
||||
</script>
|
||||
```
|
||||
|
||||
@@ -30,6 +30,6 @@ When your template is rendered, the component's import will automatically be inl
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import MyComponent from '@/components/MyComponent.vue'
|
||||
import MyComponent from '@/components/MyComponent.vue'
|
||||
</script>
|
||||
```
|
||||
|
||||
@@ -179,7 +179,9 @@ const emit = defineEmits([
|
||||
'toggle-expand',
|
||||
])
|
||||
|
||||
function normalizeOptions (options: Array<string | number | { title: string; value: string | number }>) {
|
||||
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 }
|
||||
@@ -203,7 +205,7 @@ const addSubParentItem = ref<DeptItem | null>(null)
|
||||
const addSubDraftItem = ref<Record<string, unknown> | null>(null)
|
||||
const selectedItem = ref<DeptItem | null>(null)
|
||||
|
||||
function openAddSub (item: DeptItem) {
|
||||
function openAddSub(item: DeptItem) {
|
||||
addSubParentItem.value = item
|
||||
addSubDraftItem.value = {
|
||||
name: '',
|
||||
@@ -213,12 +215,12 @@ function openAddSub (item: DeptItem) {
|
||||
addSubDialogOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit (item: DeptItem) {
|
||||
function openEdit(item: DeptItem) {
|
||||
selectedItem.value = item
|
||||
editDialogOpen.value = true
|
||||
}
|
||||
|
||||
function onAddSubSubmit (payload: Record<string, unknown>) {
|
||||
function onAddSubSubmit(payload: Record<string, unknown>) {
|
||||
if (!addSubParentItem.value) return
|
||||
const newItem: DeptItem = {
|
||||
id: Date.now(),
|
||||
@@ -231,7 +233,7 @@ function onAddSubSubmit (payload: Record<string, unknown>) {
|
||||
emit('add-sub', addSubParentItem.value, newItem)
|
||||
}
|
||||
|
||||
function onEditSubmit (updated: Record<string, unknown>) {
|
||||
function onEditSubmit(updated: Record<string, unknown>) {
|
||||
emit('edit', updated)
|
||||
}
|
||||
|
||||
|
||||
+141
-62
@@ -2,76 +2,129 @@
|
||||
<v-sheet class="bg-surface" :class="layoutClass" height="100%">
|
||||
<!-- Side Layouts -->
|
||||
<v-row
|
||||
v-if="props.layout !== 'card'" class="fill-height" :class="{ 'flex-row-reverse': props.layout === 'side-right' }"
|
||||
no-gutters>
|
||||
v-if="props.layout !== 'card'"
|
||||
class="fill-height"
|
||||
:class="{ 'flex-row-reverse': props.layout === 'side-right' }"
|
||||
no-gutters
|
||||
>
|
||||
<!-- Illustration Column -->
|
||||
<v-col
|
||||
class="illustration-panel d-none d-sm-flex align-center justify-center position-relative flex-grow-1" cols="12" lg="8"
|
||||
sm="6">
|
||||
class="illustration-panel d-none d-sm-flex align-center justify-center position-relative flex-grow-1"
|
||||
cols="12"
|
||||
lg="8"
|
||||
sm="6"
|
||||
>
|
||||
<div class="brand-header position-absolute top-0 left-0 px-2 pt-4 pa-lg-4">
|
||||
<LoginBrand :title="props.branding.title" />
|
||||
</div>
|
||||
<v-sheet
|
||||
class="board-wrapper pa-2 pa-lg-0" color="rgba(var(--v-theme-surface), 0.8)" elevation="0" max-width="680"
|
||||
rounded="lg" width="100%">
|
||||
class="board-wrapper pa-2 pa-lg-0"
|
||||
color="rgba(var(--v-theme-surface), 0.8)"
|
||||
elevation="0"
|
||||
max-width="680"
|
||||
rounded="lg"
|
||||
width="100%"
|
||||
>
|
||||
<LoginAnnouncementBoard
|
||||
:all-tab-label="props.announcementBoard.allTabLabel" :date-header="props.announcementBoard.dateHeader"
|
||||
:empty-text="props.announcementBoard.emptyText" :items="props.announcementBoard.items"
|
||||
:all-tab-label="props.announcementBoard.allTabLabel"
|
||||
:date-header="props.announcementBoard.dateHeader"
|
||||
:empty-text="props.announcementBoard.emptyText"
|
||||
:items="props.announcementBoard.items"
|
||||
:items-per-page="props.announcementBoard.itemsPerPage"
|
||||
:pagination-label="props.announcementBoard.paginationLabel" :school-header="props.announcementBoard.schoolHeader"
|
||||
:system-announcements="props.announcementBoard.systemAnnouncements" :tabs="props.announcementBoard.tabs"
|
||||
:title="props.announcementBoard.title" :title-header="props.announcementBoard.titleHeader"
|
||||
@select-announcement="handleSelectAnnouncement" />
|
||||
:pagination-label="props.announcementBoard.paginationLabel"
|
||||
:school-header="props.announcementBoard.schoolHeader"
|
||||
:system-announcements="props.announcementBoard.systemAnnouncements"
|
||||
:tabs="props.announcementBoard.tabs"
|
||||
:title="props.announcementBoard.title"
|
||||
:title-header="props.announcementBoard.titleHeader"
|
||||
@select-announcement="handleSelectAnnouncement"
|
||||
/>
|
||||
</v-sheet>
|
||||
</v-col>
|
||||
|
||||
<v-col class="bg-background d-flex flex-column flex-grow-1 px-4 py-2 py-md-0" cols="12" lg="4" sm="6">
|
||||
<v-col
|
||||
class="bg-background d-flex flex-column flex-grow-1 px-4 py-2 py-md-0"
|
||||
cols="12"
|
||||
lg="4"
|
||||
sm="6"
|
||||
>
|
||||
<div v-if="showMobileAnnouncementBanner" class="banner-wrapper px-3">
|
||||
<v-banner class=" d-sm-none mb-2" density="comfortable" lines="one" :mobile="false" :stacked="false">
|
||||
<v-banner
|
||||
class="d-sm-none mb-2"
|
||||
density="comfortable"
|
||||
lines="one"
|
||||
:mobile="false"
|
||||
:stacked="false"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-slide-x-transition appear>
|
||||
<div class="mobile-banner-icon-wrap d-flex align-center">
|
||||
<v-icon class="mobile-banner-icon" color="primary" size="small" :icon="mdiBullhornVariantOutline" />
|
||||
<v-icon
|
||||
class="mobile-banner-icon"
|
||||
color="primary"
|
||||
size="small"
|
||||
:icon="mdiBullhornVariantOutline"
|
||||
/>
|
||||
</div>
|
||||
</v-slide-x-transition>
|
||||
</template>
|
||||
<v-banner-text>{{ mobileAnnouncementBannerText }}</v-banner-text>
|
||||
<template #actions>
|
||||
|
||||
<v-btn
|
||||
class="text-none" color="primary" size="small" variant="text"
|
||||
@click="mobileAnnouncementSheetVisible = true">
|
||||
class="text-none"
|
||||
color="primary"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="mobileAnnouncementSheetVisible = true"
|
||||
>
|
||||
{{ props.mobileAnnouncement.viewAllText }}
|
||||
</v-btn>
|
||||
|
||||
</template>
|
||||
</v-banner>
|
||||
</div>
|
||||
<LoginToolBar
|
||||
v-if="props.toolbar.show" :locale="props.toolbar.locale" :locales="props.toolbar.locales"
|
||||
@change-locale="handleChangeLocale" @toggle-layout="handleToggleLayout" />
|
||||
v-if="props.toolbar.show"
|
||||
:locale="props.toolbar.locale"
|
||||
:locales="props.toolbar.locales"
|
||||
@change-locale="handleChangeLocale"
|
||||
@toggle-layout="handleToggleLayout"
|
||||
/>
|
||||
<div
|
||||
class="login-form-wrapper d-flex flex-column justify-center py-0 py-sm-4 py-lg-8 px-4 px-sm-6 px-lg-12 flex-grow-1">
|
||||
class="login-form-wrapper d-flex flex-column justify-center py-0 py-sm-4 py-lg-8 px-4 px-sm-6 px-lg-12 flex-grow-1"
|
||||
>
|
||||
<div class="login-header-height d-flex d-sm-none justify-center align-start">
|
||||
<LoginBrand :title="props.branding.title" />
|
||||
</div>
|
||||
<LoginHeader
|
||||
class="d-none d-sm-block" :welcome-description="props.header.welcomeDescription"
|
||||
:welcome-text="props.header.welcomeText" />
|
||||
class="d-none d-sm-block"
|
||||
:welcome-description="props.header.welcomeDescription"
|
||||
:welcome-text="props.header.welcomeText"
|
||||
/>
|
||||
<LoginForm
|
||||
:acc-placeholder="props.form.accPlaceholder" :forgot-password-href="props.form.forgotPassword.href"
|
||||
:forgot-password-target="props.form.forgotPassword.target" :forgot-password-text="props.form.forgotPassword.text"
|
||||
:acc-placeholder="props.form.accPlaceholder"
|
||||
:forgot-password-href="props.form.forgotPassword.href"
|
||||
:forgot-password-target="props.form.forgotPassword.target"
|
||||
:forgot-password-text="props.form.forgotPassword.text"
|
||||
:passw-placeholder="props.form.passwPlaceholder"
|
||||
:remember-me-label="props.form.rememberMeLabel"
|
||||
:remember-storage-key="props.form.rememberStorageKey" :submit-text="props.form.submitText"
|
||||
@forgot-password="handleForgotPassword" @submit="handleLogin">
|
||||
:remember-storage-key="props.form.rememberStorageKey"
|
||||
:submit-text="props.form.submitText"
|
||||
@forgot-password="handleForgotPassword"
|
||||
@submit="handleLogin"
|
||||
>
|
||||
<template v-if="props.form.withCaptcha" #verify>
|
||||
<LoginVerify
|
||||
:captcha="props.form.captcha" :captcha-placeholder="props.form.captchaPlaceholder"
|
||||
:error-message="props.form.captchaErrorMessage" :loading="props.form.captchaLoading"
|
||||
:model-value="props.form.captchaValue" :refresh-title="props.form.refreshTitle"
|
||||
:verified="props.form.captchaVerified" :verify-text="props.form.verifyText"
|
||||
@refresh="handleCaptchaRefresh" @update:model-value="handleCaptchaChange" />
|
||||
:captcha="props.form.captcha"
|
||||
:captcha-placeholder="props.form.captchaPlaceholder"
|
||||
:error-message="props.form.captchaErrorMessage"
|
||||
:loading="props.form.captchaLoading"
|
||||
:model-value="props.form.captchaValue"
|
||||
:refresh-title="props.form.refreshTitle"
|
||||
:verified="props.form.captchaVerified"
|
||||
:verify-text="props.form.verifyText"
|
||||
@refresh="handleCaptchaRefresh"
|
||||
@update:model-value="handleCaptchaChange"
|
||||
/>
|
||||
</template>
|
||||
</LoginForm>
|
||||
<div class="mt-auto py-8 text-center text-caption text-grey-darken-1">
|
||||
@@ -82,33 +135,58 @@ class="d-none d-sm-block" :welcome-description="props.header.welcomeDescription"
|
||||
</v-row>
|
||||
|
||||
<!-- Card Layout (Centered) -->
|
||||
<v-row v-else class="fill-height align-center justify-center bg-background pa-4 pa-md-0" no-gutters>
|
||||
<v-row
|
||||
v-else
|
||||
class="fill-height align-center justify-center bg-background pa-4 pa-md-0"
|
||||
no-gutters
|
||||
>
|
||||
<v-card
|
||||
class="rounded-lg" :class="props.toolbar.show ? 'px-8 pt-0 pb-4' : 'pa-8'" elevation="10" max-width="450"
|
||||
width="100%">
|
||||
class="rounded-lg"
|
||||
:class="props.toolbar.show ? 'px-8 pt-0 pb-4' : 'pa-8'"
|
||||
elevation="10"
|
||||
max-width="450"
|
||||
width="100%"
|
||||
>
|
||||
<LoginToolBar
|
||||
v-if="props.toolbar.show" :locale="props.toolbar.locale" :locales="props.toolbar.locales"
|
||||
@change-locale="handleChangeLocale" @toggle-layout="handleToggleLayout" />
|
||||
v-if="props.toolbar.show"
|
||||
:locale="props.toolbar.locale"
|
||||
:locales="props.toolbar.locales"
|
||||
@change-locale="handleChangeLocale"
|
||||
@toggle-layout="handleToggleLayout"
|
||||
/>
|
||||
<div class="d-flex justify-center mb-6 mb-md-4">
|
||||
<LoginBrand :title="props.branding.title" />
|
||||
</div>
|
||||
<LoginHeader
|
||||
class="d-none d-md-block" :welcome-description="props.header.welcomeDescription"
|
||||
:welcome-text="props.header.welcomeText" />
|
||||
class="d-none d-md-block"
|
||||
:welcome-description="props.header.welcomeDescription"
|
||||
:welcome-text="props.header.welcomeText"
|
||||
/>
|
||||
<LoginForm
|
||||
:acc-placeholder="props.form.accPlaceholder" :forgot-password-href="props.form.forgotPassword.href"
|
||||
:forgot-password-target="props.form.forgotPassword.target" :forgot-password-text="props.form.forgotPassword.text"
|
||||
:acc-placeholder="props.form.accPlaceholder"
|
||||
:forgot-password-href="props.form.forgotPassword.href"
|
||||
:forgot-password-target="props.form.forgotPassword.target"
|
||||
:forgot-password-text="props.form.forgotPassword.text"
|
||||
:passw-placeholder="props.form.passwPlaceholder"
|
||||
:remember-me-label="props.form.rememberMeLabel"
|
||||
:remember-storage-key="props.form.rememberStorageKey" :submit-text="props.form.submitText"
|
||||
@forgot-password="handleForgotPassword" @submit="handleLogin">
|
||||
:remember-storage-key="props.form.rememberStorageKey"
|
||||
:submit-text="props.form.submitText"
|
||||
@forgot-password="handleForgotPassword"
|
||||
@submit="handleLogin"
|
||||
>
|
||||
<template v-if="props.form.withCaptcha" #verify>
|
||||
<LoginVerify
|
||||
:captcha="props.form.captcha" :captcha-placeholder="props.form.captchaPlaceholder"
|
||||
:error-message="props.form.captchaErrorMessage" :loading="props.form.captchaLoading"
|
||||
:model-value="props.form.captchaValue" :refresh-title="props.form.refreshTitle"
|
||||
:verified="props.form.captchaVerified" :verify-text="props.form.verifyText"
|
||||
@refresh="handleCaptchaRefresh" @update:model-value="handleCaptchaChange" />
|
||||
:captcha="props.form.captcha"
|
||||
:captcha-placeholder="props.form.captchaPlaceholder"
|
||||
:error-message="props.form.captchaErrorMessage"
|
||||
:loading="props.form.captchaLoading"
|
||||
:model-value="props.form.captchaValue"
|
||||
:refresh-title="props.form.refreshTitle"
|
||||
:verified="props.form.captchaVerified"
|
||||
:verify-text="props.form.verifyText"
|
||||
@refresh="handleCaptchaRefresh"
|
||||
@update:model-value="handleCaptchaChange"
|
||||
/>
|
||||
</template>
|
||||
</LoginForm>
|
||||
<div class="mt-8 text-center text-caption text-grey-darken-1">
|
||||
@@ -126,7 +204,8 @@ class="d-none d-md-block" :welcome-description="props.header.welcomeDescription"
|
||||
<v-list-item v-for="item in mobileAnnouncementItems" :key="item.id">
|
||||
<v-list-item-title>{{ 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 }}
|
||||
{{ item.title }}<span v-if="item.title && item.createdAt"> ・ </span
|
||||
>{{ item.createdAt }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="mobileAnnouncementItems.length === 0">
|
||||
@@ -363,34 +442,33 @@ const layoutClass = computed(() => {
|
||||
return `layout-${props.layout}`
|
||||
})
|
||||
|
||||
function handleLogin (formData: Record<string, unknown>) {
|
||||
function handleLogin(formData: Record<string, unknown>) {
|
||||
emit('submit', formData)
|
||||
}
|
||||
|
||||
function handleCaptchaRefresh () {
|
||||
function handleCaptchaRefresh() {
|
||||
emit('captcha-refresh')
|
||||
}
|
||||
|
||||
function handleCaptchaChange (value: string) {
|
||||
function handleCaptchaChange(value: string) {
|
||||
emit('captcha-change', value)
|
||||
}
|
||||
|
||||
function handleChangeLocale (nextLocale: string) {
|
||||
function handleChangeLocale(nextLocale: string) {
|
||||
emit('change-locale', nextLocale)
|
||||
}
|
||||
|
||||
function handleToggleLayout () {
|
||||
function handleToggleLayout() {
|
||||
emit('toggle-layout')
|
||||
}
|
||||
|
||||
function handleForgotPassword (e: MouseEvent) {
|
||||
function handleForgotPassword(e: MouseEvent) {
|
||||
emit('forgot-password', e)
|
||||
}
|
||||
|
||||
function handleSelectAnnouncement (item: AnnouncementItemConfig) {
|
||||
function handleSelectAnnouncement(item: AnnouncementItemConfig) {
|
||||
emit('select-announcement', item)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -409,7 +487,6 @@ function handleSelectAnnouncement (item: AnnouncementItemConfig) {
|
||||
}
|
||||
|
||||
@keyframes mobile-banner-breathe {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.9;
|
||||
@@ -429,9 +506,11 @@ function handleSelectAnnouncement (item: AnnouncementItemConfig) {
|
||||
}
|
||||
|
||||
.illustration-panel {
|
||||
background: linear-gradient(135deg,
|
||||
rgb(var(--v-theme-background)) 0%,
|
||||
rgb(var(--v-theme-surface)) 100%);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgb(var(--v-theme-background)) 0%,
|
||||
rgb(var(--v-theme-surface)) 100%
|
||||
);
|
||||
border-right: 1px solid rgba(var(--v-theme-on-surface), 0.05);
|
||||
}
|
||||
|
||||
|
||||
@@ -143,7 +143,9 @@ 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 }>) {
|
||||
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 }
|
||||
@@ -161,12 +163,12 @@ const resolvedStatusEnabledValue = computed(() => {
|
||||
|
||||
const isEnabledStatus = (status: unknown) => status === resolvedStatusEnabledValue.value
|
||||
|
||||
function openEdit (item: MenuItem) {
|
||||
function openEdit(item: MenuItem) {
|
||||
selectedItem.value = item
|
||||
editDialogOpen.value = true
|
||||
}
|
||||
|
||||
function onEditSubmit (updated: Record<string, unknown>) {
|
||||
function onEditSubmit(updated: Record<string, unknown>) {
|
||||
emit('edit', updated)
|
||||
}
|
||||
|
||||
@@ -187,7 +189,7 @@ const formattedHeaders = computed(() => [
|
||||
},
|
||||
])
|
||||
|
||||
function getPermissionColor (permission: string) {
|
||||
function getPermissionColor(permission: string) {
|
||||
switch (permission) {
|
||||
case '管理員': {
|
||||
return 'primary'
|
||||
|
||||
@@ -107,7 +107,7 @@ const dialogModel = computed({
|
||||
set: (v: boolean) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
function normalizeOptions (options: Array<Option | string | number>) {
|
||||
function normalizeOptions(options: Array<Option | string | number>) {
|
||||
return options.map((o) => {
|
||||
if (typeof o === 'string' || typeof o === 'number') {
|
||||
return { title: String(o), value: o }
|
||||
@@ -121,7 +121,7 @@ const normalizedPermissionOptions = computed(() => normalizeOptions(props.permis
|
||||
|
||||
const form = reactive<GenericRecord>({})
|
||||
|
||||
function resetForm (next: GenericRecord) {
|
||||
function resetForm(next: GenericRecord) {
|
||||
for (const key of Object.keys(form)) {
|
||||
delete form[key]
|
||||
}
|
||||
@@ -129,7 +129,7 @@ function resetForm (next: GenericRecord) {
|
||||
}
|
||||
|
||||
const getDefaultStatus = (): OptionValue | '' => normalizedStatusOptions.value[0]?.value ?? ''
|
||||
function getDefaultPermission (): OptionValue | '' {
|
||||
function getDefaultPermission(): OptionValue | '' {
|
||||
return normalizedPermissionOptions.value[0]?.value ?? ''
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ const formPermission = computed<OptionValue | ''>({
|
||||
},
|
||||
})
|
||||
|
||||
function syncFromItem () {
|
||||
function syncFromItem() {
|
||||
const item = props.item ?? {}
|
||||
resetForm({ ...item })
|
||||
|
||||
@@ -182,12 +182,12 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
function handleCancel () {
|
||||
function handleCancel() {
|
||||
emit('cancel')
|
||||
dialogModel.value = false
|
||||
}
|
||||
|
||||
function handleSubmit () {
|
||||
function handleSubmit() {
|
||||
emit('submit', { ...form })
|
||||
|
||||
if (props.closeOnSubmit) {
|
||||
|
||||
@@ -30,7 +30,13 @@
|
||||
|
||||
<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-btn
|
||||
v-bind="activatorProps"
|
||||
density="comfortable"
|
||||
icon
|
||||
variant="text"
|
||||
@click="$emit('refresh')"
|
||||
>
|
||||
<v-icon :icon="mdiRefresh" />
|
||||
</v-btn>
|
||||
</template>
|
||||
@@ -146,7 +152,7 @@ const selectAllIndeterminate = computed(() => {
|
||||
return selectedCount > 0 && selectedCount < allSettingsKeys.value.length
|
||||
})
|
||||
|
||||
function toggleSelectAll (checked: unknown) {
|
||||
function toggleSelectAll(checked: unknown) {
|
||||
const current = Array.isArray(settingsSelectedKeys.value) ? settingsSelectedKeys.value : []
|
||||
const nonSettingsKeys = current.filter((k) => !allSettingsKeys.value.includes(k))
|
||||
|
||||
@@ -156,7 +162,7 @@ function toggleSelectAll (checked: unknown) {
|
||||
)
|
||||
}
|
||||
|
||||
function updateSettingsSelectedKeys (value: unknown) {
|
||||
function updateSettingsSelectedKeys(value: unknown) {
|
||||
emit('update:settingsSelectedKeys', Array.isArray(value) ? value : [])
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -85,7 +85,7 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function toggleExpand (id: string | number) {
|
||||
function toggleExpand(id: string | number) {
|
||||
if (expandedIds.value.has(id)) {
|
||||
expandedIds.value.delete(id)
|
||||
} else {
|
||||
|
||||
@@ -36,7 +36,7 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['click'])
|
||||
|
||||
function handleClick (e: MouseEvent) {
|
||||
function handleClick(e: MouseEvent) {
|
||||
emit('click', e)
|
||||
if (!props.href) {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<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-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">
|
||||
@@ -23,8 +25,11 @@
|
||||
<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)">
|
||||
class="px-0 text-none justify-start"
|
||||
color="primary"
|
||||
variant="text"
|
||||
@click="emit('select-announcement', item)"
|
||||
>
|
||||
{{ item.title }}
|
||||
</v-btn>
|
||||
</td>
|
||||
@@ -41,17 +46,18 @@ class="px-0 text-none justify-start" color="primary" variant="text"
|
||||
{{ 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 }}
|
||||
{{ 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-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 }}
|
||||
@@ -125,7 +131,8 @@ const systemTab = computed<AnnouncementTab>(() => ({
|
||||
}))
|
||||
|
||||
const normalizedTabs = computed<AnnouncementTab[]>(() => {
|
||||
const baseTabs = props.tabs.length > 0 ? props.tabs : [{ label: props.allTabLabel, value: allTabValue }]
|
||||
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]
|
||||
})
|
||||
|
||||
@@ -1,29 +1,59 @@
|
||||
<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-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 ? mdiEyeOff : mdiEye" bg-color="surface"
|
||||
class="mb-6 mb-md-4" color="primary" density="comfortable" hide-details :placeholder="props.passwPlaceholder" :type="showPassword ? 'text' : 'password'"
|
||||
v-model="password"
|
||||
:append-inner-icon="showPassword ? mdiEyeOff : mdiEye"
|
||||
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>
|
||||
@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>
|
||||
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">
|
||||
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">
|
||||
<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>
|
||||
@@ -96,7 +126,7 @@ watch([rememberMe, username], ([nextRemember, nextUsername]) => {
|
||||
localStorage.setItem(props.rememberStorageKey, nextUsername)
|
||||
})
|
||||
|
||||
function handleForgotPasswordClick (e: MouseEvent) {
|
||||
function handleForgotPasswordClick(e: MouseEvent) {
|
||||
emit('forgot-password', e)
|
||||
if (!props.forgotPasswordHref) {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
:icon="mdiPaletteOutline"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="toggleTheme"></v-btn>
|
||||
@click="toggleTheme"
|
||||
></v-btn>
|
||||
<v-menu location="bottom end">
|
||||
<template #activator="{ props: menuActivatorProps }">
|
||||
<v-btn
|
||||
@@ -60,7 +61,7 @@ const availableThemeNames = computed(() =>
|
||||
Object.keys(theme.themes.value ?? {}).filter((name) => name.startsWith('theme'))
|
||||
)
|
||||
|
||||
function toggleTheme () {
|
||||
function toggleTheme() {
|
||||
const names = availableThemeNames.value
|
||||
if (names.length === 0) return
|
||||
|
||||
@@ -74,9 +75,8 @@ const localeOptions = computed(() =>
|
||||
props.locales.length > 0 ? props.locales : ['zh-TW', 'en-US']
|
||||
)
|
||||
|
||||
function handleSelectLocale (locale: string) {
|
||||
function handleSelectLocale(locale: string) {
|
||||
if (locale === props.locale) return
|
||||
emit('change-locale', locale)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
@@ -7,17 +7,28 @@
|
||||
<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">
|
||||
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="mdiRefresh"></v-icon>
|
||||
</div>
|
||||
|
||||
<!-- Input and Verify -->
|
||||
<v-text-field
|
||||
v-model="inputCode" :append-inner-icon="props.verified ? mdiCheckCircle : undefined" bg-color="surface" class="flex-grow-1"
|
||||
color="primary" density="compact" :disabled="props.verified" :error="!!errorMsg" hide-details
|
||||
:placeholder="props.captchaPlaceholder" variant="outlined">
|
||||
v-model="inputCode"
|
||||
:append-inner-icon="props.verified ? mdiCheckCircle : undefined"
|
||||
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" :icon="mdiCheckCircle" />
|
||||
</template>
|
||||
@@ -78,7 +89,7 @@ const errorMsg = computed(() => props.errorMessage)
|
||||
|
||||
const loading = computed(() => props.loading)
|
||||
|
||||
function handleRefresh () {
|
||||
function handleRefresh() {
|
||||
if (props.verified) return
|
||||
emit('refresh')
|
||||
}
|
||||
|
||||
@@ -35,9 +35,9 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
favoriteHeaderLabel: '我的最愛',
|
||||
favoriteItems: () => [],
|
||||
menuItems: () => [
|
||||
{ title: '首頁', path: '/' },
|
||||
{ title: '設定', path: '/settings' },
|
||||
],
|
||||
{ title: '首頁', path: '/' },
|
||||
{ title: '設定', path: '/settings' },
|
||||
],
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
|
||||
@@ -13,13 +13,16 @@
|
||||
import type { AdminLayoutMenuItem } from './sk-admin-layout/types'
|
||||
import SKAdminLayout from './SKAdminLayout.vue'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
systemTitle?: string
|
||||
menuItems?: AdminLayoutMenuItem[]
|
||||
}>(), {
|
||||
systemTitle: '簡潔模式',
|
||||
menuItems: () => [],
|
||||
})
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
systemTitle?: string
|
||||
menuItems?: AdminLayoutMenuItem[]
|
||||
}>(),
|
||||
{
|
||||
systemTitle: '簡潔模式',
|
||||
menuItems: () => [],
|
||||
}
|
||||
)
|
||||
|
||||
defineEmits<{
|
||||
select: [item: AdminLayoutMenuItem]
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
<template>
|
||||
<v-col
|
||||
v-if="features.showBreadcrumb && breadcrumbBarVisible && !isMobile"
|
||||
class="d-flex align-center justify-space-between pr-2 pl-3 py-1 bg-surface">
|
||||
v-if="features.showBreadcrumb && breadcrumbBarVisible && !isMobile"
|
||||
class="d-flex align-center justify-space-between pr-2 pl-3 py-1 bg-surface"
|
||||
>
|
||||
<v-breadcrumbs class="pa-0" density="compact" :items="breadcrumbItems">
|
||||
<template #prepend>
|
||||
<v-btn
|
||||
v-if="features.showFavorites && !showFavoritesBar" class="mr-2" color="primary" size="small"
|
||||
variant="outlined" @click="emit('toggle-favorites-bar', true)">
|
||||
v-if="features.showFavorites && !showFavoritesBar"
|
||||
class="mr-2"
|
||||
color="primary"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
@click="emit('toggle-favorites-bar', true)"
|
||||
>
|
||||
常用
|
||||
</v-btn>
|
||||
</template>
|
||||
<template #item="{ item }">
|
||||
<div class="d-flex align-center ga-1">
|
||||
<v-icon v-if="getBreadcrumbIcon(item)" class="mr-1" size="14" :icon="getBreadcrumbIcon(item)" />
|
||||
<v-icon
|
||||
v-if="getBreadcrumbIcon(item)"
|
||||
class="mr-1"
|
||||
size="14"
|
||||
:icon="getBreadcrumbIcon(item)"
|
||||
/>
|
||||
<span class="text-caption text-no-wrap">{{ getBreadcrumbTitle(item) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -57,7 +68,7 @@ const emit = defineEmits<{
|
||||
'toggle-favorites-bar': [value: boolean]
|
||||
}>()
|
||||
|
||||
function getBreadcrumbItem (item: unknown): AdminLayoutBreadcrumbItem | null {
|
||||
function getBreadcrumbItem(item: unknown): AdminLayoutBreadcrumbItem | null {
|
||||
if (typeof item !== 'object' || item === null) return null
|
||||
|
||||
if ('title' in item) {
|
||||
@@ -70,11 +81,11 @@ function getBreadcrumbItem (item: unknown): AdminLayoutBreadcrumbItem | null {
|
||||
return raw as AdminLayoutBreadcrumbItem
|
||||
}
|
||||
|
||||
function getBreadcrumbIcon (item: unknown) {
|
||||
function getBreadcrumbIcon(item: unknown) {
|
||||
return getBreadcrumbItem(item)?.icon
|
||||
}
|
||||
|
||||
function getBreadcrumbTitle (item: unknown) {
|
||||
function getBreadcrumbTitle(item: unknown) {
|
||||
return getBreadcrumbItem(item)?.title ?? ''
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,23 +1,36 @@
|
||||
<template>
|
||||
<v-col
|
||||
v-if="features.showFavorites && showFavoritesBar && !isMobile"
|
||||
class="d-flex align-center pr-2 pl-3 py-1 bg-surface">
|
||||
v-if="features.showFavorites && showFavoritesBar && !isMobile"
|
||||
class="d-flex align-center pr-2 pl-3 py-1 bg-surface"
|
||||
>
|
||||
<div class="favorites-label text-body-2 text-no-wrap pe-2">
|
||||
{{ favoritesConfig.label }}
|
||||
</div>
|
||||
<div class="favorites-list flex-grow-1 d-flex flex-wrap ga-2">
|
||||
<transition-group class="d-flex flex-wrap ga-2" name="favorite-list" tag="div">
|
||||
<v-chip
|
||||
v-for="item in favoriteItems" :key="item.path ?? item.title" class="favorite-item" closable
|
||||
color="secondary" size="small" variant="outlined" @click="emit('select', item)"
|
||||
@click:close="emit('remove-favorite', item)">
|
||||
v-for="item in favoriteItems"
|
||||
:key="item.path ?? item.title"
|
||||
class="favorite-item"
|
||||
closable
|
||||
color="secondary"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
@click="emit('select', item)"
|
||||
@click:close="emit('remove-favorite', item)"
|
||||
>
|
||||
<v-icon v-if="item.icon" class="me-1" size="16" :icon="item.icon" />
|
||||
<span class="text-caption">{{ item.title }}</span>
|
||||
</v-chip>
|
||||
</transition-group>
|
||||
<v-btn
|
||||
v-if="favoritesConfig.showAdd" class="favorite-add" color="primary" size="small" variant="outlined"
|
||||
@click="emit('add-favorite')">
|
||||
v-if="favoritesConfig.showAdd"
|
||||
class="favorite-add"
|
||||
color="primary"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
@click="emit('add-favorite')"
|
||||
>
|
||||
<v-icon class="mr-1" size="16" :icon="mdiPlus" />
|
||||
<span class="text-caption">{{ favoritesConfig.addLabel }}</span>
|
||||
</v-btn>
|
||||
|
||||
@@ -1,19 +1,38 @@
|
||||
<template>
|
||||
<v-col class="d-flex align-center bg-surface pr-2 pl-2 pl-m-3 py-1">
|
||||
<v-btn v-if="isMobile" :icon="mdiMenu" size="small" variant="text" @click="emit('toggle-drawer')"></v-btn>
|
||||
<v-btn
|
||||
v-if="isMobile"
|
||||
:icon="mdiMenu"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="emit('toggle-drawer')"
|
||||
></v-btn>
|
||||
|
||||
<div v-if="features.showSearch" class="search-input-wrapper">
|
||||
<span id="admin-layout-search-label" class="sr-only">{{ searchConfig.label }}</span>
|
||||
<v-text-field
|
||||
v-model="searchValueModel" aria-labelledby="admin-layout-search-label" :aria-label="searchConfig.label"
|
||||
class="search-input" density="compact" hide-details name="layout-search"
|
||||
:placeholder="searchConfig.placeholder" variant="outlined"
|
||||
@keyup.enter="triggerSearch">
|
||||
v-model="searchValueModel"
|
||||
aria-labelledby="admin-layout-search-label"
|
||||
:aria-label="searchConfig.label"
|
||||
class="search-input"
|
||||
density="compact"
|
||||
hide-details
|
||||
name="layout-search"
|
||||
:placeholder="searchConfig.placeholder"
|
||||
variant="outlined"
|
||||
@keyup.enter="triggerSearch"
|
||||
>
|
||||
<template v-if="!isMobile" #prepend-inner>
|
||||
<v-icon size="small" :icon="mdiMagnify" />
|
||||
</template>
|
||||
<template #append-inner>
|
||||
<v-btn :aria-label="searchConfig.label" color="primary" size="small" variant="text" @click="triggerSearch">
|
||||
<v-btn
|
||||
:aria-label="searchConfig.label"
|
||||
color="primary"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="triggerSearch"
|
||||
>
|
||||
開始搜尋
|
||||
</v-btn>
|
||||
</template>
|
||||
@@ -22,16 +41,24 @@ v-model="searchValueModel" aria-labelledby="admin-layout-search-label" :aria-lab
|
||||
|
||||
<div v-if="features.showToolbarActions" class="top-actions">
|
||||
<slot name="actions">
|
||||
|
||||
<!-- 通知 -->
|
||||
<v-tooltip location="bottom" :text="toolbarActions.notificationsLabel">
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-btn
|
||||
v-bind="activatorProps" :aria-label="toolbarActions.notificationsLabel" icon size="small" variant="text"
|
||||
@click="emit('action', 'notifications')">
|
||||
v-bind="activatorProps"
|
||||
:aria-label="toolbarActions.notificationsLabel"
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="emit('action', 'notifications')"
|
||||
>
|
||||
<v-badge
|
||||
v-if="toolbarCounts.notifications" color="error" :content="toolbarCounts.notifications"
|
||||
offset-x="4" offset-y="-2">
|
||||
v-if="toolbarCounts.notifications"
|
||||
color="error"
|
||||
:content="toolbarCounts.notifications"
|
||||
offset-x="4"
|
||||
offset-y="-2"
|
||||
>
|
||||
<v-icon :icon="mdiBellOutline" />
|
||||
</v-badge>
|
||||
<v-icon v-else :icon="mdiBellOutline" />
|
||||
@@ -43,11 +70,20 @@ v-if="toolbarCounts.notifications" color="error" :content="toolbarCounts.notific
|
||||
<v-tooltip location="bottom" :text="toolbarActions.messagesLabel">
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-btn
|
||||
v-bind="activatorProps" :aria-label="toolbarActions.messagesLabel" icon size="small" variant="text"
|
||||
@click="emit('action', 'messages')">
|
||||
v-bind="activatorProps"
|
||||
:aria-label="toolbarActions.messagesLabel"
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="emit('action', 'messages')"
|
||||
>
|
||||
<v-badge
|
||||
v-if="toolbarCounts.messages" color="warning" :content="toolbarCounts.messages" offset-x="4"
|
||||
offset-y="-2">
|
||||
v-if="toolbarCounts.messages"
|
||||
color="warning"
|
||||
:content="toolbarCounts.messages"
|
||||
offset-x="4"
|
||||
offset-y="-2"
|
||||
>
|
||||
<v-icon :icon="mdiMessageTextOutline" />
|
||||
</v-badge>
|
||||
<v-icon v-else :icon="mdiMessageTextOutline" />
|
||||
@@ -59,8 +95,13 @@ v-if="toolbarCounts.messages" color="warning" :content="toolbarCounts.messages"
|
||||
<v-tooltip v-if="false" location="bottom" :text="toolbarActions.helpLabel">
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-btn
|
||||
v-bind="activatorProps" :aria-label="toolbarActions.helpLabel" icon size="small" variant="text"
|
||||
@click="emit('action', 'help')">
|
||||
v-bind="activatorProps"
|
||||
:aria-label="toolbarActions.helpLabel"
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="emit('action', 'help')"
|
||||
>
|
||||
<v-icon :icon="mdiHelp" />
|
||||
</v-btn>
|
||||
</template>
|
||||
@@ -72,8 +113,12 @@ v-bind="activatorProps" :aria-label="toolbarActions.helpLabel" icon size="small"
|
||||
<v-tooltip location="bottom" :text="toolbarActions.settingsLabel">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<v-btn
|
||||
v-bind="{ ...menuProps, ...tooltipProps }" :aria-label="toolbarActions.settingsLabel" icon size="small"
|
||||
variant="text">
|
||||
v-bind="{ ...menuProps, ...tooltipProps }"
|
||||
:aria-label="toolbarActions.settingsLabel"
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
>
|
||||
<v-icon :icon="mdiCogOutline" />
|
||||
</v-btn>
|
||||
</template>
|
||||
@@ -82,16 +127,26 @@ v-bind="{ ...menuProps, ...tooltipProps }" :aria-label="toolbarActions.settingsL
|
||||
<v-list density="compact" width="180">
|
||||
<v-list-subheader class="text-subtitle-1 py-2">顯示設定</v-list-subheader>
|
||||
<v-list-item>
|
||||
<v-switch v-model="showFavoritesBarModel" color="primary" density="comfortable" hide-details>
|
||||
<v-switch
|
||||
v-model="showFavoritesBarModel"
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
>
|
||||
<template #label>
|
||||
<span class="text-body-2" style="width: 8ch;">常用功能</span>
|
||||
<span class="text-body-2" style="width: 8ch">常用功能</span>
|
||||
</template>
|
||||
</v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-switch v-model="breadcrumbBarVisibleModel" color="primary" density="comfortable" hide-details>
|
||||
<v-switch
|
||||
v-model="breadcrumbBarVisibleModel"
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
>
|
||||
<template #label>
|
||||
<span class="text-body-2" style="width: 8ch;">路徑</span>
|
||||
<span class="text-body-2" style="width: 8ch">路徑</span>
|
||||
</template>
|
||||
</v-switch>
|
||||
</v-list-item>
|
||||
@@ -101,7 +156,14 @@ v-bind="{ ...menuProps, ...tooltipProps }" :aria-label="toolbarActions.settingsL
|
||||
<!-- 登出 -->
|
||||
<v-tooltip location="bottom" :text="logoutLabel">
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-btn v-bind="activatorProps" :aria-label="logoutLabel" icon size="small" variant="text" @click="emit('logout')">
|
||||
<v-btn
|
||||
v-bind="activatorProps"
|
||||
:aria-label="logoutLabel"
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="emit('logout')"
|
||||
>
|
||||
<v-icon :icon="mdiLogout" />
|
||||
</v-btn>
|
||||
</template>
|
||||
@@ -109,7 +171,13 @@ v-bind="{ ...menuProps, ...tooltipProps }" :aria-label="toolbarActions.settingsL
|
||||
|
||||
<v-tooltip v-if="features.showThemeToggle" location="bottom" :text="themeToggleLabel">
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-btn v-bind="activatorProps" :aria-label="themeToggleLabel" icon variant="text" @click="emit('toggle-theme')">
|
||||
<v-btn
|
||||
v-bind="activatorProps"
|
||||
:aria-label="themeToggleLabel"
|
||||
icon
|
||||
variant="text"
|
||||
@click="emit('toggle-theme')"
|
||||
>
|
||||
<v-icon :icon="mdiPaletteOutline" />
|
||||
</v-btn>
|
||||
</template>
|
||||
@@ -120,8 +188,23 @@ v-bind="{ ...menuProps, ...tooltipProps }" :aria-label="toolbarActions.settingsL
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AdminLayoutActionType, AdminLayoutFeatures, AdminLayoutSearchConfig, AdminLayoutToolbarActions, AdminLayoutToolbarCounts } from './types'
|
||||
import { mdiBellOutline, mdiCogOutline, mdiHelp, mdiLogout, mdiMagnify, mdiMenu, mdiMessageTextOutline, mdiPaletteOutline } from '@mdi/js'
|
||||
import type {
|
||||
AdminLayoutActionType,
|
||||
AdminLayoutFeatures,
|
||||
AdminLayoutSearchConfig,
|
||||
AdminLayoutToolbarActions,
|
||||
AdminLayoutToolbarCounts,
|
||||
} from './types'
|
||||
import {
|
||||
mdiBellOutline,
|
||||
mdiCogOutline,
|
||||
mdiHelp,
|
||||
mdiLogout,
|
||||
mdiMagnify,
|
||||
mdiMenu,
|
||||
mdiMessageTextOutline,
|
||||
mdiPaletteOutline,
|
||||
} from '@mdi/js'
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
@@ -194,7 +277,7 @@ const breadcrumbBarVisibleModel = computed({
|
||||
set: (value) => emit('update:breadcrumb-bar-visible', value),
|
||||
})
|
||||
|
||||
function triggerSearch () {
|
||||
function triggerSearch() {
|
||||
emit('search')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,23 +1,42 @@
|
||||
<template>
|
||||
<v-list v-model:opened="openedModel" color="primary" density="compact" prepend-gap="8">
|
||||
<template v-for="item in menuItems" :key="item.path ?? item.title">
|
||||
<v-list-group v-if="item.subItems?.length" :id="getGroupId(item)" :value="getGroupValue(item)">
|
||||
<v-list-group
|
||||
v-if="item.subItems?.length"
|
||||
:id="getGroupId(item)"
|
||||
:value="getGroupValue(item)"
|
||||
>
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-list-item
|
||||
v-bind="isShrink ? undefined : activatorProps" :class="{ 'px-0': isShrink }"
|
||||
:link="isNavigable(item) && !!item.path" :to="isNavigable(item) ? item.path : undefined" @click="emitSelect(item)">
|
||||
v-bind="isShrink ? undefined : activatorProps"
|
||||
:class="{ 'px-0': isShrink }"
|
||||
:link="isNavigable(item) && !!item.path"
|
||||
:to="isNavigable(item) ? item.path : undefined"
|
||||
@click="emitSelect(item)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon v-if="item.icon" size="20" :icon="item.icon" />
|
||||
<v-btn v-if="isShrink && !item.icon" class="" rounded size="36" spaced="start" variant="text">{{
|
||||
item.title?.charAt(0) }}</v-btn>
|
||||
<v-btn
|
||||
v-if="isShrink && !item.icon"
|
||||
class=""
|
||||
rounded
|
||||
size="36"
|
||||
spaced="start"
|
||||
variant="text"
|
||||
>{{ item.title?.charAt(0) }}</v-btn
|
||||
>
|
||||
</template>
|
||||
<template #title>
|
||||
<span v-if="!isShrink" class="text-body-2 nav-text-overflow">{{ item.title }}</span>
|
||||
</template>
|
||||
<template #append>
|
||||
<v-chip
|
||||
v-if="!isShrink && getItemCount(item) > 0" class="menu-count" color="secondary" size="x-small"
|
||||
variant="tonal">
|
||||
v-if="!isShrink && getItemCount(item) > 0"
|
||||
class="menu-count"
|
||||
color="secondary"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ getItemCount(item) }}
|
||||
</v-chip>
|
||||
</template>
|
||||
@@ -26,21 +45,29 @@ v-if="!isShrink && getItemCount(item) > 0" class="menu-count" color="secondary"
|
||||
|
||||
<template v-for="subItem in item.subItems" :key="subItem.path ?? subItem.title">
|
||||
<v-list-group
|
||||
v-if="subItem.subItems?.length"
|
||||
v-if="subItem.subItems?.length"
|
||||
:id="getGroupId(subItem, getGroupId(item))"
|
||||
:value="getGroupValue(subItem, getGroupValue(item))">
|
||||
:value="getGroupValue(subItem, getGroupValue(item))"
|
||||
>
|
||||
<template #activator="{ props: subProps }">
|
||||
<v-list-item
|
||||
v-bind="subProps" :link="isNavigable(subItem)"
|
||||
:prepend-icon="subItem.icon || mdiMenuRight" :to="isNavigable(subItem) ? subItem.path : undefined"
|
||||
@click="emitSelect(subItem)">
|
||||
v-bind="subProps"
|
||||
:link="isNavigable(subItem)"
|
||||
:prepend-icon="subItem.icon || mdiMenuRight"
|
||||
:to="isNavigable(subItem) ? subItem.path : undefined"
|
||||
@click="emitSelect(subItem)"
|
||||
>
|
||||
<template #title>
|
||||
<span class="text-body-2 nav-text-overflow">{{ subItem.title }}</span>
|
||||
</template>
|
||||
<template #append>
|
||||
<v-chip
|
||||
v-if="getItemCount(subItem) > 0" class="menu-count" color="secondary" size="x-small"
|
||||
variant="tonal">
|
||||
v-if="getItemCount(subItem) > 0"
|
||||
class="menu-count"
|
||||
color="secondary"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ getItemCount(subItem) }}
|
||||
</v-chip>
|
||||
</template>
|
||||
@@ -48,13 +75,19 @@ v-if="getItemCount(subItem) > 0" class="menu-count" color="secondary" size="x-sm
|
||||
</template>
|
||||
|
||||
<v-list-item
|
||||
v-for="subSubItem in subItem.subItems" :key="subSubItem.path ?? subSubItem.title"
|
||||
:link="!!subSubItem.path" :prepend-icon="mdiCircleSmall" :to="subSubItem.path"
|
||||
@click="emitSelect(subSubItem)">
|
||||
v-for="subSubItem in subItem.subItems"
|
||||
:key="subSubItem.path ?? subSubItem.title"
|
||||
:link="!!subSubItem.path"
|
||||
:prepend-icon="mdiCircleSmall"
|
||||
:to="subSubItem.path"
|
||||
@click="emitSelect(subSubItem)"
|
||||
>
|
||||
<template #title>
|
||||
<v-tooltip location="end" :text="subSubItem.title">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<span v-bind="tooltipProps" class="text-body-2 nav-text-overflow">{{ subSubItem.title }}</span>
|
||||
<span v-bind="tooltipProps" class="text-body-2 nav-text-overflow">{{
|
||||
subSubItem.title
|
||||
}}</span>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
@@ -62,12 +95,18 @@ v-for="subSubItem in subItem.subItems" :key="subSubItem.path ?? subSubItem.title
|
||||
</v-list-group>
|
||||
|
||||
<v-list-item
|
||||
v-else :link="!!subItem.path" :prepend-icon="subItem.icon || mdiMenuRight" :to="subItem.path"
|
||||
@click="emitSelect(subItem)">
|
||||
v-else
|
||||
:link="!!subItem.path"
|
||||
:prepend-icon="subItem.icon || mdiMenuRight"
|
||||
:to="subItem.path"
|
||||
@click="emitSelect(subItem)"
|
||||
>
|
||||
<template #title>
|
||||
<v-tooltip location="end" :text="subItem.title">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<span v-bind="tooltipProps" class="text-body-2 nav-text-overflow">{{ subItem.title }}</span>
|
||||
<span v-bind="tooltipProps" class="text-body-2 nav-text-overflow">{{
|
||||
subItem.title
|
||||
}}</span>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
@@ -75,16 +114,31 @@ v-else :link="!!subItem.path" :prepend-icon="subItem.icon || mdiMenuRight" :to="
|
||||
</template>
|
||||
</v-list-group>
|
||||
|
||||
<v-list-item v-else :class="{ 'px-0': isShrink }" :link="!!item.path" :to="item.path" @click="emitSelect(item)">
|
||||
<v-list-item
|
||||
v-else
|
||||
:class="{ 'px-0': isShrink }"
|
||||
:link="!!item.path"
|
||||
:to="item.path"
|
||||
@click="emitSelect(item)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon v-if="item.icon" size="20" :icon="item.icon" />
|
||||
<v-btn v-if="isShrink && !item.icon" class="" rounded size="36" spaced="start" variant="text">{{
|
||||
item.title?.charAt(0) }}</v-btn>
|
||||
<v-btn
|
||||
v-if="isShrink && !item.icon"
|
||||
class=""
|
||||
rounded
|
||||
size="36"
|
||||
spaced="start"
|
||||
variant="text"
|
||||
>{{ item.title?.charAt(0) }}</v-btn
|
||||
>
|
||||
</template>
|
||||
<template #title>
|
||||
<v-tooltip v-if="!isShrink" location="end" :text="item.title">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<span v-bind="tooltipProps" class="text-body-2 nav-text-overflow">{{ item.title }}</span>
|
||||
<span v-bind="tooltipProps" class="text-body-2 nav-text-overflow">{{
|
||||
item.title
|
||||
}}</span>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
@@ -126,15 +180,18 @@ const openedModel = computed({
|
||||
})
|
||||
|
||||
// 當側邊欄收縮時,自動收起所有展開的子選單
|
||||
watch(() => props.isShrink, (newVal) => {
|
||||
if (newVal) {
|
||||
openedModel.value = []
|
||||
watch(
|
||||
() => props.isShrink,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
openedModel.value = []
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const isNavigable = (item: AdminLayoutMenuItem) => item?.navigable !== false
|
||||
|
||||
function emitSelect (item: AdminLayoutMenuItem) {
|
||||
function emitSelect(item: AdminLayoutMenuItem) {
|
||||
// 收縮狀態下點擊選單項目時,先解除收縮再進行選擇
|
||||
// 這樣可以讓使用者看到完整的選單結構和導航結果
|
||||
if (props.isShrink) {
|
||||
@@ -143,7 +200,7 @@ function emitSelect (item: AdminLayoutMenuItem) {
|
||||
emit('select', item)
|
||||
}
|
||||
|
||||
function getItemCount (item: AdminLayoutMenuItem) {
|
||||
function getItemCount(item: AdminLayoutMenuItem) {
|
||||
if (!item?.subItems?.length) return 0
|
||||
const countLeaf = (list: AdminLayoutMenuItem[]): number =>
|
||||
(list || []).reduce((total: number, current: AdminLayoutMenuItem) => {
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
<v-sheet class="mobile-favorites-panel d-flex flex-column" color="surface">
|
||||
<v-list class="px-2 py-2 flex-grow-1 overflow-auto" density="comfortable">
|
||||
<v-list-item
|
||||
v-for="item in favoriteItems" :key="item.path ?? item.title" class="mb-1" rounded="lg"
|
||||
@click="emit('select', item)">
|
||||
v-for="item in favoriteItems"
|
||||
:key="item.path ?? item.title"
|
||||
class="mb-1"
|
||||
rounded="lg"
|
||||
@click="emit('select', item)"
|
||||
>
|
||||
<v-list-item-title class="text-body-2">{{ item.title }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
@@ -13,11 +17,14 @@ v-for="item in favoriteItems" :key="item.path ?? item.title" class="mb-1" rounde
|
||||
<script setup lang="ts">
|
||||
import type { AdminLayoutMenuItem } from './types'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
favoriteItems?: AdminLayoutMenuItem[]
|
||||
}>(), {
|
||||
favoriteItems: () => [],
|
||||
})
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
favoriteItems?: AdminLayoutMenuItem[]
|
||||
}>(),
|
||||
{
|
||||
favoriteItems: () => [],
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [item: AdminLayoutMenuItem]
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
<v-sheet class="mobile-menu-panel d-flex flex-column" color="surface">
|
||||
<v-list class="px-2 py-2 flex-grow-1 overflow-auto" density="comfortable">
|
||||
<v-list-item
|
||||
v-for="item in mobileCurrentItems" :key="item.path ?? item.title" class="mb-1" rounded="lg"
|
||||
@click="emit('item-click', item)">
|
||||
v-for="item in mobileCurrentItems"
|
||||
:key="item.path ?? item.title"
|
||||
class="mb-1"
|
||||
rounded="lg"
|
||||
@click="emit('item-click', item)"
|
||||
>
|
||||
<v-list-item-title class="text-body-2">{{ item.title }}</v-list-item-title>
|
||||
<template #append>
|
||||
<v-icon size="18" :icon="item.subItems?.length ? mdiChevronRight : mdiArrowTopRight" />
|
||||
@@ -17,11 +21,14 @@ v-for="item in mobileCurrentItems" :key="item.path ?? item.title" class="mb-1" r
|
||||
import type { AdminLayoutMenuItem } from './types'
|
||||
import { mdiArrowTopRight, mdiChevronRight } from '@mdi/js'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
mobileCurrentItems?: AdminLayoutMenuItem[]
|
||||
}>(), {
|
||||
mobileCurrentItems: () => [],
|
||||
})
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
mobileCurrentItems?: AdminLayoutMenuItem[]
|
||||
}>(),
|
||||
{
|
||||
mobileCurrentItems: () => [],
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'item-click': [item: AdminLayoutMenuItem]
|
||||
|
||||
@@ -65,7 +65,4 @@ export interface AdminLayoutDrawerConfig {
|
||||
railWidth: number
|
||||
}
|
||||
|
||||
export type AdminLayoutActionType =
|
||||
| 'notifications'
|
||||
| 'messages'
|
||||
| 'help'
|
||||
export type AdminLayoutActionType = 'notifications' | 'messages' | 'help'
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<template>
|
||||
<v-dialog
|
||||
:max-width="maxWidth" :model-value="modelValue" :persistent="persistent"
|
||||
@update:model-value="$emit('update:modelValue', $event)">
|
||||
:max-width="maxWidth"
|
||||
:model-value="modelValue"
|
||||
:persistent="persistent"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title class="text-h6">{{ title }}</v-card-title>
|
||||
<v-card-text>
|
||||
@@ -9,7 +12,12 @@
|
||||
</v-card-text>
|
||||
<v-card-actions class="justify-end">
|
||||
<v-btn variant="text" @click="$emit('update:modelValue', false)">取消</v-btn>
|
||||
<v-btn :color="confirmColor" :loading="confirmLoading" :variant="confirmVariant" @click="$emit('confirm')">
|
||||
<v-btn
|
||||
:color="confirmColor"
|
||||
:loading="confirmLoading"
|
||||
:variant="confirmVariant"
|
||||
@click="$emit('confirm')"
|
||||
>
|
||||
{{ confirmText }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
<v-card-title class="dialog-title d-flex align-center ga-2">
|
||||
<div>
|
||||
<div class="text-h6">{{ dialogTitle }}</div>
|
||||
<div v-if="dialogSubtitle" class="text-body-2 text-medium-emphasis">{{ dialogSubtitle }}</div>
|
||||
<div v-if="dialogSubtitle" class="text-body-2 text-medium-emphasis">
|
||||
{{ dialogSubtitle }}
|
||||
</div>
|
||||
</div>
|
||||
<v-spacer />
|
||||
<v-chip v-if="isViewMode" color="info" size="small" variant="tonal">檢視中</v-chip>
|
||||
|
||||
@@ -2,62 +2,119 @@
|
||||
<div v-if="mobile" class="d-flex align-center flex-wrap ga-2 w-100">
|
||||
<div class="d-flex align-center ga-1">
|
||||
<v-btn
|
||||
v-if="isViewMode || isEditMode" :disabled="!hasPrevRecord" :icon="mdiChevronLeft" size="small" variant="text"
|
||||
@click="$emit('prev')" />
|
||||
v-if="isViewMode || isEditMode"
|
||||
:disabled="!hasPrevRecord"
|
||||
:icon="mdiChevronLeft"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="$emit('prev')"
|
||||
/>
|
||||
<v-btn
|
||||
v-if="isViewMode || isEditMode" :disabled="!hasNextRecord" :icon="mdiChevronRight" size="small" variant="text"
|
||||
@click="$emit('next')" />
|
||||
v-if="isViewMode || isEditMode"
|
||||
:disabled="!hasNextRecord"
|
||||
:icon="mdiChevronRight"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="$emit('next')"
|
||||
/>
|
||||
</div>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
v-if="isViewMode" color="primary" :prepend-icon="mdiPencil" size="small" variant="tonal"
|
||||
@click="$emit('switch-to-edit')">
|
||||
v-if="isViewMode"
|
||||
color="primary"
|
||||
:prepend-icon="mdiPencil"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
@click="$emit('switch-to-edit')"
|
||||
>
|
||||
{{ editLabel }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="isEditMode" color="primary" :prepend-icon="mdiEye" size="small" variant="tonal"
|
||||
@click="$emit('switch-to-view')">
|
||||
v-if="isEditMode"
|
||||
color="primary"
|
||||
:prepend-icon="mdiEye"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
@click="$emit('switch-to-view')"
|
||||
>
|
||||
{{ viewLabel }}
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<v-btn
|
||||
v-if="isViewMode || isEditMode" :disabled="!hasPrevRecord" :prepend-icon="mdiSkipPrevious" size="small"
|
||||
variant="text" @click="$emit('first')">
|
||||
v-if="isViewMode || isEditMode"
|
||||
:disabled="!hasPrevRecord"
|
||||
:prepend-icon="mdiSkipPrevious"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="$emit('first')"
|
||||
>
|
||||
{{ firstLabel }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="isViewMode || isEditMode" :disabled="!hasPrevRecord" :prepend-icon="mdiChevronLeft" size="small"
|
||||
variant="text" @click="$emit('prev')">
|
||||
v-if="isViewMode || isEditMode"
|
||||
:disabled="!hasPrevRecord"
|
||||
:prepend-icon="mdiChevronLeft"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="$emit('prev')"
|
||||
>
|
||||
{{ prevLabel }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="isViewMode || isEditMode" :append-icon="mdiChevronRight" :disabled="!hasNextRecord" size="small"
|
||||
variant="text" @click="$emit('next')">
|
||||
v-if="isViewMode || isEditMode"
|
||||
:append-icon="mdiChevronRight"
|
||||
:disabled="!hasNextRecord"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="$emit('next')"
|
||||
>
|
||||
{{ nextLabel }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="isViewMode || isEditMode" :append-icon="mdiSkipNext" :disabled="!hasNextRecord" size="small"
|
||||
variant="text" @click="$emit('last')">
|
||||
v-if="isViewMode || isEditMode"
|
||||
:append-icon="mdiSkipNext"
|
||||
:disabled="!hasNextRecord"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="$emit('last')"
|
||||
>
|
||||
{{ lastLabel }}
|
||||
</v-btn>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
v-if="isViewMode" color="primary" :prepend-icon="mdiPencil" size="small" variant="tonal"
|
||||
@click="$emit('switch-to-edit')">
|
||||
v-if="isViewMode"
|
||||
color="primary"
|
||||
:prepend-icon="mdiPencil"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
@click="$emit('switch-to-edit')"
|
||||
>
|
||||
{{ editLabel }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="isEditMode" color="primary" :prepend-icon="mdiEye" size="small" variant="tonal"
|
||||
@click="$emit('switch-to-view')">
|
||||
v-if="isEditMode"
|
||||
color="primary"
|
||||
:prepend-icon="mdiEye"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
@click="$emit('switch-to-view')"
|
||||
>
|
||||
{{ viewLabel }}
|
||||
</v-btn>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { mdiChevronLeft, mdiChevronRight, mdiEye, mdiPencil, mdiSkipNext, mdiSkipPrevious } from '@mdi/js'
|
||||
import {
|
||||
mdiChevronLeft,
|
||||
mdiChevronRight,
|
||||
mdiEye,
|
||||
mdiPencil,
|
||||
mdiSkipNext,
|
||||
mdiSkipPrevious,
|
||||
} from '@mdi/js'
|
||||
defineProps({
|
||||
isViewMode: {
|
||||
type: Boolean,
|
||||
|
||||
@@ -202,12 +202,12 @@ const totalCredits = computed(
|
||||
() => props.semester?.courses.reduce((sum, course) => sum + course.credits, 0) ?? 0
|
||||
)
|
||||
|
||||
function updateSemester (payload: Partial<SemesterRecord>) {
|
||||
function updateSemester(payload: Partial<SemesterRecord>) {
|
||||
if (!props.semester) return
|
||||
emit('update-semester', props.semester.id, payload)
|
||||
}
|
||||
|
||||
function updateCourse (courseIndex: number, payload: Partial<CourseRecord>) {
|
||||
function updateCourse(courseIndex: number, payload: Partial<CourseRecord>) {
|
||||
if (!props.semester) return
|
||||
emit('update-course', props.semester.id, courseIndex, payload)
|
||||
}
|
||||
|
||||
@@ -170,7 +170,16 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CourseRecord, SemesterRecord } from '@/stores/semesters'
|
||||
import { mdiArrowDown, mdiArrowUp, mdiBookOpenOutline, mdiChevronRight, mdiDeleteOutline, mdiPlus, mdiSchool, mdiSwapVertical } from '@mdi/js'
|
||||
import {
|
||||
mdiArrowDown,
|
||||
mdiArrowUp,
|
||||
mdiBookOpenOutline,
|
||||
mdiChevronRight,
|
||||
mdiDeleteOutline,
|
||||
mdiPlus,
|
||||
mdiSchool,
|
||||
mdiSwapVertical,
|
||||
} from '@mdi/js'
|
||||
import { ref } from 'vue'
|
||||
|
||||
type CourseSortKey = 'name' | 'credits' | 'score'
|
||||
@@ -209,7 +218,7 @@ const semesterSortStates = ref<Record<number, CourseSortState>>({})
|
||||
|
||||
const getSortState = (semesterId: number) => semesterSortStates.value[semesterId]
|
||||
|
||||
function toggleSort (semesterId: number, key: CourseSortKey) {
|
||||
function toggleSort(semesterId: number, key: CourseSortKey) {
|
||||
const current = getSortState(semesterId)
|
||||
|
||||
semesterSortStates.value[semesterId] =
|
||||
@@ -224,19 +233,19 @@ function toggleSort (semesterId: number, key: CourseSortKey) {
|
||||
}
|
||||
}
|
||||
|
||||
function getSortIcon (semesterId: number, key: CourseSortKey) {
|
||||
function getSortIcon(semesterId: number, key: CourseSortKey) {
|
||||
const current = getSortState(semesterId)
|
||||
|
||||
if (current?.key !== key) return mdiSwapVertical
|
||||
return current.order === 'asc' ? mdiArrowUp : mdiArrowDown
|
||||
}
|
||||
|
||||
function compareCourseValue (left: CourseRecord, right: CourseRecord, key: CourseSortKey) {
|
||||
function compareCourseValue(left: CourseRecord, right: CourseRecord, key: CourseSortKey) {
|
||||
if (key === 'name') return left.name.localeCompare(right.name, 'zh-Hant')
|
||||
return left[key] - right[key]
|
||||
}
|
||||
|
||||
function getSortedCourses (semester: SemesterRecord): SortedCourseRow[] {
|
||||
function getSortedCourses(semester: SemesterRecord): SortedCourseRow[] {
|
||||
const rows = semester.courses.map((course, originalIndex) => ({
|
||||
course,
|
||||
originalIndex,
|
||||
|
||||
@@ -114,7 +114,9 @@
|
||||
:model-value="course.credits"
|
||||
type="number"
|
||||
variant="outlined"
|
||||
@update:model-value="(value) => updateCourse(idx, { credits: Number(value) || 0 })"
|
||||
@update:model-value="
|
||||
(value) => updateCourse(idx, { credits: Number(value) || 0 })
|
||||
"
|
||||
/>
|
||||
<v-text-field
|
||||
density="comfortable"
|
||||
@@ -166,7 +168,7 @@ const totalCredits = computed(
|
||||
() => props.semester?.courses.reduce((sum, course) => sum + course.credits, 0) ?? 0
|
||||
)
|
||||
|
||||
function updateCourse (courseIndex: number, payload: Partial<CourseRecord>) {
|
||||
function updateCourse(courseIndex: number, payload: Partial<CourseRecord>) {
|
||||
if (!props.semester) return
|
||||
emit('update-course', props.semester.id, courseIndex, payload)
|
||||
}
|
||||
|
||||
@@ -4,18 +4,27 @@
|
||||
子檔資料示範
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
v-if="!isMobile && !isFormReadonly && !isFormLocked" color="primary" :prepend-icon="mdiPlus" size="small"
|
||||
variant="tonal" @click="$emit('add-course')">
|
||||
v-if="!isMobile && !isFormReadonly && !isFormLocked"
|
||||
color="primary"
|
||||
:prepend-icon="mdiPlus"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
@click="$emit('add-course')"
|
||||
>
|
||||
新增成績
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div v-if="isMobile" class="d-flex flex-column ga-3">
|
||||
<v-card
|
||||
v-for="semester in semesters" :key="semester.id" class="cursor-pointer" :class="{ 'border-opacity-100': selectedSemesterId === semester.id }"
|
||||
v-for="semester in semesters"
|
||||
:key="semester.id"
|
||||
class="cursor-pointer"
|
||||
:class="{ 'border-opacity-100': selectedSemesterId === semester.id }"
|
||||
:color="selectedSemesterId === semester.id ? 'primary' : undefined"
|
||||
variant="outlined"
|
||||
@click="$emit('select-semester', semester.id)">
|
||||
@click="$emit('select-semester', semester.id)"
|
||||
>
|
||||
<v-card-text class="pa-4">
|
||||
<div class="d-flex align-start justify-space-between ga-3">
|
||||
<div>
|
||||
@@ -34,13 +43,18 @@ v-for="semester in semesters" :key="semester.id" class="cursor-pointer" :class="
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex-grow-1" style="min-height: 0;">
|
||||
<div v-else class="flex-grow-1" style="min-height: 0">
|
||||
<div class="d-flex flex-column ga-3 overflow-y-auto pr-1 h-100">
|
||||
<v-card class="flex-shrink-0" variant="flat">
|
||||
<v-card-text class="pa-2">
|
||||
<v-data-table
|
||||
class="border rounded" density="compact" :headers="headers" hide-default-footer
|
||||
:items="flattenedCourses" :items-per-page="-1">
|
||||
class="border rounded"
|
||||
density="compact"
|
||||
:headers="headers"
|
||||
hide-default-footer
|
||||
:items="flattenedCourses"
|
||||
:items-per-page="-1"
|
||||
>
|
||||
<template #[`item.semesterName`]="slotProps">
|
||||
{{ slotProps.item.semesterName }}
|
||||
</template>
|
||||
@@ -50,23 +64,56 @@ class="border rounded" density="compact" :headers="headers" hide-default-footer
|
||||
<template #[`item.credits`]="slotProps">
|
||||
<span v-if="isFormReadonly">{{ slotProps.item.credits }}</span>
|
||||
<v-text-field
|
||||
v-else :aria-label="`${slotProps.item.semesterName} ${slotProps.item.name} 學分`" density="compact" :disabled="isFormLocked" hide-details
|
||||
hide-spin-buttons :model-value="slotProps.item.credits" :name="`semester-${slotProps.item.semesterId}-course-${slotProps.item.courseIndex}-credits`" type="number" variant="outlined" @update:model-value="
|
||||
(value) => $emit('update-course', slotProps.item.semesterId, slotProps.item.courseIndex, { credits: Number(value) || 0 })
|
||||
" />
|
||||
v-else
|
||||
:aria-label="`${slotProps.item.semesterName} ${slotProps.item.name} 學分`"
|
||||
density="compact"
|
||||
:disabled="isFormLocked"
|
||||
hide-details
|
||||
hide-spin-buttons
|
||||
:model-value="slotProps.item.credits"
|
||||
:name="`semester-${slotProps.item.semesterId}-course-${slotProps.item.courseIndex}-credits`"
|
||||
type="number"
|
||||
variant="outlined"
|
||||
@update:model-value="
|
||||
(value) =>
|
||||
$emit('update-course', slotProps.item.semesterId, slotProps.item.courseIndex, {
|
||||
credits: Number(value) || 0,
|
||||
})
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<template #[`item.score`]="slotProps">
|
||||
<span v-if="isFormReadonly">{{ slotProps.item.score }}</span>
|
||||
<v-text-field
|
||||
v-else :aria-label="`${slotProps.item.semesterName} ${slotProps.item.name} 分數`" density="compact" :disabled="isFormLocked" hide-details
|
||||
hide-spin-buttons :model-value="slotProps.item.score" :name="`semester-${slotProps.item.semesterId}-course-${slotProps.item.courseIndex}-score`" type="number" variant="outlined" @update:model-value="
|
||||
(value) => $emit('update-course', slotProps.item.semesterId, slotProps.item.courseIndex, { score: Number(value) || 0 })
|
||||
" />
|
||||
v-else
|
||||
:aria-label="`${slotProps.item.semesterName} ${slotProps.item.name} 分數`"
|
||||
density="compact"
|
||||
:disabled="isFormLocked"
|
||||
hide-details
|
||||
hide-spin-buttons
|
||||
:model-value="slotProps.item.score"
|
||||
:name="`semester-${slotProps.item.semesterId}-course-${slotProps.item.courseIndex}-score`"
|
||||
type="number"
|
||||
variant="outlined"
|
||||
@update:model-value="
|
||||
(value) =>
|
||||
$emit('update-course', slotProps.item.semesterId, slotProps.item.courseIndex, {
|
||||
score: Number(value) || 0,
|
||||
})
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<template #[`item.actions`]="slotProps">
|
||||
<v-btn
|
||||
color="error" :disabled="isFormLocked" :icon="mdiDelete" size="small" variant="text"
|
||||
@click="$emit('delete-course', slotProps.item.semesterId, slotProps.item.courseIndex)" />
|
||||
color="error"
|
||||
:disabled="isFormLocked"
|
||||
:icon="mdiDelete"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="
|
||||
$emit('delete-course', slotProps.item.semesterId, slotProps.item.courseIndex)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card-text>
|
||||
@@ -74,7 +121,10 @@ color="error" :disabled="isFormLocked" :icon="mdiDelete" size="small" variant="t
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="semesters.length === 0" class="text-caption text-center py-6 text-medium-emphasis border border-dashed rounded">
|
||||
<div
|
||||
v-if="semesters.length === 0"
|
||||
class="text-caption text-center py-6 text-medium-emphasis border border-dashed rounded"
|
||||
>
|
||||
尚無成績資料
|
||||
</div>
|
||||
</template>
|
||||
@@ -95,7 +145,12 @@ const props = defineProps<{
|
||||
defineEmits<{
|
||||
(event: 'select-semester', semesterId: number): void
|
||||
(event: 'add-course'): void
|
||||
(event: 'update-course', semesterId: number, courseIndex: number, payload: Partial<CourseRecord>): void
|
||||
(
|
||||
event: 'update-course',
|
||||
semesterId: number,
|
||||
courseIndex: number,
|
||||
payload: Partial<CourseRecord>
|
||||
): void
|
||||
(event: 'delete-course', semesterId: number, courseIndex: number): void
|
||||
}>()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user