feat(ui): 优化移动端响应式布局和交互体验

- 添加移动端侧边栏抽屉导航,支持触控滑动操作
- 优化 MaterialListNew 页面移动端适配,重构工具栏布局
- 统一日历组件默认语言为中文,增强本地化支持
- 更新依赖包添加 date-fns 和 vaul-vue 用于日期处理和抽屉组件
- 改进 iOS 滚动体验,添加触摸滚动优化
- 调整主布局结构,优化全屏高度和溢出处理
- 修复移动端分类切换器显示问题,优化按钮样式
This commit is contained in:
2026-03-20 19:42:56 +08:00
parent 040fa946a0
commit 4f3de2b78f
14 changed files with 221 additions and 75 deletions

View File

@@ -3,7 +3,9 @@
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>牟野营销</title>
</head>
<body>

View File

@@ -24,6 +24,7 @@
"aplayer": "^1.10.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.18",
"lucide-vue-next": "^0.575.0",
"markdown-it": "^14.1.0",
@@ -34,6 +35,7 @@
"reka-ui": "^2.9.2",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"vaul-vue": "^0.4.1",
"vue": "^3.5.22",
"vue-router": "^4.5.1",
"vue-sonner": "^2.0.9",

View File

@@ -32,7 +32,7 @@ defineExpose({ changeLocale, locale })
</script>
<template>
<div class="app-container min-h-screen bg-background text-foreground antialiased">
<div class="app-container h-screen overflow-hidden bg-background text-foreground antialiased">
<SvgSprite />
<!-- vue-sonner Toaster 全局配置 -->

View File

