提交 33f3aff4 authored 作者: 王鹏飞's avatar 王鹏飞

feat: 优化移动端布局与实验操作体验

上级 bdff3c3f
...@@ -89,17 +89,69 @@ video { ...@@ -89,17 +89,69 @@ video {
} }
@media (max-width: 767px) { @media (max-width: 767px) {
.el-overlay-dialog {
padding: max(12px, env(safe-area-inset-top)) 12px max(12px, env(safe-area-inset-bottom));
box-sizing: border-box;
}
.el-dialog { .el-dialog {
width: 92% !important; width: 100% !important;
max-width: 500px; max-width: 500px;
margin-top: 10vh !important; margin: 0 auto !important;
max-height: calc(100dvh - max(12px, env(safe-area-inset-top)) - max(12px, env(safe-area-inset-bottom)));
display: flex;
flex-direction: column;
}
.el-dialog__header,
.el-dialog__footer {
flex-shrink: 0;
}
.el-dialog__body {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
}
.el-dialog .el-form:not(.el-form--inline) .el-form-item {
display: block;
}
.el-dialog .el-form:not(.el-form--inline) .el-form-item__label {
display: block;
width: auto !important;
height: auto;
float: none;
line-height: 24px;
padding-bottom: 4px;
white-space: normal;
text-align: left;
}
.el-dialog .el-form:not(.el-form--inline) .el-form-item__content {
margin-left: 0 !important;
min-width: 0;
}
.el-dialog__footer {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.el-dialog__footer .el-button {
min-height: 40px;
margin-left: 0;
} }
.el-message-box { .el-message-box {
width: 90% !important; width: 90% !important;
max-width: 420px; max-width: 420px;
} }
.el-drawer { .el-drawer.ltr,
.el-drawer.rtl {
width: 90% !important; width: 90% !important;
max-width: 420px;
}
.el-drawer.mobile-menu-drawer {
width: min(76vw, 280px) !important;
}
.el-input__inner,
.el-textarea__inner {
font-size: 16px;
} }
.el-form--inline .el-form-item { .el-form--inline .el-form-item {
margin-right: 0; margin-right: 0;
...@@ -115,6 +167,17 @@ video { ...@@ -115,6 +167,17 @@ video {
} }
} }
@media (max-width: 767px) { @media (max-width: 767px) {
.el-button.el-button {
--el-button-size: 40px;
min-height: 40px;
font-size: 14px;
box-sizing: border-box;
}
.el-button.el-button.is-circle {
width: 40px;
min-width: 40px;
padding: 8px;
}
.hide-on-mobile { .hide-on-mobile {
display: none !important; display: none !important;
} }
......
<script setup lang="ts"> <script setup lang="ts">
import { useMediaQuery, useStorage } from '@vueuse/core' import { useMediaQuery, useStorage } from '@vueuse/core'
import { ArrowDown, ArrowUp } from '@element-plus/icons-vue'
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'resize'): void (e: 'resize'): void
}>() }>()
defineProps<{ isLeftShow?: boolean }>() withDefaults(defineProps<{ isLeftShow?: boolean; mobileToggleLabel?: string; hideMobileToggle?: boolean }>(), { mobileToggleLabel: '试题', hideMobileToggle: false })
const isMobile = useMediaQuery('(max-width: 767px)') const isMobile = useMediaQuery('(max-width: 767px)')
const leftPanelVisible = ref<boolean>(true) const leftPanelVisible = ref<boolean>(true)
...@@ -67,11 +68,14 @@ onMounted(() => { ...@@ -67,11 +68,14 @@ onMounted(() => {
</div> </div>
</div> </div>
<button <button
v-if="!isLeftShow && isMobile" v-if="!isLeftShow && isMobile && !hideMobileToggle"
type="button" type="button"
class="panel-mobile-toggle" class="panel-mobile-toggle"
:class="{ 'is-collapsed': !leftPanelVisible }"
:aria-expanded="leftPanelVisible"
@click="leftPanelVisible = !leftPanelVisible"> @click="leftPanelVisible = !leftPanelVisible">
{{ leftPanelVisible ? '收起试题' : '展开试题' }} <span>{{ leftPanelVisible ? `收起${mobileToggleLabel}` : `展开${mobileToggleLabel}` }}</span>
<el-icon><ArrowUp v-if="leftPanelVisible" /><ArrowDown v-else /></el-icon>
</button> </button>
<div class="drag-panel-right"> <div class="drag-panel-right">
<div class="drag-cover" v-if="dragFlag"></div> <div class="drag-cover" v-if="dragFlag"></div>
...@@ -187,12 +191,17 @@ onMounted(() => { ...@@ -187,12 +191,17 @@ onMounted(() => {
margin-top: 0; margin-top: 0;
margin-bottom: 12px; margin-bottom: 12px;
border: 0; border: 0;
border-radius: 0 0 16px 16px; gap: 8px;
background: #fff; padding: 0 12px;
border-radius: 0 0 8px 8px;
background: #e1e4eb;
color: var(--main-color); color: var(--main-color);
font-size: 14px; font-size: 14px;
line-height: 1; line-height: 1;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.06); cursor: pointer;
&.is-collapsed { border-radius: 8px; }
&:focus-visible { outline: 2px solid var(--main-color); outline-offset: 2px; }
&:active { background: var(--el-fill-color); }
} }
.drag-panel-left-content { .drag-panel-left-content {
height: 100%; height: 100%;
......
...@@ -51,13 +51,13 @@ function handleClick(data: MessageType) { ...@@ -51,13 +51,13 @@ function handleClick(data: MessageType) {
</script> </script>
<template> <template>
<el-popover trigger="click" placement="top-start" :width="380"> <el-popover trigger="click" placement="top-start" :width="380" popper-class="app-message-popper">
<template #reference> <template #reference>
<div class="app-message"> <button type="button" class="app-message" aria-label="我的待办">
<div class="icon"></div> <span class="icon"></span>
<p>我的待办</p> <span class="app-message-text">我的待办</span>
<span v-if="currentList.length">{{ currentList.length }}</span> <span class="app-message-count" v-if="currentList.length">{{ currentList.length }}</span>
</div> </button>
</template> </template>
<ul class="app-message-list" v-if="currentList.length"> <ul class="app-message-list" v-if="currentList.length">
<li v-for="item in currentList" :key="item.id" @click="handleClick(item)">{{ item.payload.message }}</li> <li v-for="item in currentList" :key="item.id" @click="handleClick(item)">{{ item.payload.message }}</li>
...@@ -88,14 +88,14 @@ function handleClick(data: MessageType) { ...@@ -88,14 +88,14 @@ function handleClick(data: MessageType) {
border-radius: 50%; border-radius: 50%;
overflow: hidden; overflow: hidden;
} }
p { .app-message-text {
margin: 0 5px; margin: 0 5px;
font-size: 14px; font-size: 14px;
font-family: Source Han Sans CN; font-family: Source Han Sans CN;
line-height: 24px; line-height: 24px;
color: #666666; color: #666666;
} }
span { .app-message-count {
margin-top: -10px; margin-top: -10px;
width: 16px; width: 16px;
height: 16px; height: 16px;
...@@ -117,4 +117,18 @@ function handleClick(data: MessageType) { ...@@ -117,4 +117,18 @@ function handleClick(data: MessageType) {
} }
} }
} }
@media (max-width: 767px) {
.app-message {
right: 12px;
bottom: max(12px, env(safe-area-inset-bottom));
min-width: 44px;
width: 44px;
height: 44px;
padding: 3px;
.app-message-text { display: none; }
.app-message-count { position: absolute; top: -2px; right: -2px; margin-top: 0; }
}
:global(.app-message-popper) { max-width: calc(100vw - 24px); box-sizing: border-box; }
.app-message-list { max-height: 50dvh; overflow-y: auto; li { overflow-wrap: anywhere; min-height: 40px; box-sizing: border-box; } }
}
</style> </style>
...@@ -55,4 +55,10 @@ withDefaults(defineProps<Props>(), { hasBodyBackground: true }) ...@@ -55,4 +55,10 @@ withDefaults(defineProps<Props>(), { hasBodyBackground: true })
border-radius: 12px; border-radius: 12px;
} }
} }
@media (max-width: 767px) {
.app-card.has-background, .app-card-bd.has-background { padding: 14px; }
.app-card-hd { flex-wrap: wrap; gap: 8px; }
.app-card-hd__title { min-width: 0; font-size: 18px; line-height: 1.4; overflow-wrap: anywhere; margin-bottom: 12px; }
.app-card-hd-aside { max-width: 100%; }
}
</style> </style>
<script setup lang="ts"> <script setup lang="ts">
import { Search, RefreshLeft } from '@element-plus/icons-vue' import { Search, RefreshLeft, Filter } from '@element-plus/icons-vue'
import { useMediaQuery } from '@vueuse/core' import { useMediaQuery } from '@vueuse/core'
const isMobile = useMediaQuery('(max-width: 767px)') const isMobile = useMediaQuery('(max-width: 767px)')
const attrs = useAttrs()
// 筛选、表格和移动端卡片通过动态名称传递不同的插槽参数。 // 筛选、表格和移动端卡片通过动态名称传递不同的插槽参数。
defineSlots<{ defineSlots<{
...@@ -48,6 +49,28 @@ const dataList = ref<any[]>([]) ...@@ -48,6 +49,28 @@ const dataList = ref<any[]>([])
const page = reactive({ total: 0, size: props.limit, currentPage: 1 }) const page = reactive({ total: 0, size: props.limit, currentPage: 1 })
const params = reactive({ ...props.remote?.params }) const params = reactive({ ...props.remote?.params })
const mobileFilterVisible = ref(false)
const mobileFilterDraft = reactive<Record<string, any>>({})
watch(isMobile, () => { mobileFilterVisible.value = false })
const activeFilterCount = computed(() => props.filters.filter(item => {
const value = params[item.prop]
return value !== undefined && value !== null && value !== '' && (!Array.isArray(value) || value.length > 0)
}).length)
function openMobileFilters() {
Object.keys(mobileFilterDraft).forEach(key => delete mobileFilterDraft[key])
Object.assign(mobileFilterDraft, params)
mobileFilterVisible.value = true
}
function resetMobileFilters() {
props.filters.forEach(item => {
mobileFilterDraft[item.prop] = props.remote?.params?.[item.prop] ?? ''
})
}
function applyMobileFilters() {
Object.assign(params, mobileFilterDraft)
mobileFilterVisible.value = false
search()
}
watchEffect(() => { Object.assign(params, props.remote?.params) }) watchEffect(() => { Object.assign(params, props.remote?.params) })
watchEffect(() => { dataList.value = props.data || [] }) watchEffect(() => { dataList.value = props.data || [] })
...@@ -94,19 +117,25 @@ function toggleMobileSelectRow(row: any) { ...@@ -94,19 +117,25 @@ function toggleMobileSelectRow(row: any) {
} }
function isMobileRowSelected(row: any) { return mobileSelection.value.some((r) => r === row) } function isMobileRowSelected(row: any) { return mobileSelection.value.some((r) => r === row) }
function emitSelectionChange() { function emitSelectionChange() {
const attrs = useAttrs()
const handler = (attrs as Record<string, unknown>).onSelectionChange as ((val: any[]) => void) | undefined const handler = (attrs as Record<string, unknown>).onSelectionChange as ((val: any[]) => void) | undefined
if (handler) handler(mobileSelection.value) if (handler) handler(mobileSelection.value)
} }
watch(dataList, () => {
if (mobileSelection.value.length) {
mobileSelection.value = []
emitSelectionChange()
}
})
onMounted(() => { fetchList() }) onMounted(() => { fetchList() })
defineExpose({ refetch, tableRef }) defineExpose({ refetch, tableRef })
</script> </script>
<template> <template>
<div class="table-list"> <div class="table-list">
<div class="table-list-hd"> <div class="table-list-hd" v-if="!isMobile && filters.length">
<div class="table-list-filter" v-if="filters && filters.length"> <div class="table-list-filter" v-if="!isMobile && filters.length">
<el-form :inline="true" :model="params" v-bind="filterForm" ref="filterFormRef" @submit.prevent> <el-form :inline="true" :model="params" v-bind="filterForm" ref="filterFormRef" @submit.prevent>
<template v-for="item in filters" :key="item.prop"> <template v-for="item in filters" :key="item.prop">
<el-form-item :label="item.label" :prop="item.prop"> <el-form-item :label="item.label" :prop="item.prop">
...@@ -129,7 +158,40 @@ defineExpose({ refetch, tableRef }) ...@@ -129,7 +158,40 @@ defineExpose({ refetch, tableRef })
</el-form> </el-form>
</div> </div>
</div> </div>
<div class="table-list-buttons"><slot name="header-buttons"></slot></div> <el-drawer v-model="mobileFilterVisible" title="筛选" direction="btt" size="75%" append-to-body class="mobile-filter-drawer">
<div class="table-list-filter">
<el-form :model="mobileFilterDraft" v-bind="filterForm" :inline="false" label-position="top" @submit.prevent="applyMobileFilters">
<template v-for="item in filters" :key="item.prop">
<el-form-item :label="item.label" :prop="item.prop">
<template v-if="item.slots">
<slot :name="item.slots" v-bind="{ params: mobileFilterDraft }"></slot>
</template>
<template v-else>
<el-input v-model="mobileFilterDraft[item.prop]" v-bind="item" clearable style="width: 200px" v-if="item.type === 'input'" />
<el-select v-model="mobileFilterDraft[item.prop]" v-bind="item" filterable clearable v-if="item.type === 'select'" value-on-clear="">
<el-option :label="option[item.labelKey] || option.label" :value="option[item.valueKey] || option.value"
v-for="(option, index) in item.options" :key="index" />
</el-select>
</template>
</el-form-item>
</template>
</el-form>
</div>
<template #footer>
<div class="mobile-filter-footer">
<el-button :icon="RefreshLeft" @click="resetMobileFilters">重置</el-button>
<el-button type="primary" @click="applyMobileFilters">确定</el-button>
</div>
</template>
</el-drawer>
<div class="table-list-buttons">
<slot name="header-buttons"></slot>
<el-button v-if="isMobile && filters.length" :icon="Filter" class="mobile-filter-trigger"
:aria-label="activeFilterCount ? `筛选,已选 ${activeFilterCount} 项` : '筛选'"
:class="{ 'is-active': activeFilterCount > 0 }" @click="openMobileFilters">
<span v-if="activeFilterCount" class="mobile-filter-count">{{ activeFilterCount }}</span>
</el-button>
</div>
<slot></slot> <slot></slot>
<!-- 主体 --> <!-- 主体 -->
<div class="table-list-bd"> <div class="table-list-bd">
...@@ -154,8 +216,8 @@ defineExpose({ refetch, tableRef }) ...@@ -154,8 +216,8 @@ defineExpose({ refetch, tableRef })
<div class="mobile-card" v-for="(row, rowIndex) in dataList" :key="rowIndex" <div class="mobile-card" v-for="(row, rowIndex) in dataList" :key="rowIndex"
:class="{ 'is-selected': isMobileRowSelected(row) }"> :class="{ 'is-selected': isMobileRowSelected(row) }">
<div class="mobile-card-body"> <div class="mobile-card-body">
<div class="mobile-card-checkbox" v-if="hasSelectionColumn" @click.stop="toggleMobileSelectRow(row)"> <div class="mobile-card-checkbox" v-if="hasSelectionColumn" @click.stop>
<el-checkbox :model-value="isMobileRowSelected(row)" /> <el-checkbox :model-value="isMobileRowSelected(row)" @change="toggleMobileSelectRow(row)" />
</div> </div>
<div class="mobile-card-rows"> <div class="mobile-card-rows">
<div class="mobile-card-row" v-for="col in cardColumns" :key="col.prop || col.label"> <div class="mobile-card-row" v-for="col in cardColumns" :key="col.prop || col.label">
...@@ -226,14 +288,62 @@ defineExpose({ refetch, tableRef }) ...@@ -226,14 +288,62 @@ defineExpose({ refetch, tableRef })
// 移动端 // 移动端
@media (max-width: 767px) { @media (max-width: 767px) {
.table-list-hd .el-form-item__label { display: none; } .table-list-buttons .el-button.mobile-filter-trigger {
position: relative;
margin-left: auto;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 40px;
width: 40px;
height: 40px;
padding: 0;
border-color: #e6e8eb;
border-radius: 10px;
> .el-icon, > span { margin: 0; }
&.is-active { color: var(--main-color); border-color: var(--main-color); }
}
.table-list-buttons:has(.view-switch) .mobile-filter-trigger { margin-left: 0; }
.mobile-filter-count { position: absolute; top: -5px; right: -5px; min-width: 16px; height: 16px; padding: 0 3px; border-radius: 8px; background: var(--main-color); color: #fff; font-size: 10px; line-height: 16px; }
.mobile-filter-drawer {
border-radius: 16px 16px 0 0;
.el-drawer__header { margin-bottom: 0; padding: 16px; }
.el-drawer__body { padding: 16px; overflow-y: auto; }
.el-drawer__footer { padding: 12px 16px max(12px, env(safe-area-inset-bottom)); border-top: 1px solid #eee; }
.table-list-filter { min-width: 0; padding: 0; }
}
.mobile-filter-footer { display: flex; gap: 12px; .el-button { flex: 1; height: 40px; margin-left: 0; } }
.table-list-hd .el-form-item__label { justify-content: flex-start; height: auto; line-height: 24px; padding-bottom: 4px; }
.table-list-filter { .table-list-filter {
.el-form, .el-form--inline { width: 100%; } .el-form, .el-form--inline { width: 100%; }
.el-form-item { width: 100%; margin-right: 0; margin-bottom: 8px; } .el-form-item { display: flex; flex-direction: column; align-items: stretch; width: 100%; margin-right: 0; margin-bottom: 12px; }
.el-form-item__content { width: 100%; } .el-form-item__content { width: 100%; }
.el-input, .el-select, .el-cascader { width: 100% !important; } .el-input, .el-select, .el-cascader { width: 100% !important; }
.filter-buttons { width: auto; } .filter-buttons { width: 100%; .el-form-item__content { display: flex; gap: 8px; } .el-button { flex: 1; min-height: 40px; margin-left: 0; } }
}
.table-list-buttons { align-items: center; gap: 8px; .el-button { min-height: 40px; margin-left: 0; } }
.mobile-card-ft {
justify-content: flex-end;
gap: 8px;
padding: 4px 12px;
.el-button, .el-button.is-text {
min-height: 40px;
margin-left: 0;
padding: 8px 10px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--el-text-color-regular);
font-size: 14px;
a { margin: 0; padding: 0; }
&:hover { background: var(--el-fill-color-light); }
&.el-button--primary { color: var(--main-color); }
&.el-button--danger { color: var(--el-color-danger); }
&.is-disabled { color: var(--el-disabled-text-color); background: transparent; }
}
} }
.mobile-card-checkbox { min-height: 44px; display: flex; align-items: center; }
.table-list-ft { flex-direction: column; gap: 8px; padding: 8px 0; } .table-list-ft { flex-direction: column; gap: 8px; padding: 8px 0; }
.table-list-pagination .el-pagination { .table-list-pagination .el-pagination {
justify-content: center; justify-content: center;
......
...@@ -4,7 +4,7 @@ export default { name: 'AppHeader' } ...@@ -4,7 +4,7 @@ export default { name: 'AppHeader' }
<script setup lang="ts"> <script setup lang="ts">
import type { IMenuItem } from '@/types' import type { IMenuItem } from '@/types'
import { CaretBottom, Expand } from '@element-plus/icons-vue' import { CaretBottom, Expand, Close } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { useMenuStore } from '@/stores/menu' import { useMenuStore } from '@/stores/menu'
import { useAppConfig } from '@/composables/useAppConfig' import { useAppConfig } from '@/composables/useAppConfig'
...@@ -17,6 +17,7 @@ const router = useRouter() ...@@ -17,6 +17,7 @@ const router = useRouter()
const mobileMenuVisible = ref(false) const mobileMenuVisible = ref(false)
const toggleMobileMenu = () => { mobileMenuVisible.value = !mobileMenuVisible.value } const toggleMobileMenu = () => { mobileMenuVisible.value = !mobileMenuVisible.value }
const closeMobileMenu = () => { mobileMenuVisible.value = false } const closeMobileMenu = () => { mobileMenuVisible.value = false }
watch(() => route.fullPath, closeMobileMenu)
const userInfo = userStore.user const userInfo = userStore.user
...@@ -65,7 +66,8 @@ function handleClick(path: string) { ...@@ -65,7 +66,8 @@ function handleClick(path: string) {
<template> <template>
<header class="app-header"> <header class="app-header">
<div class="app-header-left"> <div class="app-header-left">
<button class="sidebar-toggle" @click="toggleMobileMenu"> <button v-if="menus.length" type="button" class="sidebar-toggle" aria-label="打开导航菜单"
:aria-expanded="mobileMenuVisible" aria-controls="mobile-navigation" @click="toggleMobileMenu">
<el-icon :size="22"><Expand /></el-icon> <el-icon :size="22"><Expand /></el-icon>
</button> </button>
<template v-if="appConfig.logo"> <template v-if="appConfig.logo">
...@@ -74,7 +76,7 @@ function handleClick(path: string) { ...@@ -74,7 +76,7 @@ function handleClick(path: string) {
</div> </div>
<div class="line" v-if="appConfig.title"></div> <div class="line" v-if="appConfig.title"></div>
</template> </template>
<h1 class="app-name"> <h1 class="app-name" :title="appConfig.title">
<router-link to="/">{{ appConfig.title }}</router-link> <router-link to="/">{{ appConfig.title }}</router-link>
</h1> </h1>
</div> </div>
...@@ -134,21 +136,24 @@ function handleClick(path: string) { ...@@ -134,21 +136,24 @@ function handleClick(path: string) {
</div> </div>
</header> </header>
<el-drawer v-model="mobileMenuVisible" direction="ltr" :size="260" :with-header="false" :z-index="2100"> <el-drawer v-model="mobileMenuVisible" class="mobile-menu-drawer" direction="ltr" :size="260" :with-header="false" :z-index="2100">
<div class="mobile-drawer"> <div id="mobile-navigation" class="mobile-drawer">
<div class="mobile-drawer-hd"> <div class="mobile-drawer-hd">
<img v-if="appConfig.logo" :src="appConfig.logo" class="mobile-drawer-logo" />
<span class="mobile-drawer-title">{{ appConfig.title }}</span> <span class="mobile-drawer-title">{{ appConfig.title }}</span>
<button type="button" class="mobile-drawer-close" aria-label="关闭导航菜单" @click="closeMobileMenu">
<el-icon :size="20"><Close /></el-icon>
</button>
</div> </div>
<div class="mobile-drawer-bd"> <div class="mobile-drawer-bd">
<template v-for="item in menus" :key="item.path"> <template v-for="item in menus" :key="item.path">
<div class="mobile-nav-group" v-permission="item.tag"> <div class="mobile-nav-group" v-permission="item.tag">
<div class="mobile-nav-parent" @click="handleClick(item.path)">{{ item.name }}</div> <button type="button" class="mobile-nav-parent" :class="{ 'is-active': !item.children && route.path === item.path }"
@click="handleClick(item.path); closeMobileMenu()">{{ item.name }}</button>
<template v-if="item.children"> <template v-if="item.children">
<div class="mobile-nav-child" v-for="subitem in item.children" :key="subitem.path" <button type="button" class="mobile-nav-child" v-for="subitem in item.children" :key="subitem.path"
v-permission="subitem.tag" v-permission="subitem.tag"
:class="{ 'is-active': route.path.includes(subitem.path) }" :class="{ 'is-active': route.path.includes(subitem.path) }"
@click="handleClick(subitem.path); closeMobileMenu()">{{ subitem.name }}</div> @click="handleClick(subitem.path); closeMobileMenu()">{{ subitem.name }}</button>
</template> </template>
</div> </div>
</template> </template>
...@@ -333,34 +338,41 @@ function handleClick(path: string) { ...@@ -333,34 +338,41 @@ function handleClick(path: string) {
// 移动端 (≤767px) // 移动端 (≤767px)
@media (max-width: 767px) { @media (max-width: 767px) {
.sidebar-toggle { display: flex; } .sidebar-toggle { display: flex; width: 44px; height: 44px; margin-right: 0; }
.app-header { padding: 0 12px; height: 52px; } .app-header { padding: 0 12px; height: 52px; }
.app-header-left { .app-header-left {
.logo { height: 32px; } flex: 1;
.line { margin: 0 10px; height: 22px; } min-width: 0;
.app-name { font-size: 16px; line-height: 22px; } gap: 8px;
.logo { display: none; }
.line { display: none; }
.app-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; font-size: 16px; line-height: 22px; }
} }
.app-header-nav { display: none; } .app-header-nav { display: none; }
.app-header-right { flex-shrink: 0; margin-left: 8px; }
.app-header-right .user { .app-header-right .user {
height: 36px; height: 36px;
p { display: none; } p { display: none; }
.el-icon { display: none; }
} }
.app-header-right .avatar { width: 32px; height: 32px; } .app-header-right .avatar { width: 32px; height: 32px; }
} }
// 移动端抽屉 // 移动端抽屉
.mobile-drawer { display: flex; flex-direction: column; height: 100%; } .mobile-menu-drawer .el-drawer__body { padding: 0; }
.mobile-drawer { display: flex; flex-direction: column; height: 100%; padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); box-sizing: border-box; }
.mobile-drawer-hd { display: flex; align-items: center; gap: 10px; padding: 16px; border-bottom: 1px solid #eee; } .mobile-drawer-hd { display: flex; align-items: center; gap: 10px; padding: 16px; border-bottom: 1px solid #eee; }
.mobile-drawer-logo { height: 36px; } .mobile-drawer-title { flex: 1; min-width: 0; font-size: 16px; line-height: 1.5; font-weight: 600; color: #333; overflow-wrap: anywhere; }
.mobile-drawer-title { font-size: 18px; font-weight: 600; color: #333; } .mobile-drawer-close { display: flex; align-items: center; justify-content: center; flex-shrink: 0; width: 40px; height: 44px; background: transparent; color: #666; cursor: pointer; }
.mobile-drawer-bd { flex: 1; overflow-y: auto; padding: 8px 0; } .mobile-drawer-bd { flex: 1; overflow-y: auto; padding: 8px 0; }
.mobile-nav-group { border-bottom: 1px solid #f5f5f5; .mobile-nav-group { border-bottom: 1px solid #f5f5f5;
&:last-child { border-bottom: none; } &:last-child { border-bottom: none; }
} }
.mobile-nav-parent { padding: 14px 20px; font-size: 15px; font-weight: 600; color: #333; cursor: pointer; .mobile-nav-parent { display: block; width: 100%; text-align: left; background: transparent; min-height: 44px; padding: 14px 20px; font-size: 15px; font-weight: 600; color: #333; cursor: pointer;
&:active { background: #f5f5f5; } &:active { background: #f5f5f5; }
&.is-active { color: var(--main-color); background: #fef5f7; }
} }
.mobile-nav-child { padding: 12px 20px 12px 36px; font-size: 14px; color: #666; cursor: pointer; border-top: 1px solid #fafafa; .mobile-nav-child { display: block; width: 100%; text-align: left; background: transparent; min-height: 44px; padding: 12px 20px 12px 36px; font-size: 14px; color: #666; cursor: pointer; border-top: 1px solid #fafafa;
&:active { background: #f5f5f5; } &:active { background: #f5f5f5; }
&.is-active { color: var(--main-color); background: #fef5f7; } &.is-active { color: var(--main-color); background: #fef5f7; }
} }
......
...@@ -37,7 +37,7 @@ const showOverlaySidebar = computed(() => isMobile.value && sidebarVisible.value ...@@ -37,7 +37,7 @@ const showOverlaySidebar = computed(() => isMobile.value && sidebarVisible.value
<AppHeader v-if="header"></AppHeader> <AppHeader v-if="header"></AppHeader>
<div class="app-layout-container"> <div class="app-layout-container">
<div v-if="showOverlaySidebar" class="sidebar-overlay" @click="closeSidebar"></div> <div v-if="showOverlaySidebar" class="sidebar-overlay" @click="closeSidebar"></div>
<AppAside v-if="sidebar" :class="{ 'app-aside--overlay': isMobile, 'app-aside--visible': sidebarVisible }"> <AppAside v-if="sidebar && !isMobile" :class="{ 'app-aside--overlay': isMobile, 'app-aside--visible': sidebarVisible }">
</AppAside> </AppAside>
<AppMain @click="closeSidebar"></AppMain> <AppMain @click="closeSidebar"></AppMain>
</div> </div>
......
...@@ -238,11 +238,29 @@ function onUpdateSuccess() { ...@@ -238,11 +238,29 @@ function onUpdateSuccess() {
} }
} }
@media (max-width: 767px) { @media (max-width: 767px) {
:deep(.table-list-filter) {
padding: 12px;
.el-form-item:last-child {
margin-bottom: 0;
}
}
:deep(.table-list-buttons) { :deep(.table-list-buttons) {
justify-content: flex-start; justify-content: space-between;
align-items: center;
.el-button {
height: 40px;
min-height: 40px;
}
} }
.view-switch { .view-switch {
margin-left: 0; margin-left: auto;
height: 40px;
box-sizing: border-box;
flex-shrink: 0;
}
.view-switch__button {
width: 40px;
height: 100%;
} }
.list-card { .list-card {
grid-template-columns: 1fr; grid-template-columns: 1fr;
......
...@@ -272,11 +272,29 @@ function onUpdateSuccess() { ...@@ -272,11 +272,29 @@ function onUpdateSuccess() {
} }
} }
@media (max-width: 767px) { @media (max-width: 767px) {
:deep(.table-list-filter) {
padding: 12px;
.el-form-item:last-child {
margin-bottom: 0;
}
}
:deep(.table-list-buttons) { :deep(.table-list-buttons) {
justify-content: flex-start; justify-content: space-between;
align-items: center;
.el-button {
height: 40px;
min-height: 40px;
}
} }
.view-switch { .view-switch {
margin-left: 0; margin-left: auto;
height: 40px;
box-sizing: border-box;
flex-shrink: 0;
}
.view-switch__button {
width: 40px;
height: 100%;
} }
.list-card { .list-card {
grid-template-columns: 1fr; grid-template-columns: 1fr;
......
...@@ -7,12 +7,14 @@ import AppEditor from '@/components/base/AppEditor.vue' ...@@ -7,12 +7,14 @@ import AppEditor from '@/components/base/AppEditor.vue'
interface Props { interface Props {
id: string id: string
showTrigger?: boolean
} }
const props = defineProps<Props>() const props = withDefaults(defineProps<Props>(), { showTrigger: true })
const isMobile = useMediaQuery('(max-width: 767px)') const isMobile = useMediaQuery('(max-width: 767px)')
const showAdvancedFields = false const showAdvancedFields = false
const dialogVisible = ref(false) const dialogVisible = ref(false)
defineExpose({ open: () => { dialogVisible.value = true } })
const gradeRuleList = [ const gradeRuleList = [
{ label: '商品品类管理', value: 1 }, { label: '商品品类管理', value: 1 },
...@@ -182,7 +184,7 @@ function disabledMinSubmitDate(time: Date) { ...@@ -182,7 +184,7 @@ function disabledMinSubmitDate(time: Date) {
</script> </script>
<template> <template>
<el-button type="primary" @click="dialogVisible = true">编辑大赛规则</el-button> <el-button v-if="showTrigger" type="primary" @click="dialogVisible = true">编辑大赛规则</el-button>
<el-dialog title="编辑大赛规则" :close-on-click-modal="false" width="1000px" v-model="dialogVisible" append-to-body> <el-dialog title="编辑大赛规则" :close-on-click-modal="false" width="1000px" v-model="dialogVisible" append-to-body>
<el-form ref="formRef" :model="form" :rules="rules" label-position="right" label-width="100px"> <el-form ref="formRef" :model="form" :rules="rules" label-position="right" label-width="100px">
<el-form-item label="大赛名称" prop="name"> <el-form-item label="大赛名称" prop="name">
......
...@@ -4,12 +4,13 @@ import type { ExperimentItem } from '../types' ...@@ -4,12 +4,13 @@ import type { ExperimentItem } from '../types'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { getTripConfig, updateTripConfig, getLiveCommodity } from '../api' import { getTripConfig, updateTripConfig, getLiveCommodity } from '../api'
import { useConnection, useUserAttr, useMetaEvent, useTag, useGroup, useMaterial } from '../composables/useAllData' import { useConnection, useUserAttr, useMetaEvent, useTag, useGroup, useMaterial } from '../composables/useAllData'
import { useDocumentVisibility } from '@vueuse/core' import { useDocumentVisibility, useMediaQuery } from '@vueuse/core'
import { dmlMenus, type DmlMenu } from '@/utils/dmlMenus' import { dmlMenus, type DmlMenu } from '@/utils/dmlMenus'
import { useAppConfig } from '@/composables/useAppConfig' import { useAppConfig } from '@/composables/useAppConfig'
import { urls } from '@/utils/env' import { urls } from '@/utils/env'
const appConfig = useAppConfig() const appConfig = useAppConfig()
const isMobile = useMediaQuery('(max-width: 767px)')
const props = defineProps<{ const props = defineProps<{
data: ExperimentItem data: ExperimentItem
...@@ -228,16 +229,17 @@ const handleSelectAll = () => { ...@@ -228,16 +229,17 @@ const handleSelectAll = () => {
<template> <template>
<el-dialog <el-dialog
title="配置数字营销实验" title="配置数字营销实验"
class="dml-config-dialog"
:close-on-click-modal="false" :close-on-click-modal="false"
width="1000px" width="1000px"
@update:modelValue="(value) => $emit('update:modelValue', value)"> @update:modelValue="(value) => $emit('update:modelValue', value)">
<el-form ref="formRef" :model="form" label-suffix=":"> <el-form ref="formRef" :model="form" label-suffix=":" :label-position="isMobile ? 'top' : 'right'">
<el-row justify="space-between"> <el-row justify="space-between" class="dml-config-summary">
<el-form-item label="实验名称">{{ data.name }}</el-form-item> <el-form-item label="实验名称">{{ data.name }}</el-form-item>
<el-form-item label="实验类型">{{ data.type_name }} </el-form-item> <el-form-item label="实验类型">{{ data.type_name }} </el-form-item>
<el-form-item label="实验总成绩">{{ data.score }}</el-form-item> <el-form-item label="实验总成绩">{{ data.score }}</el-form-item>
</el-row> </el-row>
<el-tabs v-model="step" tab-position="left"> <el-tabs v-model="step" :tab-position="isMobile ? 'top' : 'left'">
<el-tab-pane label="功能" :name="-1"> <el-tab-pane label="功能" :name="-1">
<el-checkbox @click="handleSelectAll" v-model="checked" label="全选" size="large" /> <el-checkbox @click="handleSelectAll" v-model="checked" label="全选" size="large" />
<div class="check-ul"> <div class="check-ul">
...@@ -411,7 +413,7 @@ const handleSelectAll = () => { ...@@ -411,7 +413,7 @@ const handleSelectAll = () => {
</el-tabs> </el-tabs>
</el-form> </el-form>
<template #footer> <template #footer>
<el-row justify="center"> <el-row justify="center" class="dml-config-actions">
<el-button round auto-insert-space @click="$emit('update:modelValue', false)" v-if="step === -1" <el-button round auto-insert-space @click="$emit('update:modelValue', false)" v-if="step === -1"
>关闭</el-button >关闭</el-button
> >
...@@ -441,4 +443,53 @@ const handleSelectAll = () => { ...@@ -441,4 +443,53 @@ const handleSelectAll = () => {
margin: 0 !important; margin: 0 !important;
} }
} }
@media (max-width: 767px) {
.dml-config-dialog {
.dml-config-summary {
display: flex;
flex-direction: column;
padding: 12px;
margin-bottom: 16px;
border-radius: 8px;
background: var(--el-fill-color-light);
.el-form-item { width: 100%; margin-bottom: 8px; }
.el-form-item:last-child { margin-bottom: 0; }
}
.el-tabs { min-width: 0; }
.el-tabs__header { margin-bottom: 16px; }
.el-tabs__item { height: 44px; padding: 0 14px; }
.el-tabs__content { min-width: 0; overflow: visible; }
.el-form-item__content { min-width: 0; gap: 8px; }
.el-select { width: 100%; margin-left: 0 !important; }
.el-radio-group { display: flex; flex-wrap: wrap; gap: 8px 16px; }
.el-radio { margin-right: 0; min-height: 40px; height: auto; }
.check-ul .li {
margin-bottom: 12px;
padding: 12px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
.check-item { display: flex; flex-direction: column; padding-left: 8px; }
.el-divider { display: none; }
}
.el-checkbox-group { display: flex; flex-direction: column; width: 100%; }
.el-checkbox {
max-width: 100%;
min-height: 44px;
height: auto;
margin-right: 0;
align-items: flex-start;
padding: 10px 0;
box-sizing: border-box;
.el-checkbox__input { margin-top: 3px; }
.el-checkbox__label { min-width: 0; white-space: normal; overflow-wrap: anywhere; line-height: 1.5; }
}
.dml-config-actions {
display: flex;
flex-wrap: nowrap;
width: 100%;
gap: 8px;
.el-button { flex: 1; min-width: 0; height: 40px; margin-left: 0; padding: 0 8px; }
}
}
}
</style> </style>
...@@ -241,3 +241,4 @@ async function handleDelete(row: ExperimentItem) { ...@@ -241,3 +241,4 @@ async function handleDelete(row: ExperimentItem) {
@update="onUpdateSuccess" @update="onUpdateSuccess"
v-if="copyDialogVisible && rowData"></CopyDialog> v-if="copyDialogVisible && rowData"></CopyDialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { ExperimentItem, ClassItem } from '../types' import type { ExperimentItem, ClassItem } from '../types'
import { CirclePlus } from '@element-plus/icons-vue' import { useMediaQuery } from '@vueuse/core'
import { CirclePlus, ArrowDown } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import AppList from '@/components/base/AppList.vue' import AppList from '@/components/base/AppList.vue'
...@@ -8,6 +9,8 @@ import { getExperiment, experimentAddClass } from '../api' ...@@ -8,6 +9,8 @@ import { getExperiment, experimentAddClass } from '../api'
import { useAppConfig } from '@/composables/useAppConfig' import { useAppConfig } from '@/composables/useAppConfig'
import { urls } from '@/utils/env' import { urls } from '@/utils/env'
const appConfig = useAppConfig() const appConfig = useAppConfig()
const isMobile = useMediaQuery('(max-width: 767px)')
const competitionRulesRef = ref<{ open: () => void } | null>(null)
const isSchool = import.meta.env.MODE === 'school' const isSchool = import.meta.env.MODE === 'school'
const SelectClassDialog = defineAsyncComponent(() => import('../components/SelectClassDialog.vue')) const SelectClassDialog = defineAsyncComponent(() => import('../components/SelectClassDialog.vue'))
...@@ -127,7 +130,7 @@ function handleEditReportRules() { ...@@ -127,7 +130,7 @@ function handleEditReportRules() {
<template> <template>
<AppCard title="实验管理"> <AppCard title="实验管理">
<template #header> <template #header>
<div> <div class="experiment-header">
<h2 class="app-card-hd__title">实验管理</h2> <h2 class="app-card-hd__title">实验管理</h2>
<div class="btn-all" style="margin-bottom: 15px"> <div class="btn-all" style="margin-bottom: 15px">
<el-button type="primary" v-if="detail?.type === '4'"> <el-button type="primary" v-if="detail?.type === '4'">
...@@ -139,22 +142,34 @@ function handleEditReportRules() { ...@@ -139,22 +142,34 @@ function handleEditReportRules() {
<el-button type="primary" v-if="detail?.type === '6'"> <el-button type="primary" v-if="detail?.type === '6'">
<a :href="aiURL" target="_blank">进入AI实验平台</a> <a :href="aiURL" target="_blank">进入AI实验平台</a>
</el-button> </el-button>
<el-button type="primary"> <el-button v-if="!isMobile" type="primary">
<router-link <router-link
:to="{ path: '/admin/lab/score', query: { course_id: detail?.course_id, experiment_id: detail?.id } }" :to="{ path: '/admin/lab/score', query: { course_id: detail?.course_id, experiment_id: detail?.id } }"
target="_blank" target="_blank"
>实验评分</router-link >实验评分</router-link
> >
</el-button> </el-button>
<el-button type="primary" @click="gradeRulesVisible = true">成绩规则</el-button> <el-button v-if="!isMobile" type="primary" @click="gradeRulesVisible = true">成绩规则</el-button>
<el-button type="primary" @click="reportRulesVisible = true">报告规则</el-button> <el-button v-if="!isMobile" type="primary" @click="reportRulesVisible = true">报告规则</el-button>
<el-button type="primary" @click="handleUpdateDML()" v-if="detail?.type === '4'">配置数字营销</el-button> <el-button type="primary" @click="handleUpdateDML()" v-if="!isMobile && detail?.type === '4'">配置数字营销</el-button>
<CompetitionRuleDialog v-if="!isSchool" :id="id"></CompetitionRuleDialog> <CompetitionRuleDialog v-if="!isSchool" :id="id" :show-trigger="!isMobile" ref="competitionRulesRef"></CompetitionRuleDialog>
<el-button type="primary" @click="handleCopy()">复制实验</el-button> <el-button v-if="!isMobile" type="primary" @click="handleCopy()">复制实验</el-button>
<el-dropdown v-if="isMobile" trigger="click" class="experiment-more">
<el-button>更多操作<el-icon class="more-arrow"><ArrowDown /></el-icon></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="gradeRulesVisible = true">成绩规则</el-dropdown-item>
<el-dropdown-item @click="reportRulesVisible = true">报告规则</el-dropdown-item>
<el-dropdown-item v-if="detail?.type === '4'" @click="handleUpdateDML">配置数字营销</el-dropdown-item>
<el-dropdown-item v-if="!isSchool" @click="competitionRulesRef?.open()">编辑大赛规则</el-dropdown-item>
<el-dropdown-item divided @click="handleCopy">复制实验</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div> </div>
</div> </div>
</template> </template>
<el-descriptions title="基本信息" v-if="detail"> <el-descriptions title="基本信息" :column="isMobile ? 1 : 3" v-if="detail">
<!-- <template #extra> <!-- <template #extra>
<el-button type="primary" v-if="detail.type === '4'"> <el-button type="primary" v-if="detail.type === '4'">
<a :href="dmlURL" target="_blank">进入实验平台</a> <a :href="dmlURL" target="_blank">进入实验平台</a>
...@@ -162,17 +177,17 @@ function handleEditReportRules() { ...@@ -162,17 +177,17 @@ function handleEditReportRules() {
<el-button type="primary" @click="gradeRulesVisible = true">查看成绩规则</el-button> <el-button type="primary" @click="gradeRulesVisible = true">查看成绩规则</el-button>
<el-button type="primary" @click="reportRulesVisible = true">查看报告规则</el-button> <el-button type="primary" @click="reportRulesVisible = true">查看报告规则</el-button>
</template> --> </template> -->
<el-descriptions-item :span="3" label="实验名称:">{{ detail.name }}</el-descriptions-item> <el-descriptions-item :span="isMobile ? 1 : 3" label="实验名称:">{{ detail.name }}</el-descriptions-item>
<el-descriptions-item label="实验课程:">{{ detail.course_name }}</el-descriptions-item> <el-descriptions-item label="实验课程:">{{ detail.course_name }}</el-descriptions-item>
<el-descriptions-item label="所属机构/学校:">{{ detail.organ_id_name }}</el-descriptions-item> <el-descriptions-item label="所属机构/学校:">{{ detail.organ_id_name }}</el-descriptions-item>
<el-descriptions-item label="实验总成绩:">{{ detail.score }}</el-descriptions-item> <el-descriptions-item label="实验总成绩:">{{ detail.score }}</el-descriptions-item>
<el-descriptions-item label="实验类型:">{{ detail.type_name }}</el-descriptions-item> <el-descriptions-item label="实验类型:">{{ detail.type_name }}</el-descriptions-item>
<el-descriptions-item :span="2" label="实验学时:">{{ detail.length }}学时</el-descriptions-item> <el-descriptions-item :span="isMobile ? 1 : 2" label="实验学时:">{{ detail.length }}学时</el-descriptions-item>
<el-descriptions-item :span="3" label="指导教师:">{{ teacherText }}</el-descriptions-item> <el-descriptions-item :span="isMobile ? 1 : 3" label="指导教师:">{{ teacherText }}</el-descriptions-item>
<el-descriptions-item :span="3" label="实验目的:">{{ detail.purpose }}</el-descriptions-item> <el-descriptions-item :span="isMobile ? 1 : 3" label="实验目的:">{{ detail.purpose }}</el-descriptions-item>
<el-descriptions-item :span="3" label="实验要求:">{{ detail.requirements }}</el-descriptions-item> <el-descriptions-item :span="isMobile ? 1 : 3" label="实验要求:">{{ detail.requirements }}</el-descriptions-item>
<el-descriptions-item :span="3" label="实验内容及原理:">{{ detail.content }}</el-descriptions-item> <el-descriptions-item :span="isMobile ? 1 : 3" label="实验内容及原理:">{{ detail.content }}</el-descriptions-item>
<el-descriptions-item :span="3" label="实验步骤及过程:">{{ detail.procedure }}</el-descriptions-item> <el-descriptions-item :span="isMobile ? 1 : 3" label="实验步骤及过程:">{{ detail.procedure }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
<el-divider /> <el-divider />
<AppList v-bind="listOptions" ref="appList"> <AppList v-bind="listOptions" ref="appList">
...@@ -254,8 +269,20 @@ function handleEditReportRules() { ...@@ -254,8 +269,20 @@ function handleEditReportRules() {
gap: 8px; gap: 8px;
} }
@media (max-width: 767px) { @media (max-width: 767px) {
.btn-all .el-button { .experiment-header { width: 100%; }
width: 100%; .btn-all {
align-items: center;
.el-button {
height: 40px;
margin-left: 0;
padding: 0 12px;
a { display: flex; align-items: center; justify-content: center; height: 100%; }
}
> .el-button { flex: 1; min-width: 0; }
} }
.experiment-more { margin-left: auto; .el-button { width: 100%; } }
.more-arrow { margin-left: 6px; }
:deep(.el-descriptions__cell) { padding-bottom: 12px; line-height: 1.7; overflow-wrap: anywhere; }
:deep(.el-descriptions__label) { color: var(--el-text-color-secondary); }
} }
</style> </style>
...@@ -245,11 +245,29 @@ function onUpdateSuccess() { ...@@ -245,11 +245,29 @@ function onUpdateSuccess() {
} }
} }
@media (max-width: 767px) { @media (max-width: 767px) {
:deep(.table-list-filter) {
padding: 12px;
.el-form-item:last-child {
margin-bottom: 0;
}
}
:deep(.table-list-buttons) { :deep(.table-list-buttons) {
justify-content: flex-start; justify-content: space-between;
align-items: center;
.el-button {
height: 40px;
min-height: 40px;
}
} }
.view-switch { .view-switch {
margin-left: 0; margin-left: auto;
height: 40px;
box-sizing: border-box;
flex-shrink: 0;
}
.view-switch__button {
width: 40px;
height: 100%;
} }
.list-card { .list-card {
grid-template-columns: 1fr; grid-template-columns: 1fr;
......
...@@ -106,11 +106,25 @@ function handleChange(id: string, type: number) { ...@@ -106,11 +106,25 @@ function handleChange(id: string, type: number) {
} }
@media (max-width: 767px) { @media (max-width: 767px) {
.bg { .bg {
padding: 20px 12px; display: none;
} }
.link1,.link2,.link3,.link4 { .link1,.link2,.link3,.link4 {
max-width: 100%; display: none;
margin-bottom: 16px; }
.select-group {
left: 12px;
right: 12px;
bottom: max(12px, env(safe-area-inset-bottom));
display: flex;
flex-wrap: wrap;
gap: 12px;
.el-select {
width: calc(50% - 6px);
min-width: 0;
}
.el-select + .el-select {
margin-left: 0;
}
} }
} }
</style> </style>
...@@ -172,11 +172,25 @@ const learnURL = urls.learn ...@@ -172,11 +172,25 @@ const learnURL = urls.learn
} }
@media (max-width: 767px) { @media (max-width: 767px) {
.bg { .bg {
padding: 20px 12px; display: none;
} }
.link1,.link2,.link3,.link4 { .link1,.link2,.link3,.link4 {
max-width: 100%; display: none;
margin-bottom: 16px; }
.select-group {
left: 12px;
right: 12px;
bottom: max(12px, env(safe-area-inset-bottom));
display: flex;
flex-wrap: wrap;
gap: 12px;
.el-select {
width: calc(50% - 6px);
min-width: 0;
}
.el-select + .el-select {
margin-left: 0;
}
} }
} }
</style> </style>
...@@ -133,11 +133,25 @@ function handleChange(id: string, type: number) { ...@@ -133,11 +133,25 @@ function handleChange(id: string, type: number) {
} }
@media (max-width: 767px) { @media (max-width: 767px) {
.bg { .bg {
padding: 20px 12px; display: none;
} }
.link1,.link2,.link3,.link4 { .link1,.link2,.link3,.link4 {
max-width: 100%; display: none;
margin-bottom: 16px; }
.select-group {
left: 12px;
right: 12px;
bottom: max(12px, env(safe-area-inset-bottom));
display: flex;
flex-wrap: wrap;
gap: 12px;
.el-select {
width: calc(50% - 6px);
min-width: 0;
}
.el-select + .el-select {
margin-left: 0;
}
} }
} }
</style> </style>
...@@ -22,4 +22,20 @@ const StudentHome = defineAsyncComponent(() => import('../components/StudentHome ...@@ -22,4 +22,20 @@ const StudentHome = defineAsyncComponent(() => import('../components/StudentHome
.home { .home {
margin: 0 30px; margin: 0 30px;
} }
@media (max-width: 767px) {
.home {
margin: 12px;
min-height: calc(100dvh - 76px);
padding-bottom: 116px;
box-sizing: border-box;
}
}
</style>
<style lang="scss">
@media (max-width: 767px) {
.app-main:has(.home) {
padding: 0;
}
}
</style> </style>
<script setup> <script setup>
import { useMediaQuery } from '@vueuse/core'
const isMobile = useMediaQuery('(max-width: 767px)')
const props = defineProps({ data: Object }) const props = defineProps({ data: Object })
const teacherText = computed(() => { const teacherText = computed(() => {
...@@ -9,7 +11,7 @@ const teacherText = computed(() => { ...@@ -9,7 +11,7 @@ const teacherText = computed(() => {
<template> <template>
<template v-if="data"> <template v-if="data">
<el-form label-suffix=":" label-width="120px" label-position="right"> <el-form label-suffix=":" label-width="120px" :label-position="isMobile ? 'top' : 'right'">
<el-form-item label="实验名称">{{ data.name }}</el-form-item> <el-form-item label="实验名称">{{ data.name }}</el-form-item>
<el-form-item label="实验课程名称">{{ data.course.name }}</el-form-item> <el-form-item label="实验课程名称">{{ data.course.name }}</el-form-item>
<el-form-item label="所属机构/学校">{{ data.organization.name }}</el-form-item> <el-form-item label="所属机构/学校">{{ data.organization.name }}</el-form-item>
...@@ -28,3 +30,11 @@ const teacherText = computed(() => { ...@@ -28,3 +30,11 @@ const teacherText = computed(() => {
</template> </template>
<el-empty description="暂无数据" v-else /> <el-empty description="暂无数据" v-else />
</template> </template>
<style lang="scss" scoped>
@media (max-width: 767px) {
:deep(.el-form-item__label) { width: auto !important; line-height: 24px; height: auto; padding-bottom: 4px; color: var(--el-text-color-secondary); }
:deep(.el-form-item__content) { line-height: 1.7; overflow-wrap: anywhere; min-width: 0; }
:deep(.el-divider) { margin: 16px 0; }
}
</style>
<script setup lang="ts"> <script setup lang="ts">
import type { CourseType, ExperimentInfo, ExperimentRecord } from '../types' import type { CourseType, ExperimentInfo, ExperimentRecord } from '../types'
import { HomeFilled } from '@element-plus/icons-vue' import { HomeFilled, ArrowDown } from '@element-plus/icons-vue'
import { ElMessageBox, ElMessage } from 'element-plus' import { ElMessageBox, ElMessage } from 'element-plus'
import { useMediaQuery } from '@vueuse/core' import { useMediaQuery } from '@vueuse/core'
import DragPanel from '@/components/DragPanel.vue' import DragPanel from '@/components/DragPanel.vue'
...@@ -105,20 +105,13 @@ watchEffect(() => { ...@@ -105,20 +105,13 @@ watchEffect(() => {
}) })
const tabActive = ref('info') const tabActive = ref('info')
const mobilePanelExpanded = ref(false) const mobileActionsVisible = ref(false)
const tabLabelMap: Record<string, string> = { watch(isMobile, () => { mobileActionsVisible.value = false })
info: '实验信息', function closeMobileActionPanel(event: MouseEvent) {
example: '实验案例', const target = event.target as HTMLElement
caseResource: '案例资源', const button = target.closest('button')
case: '案例原文', if (button && !button.disabled) mobileActionsVisible.value = false
book: '实训指导',
video: '操作视频',
exam: '理论试题',
qa: '实操试题',
result: '过程与结果',
discuss: '讨论交流',
} }
const currentTabLabel = computed(() => tabLabelMap[tabActive.value] || '实验面板')
const examURL = ref('') const examURL = ref('')
// 右侧 // 右侧
...@@ -271,11 +264,11 @@ function handleGoToFunction(path: string) { ...@@ -271,11 +264,11 @@ function handleGoToFunction(path: string) {
</script> </script>
<template> <template>
<DragPanel @resize="handleResize" :isLeftShow="experimentInfo?.exam_status === 1"> <DragPanel class="student-lab-layout" @resize="handleResize" :isLeftShow="experimentInfo?.exam_status === 1" mobile-toggle-label="实验资料">
<template #left> <template #left>
<div class="lab-left"> <div class="lab-left">
<div class="lab-left-body"> <div class="lab-left-body">
<el-form :model="form" label-suffix=":" hide-required-asterisk> <el-form :model="form" label-suffix=":" hide-required-asterisk :label-position="isMobile ? 'top' : 'right'" class="lab-course-picker">
<el-form-item label="请选择课程"> <el-form-item label="请选择课程">
<el-select filterable :model-value="form.course_id" @change="handleCourseChange" style="width: 100%"> <el-select filterable :model-value="form.course_id" @change="handleCourseChange" style="width: 100%">
<el-option v-for="item in courses" :key="item.id" :label="item.name" :value="item.id"></el-option> <el-option v-for="item in courses" :key="item.id" :label="item.name" :value="item.id"></el-option>
...@@ -287,13 +280,7 @@ function handleGoToFunction(path: string) { ...@@ -287,13 +280,7 @@ function handleGoToFunction(path: string) {
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-form> </el-form>
<div class="mobile-panel-toggle" v-if="isMobile && experimentInfo"> <el-tabs type="border-card" v-model="tabActive" v-if="experimentInfo">
<span>当前面板:{{ currentTabLabel }}</span>
<el-button text type="primary" @click="mobilePanelExpanded = !mobilePanelExpanded">
{{ mobilePanelExpanded ? '收起' : '展开' }}
</el-button>
</div>
<el-tabs type="border-card" v-model="tabActive" v-if="experimentInfo" v-show="!isMobile || mobilePanelExpanded">
<el-tab-pane label="实验信息" name="info"> <el-tab-pane label="实验信息" name="info">
<Info :data="experimentInfo"></Info> <Info :data="experimentInfo"></Info>
</el-tab-pane> </el-tab-pane>
...@@ -348,19 +335,20 @@ function handleGoToFunction(path: string) { ...@@ -348,19 +335,20 @@ function handleGoToFunction(path: string) {
</div> </div>
</template> </template>
<template #right> <template #right>
<AppCard> <AppCard class="lab-operation-card">
<div class="exam-status" v-if="experimentInfo?.exam_status === 1"> <div class="exam-status" v-if="experimentInfo?.exam_status === 1">
<el-button type="primary" @click="tabActive = 'exam'">理论试题</el-button> <el-button type="primary" @click="tabActive = 'exam'">理论试题</el-button>
<el-button type="primary" @click="tabActive = 'qa'">实操环境</el-button> <el-button type="primary" @click="tabActive = 'qa'">实操环境</el-button>
</div> </div>
<el-row justify="space-between" v-else class="lab-toolbar"> <el-row justify="space-between" v-else class="lab-toolbar">
<div class="lab-toolbar-group"> <div class="lab-toolbar-group">
<el-button type="primary" :icon="HomeFilled" :disabled="submitted" @click="handleBackHome" <el-button v-if="!isMobile" type="primary" :icon="HomeFilled" :disabled="submitted" @click="handleBackHome"
>返回首页</el-button >返回首页</el-button
> >
<el-button type="primary" :disabled="disabled" @click="handleSubmit">提交实验</el-button> <el-button type="primary" :disabled="disabled" @click="handleSubmit">提交实验</el-button>
<el-button v-if="isMobile" @click="mobileActionsVisible = true">更多操作<el-icon style="margin-left: 6px"><ArrowDown /></el-icon></el-button>
</div> </div>
<div class="lab-toolbar-group"> <div class="lab-toolbar-group" v-if="!isMobile">
<el-button type="primary" v-if="appConfig.system == 'default'"> <el-button type="primary" v-if="appConfig.system == 'default'">
<router-link to="/ai" target="_blank">AI数据分析</router-link> <router-link to="/ai" target="_blank">AI数据分析</router-link>
</el-button> </el-button>
...@@ -429,6 +417,44 @@ function handleGoToFunction(path: string) { ...@@ -429,6 +417,44 @@ function handleGoToFunction(path: string) {
</template> </template>
</DragPanel> </DragPanel>
<el-drawer v-model="mobileActionsVisible" title="实验操作" direction="btt" size="65%" append-to-body class="student-lab-actions">
<div class="student-lab-action-list" @click="closeMobileActionPanel">
<el-button :icon="HomeFilled" :disabled="submitted" @click="handleBackHome">返回实验首页</el-button>
<el-button type="primary" v-if="appConfig.system == 'default'">
<router-link to="/ai" target="_blank">AI数据分析</router-link>
</el-button>
<el-button type="primary" :disabled="disabled" :loading="screenshotLoading" @click="handleCapture"
>截图</el-button
>
<el-button type="primary" :disabled="disabled" @click="prepareDialogVisible = true">实验准备</el-button>
<el-button type="primary" :disabled="disabled" @click="resultDialogVisible = true">实验结果</el-button>
<el-button
type="primary"
:disabled="disabled"
v-if="experimentInfo?.report_upload_way === 2 && !experimentInfo?.is_commit_report">
<router-link :to="`/student/lab/report/${form.experiment_id}`" target="_blank">在线实验报告</router-link>
</el-button>
<el-button
type="primary"
:disabled="disabled"
@click="reportDialogVisible = true"
v-if="experimentInfo?.report_upload_way === 1 && !submitted"
>上传实验报告</el-button
>
<el-button type="primary" @click="handleReportView" v-if="experimentInfo?.is_commit_report"
>查看实验报告</el-button
>
<el-button
type="primary"
@click="handleReportExport"
v-if="detail?.status === 2 && experimentInfo?.is_commit_report"
>导出实验报告</el-button
>
</div>
</el-drawer>
<!-- 上传报告 --> <!-- 上传报告 -->
<ReportDialog <ReportDialog
v-model="reportDialogVisible" v-model="reportDialogVisible"
...@@ -492,23 +518,6 @@ function handleGoToFunction(path: string) { ...@@ -492,23 +518,6 @@ function handleGoToFunction(path: string) {
min-height: 0; min-height: 0;
flex-direction: column; flex-direction: column;
} }
.mobile-panel-toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
padding: 10px 12px;
background: #fff;
border-radius: 8px;
span {
flex: 1;
min-width: 0;
color: #606266;
font-size: 14px;
line-height: 20px;
}
}
.lab-toolbar { .lab-toolbar {
gap: 12px; gap: 12px;
} }
...@@ -539,25 +548,49 @@ function handleGoToFunction(path: string) { ...@@ -539,25 +548,49 @@ function handleGoToFunction(path: string) {
min-height: 100%; min-height: 100%;
} }
@media (max-width: 767px) { @media (max-width: 767px) {
.student-lab-layout {
height: calc(100dvh - 52px - 24px);
min-height: 0;
}
.student-lab-layout:has(.drag-panel-left:not(.is-hidden)) {
height: auto;
:deep(.drag-panel-right) {
flex: none;
height: calc(100dvh - 52px - 24px - 56px);
min-height: calc(100dvh - 52px - 24px - 56px);
}
}
:deep(.drag-panel-left:not(.is-hidden)) {
flex-shrink: 0;
height: auto;
min-height: 0;
max-height: none;
overflow: visible;
border-radius: 8px 8px 0 0;
}
:deep(.panel-mobile-toggle) { flex-shrink: 0; }
:deep(.drag-panel-left-content) { height: auto; overflow: visible; }
:deep(.drag-panel-right) {
flex: 1;
height: auto;
min-height: 0;
}
.lab-operation-card {
flex-shrink: 0;
:deep(.app-card-hd) { display: none; }
}
.lab-course-picker {
:deep(.el-form-item__label) { height: auto; line-height: 24px; padding-bottom: 4px; }
:deep(.el-form-item__content) { min-width: 0; }
}
.lab-left { .lab-left {
height: auto;
overflow: visible;
.el-tabs { flex: none; }
:deep(.el-tab-pane) { height: auto; }
:deep(.el-form-item) { :deep(.el-form-item) {
margin-bottom: 12px; margin-bottom: 12px;
} }
:deep(.el-tabs__header) {
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
-ms-overflow-style: none;
}
:deep(.el-tabs__header::-webkit-scrollbar) {
display: none;
}
:deep(.el-tabs__nav-wrap) {
overflow: visible;
}
:deep(.el-tabs__nav) {
flex-wrap: nowrap;
}
:deep(.el-tabs__item) { :deep(.el-tabs__item) {
flex-shrink: 0; flex-shrink: 0;
min-width: max-content; min-width: max-content;
...@@ -590,15 +623,37 @@ function handleGoToFunction(path: string) { ...@@ -590,15 +623,37 @@ function handleGoToFunction(path: string) {
justify-content: center; justify-content: center;
} }
.lab-box { .lab-box {
flex: 1;
margin-top: 12px; margin-top: 12px;
display: block; display: flex;
height: 74vh; flex-direction: column;
min-height: 74vh; height: auto;
min-height: 0;
overflow: hidden; overflow: hidden;
} }
.iframe { .lab-box :deep(iframe) {
height: 74vh; display: block;
min-height: 74vh; flex: 1;
width: 100%;
height: 100%;
min-height: 0;
}
}
</style>
<style lang="scss">
@media (max-width: 767px) {
.student-lab-actions {
border-radius: 16px 16px 0 0;
.el-drawer__header { padding: 16px; margin-bottom: 0; }
.el-drawer__body { padding: 16px 16px max(16px, env(safe-area-inset-bottom)); }
.student-lab-action-list {
display: flex;
flex-direction: column;
gap: 12px;
.el-button { width: 100%; margin-left: 0; }
.el-button a { display: flex; align-items: center; justify-content: center; width: 100%; }
}
} }
} }
</style> </style>
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论