improve(ui):移动端显示效果
This commit is contained in:
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'],
|
||||
})
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
Reference in new issue
Block a user