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

feat: 优化移动端布局与学生操作入口

- 优化导航、筛选抽屉、列表卡片及弹窗表单 - 修复嵌套字段展示和树形表格数据遗漏 - 隐藏学生无权限的操作按钮 - 修复首次访问的实验参数与菜单初始化 - 清理无用菜单配置和重复筛选逻辑
上级 0441efbf
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>数字营销实验室DML</title> <title>数字营销实验室DML</title>
</head> </head>
<body> <body>
......
...@@ -95,12 +95,6 @@ textarea:focus { ...@@ -95,12 +95,6 @@ textarea:focus {
padding: 0 16px; padding: 0 16px;
} }
/* 移动端弹框宽度 90% */
@media (max-width: 767px) {
.el-overlay-dialog .el-dialog {
width: 90% !important;
}
}
.info .el-form-item { .info .el-form-item {
margin-bottom: 0; margin-bottom: 0;
} }
...@@ -136,98 +130,167 @@ textarea:focus { ...@@ -136,98 +130,167 @@ textarea:focus {
border: 1px solid #e6e6e6; border: 1px solid #e6e6e6;
} }
/* ============ 系统级响应式 ============ */ /* ========== 全局响应式 ========== */
/* 移动端弹框宽度 90% */ img,
@media (max-width: 767px) { video {
.el-overlay-dialog .el-dialog { max-width: 100%;
width: 90% !important; height: auto;
}
} }
@media (max-width: 767px) { @media (max-width: 767px) {
/* AppCard 移动端内边距缩减 */ .el-overlay-dialog {
.app-card { padding: max(12px, env(safe-area-inset-top)) 12px max(12px, env(safe-area-inset-bottom));
padding: 12px !important; box-sizing: border-box;
} }
.app-card + .app-card { .el-dialog {
margin-top: 12px !important; width: 100% !important;
max-width: 500px;
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;
} }
.app-card-hd__title { .el-dialog__header,
font-size: 16px; .el-dialog__footer {
margin-bottom: 16px; flex-shrink: 0;
} }
.el-dialog__body {
/* 全局 el-table 横向滚动 */ min-height: 0;
.el-table { overflow-y: auto;
width: 100%; overscroll-behavior: contain;
} }
.el-table__body-wrapper { .el-dialog .el-form:not(.el-form--inline) .el-form-item {
overflow-x: auto; display: block;
-webkit-overflow-scrolling: touch;
} }
.el-dialog .el-form:not(.el-form--inline) .el-form-item__label {
/* el-pagination 移动端简化 */ display: block;
.el-pagination { 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; flex-wrap: wrap;
justify-content: center; justify-content: flex-end;
gap: 4px; gap: 8px;
font-size: 12px;
} }
.el-pagination__total, .el-dialog__footer .el-button {
.el-pagination__sizes, min-height: 40px;
.el-pagination__jump { margin-left: 0;
display: none !important;
} }
.el-pager .number { .el-message-box {
min-width: 28px; width: 90% !important;
height: 28px; max-width: 420px;
line-height: 28px; }
.el-drawer.ltr,
.el-drawer.rtl {
width: 90% !important;
max-width: 420px;
} }
.btn-prev, .el-drawer.mobile-menu-drawer {
.btn-next { width: min(76vw, 280px) !important;
height: 28px;
min-width: 28px;
} }
.el-input__inner,
.el-textarea__inner {
font-size: 16px;
}
.el-form--inline .el-form-item {
margin-right: 0;
}
.el-table {
font-size: 13px;
}
}
/* el-steps 移动端垂直 */ @media (min-width: 768px) {
.el-steps--horizontal { .hide-on-desktop {
flex-direction: column; display: none !important;
}
}
@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 {
display: none !important;
}
}
/* 弹框底部按钮组换行 */ @media (max-width: 767px) {
.el-dialog__footer { .app-main .el-form:not(.el-form--inline) > .el-form-item {
display: block;
}
.app-main .el-form:not(.el-form--inline) > .el-form-item > .el-form-item__label {
width: auto !important;
height: auto;
float: none;
justify-content: flex-start;
line-height: 24px;
padding-bottom: 4px;
white-space: normal;
}
.app-main .el-form:not(.el-form--inline) > .el-form-item > .el-form-item__content {
margin-left: 0 !important;
min-width: 0;
}
.el-form-item__content > .el-input,
.el-form-item__content > .el-select,
.el-form-item__content > .el-select-v2,
.el-form-item__content > .el-cascader,
.el-form-item__content > .el-date-editor,
.el-form-item__content > .el-autocomplete {
max-width: 100%;
}
.el-radio-group, .el-checkbox-group {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center; gap: 4px 12px;
gap: 8px; max-width: 100%;
.el-button {
margin-left: 0 !important;
}
} }
/* 弹框按钮选中不独占一行,保持自然间距 */ .el-radio, .el-checkbox {
.el-dialog__footer .el-button + .el-button { min-height: 40px;
margin-left: 0; height: auto;
margin-right: 0;
max-width: 100%;
} }
.el-radio__label, .el-checkbox__label {
/* 固定宽度容器防溢出 */ white-space: normal;
[style*='width: 200px'], overflow-wrap: anywhere;
[style*='width: 220px'], line-height: 1.5;
[style*='width: 300px'],
[style*='width: 340px'],
[style*='width: 400px'],
[style*='width: 150px'] {
max-width: 100% !important;
} }
.el-dialog__footer > .el-row {
/* hover 显示的元素在触摸设备上常显 */ flex-wrap: wrap;
.connect-item__remove, gap: 8px;
.connect-item__edit,
.hover-show {
display: block !important;
} }
[class*='remove']:hover, .markdown-body {
[class*='delete']:hover { overflow-wrap: anywhere;
opacity: 1; }
.markdown-body pre, .markdown-body table {
display: block;
max-width: 100%;
overflow-x: auto;
} }
} }
@media (max-width: 767px) {
.el-dialog .el-form { max-width: 100%; }
.el-popover.el-popper { max-width: calc(100vw - 24px); }
.rfm-popover { overflow-x: auto; }
}
...@@ -80,3 +80,25 @@ ...@@ -80,3 +80,25 @@
.rule-input { .rule-input {
width: 100px; width: 100px;
} }
@media (max-width: 767px) {
.rule { min-width: 0; }
.rule-list { min-width: 0; }
.rule-operator { min-width: 32px; margin-right: 8px; }
.rule-operator span { width: 32px; height: 40px; line-height: 38px; border-radius: 16px; }
.rule-item { min-width: 0; gap: 8px; }
.rule-item > div:not(.el-form-item), .rule-item > .el-row {
min-width: 0;
max-width: 100%;
flex-wrap: wrap;
gap: 8px;
}
.rule .el-form-item { max-width: 100%; margin-right: 0; margin-bottom: 8px; }
.rule .el-form-item__content { min-width: 0; max-width: 100%; gap: 8px; }
.rule .el-input, .rule .el-select, .rule .el-select-v2,
.rule .el-date-editor, .rule .el-autocomplete, .rule .el-input-number {
max-width: 100%;
}
.rule-tips { align-items: flex-start; line-height: 1.6; overflow-wrap: anywhere; }
.rule-tips .el-icon { flex-shrink: 0; margin-top: 4px; }
}
...@@ -66,6 +66,7 @@ const color = ['#af1c40', '#c17933', '#8f0034', '#d45548', '#ab3259', '#dec34c', ...@@ -66,6 +66,7 @@ const color = ['#af1c40', '#c17933', '#8f0034', '#d45548', '#ab3259', '#dec34c',
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1; flex: 1;
min-width: 0;
background-color: rgba(234, 234, 234, 0.6); background-color: rgba(234, 234, 234, 0.6);
border-radius: 6px; border-radius: 6px;
overflow: hidden; overflow: hidden;
...@@ -99,6 +100,7 @@ const color = ['#af1c40', '#c17933', '#8f0034', '#d45548', '#ab3259', '#dec34c', ...@@ -99,6 +100,7 @@ const color = ['#af1c40', '#c17933', '#8f0034', '#d45548', '#ab3259', '#dec34c',
} }
.chart-bd { .chart-bd {
flex: 1; flex: 1;
min-width: 0;
background-color: #fff; background-color: #fff;
margin: 0 20px 20px; margin: 0 20px 20px;
border-radius: 4px; border-radius: 4px;
......
...@@ -466,4 +466,10 @@ const handleSend = () => { ...@@ -466,4 +466,10 @@ const handleSend = () => {
.prompt-tag:active { .prompt-tag:active {
transform: translateY(0); transform: translateY(0);
} }
@media (max-width: 767px) {
.chat-container { height: min(600px, 72dvh); min-height: 0; }
.chat-messages { min-height: 0; padding: 12px; }
.message-content { max-width: 100%; overflow-wrap: anywhere; }
.chat-input-container { flex-wrap: wrap; gap: 8px; }
}
</style> </style>
...@@ -26,6 +26,7 @@ const styleHeight = computed(() => { ...@@ -26,6 +26,7 @@ const styleHeight = computed(() => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: v-bind(styleHeight); min-height: v-bind(styleHeight);
min-width: 0;
background: #fff; background: #fff;
box-shadow: 0 1px 6px 0 rgb(228 232 235 / 20%); box-shadow: 0 1px 6px 0 rgb(228 232 235 / 20%);
border-radius: 6px; border-radius: 6px;
...@@ -52,6 +53,7 @@ const styleHeight = computed(() => { ...@@ -52,6 +53,7 @@ const styleHeight = computed(() => {
} }
.app-card-bd { .app-card-bd {
flex: 1; flex: 1;
min-width: 0;
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
} }
...@@ -67,4 +69,12 @@ const styleHeight = computed(() => { ...@@ -67,4 +69,12 @@ const styleHeight = computed(() => {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
} }
@media (max-width: 767px) {
.app-card { padding: 12px; min-height: auto; }
.app-card + .app-card { margin-top: 12px; }
.app-card-hd { flex-wrap: wrap; gap: 8px; }
.app-card-hd__title { font-size: 16px; line-height: 1.4; margin-bottom: 16px; }
.app-card-hd-aside { max-width: 100%; }
.h2-title { font-size: 16px; line-height: 1.4; flex-wrap: wrap; gap: 8px; }
}
</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'
import { cloneDeep, get } from 'lodash-es'
const isMobile = useMediaQuery('(max-width: 767px)') const isMobile = useMediaQuery('(max-width: 767px)')
const attrs = useAttrs()
// 移动端卡片列(过滤序号、操作等 slot 列留给卡片底部渲染) // 筛选、表格和移动端卡片通过动态名称传递不同的插槽参数。
const cardColumns = computed(() => props.columns.filter((c) => c.type !== 'index' && c.slots !== 'table-x')) defineSlots<{
[name: string]: (props: any) => any
}>()
const cardColumns = computed(() => props.columns.filter((c) => c.type !== 'index' && c.type !== 'selection' && c.slots !== 'table-x'))
const hasSelectionColumn = computed(() => props.columns.some((c) => c.type === 'selection'))
const hasActionsColumn = computed(() => props.columns.some((c) => c.slots === 'table-x')) const hasActionsColumn = computed(() => props.columns.some((c) => c.slots === 'table-x'))
interface IRemoteProps { interface IRemoteProps {
...@@ -40,153 +47,169 @@ const filterFormRef = ref() ...@@ -40,153 +47,169 @@ const filterFormRef = ref()
const loading = ref(false) const loading = ref(false)
const tableRef = ref() const tableRef = ref()
const dataList = ref<any[]>([]) const dataList = ref<any[]>([])
// 树形和展开表格保留原有结构,避免卡片模式漏掉子级及详情。
const useMobileCards = computed(() => isMobile.value && props.mobileCards
&& !props.columns.some(column => column.type === 'expand')
&& !dataList.value.some(row => row.children?.length))
const page = reactive({ total: 0, size: props.limit, currentPage: 1 }) const page = reactive({ total: 0, size: props.limit, currentPage: 1 })
const initialFilterParams = cloneDeep(props.remote?.params || {})
const params = reactive({ ...props.remote?.params }) const params = reactive({ ...props.remote?.params })
watchEffect(() => { const mobileFilterVisible = ref(false)
Object.assign(params, props.remote?.params) const mobileFilterDraft = reactive<Record<string, any>>({})
}) watch(isMobile, () => { mobileFilterVisible.value = false })
watchEffect(() => { const activeFilterCount = computed(() => props.filters.filter(item => {
dataList.value = props.data || [] 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, cloneDeep(params))
mobileFilterVisible.value = true
}
function resetMobileFilters() {
props.filters.forEach(item => {
mobileFilterDraft[item.prop] = cloneDeep(initialFilterParams[item.prop] ?? '')
})
}
function applyMobileFilters() {
Object.assign(params, cloneDeep(mobileFilterDraft))
mobileFilterVisible.value = false
search()
}
watchEffect(() => { Object.assign(params, props.remote?.params) })
watchEffect(() => { dataList.value = props.data || [] })
// 获取数据 // 获取数据
const fetchList = (isReset = false) => { const fetchList = (isReset = false) => {
/**
* @param function httpRequest api接口
* @param function beforeRequest 接口请求之前
* @param function callback 接口请求成功回调
*/
const { httpRequest, beforeRequest, callback } = props.remote || {} const { httpRequest, beforeRequest, callback } = props.remote || {}
if (!httpRequest) { if (!httpRequest) return
return
}
// 参数设置
let requestParams = { ...params } let requestParams = { ...params }
// 翻页参数设置
if (props.hasPagination) { if (props.hasPagination) {
requestParams.page = page.currentPage requestParams.page = page.currentPage
requestParams['per-page'] = page.size requestParams['per-page'] = page.size
} }
// 接口请求之前 if (beforeRequest) requestParams = beforeRequest(requestParams, isReset)
if (beforeRequest) {
requestParams = beforeRequest(requestParams, isReset)
}
for (const key in requestParams) { for (const key in requestParams) {
if (requestParams[key] === '' || requestParams[key] === undefined || requestParams[key] === null) { if (requestParams[key] === '' || requestParams[key] === undefined || requestParams[key] === null) delete requestParams[key]
delete requestParams[key]
}
} }
loading.value = true loading.value = true
return ( return httpRequest(requestParams)
httpRequest(requestParams) .then((res: any) => {
.then((res: any) => { const { list = [], total = 0 } = callback ? callback(res.data, requestParams) : res.data || {}
const { list = [], total = 0 } = callback ? callback(res.data, requestParams) : res.data || {} page.total = parseInt(total)
page.total = parseInt(total) dataList.value = list
dataList.value = list })
}) .finally(() => { loading.value = false })
// .catch(() => {
// page.total = 0
// dataList.value = []
// })
.finally(() => {
loading.value = false
})
)
} }
// 搜索 const search = () => { page.currentPage = 1; return fetchList() }
const search = () => {
page.currentPage = 1
return fetchList()
}
// 重置
const reset = () => { const reset = () => {
// 清空筛选条件
filterFormRef.value?.resetFields() filterFormRef.value?.resetFields()
// 初始化页码 props.filters.forEach(item => { params[item.prop] = cloneDeep(initialFilterParams[item.prop] ?? '') })
page.currentPage = 1 page.currentPage = 1
// 刷新列表
return fetchList(true) return fetchList(true)
} }
const refetch = (isForce = false) => isForce ? reset() : fetchList()
const pageSizeChange = (value: number) => { page.currentPage = 1; page.size = value; fetchList() }
// 刷新 // 移动端多选
const refetch = (isForce = false) => { const mobileSelection = ref<any[]>([])
return isForce ? reset() : fetchList() const mobileIsAllSelected = computed(() => dataList.value.length > 0 && mobileSelection.value.length === dataList.value.length)
function toggleMobileSelectAll() {
mobileSelection.value = mobileIsAllSelected.value ? [] : [...dataList.value]
emitSelectionChange()
} }
function toggleMobileSelectRow(row: any) {
// 页数改变 const idx = mobileSelection.value.findIndex((r) => r === row)
const pageSizeChange = (value: number) => { if (idx > -1) mobileSelection.value.splice(idx, 1)
page.currentPage = 1 else mobileSelection.value.push(row)
page.size = value emitSelectionChange()
fetchList() }
function isMobileRowSelected(row: any) { return mobileSelection.value.some((r) => r === row) }
function emitSelectionChange() {
const handler = (attrs as Record<string, unknown>).onSelectionChange as ((val: any[]) => void) | undefined
if (handler) handler(mobileSelection.value)
} }
onMounted(() => { watch([dataList, isMobile], () => {
fetchList() if (mobileSelection.value.length) {
mobileSelection.value = []
emitSelectionChange()
}
}) })
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="!isMobile && filters.length">
<div class="table-list-filter" v-if="filters && 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">
<template v-if="item.slots"> <template v-if="item.slots">
<slot :name="item.slots" v-bind="{ params }"></slot> <slot :name="item.slots" v-bind="{ params, onChange: search }"></slot>
</template> </template>
<template v-else> <template v-else>
<!-- input --> <el-input v-model="params[item.prop]" v-bind="item" clearable @change="search" style="width: 200px" v-if="item.type === 'input'" />
<el-input <el-select v-model="params[item.prop]" v-bind="item" filterable clearable @change="search" v-if="item.type === 'select'" value-on-clear="">
v-model="params[item.prop]" <el-option :label="option[item.labelKey] || option.label" :value="option[item.valueKey] || option.value"
v-bind="item" v-for="(option, index) in item.options" :key="index" />
clearable
@change="search"
v-if="item.type === 'input'" />
<!-- select -->
<el-select
v-model="params[item.prop]"
v-bind="item"
filterable
clearable
@change="search"
v-if="item.type === 'select'">
<el-option
v-for="(option, index) in item.options"
:label="option[item.labelKey] || option.label || option"
:value="option[item.valueKey] || option.value || option"
:key="index" />
</el-select> </el-select>
</template> </template>
</el-form-item> </el-form-item>
</template> </template>
<el-form-item class="filter-buttons" v-if="!isMobile"> <el-form-item class="filter-buttons">
<el-button type="primary" :icon="Search" @click="search">搜索</el-button> <el-button type="primary" :icon="Search" @click="search">搜索</el-button>
<el-button :icon="RefreshLeft" @click="reset">重置</el-button> <el-button :icon="RefreshLeft" @click="reset">重置</el-button>
</el-form-item> </el-form-item>
</el-form> </el-form>
</div> </div>
<!-- <div class="table-list-hd-aside"><slot name="header-aside" /></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, onChange: () => {} }"></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">
<slot name="body" v-bind="{ data: dataList, loading }"> <slot name="body" v-bind="{ data: dataList, loading }">
<!-- 桌面端 / 禁用移动端卡片时:表格 --> <!-- 桌面端表格 -->
<el-table <el-table v-if="!useMobileCards" stripe :header-cell-style="{ background: '#ededed' }"
v-if="!isMobile || !mobileCards" :data="dataList" v-loading="loading && hasLoading" v-bind="$attrs" ref="tableRef">
:header-cell-style="{ background: '#ededed' }"
:data="dataList"
v-loading="loading && hasLoading"
v-bind="$attrs"
ref="tableRef">
<el-table-column align="center" v-bind="item || {}" v-for="item in columns" :key="item.prop"> <el-table-column align="center" v-bind="item || {}" v-for="item in columns" :key="item.prop">
<template #default="scope" v-if="item.slots || item.computed"> <template #default="scope" v-if="item.slots || item.computed">
<slot :name="item.slots" v-bind="scope" v-if="item.slots"></slot> <slot :name="item.slots" v-bind="scope" v-if="item.slots"></slot>
...@@ -194,24 +217,31 @@ defineExpose({ refetch, tableRef }) ...@@ -194,24 +217,31 @@ defineExpose({ refetch, tableRef })
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<!-- 移动端:卡片列表 --> <!-- 移动端卡片 -->
<div v-if="isMobile && mobileCards" class="mobile-card-list" v-loading="loading && hasLoading"> <div v-if="useMobileCards" class="mobile-card-list" v-loading="loading && hasLoading">
<div class="mobile-card" v-for="(row, rowIndex) in dataList" :key="rowIndex"> <div class="mobile-card-select-all" v-if="hasSelectionColumn && dataList.length">
<el-checkbox :model-value="mobileIsAllSelected" @change="toggleMobileSelectAll">
全选({{ mobileSelection.length }}/{{ dataList.length }})
</el-checkbox>
</div>
<div class="mobile-card" v-for="(row, rowIndex) in dataList" :key="rowIndex"
:class="{ 'is-selected': isMobileRowSelected(row) }">
<div class="mobile-card-body"> <div class="mobile-card-body">
<div class="mobile-card-row" v-for="col in cardColumns" :key="col.prop || col.label"> <div class="mobile-card-checkbox" v-if="hasSelectionColumn" @click.stop>
<span class="mobile-card-label">{{ col.label }}</span> <el-checkbox :model-value="isMobileRowSelected(row)" @change="toggleMobileSelectRow(row)" />
<span class="mobile-card-colon">:</span> </div>
<span class="mobile-card-value"> <div class="mobile-card-rows">
<slot <div class="mobile-card-row" v-for="col in cardColumns" :key="col.prop || col.label">
v-if="col.slots && $slots[col.slots]" <span class="mobile-card-label">{{ col.label }}</span>
:name="col.slots" <span class="mobile-card-colon">:</span>
v-bind="{ row, $index: rowIndex }"></slot> <span class="mobile-card-value">
<div v-html="col.computed({ row, $index: rowIndex })" v-else-if="col.computed"></div> <slot v-if="col.slots && $slots[col.slots]" :name="col.slots" v-bind="{ row, $index: rowIndex }"></slot>
<template v-else>{{ row[col.prop] ?? '-' }}</template> <div v-html="col.computed({ row, $index: rowIndex })" v-else-if="col.computed"></div>
</span> <template v-else>{{ get(row, col.prop, '-') ?? '-' }}</template>
</span>
</div>
</div> </div>
</div> </div>
<!-- 操作栏:如果有 slot 列,提取渲染 -->
<div class="mobile-card-ft" v-if="hasActionsColumn"> <div class="mobile-card-ft" v-if="hasActionsColumn">
<slot name="table-x" v-bind="{ row, $index: rowIndex }"></slot> <slot name="table-x" v-bind="{ row, $index: rowIndex }"></slot>
</div> </div>
...@@ -222,204 +252,116 @@ defineExpose({ refetch, tableRef }) ...@@ -222,204 +252,116 @@ defineExpose({ refetch, tableRef })
</div> </div>
<!-- 底部 --> <!-- 底部 -->
<div class="table-list-ft"> <div class="table-list-ft">
<div> <div><slot name="footer"></slot></div>
<slot name="footer"></slot> <el-pagination class="table-list-pagination" background
</div> :layout="isMobile ? 'prev, pager, next' : 'total, sizes, prev, pager, next, jumper'"
<el-pagination :page-sizes="[10, 20, 30, 50]" :page-size="page.size" :total="page.total"
class="table-list-pagination" :pager-count="isMobile ? 5 : 7"
background v-model:currentPage="page.currentPage" @size-change="pageSizeChange" @current-change="fetchList()"
layout="total, sizes, prev, pager, next, jumper" :hide-on-single-page="true" v-if="hasPagination">
:page-sizes="[10, 20, 30, 50]"
:page-size="page.size"
:total="page.total"
v-model:currentPage="page.currentPage"
@size-change="pageSizeChange"
@current-change="fetchList()"
:hide-on-single-page="true"
v-if="hasPagination">
</el-pagination> </el-pagination>
</div> </div>
</div> </div>
</template> </template>
<style lang="scss"> <style lang="scss">
.table-list-hd { .table-list-hd { display: flex; margin-bottom: 20px; &:empty { display: none; } }
display: flex; .table-list { min-width: 0; }
margin-bottom: 20px; .table-list-filter { flex: 1; min-width: 0; }
&:empty { .table-list-bd { min-width: 0; max-width: 100%; overflow-x: auto; }
display: none; .table-list-buttons { margin-bottom: 20px; display: flex; flex-wrap: wrap; gap: 8px; &:empty { display: none; } }
} .table-list-ft { padding: 10px 0; display: flex; align-items: center; justify-content: space-between; }
} .table-list-pagination { text-align: right; }
.table-list-filter { .el-table-column--selection .cell { padding: 0 14px !important; }
flex: 1; .table-list-bd .el-button {
} a { margin: -8px -15px; padding: 8px 15px; }
.table-list-buttons { &.is-text { padding: 8px; }
margin-bottom: 20px; &.is-text + .is-text { margin-left: 0; }
&:empty { &.is-plain { --el-button-border-color: var(--el-button-bg-color); }
display: none;
}
} }
.table-list-ft { // 移动端卡片
padding: 10px 0; .mobile-card-list { min-height: 120px; }
display: flex; .mobile-card-select-all { padding: 8px 0; .el-checkbox { font-size: 13px; color: #666; } }
align-items: center; .mobile-card { background: #fff; border-radius: 8px; border: 1px solid #eee; margin-bottom: 12px; overflow: hidden; display: flex; flex-direction: column;
justify-content: space-between; &.is-selected { border-color: var(--main-color); background: #fef5f7; }
} }
.table-list-pagination { .mobile-card-body { flex: 1; padding: 14px 16px; display: flex; align-items: flex-start; gap: 10px; }
text-align: right; .mobile-card-checkbox { flex-shrink: 0; padding-top: 2px; }
.mobile-card-rows { flex: 1; min-width: 0; }
.mobile-card-row { display: flex; padding: 5px 0; border-bottom: 1px solid #f5f5f5; line-height: 1.6;
&:last-child { border-bottom: none; }
} }
.el-table-column--selection .cell { .mobile-card-label { flex-shrink: 0; max-width: 35%; overflow-wrap: anywhere; color: #999; font-size: 14px; }
padding: 0 14px !important; .mobile-card-colon { color: #999; font-size: 14px; margin-right: 6px; }
} .mobile-card-value { flex: 1; font-size: 14px; color: #333; min-width: 0; word-break: break-all; }
.table-list-bd { .mobile-card-ft { padding: 8px 16px; display: flex; gap: 2px; flex-wrap: wrap; background: #fafafa; border-top: 1px solid #f0f0f0;
.el-button { .el-button.is-text { padding: 4px 6px; font-size: 12px; }
a {
margin: -8px -15px;
padding: 8px 15px;
}
&.is-text {
padding: 8px;
}
&.is-text + .is-text {
margin-left: 0;
}
&.is-plain {
--el-button-border-color: var(--el-button-bg-color);
}
}
} }
// 移动端卡片列表 // 移动端
.mobile-card-list { @media (max-width: 767px) {
min-height: 120px; .table-list-buttons .el-button.mobile-filter-trigger {
} position: relative;
.mobile-card { margin-left: auto;
background: #fff; display: inline-flex;
border-radius: 12px; align-items: center;
border: 1px solid #e5e5e5; justify-content: center;
margin-bottom: 12px; flex: 0 0 40px;
overflow: hidden; width: 40px;
display: flex; height: 40px;
flex-direction: column; padding: 0;
} border-color: #e6e8eb;
.mobile-card-body { border-radius: 10px;
flex: 1; > .el-icon, > span { margin: 0; }
padding: 14px; &.is-active { color: var(--main-color); border-color: var(--main-color); }
}
.mobile-card-row {
display: flex;
align-items: flex-start;
padding: 8px 0;
border-bottom: 1px solid #f2f2f2;
line-height: 1.6;
&:last-child {
border-bottom: none;
}
}
.mobile-card-label {
flex-shrink: 0;
color: #999;
font-size: 13px;
}
.mobile-card-colon {
color: #999;
font-size: 13px;
margin-right: 6px;
}
.mobile-card-value {
flex: 1;
font-size: 14px;
color: #333;
min-width: 0;
word-break: break-all;
}
.mobile-card-row:first-child {
.mobile-card-value {
color: var(--main-color);
font-size: 16px;
font-weight: 600;
line-height: 1.5;
}
}
.mobile-card-ft {
padding: 10px 12px;
display: flex;
flex-wrap: wrap;
gap: 4px 8px;
background: #fafafa;
border-top: 1px solid #f0f0f0;
.el-button {
padding: 5px 8px;
font-size: 12px;
min-width: 44px;
text-align: center;
+ .el-button {
margin-left: 0;
}
} }
} .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 {
@media (max-width: 767px) { border-radius: 16px 16px 0 0;
.table-list-hd .el-form-item__label { .el-drawer__header { margin-bottom: 0; padding: 16px; }
display: none; .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 {
width: 100%; .el-form, .el-form--inline { width: 100%; }
.el-form, .el-form-item { display: flex; flex-direction: column; align-items: stretch; width: 100%; margin-right: 0; margin-bottom: 12px; }
.el-form--inline { .el-form-item__content { width: 100%; }
width: 100%; .el-input, .el-select, .el-cascader { width: 100% !important; }
} .filter-buttons { width: 100%; .el-form-item__content { display: flex; gap: 8px; } .el-button { flex: 1; min-height: 40px; margin-left: 0; } }
.el-form-item {
width: 100%;
margin-right: 0;
margin-bottom: 8px;
}
.el-form-item__content {
width: 100%;
}
.el-input,
.el-select,
.el-cascader {
width: 100% !important;
}
.filter-buttons {
display: none;
}
} }
.table-list-buttons { align-items: center; gap: 8px; .el-button { min-height: 40px; margin-left: 0; } }
// 移动端分页 .mobile-card-ft {
.table-list-ft { justify-content: flex-end;
flex-direction: column;
gap: 8px; gap: 8px;
padding: 8px 0; padding: 4px 12px;
} .el-button, .el-button.is-text {
.table-list-pagination { min-height: 40px;
.el-pagination { margin-left: 0;
display: flex; padding: 8px 10px;
flex-wrap: wrap; border: 0;
justify-content: center; border-radius: 4px;
gap: 2px; background: transparent;
} color: var(--el-text-color-regular);
.el-pagination__total, font-size: 14px;
.el-pagination__sizes, a { margin: 0; padding: 0; }
.el-pagination__jump { &:hover { background: var(--el-fill-color-light); }
display: none; &.el-button--primary { color: var(--main-color); }
} &.el-button--danger { color: var(--el-color-danger); }
.el-pager { &.is-disabled { color: var(--el-disabled-text-color); background: transparent; }
.number {
min-width: 28px;
height: 28px;
line-height: 28px;
font-size: 12px;
}
}
.btn-prev,
.btn-next {
height: 28px;
min-width: 28px;
} }
} }
.mobile-card-checkbox { min-height: 44px; display: flex; align-items: center; }
.table-list-ft { flex-direction: column; gap: 8px; padding: 8px 0; }
.table-list-pagination .el-pagination {
justify-content: center;
}
.el-pager .number { min-width: 28px; height: 28px; line-height: 28px; font-size: 13px; }
.btn-prev, .btn-next { height: 28px; min-width: 28px; }
} }
</style> </style>
...@@ -211,4 +211,12 @@ const onAddText = (event: MouseEvent) => { ...@@ -211,4 +211,12 @@ const onAddText = (event: MouseEvent) => {
} }
} }
} }
@media (max-width: 767px) {
.flow { flex-direction: column; height: auto; gap: 12px; }
.flow-main { min-width: 0; height: 70dvh; min-height: 400px; flex: none; }
.flow-main .el-card__body { padding: 12px; }
.flow-main .vue-flow { min-height: 0; }
.flow-main .vue-flow__controls { flex-wrap: wrap; max-width: calc(100% - 20px); margin: 8px; }
}
</style> </style>
...@@ -419,4 +419,7 @@ const onDragStart = (event: DragEvent, data: any) => { ...@@ -419,4 +419,7 @@ const onDragStart = (event: DragEvent, data: any) => {
} }
} }
} }
@media (max-width: 767px) {
.flow-sidebar { width: 100%; max-height: 240px; }
}
</style> </style>
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="24" height="24">
<path d="M256 32 16 224l40 50 40-32v206c0 18 14 32 32 32h96V320h64v160h96c18 0 32-14 32-32V242l40 32 40-50L256 32z" />
</svg>
</template>
...@@ -5,9 +5,11 @@ export default { name: 'AppAside' } ...@@ -5,9 +5,11 @@ export default { name: 'AppAside' }
<script setup lang="ts"> <script setup lang="ts">
import type { IMenuItem } from '@/types' import type { IMenuItem } from '@/types'
import { useMenuStore } from '@/stores/menu' import { useMenuStore } from '@/stores/menu'
import { ArrowLeft, ArrowRight, Menu as MenuIcon } from '@element-plus/icons-vue' import { ArrowLeft, ArrowRight, Close, Menu as MenuIcon } from '@element-plus/icons-vue'
import IconHome from '@/components/icon/IconHome.vue'
const menus = useMenuStore().menus const menuStore = useMenuStore()
const menus = computed(() => menuStore.menus)
const route = useRoute() const route = useRoute()
const isMobile = useMediaQuery('(max-width: 767px)') const isMobile = useMediaQuery('(max-width: 767px)')
...@@ -17,17 +19,21 @@ const mobileMenuVisible = ref(false) ...@@ -17,17 +19,21 @@ const mobileMenuVisible = ref(false)
const mobileActiveGroup = ref('') const mobileActiveGroup = ref('')
const currentMenu = computed(() => { const currentMenu = computed(() => {
return findMenu(route.fullPath, menus) return findMenu(route.fullPath, menus.value)
}) })
const currentSubmenu = computed(() => { const currentSubmenu = computed(() => {
return currentMenu.value?.children ? findMenu(route.fullPath, currentMenu.value.children) : null return currentMenu.value?.children ? findMenu(route.fullPath, currentMenu.value.children) : null
}) })
const mobilePageTitle = computed(() => currentSubmenu.value?.name || currentMenu.value?.name || (route.path === '/' ? '主页' : ''))
watchEffect(() => { watchEffect(() => {
mobileActiveGroup.value = currentMenu.value?.children ? currentMenu.value.path : '' mobileActiveGroup.value = currentMenu.value?.children ? currentMenu.value.path : ''
}) })
watch(isMobile, () => { mobileMenuVisible.value = false })
watch( watch(
() => route.fullPath, () => route.fullPath,
() => { () => {
...@@ -61,6 +67,9 @@ function toggleCollapse() { ...@@ -61,6 +67,9 @@ function toggleCollapse() {
} }
function toggleMobileMenu() { function toggleMobileMenu() {
if (!mobileMenuVisible.value) {
mobileActiveGroup.value = currentMenu.value?.children ? currentMenu.value.path : ''
}
mobileMenuVisible.value = !mobileMenuVisible.value mobileMenuVisible.value = !mobileMenuVisible.value
} }
...@@ -80,17 +89,35 @@ function menuLinkTo(item: { path: string }) { ...@@ -80,17 +89,35 @@ function menuLinkTo(item: { path: string }) {
<template> <template>
<template v-if="isMobile"> <template v-if="isMobile">
<header class="mobile-nav">
<button class="mobile-nav__trigger" type="button" aria-label="打开导航菜单" :aria-expanded="mobileMenuVisible" @click="toggleMobileMenu">
<el-icon><MenuIcon /></el-icon>
</button>
<h1 v-if="mobilePageTitle" class="mobile-nav__title">{{ mobilePageTitle }}</h1>
</header>
<el-drawer <el-drawer
v-model="mobileMenuVisible" v-model="mobileMenuVisible"
direction="ltr" direction="ltr"
size="84%" size="85%"
title="导航菜单"
append-to-body
:with-header="false" :with-header="false"
class="app-aside-drawer"> class="app-aside-drawer">
<div class="mobile-menu"> <div class="mobile-menu">
<div class="mobile-menu__header"> <div class="mobile-menu__header">
<el-button circle plain :icon="MenuIcon" @click="toggleMobileMenu" /> <button class="mobile-nav__trigger" type="button" aria-label="关闭导航菜单" @click="mobileMenuVisible = false">
<el-icon><Close /></el-icon>
</button>
</div> </div>
<div class="mobile-menu__body"> <div class="mobile-menu__body">
<RouterLink
:to="menuLinkTo({ path: '/' })"
class="mobile-menu__link"
:class="{ 'is-active': route.path === '/' }"
@click="mobileMenuVisible = false">
<IconHome class="mobile-menu__link-icon home-menu-icon" />
<span>主页</span>
</RouterLink>
<template v-for="item in menus" :key="item.path"> <template v-for="item in menus" :key="item.path">
<RouterLink <RouterLink
v-if="!item.children" v-if="!item.children"
...@@ -98,7 +125,7 @@ function menuLinkTo(item: { path: string }) { ...@@ -98,7 +125,7 @@ function menuLinkTo(item: { path: string }) {
class="mobile-menu__link" class="mobile-menu__link"
:class="{ 'is-active': item.path === currentMenu?.path }" :class="{ 'is-active': item.path === currentMenu?.path }"
v-permission="item.tag" v-permission="item.tag"
@click="toggleMobileMenu"> @click="mobileMenuVisible = false">
<component :is="item.icon" class="mobile-menu__link-icon" v-if="item.icon" /> <component :is="item.icon" class="mobile-menu__link-icon" v-if="item.icon" />
<span>{{ item.name }}</span> <span>{{ item.name }}</span>
</RouterLink> </RouterLink>
...@@ -110,7 +137,6 @@ function menuLinkTo(item: { path: string }) { ...@@ -110,7 +137,6 @@ function menuLinkTo(item: { path: string }) {
<component :is="item.icon" class="mobile-menu__link-icon" v-if="item.icon" /> <component :is="item.icon" class="mobile-menu__link-icon" v-if="item.icon" />
<span>{{ item.name }}</span> <span>{{ item.name }}</span>
</div> </div>
<span class="mobile-menu__group-count">{{ item.children.length }} 项</span>
</div> </div>
</template> </template>
<div class="mobile-menu__children"> <div class="mobile-menu__children">
...@@ -121,7 +147,7 @@ function menuLinkTo(item: { path: string }) { ...@@ -121,7 +147,7 @@ function menuLinkTo(item: { path: string }) {
class="mobile-menu__sublink" class="mobile-menu__sublink"
:class="{ 'is-active': submenu.path === currentSubmenu?.path }" :class="{ 'is-active': submenu.path === currentSubmenu?.path }"
v-permission="submenu.tag" v-permission="submenu.tag"
@click="toggleMobileMenu"> @click="mobileMenuVisible = false">
<component :is="submenu.icon" class="mobile-menu__sublink-icon" v-if="submenu.icon"></component> <component :is="submenu.icon" class="mobile-menu__sublink-icon" v-if="submenu.icon"></component>
<span>{{ submenu.name }}</span> <span>{{ submenu.name }}</span>
</RouterLink> </RouterLink>
...@@ -132,15 +158,20 @@ function menuLinkTo(item: { path: string }) { ...@@ -132,15 +158,20 @@ function menuLinkTo(item: { path: string }) {
</div> </div>
</div> </div>
</el-drawer> </el-drawer>
<button class="mobile-menu-float-btn" type="button" @click="toggleMobileMenu">
<el-icon><ArrowRight /></el-icon>
</button>
</template> </template>
<aside v-else class="app-aside" :class="{ 'is-collapsed': collapsed }"> <aside v-else class="app-aside" :class="{ 'is-collapsed': collapsed }">
<nav class="menu"> <nav class="menu">
<ul> <ul>
<li :class="{ 'is-active': route.path === '/' }">
<div class="menu-item">
<el-tooltip content="主页" placement="right">
<RouterLink :to="menuLinkTo({ path: '/' })" aria-label="主页">
<IconHome class="menu-icon home-menu-icon" />
</RouterLink>
</el-tooltip>
</div>
</li>
<li <li
v-for="item in menus" v-for="item in menus"
:key="item.path" :key="item.path"
...@@ -344,9 +375,7 @@ function menuLinkTo(item: { path: string }) { ...@@ -344,9 +375,7 @@ function menuLinkTo(item: { path: string }) {
} }
.app-aside-drawer { .app-aside-drawer {
.el-drawer { background: #ecf2f7;
background: #ecf2f7;
}
.el-drawer__body { .el-drawer__body {
padding: 0; padding: 0;
} }
...@@ -362,14 +391,18 @@ function menuLinkTo(item: { path: string }) { ...@@ -362,14 +391,18 @@ function menuLinkTo(item: { path: string }) {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
padding: 14px 18px 10px; gap: 8px;
padding: 10px 16px;
flex-shrink: 0;
background: #fff; background: #fff;
border-bottom: 1px solid #e5e5e5; border-bottom: 1px solid #e5e5e5;
} }
.mobile-menu__body { .mobile-menu__body {
flex: 1; flex: 1;
min-height: 0;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain;
padding: 14px; padding: 14px;
} }
...@@ -388,8 +421,9 @@ function menuLinkTo(item: { path: string }) { ...@@ -388,8 +421,9 @@ function menuLinkTo(item: { path: string }) {
color: #333; color: #333;
border: 1px solid #e5e5e5; border: 1px solid #e5e5e5;
&.is-active { &.is-active {
background: var(--main-color); background: rgba(186, 20, 62, 0.06);
color: #fff; color: var(--main-color);
font-weight: 600;
border-color: var(--main-color); border-color: var(--main-color);
.mobile-menu__link-icon { .mobile-menu__link-icon {
fill: currentColor; fill: currentColor;
...@@ -398,9 +432,21 @@ function menuLinkTo(item: { path: string }) { ...@@ -398,9 +432,21 @@ function menuLinkTo(item: { path: string }) {
} }
} }
.home-menu-icon {
fill: currentColor;
}
.app-aside .home-menu-icon {
color: var(--main-color);
}
.app-aside .menu > ul > li.is-active .home-menu-icon {
color: #fff;
}
.mobile-menu__link-icon, .mobile-menu__link-icon,
.mobile-menu__sublink-icon { .mobile-menu__sublink-icon {
width: 18px; width: 18px;
height: 18px;
fill: currentColor;
flex-shrink: 0; flex-shrink: 0;
} }
...@@ -444,10 +490,7 @@ function menuLinkTo(item: { path: string }) { ...@@ -444,10 +490,7 @@ function menuLinkTo(item: { path: string }) {
font-weight: 600; font-weight: 600;
} }
.mobile-menu__group-count {
font-size: 12px;
color: #999;
}
.mobile-menu__children { .mobile-menu__children {
display: flex; display: flex;
...@@ -473,21 +516,48 @@ function menuLinkTo(item: { path: string }) { ...@@ -473,21 +516,48 @@ function menuLinkTo(item: { path: string }) {
} }
} }
.mobile-menu-float-btn { .mobile-nav {
position: fixed; position: sticky;
left: 0; top: 0;
bottom: 24px; z-index: 100;
width: 32px; display: flex;
height: 52px; align-items: center;
gap: 8px;
min-height: 52px;
flex-shrink: 0;
padding: env(safe-area-inset-top) 12px 0;
background: #fff;
border-bottom: 1px solid #e6e6e6;
}
.mobile-nav__trigger {
flex-shrink: 0;
width: 44px;
height: 44px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border: 1px solid #e0e0e0; border-radius: 8px;
border-left: none; background: transparent;
border-radius: 0 18px 18px 0;
background: #fff;
color: var(--main-color); color: var(--main-color);
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.08); font-size: 22px;
z-index: 2000; cursor: pointer;
&:hover { background: var(--el-fill-color-light); }
&:focus-visible { outline: 2px solid var(--main-color); outline-offset: 2px; }
}
.mobile-nav__title {
min-width: 0;
font-size: 16px;
font-weight: 600;
line-height: 24px;
overflow-wrap: anywhere;
padding: 10px 0;
}
@media (max-width: 767px) {
.app-aside-drawer.el-drawer { width: min(85vw, 340px) !important; }
.mobile-menu__header { padding-top: max(14px, env(safe-area-inset-top)); }
.mobile-menu__body { padding-bottom: max(14px, env(safe-area-inset-bottom)); }
.mobile-menu__link, .mobile-menu__sublink { min-height: 44px; overflow-wrap: anywhere; }
.mobile-menu__group-main { min-width: 0; }
} }
</style> </style>
...@@ -22,4 +22,7 @@ export default { name: 'AppFooter' } ...@@ -22,4 +22,7 @@ export default { name: 'AppFooter' }
margin: 10px; margin: 10px;
border-radius: 6px; border-radius: 6px;
} }
@media (max-width: 767px) {
.app-footer { height: auto; min-height: 50px; padding: 12px; flex-direction: column; gap: 8px; text-align: center; line-height: 1.6; overflow-wrap: anywhere; }
}
</style> </style>
...@@ -32,8 +32,12 @@ withDefaults(defineProps<Props>(), { ...@@ -32,8 +32,12 @@ withDefaults(defineProps<Props>(), {
} }
.app-layout-container { .app-layout-container {
flex: 1; flex: 1;
min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
} }
@media (max-width: 767px) {
.app-layout { flex-direction: column; min-height: 100dvh; }
}
</style> </style>
...@@ -20,6 +20,7 @@ const padding = computed(() => { ...@@ -20,6 +20,7 @@ const padding = computed(() => {
<style> <style>
.app-main { .app-main {
flex: 1; flex: 1;
min-width: 0;
padding: v-bind(padding); padding: v-bind(padding);
overflow: hidden; overflow: hidden;
box-sizing: border-box; box-sizing: border-box;
......
...@@ -13,6 +13,7 @@ const { buttonText = '添加条件' } = defineProps<{ ...@@ -13,6 +13,7 @@ const { buttonText = '添加条件' } = defineProps<{
const { tagList } = useTag() const { tagList } = useTag()
const { rfmResList } = useRfmRes() const { rfmResList } = useRfmRes()
const isMobile = useMediaQuery('(max-width: 767px)')
// 获取逻辑运算符名称 // 获取逻辑运算符名称
function getLogicalName(value: 'and' | 'or') { function getLogicalName(value: 'and' | 'or') {
...@@ -102,7 +103,7 @@ function handleRfmChange(rfmKey: string, item: any) { ...@@ -102,7 +103,7 @@ function handleRfmChange(rfmKey: string, item: any) {
</div> </div>
</el-option> </el-option>
</el-select> </el-select>
<el-popover popper-class="rfm-popover" placement="top" :width="800" trigger="hover"> <el-popover popper-class="rfm-popover" placement="top" :width="800" :trigger="isMobile ? 'click' : 'hover'">
<el-table :data="rfmResList" border stripe> <el-table :data="rfmResList" border stripe>
<el-table-column prop="frm_extend_info.group" label="组合" width="70" /> <el-table-column prop="frm_extend_info.group" label="组合" width="70" />
<el-table-column prop="frm_extend_info.r" label="R值" width="52" /> <el-table-column prop="frm_extend_info.r" label="R值" width="52" />
......
...@@ -330,4 +330,10 @@ const defaultOptions = Array.from({ length: 5 }).map((_, index) => ({ value: ind ...@@ -330,4 +330,10 @@ const defaultOptions = Array.from({ length: 5 }).map((_, index) => ({ value: ind
display: flex; display: flex;
align-items: center; align-items: center;
} }
@media (max-width: 767px) {
.rfm-top, .rfm-header, .rfm-header-extra { flex-wrap: wrap; gap: 8px; min-width: 0; }
.rfm-body { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.rfm-box { min-width: 0; }
.rfm-header-extra > .el-input { max-width: 100%; }
}
</style> </style>
...@@ -107,4 +107,10 @@ watch( ...@@ -107,4 +107,10 @@ watch(
} }
} }
} }
@media (max-width: 767px) {
.user-label { height: auto; min-height: 160px; margin: 16px 0; background: none; }
.user-label ul { width: 100%; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.user-label li { scale: 1 !important; text-align: center; min-width: 0; }
.user-label .user-label__inner { border-radius: 8px; max-width: 100%; overflow-wrap: anywhere; }
}
</style> </style>
...@@ -115,15 +115,15 @@ const handleStudentFollow = function () { ...@@ -115,15 +115,15 @@ const handleStudentFollow = function () {
<span>自动生成用户数据</span> <span>自动生成用户数据</span>
</li> </li>
<li @click.stop="generateEventData"> <li @click.stop="generateEventData">
<el-icon size="16" color="#000" @click.stop="edit"><Avatar /></el-icon> <el-icon size="16" color="#000"><Avatar /></el-icon>
<span>自动生成事件数据</span> <span>自动生成事件数据</span>
</li> </li>
<li @click.stop="viewDataProgress"> <li @click.stop="viewDataProgress">
<el-icon size="16" color="#000" @click.stop="edit"><PieChart /></el-icon> <el-icon size="16" color="#000"><PieChart /></el-icon>
<span>数据生成进度</span> <span>数据生成进度</span>
</li> </li>
<li @click.stop="handleRemove" v-if="userStore.role?.id !== 1"> <li @click.stop="handleRemove" v-if="userStore.role?.id !== 1">
<el-icon size="16" color="#000" @click.stop="edit"><Delete /></el-icon> <el-icon size="16" color="#000"><Delete /></el-icon>
<span>删除</span> <span>删除</span>
</li> </li>
</ul> </ul>
......
...@@ -26,7 +26,7 @@ const listOptions = computed(() => { ...@@ -26,7 +26,7 @@ const listOptions = computed(() => {
params: listParams, params: listParams,
beforeRequest(params: any, isReset: boolean) { beforeRequest(params: any, isReset: boolean) {
if (isReset) listParams.updated_operator = '' if (isReset) listParams.updated_operator = ''
params.updated_operator = listParams.updated_operator
return params return params
} }
}, },
...@@ -145,8 +145,8 @@ function handleView(row: Group) { ...@@ -145,8 +145,8 @@ function handleView(row: Group) {
> >
</el-space> </el-space>
</template> </template>
<template #filter-user> <template #filter-user="{ params, onChange }">
<SelectUser v-model="listParams.updated_operator" placeholder="更新人" @change="handleRefresh"></SelectUser> <SelectUser v-model="params.updated_operator" placeholder="更新人" @change="onChange"></SelectUser>
</template> </template>
<template #table-x="{ row }"> <template #table-x="{ row }">
......
...@@ -288,7 +288,7 @@ onMounted(() => { ...@@ -288,7 +288,7 @@ onMounted(() => {
</AppCard> </AppCard>
</div> </div>
</div> </div>
<div style="width: 340px"> <div class="home-user-tracking">
<AppCard class="card" title="最近活跃用户跟踪"> <AppCard class="card" title="最近活跃用户跟踪">
<div class="content-user"> <div class="content-user">
<template v-for="(item, index) in userList" :key="item.id"> <template v-for="(item, index) in userList" :key="item.id">
...@@ -359,6 +359,7 @@ onMounted(() => { ...@@ -359,6 +359,7 @@ onMounted(() => {
margin-top: 10px; margin-top: 10px;
} }
.col { .col {
min-width: 0;
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
...@@ -383,7 +384,11 @@ onMounted(() => { ...@@ -383,7 +384,11 @@ onMounted(() => {
background-color: #fff; background-color: #fff;
} }
.home-user-tracking { width: 340px; flex-shrink: 0; }
@media (max-width: 767px) { @media (max-width: 767px) {
.home-user-tracking { width: 100%; }
:deep(.chart-card) { min-width: 0; width: 100%; }
> .row { > .row {
flex-direction: column; flex-direction: column;
} }
...@@ -394,6 +399,7 @@ onMounted(() => { ...@@ -394,6 +399,7 @@ onMounted(() => {
.row { .row {
flex-wrap: wrap; flex-wrap: wrap;
.card { .card {
min-width: 0;
flex: 0 0 calc(50% - 5px); flex: 0 0 calc(50% - 5px);
p { p {
line-height: 60px; line-height: 60px;
...@@ -412,7 +418,8 @@ onMounted(() => { ...@@ -412,7 +418,8 @@ onMounted(() => {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
.content-user_item { .content-user_item {
width: 50px; flex: 0 0 60px;
width: 60px;
padding: 10px; padding: 10px;
border-radius: 10px; border-radius: 10px;
cursor: pointer; cursor: pointer;
...@@ -423,6 +430,7 @@ onMounted(() => { ...@@ -423,6 +430,7 @@ onMounted(() => {
img { img {
width: 40px; width: 40px;
height: 40px; height: 40px;
object-fit: contain;
margin: 0 auto; margin: 0 auto;
display: block; display: block;
} }
......
...@@ -34,8 +34,6 @@ const listOptions = computed(() => { ...@@ -34,8 +34,6 @@ const listOptions = computed(() => {
if (isReset) { if (isReset) {
listParams.updated_operator = '' listParams.updated_operator = ''
listParams.type_id = '' listParams.type_id = ''
} else {
params.updated_operator = listParams.updated_operator
} }
return params return params
}, },
...@@ -171,7 +169,7 @@ const handleRemoves = async function () { ...@@ -171,7 +169,7 @@ const handleRemoves = async function () {
<div class="label-left"><LabelType :active-id="listParams.type_id" @select="handleSelect"></LabelType></div> <div class="label-left"><LabelType :active-id="listParams.type_id" @select="handleSelect"></LabelType></div>
<AppList v-bind="listOptions" ref="appList" class="label-right" @selection-change="handleSelectionChange"> <AppList v-bind="listOptions" ref="appList" class="label-right" @selection-change="handleSelectionChange">
<template #header-buttons> <template #header-buttons>
<div style="display: flex; justify-content: space-between"> <div class="label-toolbar">
<div> <div>
<el-button type="primary" :icon="Plus" @click="handleAdd" v-if="!userStore.status.tag_status" <el-button type="primary" :icon="Plus" @click="handleAdd" v-if="!userStore.status.tag_status"
>新建</el-button >新建</el-button
...@@ -189,8 +187,8 @@ const handleRemoves = async function () { ...@@ -189,8 +187,8 @@ const handleRemoves = async function () {
<el-button type="primary" @click="aiDialogVisible = true">AI建议</el-button> <el-button type="primary" @click="aiDialogVisible = true">AI建议</el-button>
</div> </div>
</template> </template>
<template #filter-user> <template #filter-user="{ params, onChange }">
<SelectUser v-model="listParams.updated_operator" placeholder="更新人" @change="handleRefresh"></SelectUser> <SelectUser v-model="params.updated_operator" placeholder="更新人" @change="onChange"></SelectUser>
</template> </template>
<template #table-x="{ row }"> <template #table-x="{ row }">
...@@ -224,6 +222,13 @@ const handleRemoves = async function () { ...@@ -224,6 +222,13 @@ const handleRemoves = async function () {
height: calc(100vh - 130px); height: calc(100vh - 130px);
display: flex; display: flex;
} }
.label-toolbar {
flex: 1;
min-width: 0;
display: flex;
justify-content: space-between;
gap: 8px;
}
.label-left { .label-left {
padding-right: 20px; padding-right: 20px;
margin-right: 20px; margin-right: 20px;
......
...@@ -3,10 +3,6 @@ import LiveProductCategory from '@/components/LiveProductCategory.vue' ...@@ -3,10 +3,6 @@ import LiveProductCategory from '@/components/LiveProductCategory.vue'
import { getOrderList } from '../api' import { getOrderList } from '../api'
const appList = ref(null) const appList = ref(null)
// 刷新
const handleRefresh = () => {
appList.value?.refetch()
}
const listParams = reactive({ name: '', live_commodity_type_id: '', live_commodity_title: '' }) const listParams = reactive({ name: '', live_commodity_type_id: '', live_commodity_title: '' })
// 列表配置 // 列表配置
...@@ -17,7 +13,7 @@ const listOptions = computed(() => { ...@@ -17,7 +13,7 @@ const listOptions = computed(() => {
params: listParams, params: listParams,
beforeRequest(params, isReset) { beforeRequest(params, isReset) {
if (isReset) listParams.live_commodity_type_id = '' if (isReset) listParams.live_commodity_type_id = ''
params.live_commodity_type_id = listParams.live_commodity_type_id
return params return params
}, },
}, },
...@@ -65,10 +61,10 @@ const listOptions = computed(() => { ...@@ -65,10 +61,10 @@ const listOptions = computed(() => {
<template> <template>
<AppCard title="订单管理"> <AppCard title="订单管理">
<AppList v-bind="listOptions" ref="appList"> <AppList v-bind="listOptions" ref="appList">
<template #filter-category> <template #filter-category="{ params, onChange }">
<LiveProductCategory <LiveProductCategory
v-model="listParams.live_commodity_type_id" v-model="params.live_commodity_type_id"
@change="handleRefresh" @change="onChange"
placeholder="请选择直播主题品类"></LiveProductCategory> placeholder="请选择直播主题品类"></LiveProductCategory>
</template> </template>
</AppList> </AppList>
......
...@@ -25,7 +25,7 @@ const listOptions = computed(() => { ...@@ -25,7 +25,7 @@ const listOptions = computed(() => {
params: listParams, params: listParams,
beforeRequest(params, isReset) { beforeRequest(params, isReset) {
if (isReset) listParams.live_commodity_type_id = '' if (isReset) listParams.live_commodity_type_id = ''
params.live_commodity_type_id = listParams.live_commodity_type_id
return params return params
}, },
}, },
...@@ -89,10 +89,10 @@ const handleRemove = async (row) => { ...@@ -89,10 +89,10 @@ const handleRemove = async (row) => {
<AppCard title="商品属性管理"> <AppCard title="商品属性管理">
<AppList v-bind="listOptions" ref="appList"> <AppList v-bind="listOptions" ref="appList">
<template #header-buttons><el-button type="primary" @click="handleAdd">新增商品属性</el-button></template> <template #header-buttons><el-button type="primary" @click="handleAdd">新增商品属性</el-button></template>
<template #filter-category> <template #filter-category="{ params, onChange }">
<LiveProductCategory <LiveProductCategory
v-model="listParams.live_commodity_type_id" v-model="params.live_commodity_type_id"
@change="handleRefresh" @change="onChange"
placeholder="请选择商品品类"></LiveProductCategory> placeholder="请选择商品品类"></LiveProductCategory>
</template> </template>
<template #table-x="{ row }"> <template #table-x="{ row }">
......
...@@ -326,21 +326,21 @@ function batchSetReferencePrice() { ...@@ -326,21 +326,21 @@ function batchSetReferencePrice() {
<span>{{ row.specs ? row.specs.join(' / ') : '-' }}</span> <span>{{ row.specs ? row.specs.join(' / ') : '-' }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="price" label="价格" align="center" column-key="price"> <el-table-column prop="price" label="价格" min-width="150" align="center" column-key="price">
<template #default="{ row }"> <template #default="{ row }">
<el-input v-model="row.price" placeholder="请输入价格" type="number"> <el-input v-model="row.price" placeholder="请输入价格" type="number">
<template #suffix>¥</template> <template #suffix>¥</template>
</el-input> </el-input>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="reference_price" label="参考价" align="center" column-key="reference_price"> <el-table-column prop="reference_price" label="参考价" min-width="150" align="center" column-key="reference_price">
<template #default="{ row }"> <template #default="{ row }">
<el-input v-model="row.reference_price" placeholder="请输入参考价" type="number"> <el-input v-model="row.reference_price" placeholder="请输入参考价" type="number">
<template #suffix>¥</template> <template #suffix>¥</template>
</el-input> </el-input>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="stock" label="库存" align="center" column-key="stock"> <el-table-column prop="stock" label="库存" min-width="150" align="center" column-key="stock">
<template #default="{ row }"> <template #default="{ row }">
<el-input v-model.number="row.stock" placeholder="请输入库存" type="number"> <el-input v-model.number="row.stock" placeholder="请输入库存" type="number">
<template #suffix>件</template> <template #suffix>件</template>
...@@ -421,15 +421,19 @@ function batchSetReferencePrice() { ...@@ -421,15 +421,19 @@ function batchSetReferencePrice() {
} }
.sku-table { .sku-table {
width: 100%; width: 100%;
min-width: 0;
} }
@media (max-width: 768px) { @media (max-width: 767px) {
.spec-list { .spec-list {
width: 100%;
flex-direction: column; flex-direction: column;
} }
.spec-item { .spec-item {
min-width: 100%; min-width: 0;
width: 100%;
padding: 12px;
} }
.batch-set-container { .batch-set-container {
......
...@@ -5,10 +5,6 @@ import { getProductList, deleteProduct } from '../api' ...@@ -5,10 +5,6 @@ import { getProductList, deleteProduct } from '../api'
import { getOperatorName } from '@/utils/utils' import { getOperatorName } from '@/utils/utils'
const appList = ref(null) const appList = ref(null)
// 刷新
const handleRefresh = () => {
appList.value?.refetch()
}
const listParams = reactive({ name: '', live_commodity_type_id: '' }) const listParams = reactive({ name: '', live_commodity_type_id: '' })
...@@ -20,7 +16,7 @@ const listOptions = computed(() => { ...@@ -20,7 +16,7 @@ const listOptions = computed(() => {
params: listParams, params: listParams,
beforeRequest(params, isReset) { beforeRequest(params, isReset) {
if (isReset) listParams.live_commodity_type_id = '' if (isReset) listParams.live_commodity_type_id = ''
params.live_commodity_type_id = listParams.live_commodity_type_id
return params return params
}, },
callback(data) { callback(data) {
...@@ -64,8 +60,8 @@ const handleRemove = async (row) => { ...@@ -64,8 +60,8 @@ const handleRemove = async (row) => {
<template #header-buttons> <template #header-buttons>
<el-button type="primary"><router-link to="management/add">添加商品</router-link></el-button> <el-button type="primary"><router-link to="management/add">添加商品</router-link></el-button>
</template> </template>
<template #filter-category> <template #filter-category="{ params, onChange }">
<LiveProductCategory v-model="listParams.live_commodity_type_id" @change="handleRefresh" placeholder="请选择商品品类"></LiveProductCategory> <LiveProductCategory v-model="params.live_commodity_type_id" @change="onChange" placeholder="请选择商品品类"></LiveProductCategory>
</template> </template>
<template #table-picture="{ row }"> <template #table-picture="{ row }">
<el-image fit="cover" preview-teleported :src="row.picture_url" :previewSrcList="row.picture_url_list" style="width:100px;height:100px"></el-image> <el-image fit="cover" preview-teleported :src="row.picture_url" :previewSrcList="row.picture_url_list" style="width:100px;height:100px"></el-image>
......
...@@ -20,7 +20,7 @@ const listOptions = computed(() => { ...@@ -20,7 +20,7 @@ const listOptions = computed(() => {
params: listParams, params: listParams,
beforeRequest(params, isReset) { beforeRequest(params, isReset) {
if (isReset) listParams.live_commodity_type_id = '' if (isReset) listParams.live_commodity_type_id = ''
params.live_commodity_type_id = listParams.live_commodity_type_id
return params return params
}, },
}, },
...@@ -74,8 +74,8 @@ const handleRemove = async (row) => { ...@@ -74,8 +74,8 @@ const handleRemove = async (row) => {
<template #header-buttons> <template #header-buttons>
<el-button type="primary" @click="handleAdd">上传总结报告</el-button> <el-button type="primary" @click="handleAdd">上传总结报告</el-button>
</template> </template>
<template #filter-category> <template #filter-category="{ params, onChange }">
<LiveProductCategory v-model="listParams.live_commodity_type_id" @change="handleRefresh" placeholder="请选择直播主题品类"></LiveProductCategory> <LiveProductCategory v-model="params.live_commodity_type_id" @change="onChange" placeholder="请选择直播主题品类"></LiveProductCategory>
</template> </template>
<template #table-x="{ row }"> <template #table-x="{ row }">
<el-button text type="primary" @click="handleView(row)">查看</el-button> <el-button text type="primary" @click="handleView(row)">查看</el-button>
......
...@@ -22,7 +22,7 @@ const listOptions = computed(() => { ...@@ -22,7 +22,7 @@ const listOptions = computed(() => {
params: listParams, params: listParams,
beforeRequest(params, isReset) { beforeRequest(params, isReset) {
if (isReset) listParams.live_commodity_type_id = '' if (isReset) listParams.live_commodity_type_id = ''
params.live_commodity_type_id = listParams.live_commodity_type_id
return params return params
}, },
callback(data) { callback(data) {
...@@ -89,10 +89,10 @@ const handleRemove = async (row) => { ...@@ -89,10 +89,10 @@ const handleRemove = async (row) => {
<template #header-buttons> <template #header-buttons>
<el-button type="primary" @click="handleAdd">添加话术</el-button> <el-button type="primary" @click="handleAdd">添加话术</el-button>
</template> </template>
<template #filter-category> <template #filter-category="{ params, onChange }">
<LiveProductCategory <LiveProductCategory
v-model="listParams.live_commodity_type_id" v-model="params.live_commodity_type_id"
@change="handleRefresh" @change="onChange"
placeholder="请选择直播主题品类"></LiveProductCategory> placeholder="请选择直播主题品类"></LiveProductCategory>
</template> </template>
<template #table-x="{ row }"> <template #table-x="{ row }">
......
...@@ -28,7 +28,7 @@ const listOptions = computed(() => { ...@@ -28,7 +28,7 @@ const listOptions = computed(() => {
params: listParams, params: listParams,
beforeRequest(params, isReset) { beforeRequest(params, isReset) {
if (isReset) listParams.live_commodity_type_id = '' if (isReset) listParams.live_commodity_type_id = ''
params.live_commodity_type_id = listParams.live_commodity_type_id
return params return params
}, },
}, },
...@@ -82,10 +82,10 @@ const handleStart = async (row) => { ...@@ -82,10 +82,10 @@ const handleStart = async (row) => {
<template #header-buttons> <template #header-buttons>
<el-button type="primary" @click="formVisible = true">添加直播</el-button> <el-button type="primary" @click="formVisible = true">添加直播</el-button>
</template> </template>
<template #filter-category> <template #filter-category="{ params, onChange }">
<LiveProductCategory <LiveProductCategory
v-model="listParams.live_commodity_type_id" v-model="params.live_commodity_type_id"
@change="handleRefresh" @change="onChange"
placeholder="请选择直播主题品类"></LiveProductCategory> placeholder="请选择直播主题品类"></LiveProductCategory>
</template> </template>
<template #table-x="{ row }"> <template #table-x="{ row }">
......
...@@ -378,4 +378,12 @@ defineExpose({ generateImage, generatePdf }) ...@@ -378,4 +378,12 @@ defineExpose({ generateImage, generatePdf })
} }
} }
} }
@media (max-width: 767px) {
.market-report { padding: 0 12px; overflow-wrap: anywhere; }
.market-report .market-report-header { padding: 20px 0; margin-bottom: 20px; }
.market-report .market-report-header ul { margin-top: 16px; flex-wrap: wrap; gap: 8px 16px; }
.market-report h2 { font-size: 18px; }
.market-report p { margin-left: 0; }
.market-report .market-report-step { display: none; }
}
</style> </style>
...@@ -345,4 +345,12 @@ defineExpose({ generateImage, generatePdf }) ...@@ -345,4 +345,12 @@ defineExpose({ generateImage, generatePdf })
} }
} }
} }
@media (max-width: 767px) {
.market-report { padding: 0 12px; overflow-wrap: anywhere; }
.market-report .market-report-header { padding: 20px 0; margin-bottom: 20px; }
.market-report .market-report-header ul { margin-top: 16px; flex-wrap: wrap; gap: 8px 16px; }
.market-report h2 { font-size: 18px; }
.market-report p { margin-left: 0; }
.market-report .market-report-step { display: none; }
}
</style> </style>
...@@ -365,4 +365,11 @@ async function handleSave(message) { ...@@ -365,4 +365,11 @@ async function handleSave(message) {
font-weight: bold; font-weight: bold;
color: var(--main-color); color: var(--main-color);
} }
@media (max-width: 767px) {
.chat { height: auto; max-height: 55dvh; min-height: 180px; }
.chat-footer { margin-top: 16px; }
.chat-message { margin: 16px 0; gap: 8px; }
.chat-message-main { min-width: 0; padding: 12px; }
.chat-message-avatar { flex-basis: 32px; height: 32px; }
}
</style> </style>
...@@ -79,4 +79,8 @@ function onClose() { ...@@ -79,4 +79,8 @@ function onClose() {
width: 100%; width: 100%;
} }
} }
@media (max-width: 767px) {
.image-design ul { margin: 16px 0; gap: 12px; }
.image-design li { width: calc(50% - 6px); min-width: 0; border-radius: 12px; }
}
</style> </style>
...@@ -167,4 +167,8 @@ async function handleNext() { ...@@ -167,4 +167,8 @@ async function handleNext() {
margin-bottom: 0; margin-bottom: 0;
} }
} }
@media (max-width: 767px) {
.info-form { flex-direction: column; gap: 12px; }
.info-form .el-form-item { width: 100%; }
}
</style> </style>
...@@ -127,4 +127,10 @@ async function handleChatSave(url: string) { ...@@ -127,4 +127,10 @@ async function handleChatSave(url: string) {
background-color: rgba(242, 242, 242, 0.49); background-color: rgba(242, 242, 242, 0.49);
} }
} }
@media (max-width: 767px) {
.material-tabs .el-tabs__header { width: 100%; max-width: 300px; }
.material .el-card__body { padding: 12px; }
.material .el-row { gap: 8px; }
.material .el-row > .el-button { margin-left: 0; }
}
</style> </style>
...@@ -5,9 +5,12 @@ import { ElMessageBox, ElMessage } from 'element-plus' ...@@ -5,9 +5,12 @@ import { ElMessageBox, ElMessage } from 'element-plus'
import AppList from '@/components/base/AppList.vue' import AppList from '@/components/base/AppList.vue'
import { getMemberMeta, deleteMemberMeta } from '../api' import { getMemberMeta, deleteMemberMeta } from '../api'
import { useMapStore } from '@/stores/map' import { useMapStore } from '@/stores/map'
import { useUserStore } from '@/stores/user'
import { getNameByValue } from '@/utils/dictionary' import { getNameByValue } from '@/utils/dictionary'
const store = useMapStore() const store = useMapStore()
const userStore = useUserStore()
const canManage = computed(() => userStore.role?.id !== 1)
const FormDialog = defineAsyncComponent(() => import('../components/FormDialog.vue')) const FormDialog = defineAsyncComponent(() => import('../components/FormDialog.vue'))
const ViewDialog = defineAsyncComponent(() => import('../components/ViewDialog.vue')) const ViewDialog = defineAsyncComponent(() => import('../components/ViewDialog.vue'))
...@@ -110,14 +113,14 @@ function handleRemove(row: UserProp) { ...@@ -110,14 +113,14 @@ function handleRemove(row: UserProp) {
<AppCard> <AppCard>
<AppList v-bind="listOptions" ref="appList"> <AppList v-bind="listOptions" ref="appList">
<template #header-buttons> <template #header-buttons>
<el-space> <el-space v-if="canManage">
<el-button type="primary" :icon="Plus" @click="handleAdd">新建</el-button> <el-button type="primary" :icon="Plus" @click="handleAdd">新建</el-button>
</el-space> </el-space>
</template> </template>
<template #table-x="{ row }"> <template #table-x="{ row }">
<el-button type="primary" plain @click="handleView(row)">查看</el-button> <el-button type="primary" plain @click="handleView(row)">查看</el-button>
<el-button type="primary" plain @click="handleUpdate(row)" v-if="row.id !== '10000'">编辑</el-button> <el-button type="primary" plain @click="handleUpdate(row)" v-if="canManage && row.id !== '10000'">编辑</el-button>
<el-button type="primary" plain @click="handleRemove(row)" v-if="row.id !== '10000'">删除</el-button> <el-button type="primary" plain @click="handleRemove(row)" v-if="canManage && row.id !== '10000'">删除</el-button>
</template> </template>
</AppList> </AppList>
</AppCard> </AppCard>
......
...@@ -99,10 +99,10 @@ useIntervalFn(() => { ...@@ -99,10 +99,10 @@ useIntervalFn(() => {
</el-row> </el-row>
</template> </template>
<template #filter-category> <template #filter-category="{ params, onChange }">
<LiveProductCategory <LiveProductCategory
v-model="listParams.live_commodity_type_id" v-model="params.live_commodity_type_id"
@change="handleRefresh" @change="onChange"
placeholder="请选择直播主题品类"></LiveProductCategory> placeholder="请选择直播主题品类"></LiveProductCategory>
</template> </template>
......
...@@ -30,8 +30,6 @@ const listOptions = computed(() => { ...@@ -30,8 +30,6 @@ const listOptions = computed(() => {
if (isReset) { if (isReset) {
listParams.updated_operator = '' listParams.updated_operator = ''
listParams.type_id = '' listParams.type_id = ''
} else {
params.updated_operator = listParams.updated_operator
} }
return params return params
}, },
...@@ -169,8 +167,8 @@ const handleRemoves = async function () { ...@@ -169,8 +167,8 @@ const handleRemoves = async function () {
</div> </div>
</div> </div>
</template> </template>
<template #filter-user> <template #filter-user="{ params, onChange }">
<SelectUser v-model="listParams.updated_operator" placeholder="更新人" @change="handleRefresh"></SelectUser> <SelectUser v-model="params.updated_operator" placeholder="更新人" @change="onChange"></SelectUser>
</template> </template>
<template #table-x="{ row }"> <template #table-x="{ row }">
......
...@@ -3,6 +3,8 @@ import type { Trip } from '../types' ...@@ -3,6 +3,8 @@ import type { Trip } from '../types'
import TripFlow from '@/components/flow/Index.vue' import TripFlow from '@/components/flow/Index.vue'
import { getStudentTrip } from '../api' import { getStudentTrip } from '../api'
const isMobile = useMediaQuery('(max-width: 767px)')
const detail = ref<Trip>() const detail = ref<Trip>()
const elements = ref([]) const elements = ref([])
...@@ -24,7 +26,7 @@ const commentVisible = ref(false) ...@@ -24,7 +26,7 @@ const commentVisible = ref(false)
<template> <template>
<AppCard> <AppCard>
<el-descriptions :column="4" v-if="detail"> <el-descriptions :column="isMobile ? 1 : 4" v-if="detail">
<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.experiment_name }}</el-descriptions-item> <el-descriptions-item label="实验名称:">{{ detail.experiment_name }}</el-descriptions-item>
<el-descriptions-item label="用户旅程名称:">{{ detail.experiment_itinerary_name }}</el-descriptions-item> <el-descriptions-item label="用户旅程名称:">{{ detail.experiment_itinerary_name }}</el-descriptions-item>
......
...@@ -5,6 +5,8 @@ import TripFlow from '@/components/flow/Index.vue' ...@@ -5,6 +5,8 @@ import TripFlow from '@/components/flow/Index.vue'
import { getStudentTripRecord, submitStudentTripRecordComment, checkStudentTripRecord } from '../api' import { getStudentTripRecord, submitStudentTripRecordComment, checkStudentTripRecord } from '../api'
import { getNameByValue, tripTemplateTypeList } from '@/utils/dictionary' import { getNameByValue, tripTemplateTypeList } from '@/utils/dictionary'
const isMobile = useMediaQuery('(max-width: 767px)')
const detail = ref() const detail = ref()
const elements = ref([]) const elements = ref([])
...@@ -58,7 +60,7 @@ function handleSubmit() { ...@@ -58,7 +60,7 @@ function handleSubmit() {
</script> </script>
<template> <template>
<AppCard v-if="detail"> <AppCard v-if="detail">
<el-descriptions :column="4"> <el-descriptions :column="isMobile ? 1 : 4">
<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.experiment.name }}</el-descriptions-item> <el-descriptions-item label="实验名称:">{{ detail.experiment.name }}</el-descriptions-item>
<el-descriptions-item label="用户旅程名称:">{{ detail.itinerary.name }}</el-descriptions-item> <el-descriptions-item label="用户旅程名称:">{{ detail.itinerary.name }}</el-descriptions-item>
......
...@@ -108,7 +108,7 @@ const submitForm = (formEl: FormInstance | undefined) => { ...@@ -108,7 +108,7 @@ const submitForm = (formEl: FormInstance | undefined) => {
<el-form <el-form
:disabled="props.data?.isView" :disabled="props.data?.isView"
ref="ruleFormRef" ref="ruleFormRef"
style="width: 400px; margin: 0 auto; padding: 0 30px 30px 0" class="user-update-form"
:model="form" :model="form"
:rules="rules" :rules="rules"
label-suffix=":" label-suffix=":"
...@@ -172,3 +172,10 @@ const submitForm = (formEl: FormInstance | undefined) => { ...@@ -172,3 +172,10 @@ const submitForm = (formEl: FormInstance | undefined) => {
margin-bottom: 20px; margin-bottom: 20px;
} }
</style> </style>
<style scoped>
.user-update-form { width: 400px; max-width: 100%; margin: 0 auto; padding: 0 30px 30px 0; }
@media (max-width: 767px) {
.user-update-form { width: 100%; padding: 0; }
}
</style>
...@@ -65,7 +65,7 @@ const refetch = function () { ...@@ -65,7 +65,7 @@ const refetch = function () {
@closed="$emit('update:modelValue', false)"> @closed="$emit('update:modelValue', false)">
<AppList v-bind="listOptions" ref="appList"> <AppList v-bind="listOptions" ref="appList">
<template #table-x> <template #table-x>
<el-button type="primary" plain @click="refetch" v-permission="'v1-experiment-member-delete'">刷新</el-button> <el-button type="primary" plain @click="refetch">刷新</el-button>
</template> </template>
</AppList> </AppList>
</el-dialog> </el-dialog>
......
...@@ -5,6 +5,9 @@ import AppList from '@/components/base/AppList.vue' ...@@ -5,6 +5,9 @@ import AppList from '@/components/base/AppList.vue'
import UpdateEventsDialog from '../components/UpdateEventsDialog.vue' import UpdateEventsDialog from '../components/UpdateEventsDialog.vue'
import { getMemberEventList, deleteEvent } from '../api' import { getMemberEventList, deleteEvent } from '../api'
import type { MemberProp, AttributesProp } from '../types' import type { MemberProp, AttributesProp } from '../types'
import { useUserStore } from '@/stores/user'
const userStore = useUserStore()
const route = useRoute() const route = useRoute()
...@@ -85,12 +88,12 @@ const handleView = function (row: AttributesProp) { ...@@ -85,12 +88,12 @@ const handleView = function (row: AttributesProp) {
<AppCard> <AppCard>
<AppList v-bind="listOptions" ref="appList"> <AppList v-bind="listOptions" ref="appList">
<template #header-buttons> <template #header-buttons>
<el-button type="primary" :icon="Plus" @click="handleAdd">新建用户事件</el-button> <el-button v-if="!userStore.status.status" type="primary" :icon="Plus" @click="handleAdd">新建用户事件</el-button>
</template> </template>
<template #table-x="{ row }"> <template #table-x="{ row }">
<el-button type="primary" plain @click="handleView(row)">查看</el-button> <el-button type="primary" plain @click="handleView(row)">查看</el-button>
<el-button type="primary" plain @click="handleEdit(row)">编辑</el-button> <el-button v-if="!userStore.status.status" type="primary" plain @click="handleEdit(row)">编辑</el-button>
<el-button type="primary" plain @click="handleRemove(row)">删除</el-button> <el-button v-if="!userStore.status.status" type="primary" plain @click="handleRemove(row)">删除</el-button>
</template> </template>
</AppList> </AppList>
</AppCard> </AppCard>
......
...@@ -239,17 +239,16 @@ const handleClear = async () => { ...@@ -239,17 +239,16 @@ const handleClear = async () => {
</script> </script>
<template> <template>
<AppCard> <AppCard class="user-list-page">
<AppList v-bind="listOptions" ref="appList" @selection-change="handleSelectionChange"> <AppList v-bind="listOptions" ref="appList" @selection-change="handleSelectionChange">
<template #header-buttons> <template #header-buttons>
<el-row justify="space-between"> <div class="user-toolbar">
<el-space> <div class="user-toolbar-group">
<el-button <el-button
v-if="!userStore.status.status" v-if="!userStore.status.status"
type="primary" type="primary"
:icon="Plus" :icon="Plus"
@click="handleAdd" @click="handleAdd"
v-permission="'v1-experiment-member-create'"
>新建</el-button >新建</el-button
> >
<el-button v-if="userStore.status.status" type="primary" @click="handleSync">同步用户和事件数据</el-button> <el-button v-if="userStore.status.status" type="primary" @click="handleSync">同步用户和事件数据</el-button>
...@@ -265,7 +264,6 @@ const handleClear = async () => { ...@@ -265,7 +264,6 @@ const handleClear = async () => {
</el-dropdown-menu> </el-dropdown-menu>
</template> </template>
</el-dropdown> </el-dropdown>
<!-- v-permission="['v1-experiment-member-member-upload', 'v1-experiment-member-event-upload']" -->
<el-dropdown v-if="!userStore.status.status"> <el-dropdown v-if="!userStore.status.status">
<el-button type="primary" :icon="Upload">导入</el-button> <el-button type="primary" :icon="Upload">导入</el-button>
<template #dropdown> <template #dropdown>
...@@ -284,7 +282,7 @@ const handleClear = async () => { ...@@ -284,7 +282,7 @@ const handleClear = async () => {
> >
<!-- <el-button type="danger" plain :icon="Delete" :disabled="!multipleSelection.length" @click="handleRemoves()" v-permission="'v1-experiment-member-delete'">删除</el-button> --> <!-- <el-button type="danger" plain :icon="Delete" :disabled="!multipleSelection.length" @click="handleRemoves()" v-permission="'v1-experiment-member-delete'">删除</el-button> -->
<!-- v-permission="'v1-experiment-member-delete'" --> <!-- v-permission="'v1-experiment-member-delete'" -->
<el-dropdown> <el-dropdown v-if="!userStore.status.status">
<el-button type="danger" :icon="Delete">删除</el-button> <el-button type="danger" :icon="Delete">删除</el-button>
<template #dropdown> <template #dropdown>
<el-dropdown-menu> <el-dropdown-menu>
...@@ -296,21 +294,21 @@ const handleClear = async () => { ...@@ -296,21 +294,21 @@ const handleClear = async () => {
</template> </template>
</el-dropdown> </el-dropdown>
<el-button type="danger" @click="handleClear" v-if="userStore.role?.id == 1">清空数据</el-button> <el-button type="danger" @click="handleClear" v-if="userStore.role?.id == 1">清空数据</el-button>
</el-space> </div>
<el-space> <div class="user-toolbar-group">
<router-link to="/analyze/user"><el-button type="primary">用户分析</el-button></router-link> <router-link to="/analyze/user"><el-button type="primary">用户分析</el-button></router-link>
<el-button type="primary" @click="generateEventVisible = true">用户事件数据</el-button> <el-button v-if="!userStore.status.status" type="primary" @click="generateEventVisible = true">用户事件数据</el-button>
<ListColumnOptions :fields="defaultFields" /> <div class="user-column-options"><ListColumnOptions :fields="defaultFields" /></div>
</el-space> </div>
</el-row> </div>
</template> </template>
<template #table-x="{ row }"> <template #table-x="{ row }">
<el-button type="primary" plain @click="handleImage(row)">画像</el-button> <el-button type="primary" plain @click="handleImage(row)">画像</el-button>
<el-button type="primary" plain @click="handleView(row)">查看</el-button> <el-button type="primary" plain @click="handleView(row)">查看</el-button>
<el-button type="primary" plain @click="handleEdit(row)" v-permission="'v1-experiment-member-update'" <el-button v-if="!userStore.status.status" type="primary" plain @click="handleEdit(row)" v-permission="'v1-experiment-member-update'"
>编辑</el-button >编辑</el-button
> >
<el-button type="primary" plain @click="handleRemove(row)" v-permission="'v1-experiment-member-delete'" <el-button v-if="!userStore.status.status" type="primary" plain @click="handleRemove(row)" v-permission="'v1-experiment-member-delete'"
>删除</el-button >删除</el-button
> >
<el-button type="primary" plain @click="goPage(row)">事件</el-button> <el-button type="primary" plain @click="goPage(row)">事件</el-button>
...@@ -330,14 +328,64 @@ const handleClear = async () => { ...@@ -330,14 +328,64 @@ const handleClear = async () => {
</template> </template>
<style scoped> <style scoped>
.user-toolbar {
width: 100%;
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
}
.user-toolbar-group {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.user-toolbar-group > .el-button {
margin-left: 0;
}
@media (max-width: 767px) { @media (max-width: 767px) {
.el-row[style*="space-between"] { .user-toolbar {
flex-direction: column; flex-direction: column;
gap: 12px;
}
.user-toolbar-group {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px; gap: 8px;
width: 100%;
}
.user-toolbar-group > * {
min-width: 0;
}
.user-toolbar-group :deep(.el-button) {
width: 100%;
min-height: 40px;
height: auto;
margin-left: 0;
padding: 10px 8px;
line-height: 20px;
white-space: normal;
}
.user-toolbar-group :deep(.el-button > span) {
min-width: 0;
white-space: normal;
}
.user-toolbar-group > .el-dropdown {
width: 100%;
}
.user-column-options {
grid-column: 1 / -1;
justify-self: end;
}
.user-list-page :deep(.mobile-card-ft) {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 4px;
} }
.el-space { .user-list-page :deep(.mobile-card-ft .el-button) {
flex-wrap: wrap; width: 100%;
gap: 6px; margin-left: 0;
} }
} }
</style> </style>
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { setRequestContext } from '@/utils/requestContext'
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
...@@ -7,6 +8,21 @@ const router = createRouter({ ...@@ -7,6 +8,21 @@ const router = createRouter({
}) })
router.beforeEach(async (to, from, next) => { router.beforeEach(async (to, from, next) => {
if (!to.query.experiment_id) {
next({
path: to.path,
hash: to.hash,
replace: true,
query: {
...to.query,
experiment_id: from.query.experiment_id || '7028276368903241728',
student_id: to.query.student_id ?? from.query.student_id,
force_tgc: to.query.force_tgc ?? from.query.force_tgc,
},
})
return
}
setRequestContext(to.fullPath)
const whiteList = ['/401', '/one/live/test/view'] const whiteList = ['/401', '/one/live/test/view']
const user = useUserStore() const user = useUserStore()
if (!user.isLogin && !whiteList.includes(to.path)) { if (!user.isLogin && !whiteList.includes(to.path)) {
...@@ -16,23 +32,11 @@ router.beforeEach(async (to, from, next) => { ...@@ -16,23 +32,11 @@ router.beforeEach(async (to, from, next) => {
console.error(e) console.error(e)
} }
if (!user.isLogin) { if (!user.isLogin) {
next('/401') next({ path: '/401', query: to.query, replace: true })
return return
} }
} }
if (!to.query.experiment_id) { next()
next({
path: to.path,
query: {
...to.query,
experiment_id: from.query.experiment_id || '7028276368903241728',
student_id: from.query.student_id,
force_tgc: from.query.force_tgc,
},
})
} else {
next()
}
}) })
export default router export default router
...@@ -43,166 +43,10 @@ import { ...@@ -43,166 +43,10 @@ import {
} from '@remixicon/vue' } from '@remixicon/vue'
interface State { interface State {
studentMenus: IMenuItem[] baseMenus: IMenuItem[]
adminMenus: IMenuItem[]
} }
// 学生菜单 const baseMenus: IMenuItem[] = [
const studentMenus: IMenuItem[] = [
{
id: 1,
name: '基础配置',
path: '/connect',
icon: markRaw(IconMetadata),
children: [
{
id: 2,
name: '连接管理',
path: '/connect',
icon: markRaw(IconConnect),
},
],
},
{
id: 5,
name: '营销策划',
path: '/market/my',
icon: markRaw(IconMarket),
},
{
id: 6,
name: '用户画像',
path: '/user',
icon: markRaw(IconUser),
},
{
id: 7,
name: '用户识别',
path: '/label',
icon: markRaw(IconGroup2),
children: [
{
id: 8,
name: '标签管理',
path: '/label',
icon: markRaw(IconLabel),
},
{
id: 9,
name: '群组管理',
path: '/group',
icon: markRaw(IconGroup),
},
],
},
{
id: 10,
name: '营销内容设计',
path: '/material',
icon: markRaw(IconMaterial),
children: [
{
id: 11,
name: '文本资料管理',
path: '/material?type=1',
icon: markRaw(IconText),
// tag: 'v1-experiment-marketing-material-list'
tag: '',
},
{
id: 12,
name: '图片资料管理',
path: '/material?type=2',
icon: markRaw(IconImage),
// tag: 'v1-experiment-marketing-material-list'
tag: '',
},
{
id: 13,
name: '卡券资料管理',
path: '/material?type=8',
icon: markRaw(IconCard),
// tag: 'v1-experiment-marketing-material-list'
tag: '',
},
{
id: 17,
name: '语音资料管理',
path: '/material?type=3',
icon: markRaw(IconAudio),
// tag: 'v1-experiment-marketing-material-list'
tag: '',
},
{
id: 14,
name: '视频资料管理',
path: '/material?type=4',
icon: markRaw(IconVideo),
// tag: 'v1-experiment-marketing-material-list'
tag: '',
},
{
id: 15,
name: 'H5资料管理',
path: '/material?type=5',
icon: markRaw(IconH5),
// tag: 'v1-experiment-marketing-material-list',
},
{
id: 16,
name: '二维码资料管理',
path: '/material?type=6',
icon: markRaw(IconQrcode),
// tag: 'v1-experiment-marketing-material-list'
tag: '',
},
{
id: 18,
name: '小程序资料管理',
path: '/material?type=7',
icon: markRaw(IconMiniProgram),
// tag: 'v1-experiment-marketing-material-list'
tag: '',
},
],
},
{
id: 19,
name: '自动化营销',
path: '/trip/my',
icon: markRaw(IconTrip),
},
{
id: 20,
name: '直播',
path: '/live',
icon: markRaw(RiLiveLine),
children: [
{
id: 23,
name: '商品管理',
path: '/live/product/management',
icon: markRaw(RiShoppingBagLine),
},
{ id: 24, name: '直播话术管理', path: '/live/talk', icon: markRaw(RiMessage3Line) },
{ id: 25, name: '直播', path: '/live/test', icon: markRaw(RiVideoLine) },
],
},
{
id: 26,
name: '数据分析',
path: '/analyze',
icon: markRaw(IconAnalyze),
children: [
{ id: 27, name: '用户分析', path: '/analyze/user', icon: markRaw(RiUserLine) },
{ id: 28, name: '标签群组分析', path: '/analyze/label', icon: markRaw(RiGroupLine) },
{ id: 29, name: '事件分析', path: '/analyze/event', icon: markRaw(RiCalendarEventLine) },
{ id: 30, name: '营销分析', path: '/analyze/marketing', icon: markRaw(RiBarChartLine) },
],
},
]
const adminMenus: IMenuItem[] = [
{ {
id: 1, id: 1,
name: '基础配置', name: '基础配置',
...@@ -228,6 +72,7 @@ const adminMenus: IMenuItem[] = [ ...@@ -228,6 +72,7 @@ const adminMenus: IMenuItem[] = [
name: '事件属性管理', name: '事件属性管理',
path: '/metadata/event', path: '/metadata/event',
icon: markRaw(IconEvent), icon: markRaw(IconEvent),
role: [5, 6],
// tag: 'v1-experiment-meta-event', // tag: 'v1-experiment-meta-event',
}, },
], ],
...@@ -426,8 +271,7 @@ const adminMenus: IMenuItem[] = [ ...@@ -426,8 +271,7 @@ const adminMenus: IMenuItem[] = [
export const useMenuStore = defineStore({ export const useMenuStore = defineStore({
id: 'menu', id: 'menu',
state: (): State => ({ state: (): State => ({
studentMenus, baseMenus,
adminMenus,
}), }),
getters: { getters: {
menus: (state) => { menus: (state) => {
...@@ -452,12 +296,11 @@ export const useMenuStore = defineStore({ ...@@ -452,12 +296,11 @@ export const useMenuStore = defineStore({
} }
return filteredMenu return filteredMenu
}) })
.filter((menu) => !menu.children || menu.children.length > 0)
} }
// 根据用户角色选择基础菜单 const filteredMenus = filterMenus(state.baseMenus)
// const baseMenus = userRole === 1 ? state.studentMenus : state.adminMenus return filteredMenus.length === 1 && filteredMenus[0].children ? filteredMenus[0].children : filteredMenus
const filteredMenus = filterMenus(state.adminMenus)
return filteredMenus.length === 1 ? filteredMenus[0]?.children || [] : filteredMenus
}, },
}, },
}) })
...@@ -46,7 +46,6 @@ export const useUserStore = defineStore({ ...@@ -46,7 +46,6 @@ export const useUserStore = defineStore({
const res = await getUser() const res = await getUser()
const { info, role } = res.data const { info, role } = res.data
const { organization, project, roles = [], permissions = [] } = res.data.permissions || {} const { organization, project, roles = [], permissions = [] } = res.data.permissions || {}
this.user = info
this.role = role this.role = role
this.organization = organization this.organization = organization
this.project = project this.project = project
...@@ -59,10 +58,14 @@ export const useUserStore = defineStore({ ...@@ -59,10 +58,14 @@ export const useUserStore = defineStore({
await getAuth().then((res) => { await getAuth().then((res) => {
this.menus = res.data.items || [] this.menus = res.data.items || []
}) })
this.user = info
}, },
async logout() { async logout() {
await logout() await logout()
this.user = null this.user = null
this.status = {}
this.menus = []
this.permissions = []
}, },
}, },
}) })
import axios from 'axios' import axios from 'axios'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { getLoginUrl } from '@/utils/env' import { getLoginUrl } from '@/utils/env'
import { getRequestContext } from '@/utils/requestContext'
// import router from '@/router' // import router from '@/router'
const httpRequest = axios.create({ const httpRequest = axios.create({
...@@ -14,7 +15,7 @@ const httpRequest = axios.create({ ...@@ -14,7 +15,7 @@ const httpRequest = axios.create({
// 请求拦截 // 请求拦截
httpRequest.interceptors.request.use( httpRequest.interceptors.request.use(
function (config) { function (config) {
const params = new URLSearchParams(window.location.search) const params = getRequestContext()
if (config.method === 'get') if (config.method === 'get')
config.params = Object.assign({}, config.params, { config.params = Object.assign({}, config.params, {
......
let routeParams: URLSearchParams | undefined
export function setRequestContext(fullPath: string) {
routeParams = new URL(fullPath, window.location.origin).searchParams
}
export function getRequestContext() {
return routeParams || new URLSearchParams(window.location.search)
}
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论