improve(ui):移动端显示效果

This commit is contained in:
qian committed 2026-08-11 23:35:27 +08:00
1 parent af0284902f
commit f8d021eb29
6 files changed
+249 -8

No files matched your search

@@ -0,0 +1,174 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import Icon from './Icon.vue'
// 快捷范围项:label 为按钮文字,range 返回 [startTs, endTs](ms 时间戳)
export interface RangeShortcut {
label: string
range: () => [number, number]
}
const props = withDefaults(
defineProps<{
modelValue: [number, number] | null
// 快捷范围配置(未传时使用默认:今日/昨天/近3天/近一周)
shortcuts?: Record<string, () => [number, number]>
// 字段类型:datetimerange 用 datetime-local 原生输入,daterange 用 date
type?: 'daterange' | 'datetimerange'
}>(),
{
shortcuts: undefined,
type: 'datetimerange',
}
)
const emit = defineEmits<{
'update:modelValue': [value: [number, number] | null]
}>()
// 以本地时区"天"为粒度的快捷范围
function dayStart(offsetDays: number): number {
const d = new Date()
d.setDate(d.getDate() + offsetDays)
d.setHours(0, 0, 0, 0)
return d.getTime()
}
function dayEnd(offsetDays: number): number {
const d = new Date()
d.setDate(d.getDate() + offsetDays)
d.setHours(23, 59, 59, 999)
return d.getTime()
}
const defaultShortcuts: Record<string, () => [number, number]> = {
今日: () => [dayStart(0), dayEnd(0)],
昨天: () => [dayStart(-1), dayEnd(-1)],
近3天: () => [dayStart(-2), dayEnd(0)],
近一周: () => [dayStart(-6), dayEnd(0)],
}
const quickItems = computed<RangeShortcut[]>(() => {
const src = props.shortcuts || defaultShortcuts
return Object.entries(src).map(([label, range]) => ({ label, range }))
})
// 时间戳 ↔ 原生输入框字符串(datetime-local: YYYY-MM-DDTHH:mm / date: YYYY-MM-DD)
function tsToInput(ts: number): string {
const d = new Date(ts)
const pad = (n: number) => String(n).padStart(2, '0')
const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
if (props.type === 'daterange') return date
return `${date}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function inputToTs(v: string): number | null {
if (!v) return null
// 纯日期串(YYYY-MM-DD)会被 JS 按 UTC 解析,需按本地时区构造,避免时区偏移
if (props.type === 'daterange') {
const parts = v.split('-').map(Number)
if (parts.length !== 3) return null
const t = new Date(parts[0], parts[1] - 1, parts[2]).getTime()
return Number.isNaN(t) ? null : t
}
const t = new Date(v).getTime()
return Number.isNaN(t) ? null : t
}
const showCustom = ref(false)
const customStart = ref('')
const customEnd = ref('')
function isActive(item: RangeShortcut): boolean {
const v = props.modelValue
if (!v || v[0] == null || v[1] == null) return false
const [s, e] = item.range()
return v[0] === s && v[1] === e
}
// 点击快捷项:直接写值并同步到自定义输入框
function pick(item: RangeShortcut) {
const [s, e] = item.range()
customStart.value = tsToInput(s)
customEnd.value = tsToInput(e)
emit('update:modelValue', [s, e])
showCustom.value = false
}
// 自定义输入变化:两端均有效才写回,任一端无效则置空
function onCustomInput() {
const s = inputToTs(customStart.value)
const e = inputToTs(customEnd.value)
if (s != null && e != null) {
emit('update:modelValue', [s, e])
} else {
emit('update:modelValue', null)
}
}
onMounted(() => {
// 初始值不是快捷项(自定义范围)时,自动展开自定义输入框并回填
if (props.modelValue && props.modelValue[0] != null && props.modelValue[1] != null) {
customStart.value = tsToInput(props.modelValue[0])
customEnd.value = tsToInput(props.modelValue[1])
if (!quickItems.value.some(isActive)) {
showCustom.value = true
}
}
})
</script>
<template>
<div class="space-y-2">
<!-- 快捷范围按钮 -->
<div class="flex flex-wrap gap-1.5">
<button
v-for="item in quickItems"
:key="item.label"
type="button"
class="rounded-lg border px-2.5 py-1 text-xs font-medium transition-colors"
:class="
isActive(item)
? 'border-primary-500 bg-primary-50 text-primary-700'
: 'border-gray-200 bg-white text-gray-600 hover:border-primary-300 hover:text-primary-600'
"
@click="pick(item)"
>
{{ item.label }}
</button>
<button
type="button"
class="rounded-lg border px-2.5 py-1 text-xs font-medium transition-colors"
:class="
showCustom
? 'border-primary-500 bg-primary-50 text-primary-700'
: 'border-gray-200 bg-white text-gray-600 hover:border-primary-300 hover:text-primary-600'
"
@click="showCustom = !showCustom"
>
自定义
<Icon :name="showCustom ? 'chevronUp' : 'chevronDown'" size="xs" />
</button>
</div>
<!-- 自定义范围:原生输入,移动端弹出系统全屏选择器,无溢出 -->
<div v-if="showCustom" class="space-y-2">
<label class="block min-w-0">
<span class="mb-1 block text-xs text-gray-500">开始</span>
<input
v-model="customStart"
:type="type === 'daterange' ? 'date' : 'datetime-local'"
class="w-full rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-sm text-gray-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30"
@change="onCustomInput"
/>
</label>
<label class="block min-w-0">
<span class="mb-1 block text-xs text-gray-500">结束</span>
<input
v-model="customEnd"
:type="type === 'daterange' ? 'date' : 'datetime-local'"
class="w-full rounded-lg border border-gray-200 bg-white px-2.5 py-1.5 text-sm text-gray-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30"
@change="onCustomInput"
/>
</label>
</div>
</div>
</template>
@@ -2,6 +2,7 @@
import { ref, computed, h } from 'vue'
import { NForm, NFormItem, NInput, NSelect, NDatePicker, NInputNumber, NButton, NDrawer, NDrawerContent } from 'naive-ui'
import Icon from './Icon.vue'
import MobileRangeFilter from './MobileRangeFilter.vue'
// 表单字段配置类型
export interface FilterField {
@@ -165,23 +166,41 @@ const renderField = (field: FilterField) => {
})
case 'daterange':
// 移动端抽屉:用快捷范围 + 原生输入替代日历面板(面板固定宽度,移动端会溢出屏幕)
if (useDrawer.value) {
return h(MobileRangeFilter, {
modelValue: modelValue.value[field.key],
'onUpdate:modelValue': (val: [number, number] | null) => { modelValue.value[field.key] = val },
type: 'daterange',
shortcuts: field.shortcuts || defaultShortcuts,
})
}
return h(NDatePicker, {
value: modelValue.value[field.key],
'onUpdate:value': (val: [number, number] | null) => { modelValue.value[field.key] = val },
type: 'daterange',
placeholder: field.placeholder || '选择日期范围',
style: { width: useDrawer.value ? '100%' : (field.width || '280px') },
style: { width: field.width || '280px' },
clearable: field.clearable !== false,
})
case 'datetimerange':
// 移动端抽屉:用快捷范围 + 原生输入替代日历面板(面板固定宽度,移动端会溢出屏幕)
if (useDrawer.value) {
return h(MobileRangeFilter, {
modelValue: modelValue.value[field.key],
'onUpdate:modelValue': (val: [number, number] | null) => { modelValue.value[field.key] = val },
type: 'datetimerange',
shortcuts: field.shortcuts || defaultShortcuts,
})
}
return h(NDatePicker, {
value: modelValue.value[field.key],
'onUpdate:value': (val: [number, number] | null) => { modelValue.value[field.key] = val },
type: 'datetimerange',
shortcuts: field.shortcuts || defaultShortcuts,
placeholder: field.placeholder || '选择时间范围',
style: { width: useDrawer.value ? '100%' : (field.width || '400px') },
style: { width: field.width || '400px' },
clearable: field.clearable !== false,
actions: ['clear', 'confirm'],
})
+1 -1
View File
@@ -233,7 +233,7 @@ onBeforeUnmount(() => {
</script>
<template>
<div class="tenant-layout min-h-screen overflow-x-hidden">
<div class="tenant-layout min-h-screen overflow-x-clip">
<!-- Maintenance Banner -->
<MaintenanceBanner />
<AnnouncementBanner v-if="consoleAnnouncements.length" :announcements="consoleAnnouncements" />
@@ -289,7 +289,7 @@ const columns = computed<DataTableColumns<RequestLog>>(() => [
{
title: '操作',
key: 'actions',
width: 120,
width: 60,
fixed: 'right',
align: 'right',
render: (row) =>
@@ -485,7 +485,7 @@ const columns = computed<DataTableColumns<any>>(() => [
{
title: '操作',
key: 'actions',
width: 120,
width: 60,
fixed: 'right',
align: 'right',
render: (row) =>
+51 -3
View File
@@ -34,6 +34,8 @@ const filterFields: FilterField[] = [
},
]
const showExportDropdown = ref(false)
const showMoreMenu = ref(false)
const { exporting, exportFile } = useExport({
url: '/tenant/members/export',
getFilters: () => ({
@@ -332,12 +334,13 @@ onMounted(() => {
<TeamLockedBanner v-if="!teamEnabled" />
<div v-else class="viewport-table-page space-y-6">
<!-- Page Header -->
<div class="page-header flex items-center justify-between">
<div>
<div class="page-header flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div class="min-w-0">
<h1 class="page-title">成员管理</h1>
<p class="page-description">管理组织团队中的成员</p>
</div>
<div class="flex items-center gap-2">
<!-- 桌面端操作区 -->
<div class="hidden lg:flex flex-wrap items-center gap-2">
<router-link to="/tenant/members/invitations" class="btn btn-secondary">
<Icon name="document" size="sm" />
邀请记录
@@ -359,6 +362,51 @@ onMounted(() => {
邀请成员
</button>
</div>
<!-- 移动端操作区 -->
<div class="flex lg:hidden items-center gap-2">
<button class="btn btn-primary flex-1" @click="showInviteModal = true">
<Icon name="userPlus" size="sm" />
邀请成员
</button>
<div class="relative">
<button
class="btn btn-secondary"
aria-haspopup="true"
:aria-expanded="showMoreMenu"
@click="showMoreMenu = !showMoreMenu"
>
<Icon name="more" size="sm" />
更多
</button>
<div v-if="showMoreMenu" class="fixed inset-0 z-40" @click="showMoreMenu = false"></div>
<div v-if="showMoreMenu" class="dropdown right-0 mt-2 w-44">
<router-link to="/tenant/members/invitations" class="dropdown-item" @click="showMoreMenu = false">
<Icon name="document" size="sm" />
邀请记录
</router-link>
<div class="dropdown-item" @click="downloadTemplate(); showMoreMenu = false">
<Icon name="document" size="sm" />
导入模板
</div>
<div class="dropdown-item" @click="showImportModal = true; showMoreMenu = false">
<Icon name="plus" size="sm" />
批量导入
</div>
<div class="dropdown-item" @click="showCreateModal = true; showMoreMenu = false">
<Icon name="userPlus" size="sm" />
创建成员
</div>
<div class="dropdown-item" @click="exportFile('csv'); showMoreMenu = false">
<Icon name="download" size="sm" />
导出 CSV
</div>
<div class="dropdown-item" @click="exportFile('xlsx'); showMoreMenu = false">
<Icon name="download" size="sm" />
导出 Excel
</div>
</div>
</div>
</div>
</div>
<!-- Search & Export -->