@@ -74,7 +74,7 @@ function isActive(item) {
</script>
<template>
<Sidebar collapsible="none" class="h-[calc(100vh-70px)] border-r">
<Sidebar collapsible="offcanvas" class="h-svh border-r">
<SidebarContent>
<SidebarGroup v-for="group in visibleNavConfig" :key="group.group">
<SidebarGroupLabel>{{ group.group }}</SidebarGroupLabel>

View File

@@ -3,11 +3,13 @@ import { computed } from 'vue'
import { Icon } from '@iconify/vue'
import { useUserStore } from '@/stores/user'
import { useTheme } from '@/composables/useTheme'
import { useSidebar } from '@/components/ui/sidebar'
import UserDropdown from '@/components/UserDropdown.vue'
import BrandLogo from '@/components/BrandLogo.vue'
const userStore = useUserStore()
const { isDark, toggleTheme } = useTheme()
const { isMobile, toggleSidebar } = useSidebar()
// 计算是否应该显示用户组件
const shouldShowUser = computed(() => {
@@ -18,10 +20,16 @@ const shouldShowUser = computed(() => {
<template>
<header
class="p-1 fixed top-0 left-0 right-0 flex items-center px-6 h-[70px] bg-background/80 border-b border-border/50 z-50"
style="backdrop-filter: blur(12px)"
class="sticky top-0 flex items-center px-6 h-[70px] bg-background/80 border-b border-border/50 z-40 backdrop-blur-xl"
>
<div class="flex items-center gap-4 flex-1">
<button
v-if="isMobile"
class="flex items-center justify-center w-9 h-9 rounded-md hover:bg-accent transition-colors"
@click="toggleSidebar"
>
<Icon icon="lucide:menu" class="w-5 h-5" />
</button>
<BrandLogo :size="36" />
</div>
@@ -41,10 +49,6 @@ const shouldShowUser = computed(() => {
</template>
<style scoped>
header {
backdrop-filter: blur(12px);
}
.theme-toggle {
display: flex;
align-items: center;

View File

@@ -14,6 +14,7 @@ import { CalendarCell, CalendarCellTrigger, CalendarGrid, CalendarGridBody, Cale
const props = withDefaults(defineProps<CalendarRootProps & { class?: HTMLAttributes["class"], layout?: LayoutTypes, yearRange?: DateValue[] }>(), {
modelValue: undefined,
layout: undefined,
locale: 'zh-CN',
})
const emits = defineEmits<CalendarRootEmits>()

View File

@@ -6,7 +6,9 @@ import { RangeCalendarRoot, useForwardPropsEmits } from "reka-ui"
import { cn } from "@/lib/utils"
import { RangeCalendarCell, RangeCalendarCellTrigger, RangeCalendarGrid, RangeCalendarGridBody, RangeCalendarGridHead, RangeCalendarGridRow, RangeCalendarHeadCell, RangeCalendarHeader, RangeCalendarHeading, RangeCalendarNextButton, RangeCalendarPrevButton } from "."
const props = defineProps<RangeCalendarRootProps & { class?: HTMLAttributes["class"] }>()
const props = withDefaults(defineProps<RangeCalendarRootProps & { class?: HTMLAttributes["class"] }>(), {
locale: 'zh-CN',
})
const emits = defineEmits<RangeCalendarRootEmits>()

View File

@@ -1,24 +1,21 @@
<script setup>
import { SidebarProvider } from '@/components/ui/sidebar'
import { SidebarProvider, SidebarInset } from '@/components/ui/sidebar'
import TopNav from '@/components/TopNav.vue'
import SidebarNav from '@/components/SidebarNav.vue'
</script>
<template>
<SidebarProvider
:style="{ '--sidebar-width': '220px' }"
class="flex flex-col min-h-screen bg-background"
>
<TopNav />
<div class="flex flex-1 pt-[70px]">
<SidebarNav />
<main class="flex-1 h-[calc(100vh-70px)] overflow-hidden">
<SidebarProvider class="h-full bg-background">
<SidebarNav />
<SidebarInset class="flex flex-col h-full overflow-hidden">
<TopNav />
<div class="flex-1 overflow-auto">
<RouterView v-slot="{ Component }">
<keep-alive>
<component :is="Component" />
</keep-alive>
</RouterView>
</main>
</div>
</div>
</SidebarInset>
</SidebarProvider>
</template>

View File

@@ -1,15 +1,18 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import 'normalize.css'
import 'aplayer/dist/APlayer.min.css'
import 'vue-sonner/style.css'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import App from './App.vue'
import router from './router'
import './theme.css'
// 初始化 dayjs 为中文
dayjs.locale('zh-cn');
const app = createApp(App)
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

View File

@@ -410,11 +410,23 @@ body {
scrollbar-gutter: stable both-edges;
}
/* 平滑滚动 */
/* 平滑滚动 - 桌面端 */
html {
scroll-behavior: smooth;
}
/* iOS 丝滑滚动优化 */
@supports (-webkit-touch-callout: none) {
html, body {
-webkit-overflow-scrolling: touch;
}
/* 任何可滚动容器 */
[style*="overflow"], .overflow-auto, .overflow-y-auto, .overflow-x-auto {
-webkit-overflow-scrolling: touch;
}
}
/* 选中文本样式 */
::selection {
background: oklch(0.55 0.14 270 / 0.20);

View File

@@ -1,11 +1,11 @@
<template>
<FullWidthLayout :show-padding="false" class="material-list-layout">
<div class="material-list-container">
<!-- 左侧分组面板 - 仅混剪素材显示 -->
<!-- 左侧分组面板 - 桌面端显示移动端隐藏 -->
<transition name="sidebar-slide">
<div
v-show="activeCategory === 'MIX'"
class="w-[220px] bg-card border-r border-border flex flex-col px-3 py-5 shrink-0"
class="hidden md:flex w-[220px] bg-card border-r border-border flex-col px-3 py-5 shrink-0"
>
<!-- 分组列表 -->
<div class="flex flex-col h-full">
@@ -72,49 +72,67 @@
<!-- 右侧内容区域 -->
<div class="material-content">
<!-- 顶部工具栏 -->
<div class="flex items-center justify-between px-6 py-4 bg-card border-b border-border gap-6">
<!-- 分类切换 - 胶囊式设计 -->
<div class="flex bg-muted/50 rounded-full p-1 gap-1">
<button
class="flex items-center gap-2 px-6 py-2 rounded-full cursor-pointer text-sm font-medium transition-all duration-200"
:class="activeCategory === 'MIX'
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'"
@click="handleCategoryChange('MIX')"
>
<Icon icon="lucide:video" class="text-base" />
<span>混剪素材</span>
</button>
<button
class="flex items-center gap-2 px-6 py-2 rounded-full cursor-pointer text-sm font-medium transition-all duration-200"
:class="activeCategory === 'DIGITAL_HUMAN'
? 'bg-primary text-primary-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'"
@click="handleCategoryChange('DIGITAL_HUMAN')"
>
<Icon icon="lucide:user" class="text-base" />
<span>数字人素材</span>
</button>
</div>
<!-- 搜索和操作区 -->
<div class="flex items-center gap-3">
<!-- 存储配额显示 -->
<div class="flex items-center gap-2 px-3 py-2 bg-muted rounded-lg border border-border">
<span class="text-xs text-muted-foreground">存储空间</span>
<span class="text-xs font-medium">{{ userStore.usedStorage.toFixed(2) }} / {{ userStore.totalStorage }} GB</span>
<div class="w-20 h-1 bg-border rounded-full overflow-hidden">
<div
class="h-full bg-gradient-to-r from-primary to-primary/70 rounded-full transition-all duration-300"
:style="{ width: `${Math.min((userStore.usedStorage / userStore.totalStorage) * 100, 100)}%` }"
></div>
<div class="toolbar-container">
<!-- 第一行分类切换 + 操作按钮 -->
<div class="flex items-center justify-between gap-3">
<!-- 移动端分组按钮 + 分类切换器 -->
<div class="flex items-center gap-2">
<Button
v-if="activeCategory === 'MIX'"
variant="outline"
size="sm"
class="md:hidden shrink-0"
@click="groupDrawerOpen = true"
>
<Icon icon="lucide:folder" class="size-4 mr-1" />
{{ currentGroupName }}
</Button>
<!-- 分类切换器 -->
<div class="flex bg-muted/50 rounded-full p-0.5 md:p-1 gap-0.5 md:gap-1">
<button
class="category-btn"
:class="activeCategory === 'MIX' ? 'active' : ''"
@click="handleCategoryChange('MIX')"
>
<Icon icon="lucide:video" class="size-4 md:size-[18px]" />
<span class="hidden sm:inline">混剪素材</span>
</button>
<button
class="category-btn"
:class="activeCategory === 'DIGITAL_HUMAN' ? 'active' : ''"
@click="handleCategoryChange('DIGITAL_HUMAN')"
>
<Icon icon="lucide:user" class="size-4 md:size-[18px]" />
<span class="hidden sm:inline">数字人素材</span>
</button>
</div>
</div>
<div class="relative flex items-center">
<!-- 操作按钮区 -->
<div class="flex items-center gap-2">
<!-- 存储配额 - 桌面端显示 -->
<div class="hidden lg:flex items-center gap-2 px-3 py-1.5 bg-muted rounded-lg border border-border text-xs">
<span class="text-muted-foreground">存储</span>
<span class="font-medium">{{ userStore.usedStorage.toFixed(1) }}/{{ userStore.totalStorage }}GB</span>
</div>
<!-- 上传按钮 -->
<Button
size="sm"
:disabled="activeCategory === 'MIX' && (!selectedGroupId || groupList.length === 0)"
@click="handleOpenUploadModal"
>
<Icon icon="lucide:upload" class="size-4 md:mr-2" />
<span class="hidden md:inline">上传</span>
</Button>
</div>
</div>
<!-- 第二行搜索框 -->
<div class="flex items-center gap-2 mt-3">
<div class="relative flex-1">
<Input
v-model="searchKeyword"
placeholder="搜索文件名..."
class="w-72"
@keypress-enter="handleSearch"
>
<template #prefix>
@@ -125,13 +143,6 @@
<Icon icon="lucide:x" class="size-3" />
</Button>
</div>
<Button
:disabled="activeCategory === 'MIX' && (!selectedGroupId || groupList.length === 0)"
@click="handleOpenUploadModal"
>
<Icon icon="lucide:upload" class="mr-2 size-4" />
上传
</Button>
</div>
</div>
@@ -334,6 +345,48 @@
:video-url="previewUrl"
:title="previewTitle"
/>
<!-- 移动端分组抽屉 -->
<Drawer :open="groupDrawerOpen" @update:open="groupDrawerOpen = $event">
<DrawerContent class="max-h-[85vh]">
<DrawerHeader>
<DrawerTitle>选择分组</DrawerTitle>
</DrawerHeader>
<div class="flex-1 overflow-y-auto px-4 pb-4">
<!-- 新建分组按钮 -->
<Button
variant="outline"
class="w-full mb-3"
@click="groupDrawerOpen = false; handleOpenCreateGroupModal()"
>
<Icon icon="lucide:plus" class="size-4 mr-2" />
新建分组
</Button>
<!-- 分组列表 -->
<div class="space-y-1">
<div
v-for="group in groupList"
:key="group.id"
class="flex items-center justify-between px-4 py-3 cursor-pointer rounded-xl transition-all"
:class="selectedGroupId === group.id
? 'bg-primary/10 text-primary'
: 'hover:bg-muted active:bg-muted'"
@click="handleSelectGroup(group)"
>
<div class="flex items-center gap-3">
<Icon
icon="lucide:folder"
class="size-5"
:class="selectedGroupId === group.id ? 'text-primary' : 'text-muted-foreground'"
/>
<span class="font-medium">{{ group.name }}</span>
</div>
<span class="text-sm text-muted-foreground">{{ group.fileCount }}</span>
</div>
</div>
</div>
</DrawerContent>
</Drawer>
</div>
</FullWidthLayout>
</template>
@@ -356,6 +409,15 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from '@/components/ui/drawer';
import {
AlertDialog,
AlertDialogAction,
@@ -370,8 +432,8 @@ import {
import MaterialUploadModal from '@/components/material/MaterialUploadModal.vue';
import MaterialService, { MaterialGroupService } from '@/api/material';
import FullWidthLayout from '@/layouts/components/FullWidthLayout.vue';
import { useUserStore } from '@/stores/user';
import VideoPreviewModal from '@/components/VideoPreviewModal.vue';
import { useUserStore } from '@/stores/user';
// 用户状态(获取存储配额)
const userStore = useUserStore()
@@ -388,6 +450,7 @@ const searchKeyword = ref('')
// 模态框状态
const uploadModalVisible = ref(false)
const createGroupModalVisible = ref(false)
const groupDrawerOpen = ref(false)
// 表单数据
const createGroupForm = reactive({
@@ -421,6 +484,12 @@ const pagination = reactive({
// 计算总页数
const totalPages = computed(() => Math.ceil(pagination.total / pagination.pageSize))
// 当前分组名称
const currentGroupName = computed(() => {
const group = groupList.value.find(g => g.id === selectedGroupId.value)
return group?.name || '选择分组'
})
// 方法
const handleCategoryChange = async (category) => {
activeCategory.value = category
@@ -448,6 +517,7 @@ const handleSelectGroup = (group) => {
if (selectedGroupId.value === group.id) return
selectedGroupId.value = group.id
groupDrawerOpen.value = false // 关闭抽屉
loadFileList()
}
@@ -831,7 +901,6 @@ onMounted(async () => {
display: flex;
height: 100%;
background: var(--background);
gap: var(--space-4);
}
// ========================================
@@ -845,6 +914,59 @@ onMounted(async () => {
transition: all var(--duration-slow) cubic-bezier(0.4, 0, 0.2, 1);
}
// ========================================
// 工具栏容器
// ========================================
.toolbar-container {
display: flex;
flex-direction: column;
padding: 12px 16px;
background: var(--card);
border-bottom: 1px solid var(--border);
@media (min-width: 768px) {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 16px 24px;
}
}
// ========================================
// 分类按钮
// ========================================
.category-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 9999px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.2s;
color: var(--muted-foreground);
border: none;
background: transparent;
@media (min-width: 768px) {
padding: 8px 20px;
font-size: 14px;
}
&:hover {
color: var(--foreground);
background: var(--muted);
}
&.active {
background: var(--primary);
color: var(--primary-foreground);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
}
// ========================================
// 过渡动画
// ========================================

View File

@@ -718,7 +718,7 @@ onMounted(() => {
.mix-page {
display: flex;
flex-direction: column;
height: 100vh;
height: 100%;
background: @bg-page;
overflow: hidden;
}

View File

@@ -44,6 +44,7 @@
<RangeCalendar
v-model="selectedDateRange"
:number-of-months="2"
locale="zh-CN"
@update:model-value="handleDateSelect"
/>
</PopoverContent>

View File

@@ -9,10 +9,10 @@
v-for="item in NAV_ITEMS"
:key="item.type"
:value="item.type"
class="h-9 px-4 gap-2 rounded-lg bg-transparent transition-all data-[state=active]:bg-primary data-[state=active]:text-white data-[state=inactive]:text-muted-foreground data-[state=inactive]:hover:bg-muted focus-visible:ring-0 focus-visible:outline-none"
class="h-9 px-3 md:px-4 gap-2 rounded-lg bg-transparent transition-all data-[state=active]:bg-primary data-[state=active]:text-white data-[state=inactive]:text-muted-foreground data-[state=inactive]:hover:bg-muted focus-visible:ring-0 focus-visible:outline-none"
>
<Icon :icon="item.icon" class="size-4" />
<span class="font-medium">{{ item.label }}</span>
<span class="font-medium hidden sm:inline">{{ item.label }}</span>
</TabsTrigger>
</TabsList>
</Tabs>