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

feat: 页面响应式设计

上级 130196c4
差异被折叠。
......@@ -85,6 +85,13 @@ textarea:focus {
margin: 0 -16px 30px;
padding: 0 16px;
}
/* 移动端弹框宽度 90% */
@media (max-width: 767px) {
.el-overlay-dialog .el-dialog {
width: 90% !important;
}
}
.info .el-form-item {
margin-bottom: 0;
}
......@@ -119,3 +126,100 @@ textarea:focus {
padding: 8px 16px;
border: 1px solid #e6e6e6;
}
/* ============ 系统级响应式 ============ */
/* 移动端弹框宽度 90% */
@media (max-width: 767px) {
.el-overlay-dialog .el-dialog {
width: 90% !important;
}
}
@media (max-width: 767px) {
/* AppCard 移动端内边距缩减 */
.app-card {
padding: 12px !important;
}
.app-card + .app-card {
margin-top: 12px !important;
}
.app-card-hd__title {
font-size: 16px;
margin-bottom: 16px;
}
/* 全局 el-table 横向滚动 */
.el-table {
width: 100%;
}
.el-table__body-wrapper {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
/* el-pagination 移动端简化 */
.el-pagination {
flex-wrap: wrap;
justify-content: center;
gap: 4px;
font-size: 12px;
}
.el-pagination__total,
.el-pagination__sizes,
.el-pagination__jump {
display: none !important;
}
.el-pager .number {
min-width: 28px;
height: 28px;
line-height: 28px;
}
.btn-prev,
.btn-next {
height: 28px;
min-width: 28px;
}
/* el-steps 移动端垂直 */
.el-steps--horizontal {
flex-direction: column;
}
/* 弹框底部按钮组换行 */
.el-dialog__footer {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
.el-button {
margin-left: 0 !important;
}
}
/* 弹框按钮选中不独占一行,保持自然间距 */
.el-dialog__footer .el-button + .el-button {
margin-left: 0;
}
/* 固定宽度容器防溢出 */
[style*="width: 200px"],
[style*="width: 220px"],
[style*="width: 300px"],
[style*="width: 340px"],
[style*="width: 400px"],
[style*="width: 150px"] {
max-width: 100% !important;
}
/* hover 显示的元素在触摸设备上常显 */
.connect-item__remove,
.connect-item__edit,
.hover-show {
display: block !important;
}
[class*="remove"]:hover,
[class*="delete"]:hover {
opacity: 1;
}
}
......@@ -107,4 +107,17 @@ const color = ['#af1c40', '#c17933', '#8f0034', '#d45548', '#ab3259', '#dec34c',
height: 290px;
}
}
@media (max-width: 767px) {
.chart-hd {
padding: 8px 12px;
height: auto;
flex-wrap: wrap;
gap: 4px;
h3 { font-size: 14px; }
}
.chart-bd {
margin: 0 12px 12px;
.chart { height: 220px; }
}
}
</style>
<script setup lang="ts">
import { getCategoryList } from '@/api/base'
withDefaults(defineProps<{ placeholder?: string }>(), { placeholder: '请选择' })
const options = ref([])
async function fetchList() {
const res = await getCategoryList()
......@@ -14,7 +16,7 @@ watchEffect(() => {
<template>
<el-cascader
clearable
placeholder="请选择"
:placeholder="placeholder"
style="width: 100%"
:options="options"
:props="{
......
<script setup lang="ts">
import { Search, RefreshLeft } from '@element-plus/icons-vue'
import { useMediaQuery } from '@vueuse/core'
const isMobile = useMediaQuery('(max-width: 767px)')
// 移动端卡片列(过滤序号、操作等 slot 列留给卡片底部渲染)
const cardColumns = computed(() => props.columns.filter((c) => c.type !== 'index' && c.slots !== 'table-x'))
const hasActionsColumn = computed(() => props.columns.some((c) => c.slots === 'table-x'))
interface IRemoteProps {
params?: any
......@@ -17,6 +24,7 @@ interface Props {
hasPagination?: boolean
limit?: number
hasLoading?: boolean
mobileCards?: boolean
}
const props = withDefaults(defineProps<Props>(), {
hasPagination: true,
......@@ -25,6 +33,7 @@ const props = withDefaults(defineProps<Props>(), {
filters: () => [],
columns: () => [],
data: () => [],
mobileCards: true,
})
const filterFormRef = ref()
......@@ -170,7 +179,9 @@ defineExpose({ refetch, tableRef })
<!-- 主体 -->
<div class="table-list-bd">
<slot name="body" v-bind="{ data: dataList }">
<!-- 桌面端 / 禁用移动端卡片时:表格 -->
<el-table
v-if="!isMobile || !mobileCards"
:header-cell-style="{ background: '#ededed' }"
:data="dataList"
v-loading="loading && hasLoading"
......@@ -183,6 +194,27 @@ defineExpose({ refetch, tableRef })
</template>
</el-table-column>
</el-table>
<!-- 移动端:卡片列表 -->
<div v-if="isMobile && mobileCards" class="mobile-card-list" v-loading="loading && hasLoading">
<div class="mobile-card" v-for="(row, rowIndex) in dataList" :key="rowIndex">
<div class="mobile-card-body">
<div class="mobile-card-row" v-for="col in cardColumns" :key="col.prop || col.label">
<span class="mobile-card-label">{{ col.label }}</span>
<span class="mobile-card-colon">:</span>
<span class="mobile-card-value">
<slot v-if="col.slots && $slots[col.slots]" :name="col.slots" v-bind="{ row, $index: rowIndex }"></slot>
<div v-html="col.computed({ row, $index: rowIndex })" v-else-if="col.computed"></div>
<template v-else>{{ row[col.prop] ?? '-' }}</template>
</span>
</div>
</div>
<!-- 操作栏:如果有 slot 列,提取渲染 -->
<div class="mobile-card-ft" v-if="hasActionsColumn">
<slot name="table-x" v-bind="{ row, $index: rowIndex }"></slot>
</div>
</div>
<el-empty v-if="!dataList.length && !loading" description="暂无数据" />
</div>
</slot>
</div>
<!-- 底部 -->
......@@ -254,4 +286,96 @@ defineExpose({ refetch, tableRef })
}
}
}
// 移动端卡片列表
.mobile-card-list {
min-height: 120px;
}
.mobile-card {
background: #fff; border-radius: 8px; border: 1px solid #eee;
margin-bottom: 12px; overflow: hidden; display: flex; flex-direction: column;
}
.mobile-card-body { flex: 1; padding: 14px 16px; }
.mobile-card-row {
display: flex; padding: 5px 0; border-bottom: 1px solid #f5f5f5; line-height: 1.6;
&:last-child { border-bottom: none; }
}
.mobile-card-label { flex-shrink: 0; color: #999; font-size: 14px; }
.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; }
.mobile-card-ft {
padding: 6px 12px; display: flex; flex-wrap: wrap;
gap: 6px; row-gap: 4px;
background: #fafafa; border-top: 1px solid #f0f0f0;
.el-button {
padding: 5px 10px;
font-size: 12px;
min-width: 44px;
text-align: center;
+ .el-button { margin-left: 0; }
}
}
// 移动端筛选表单
@media (max-width: 767px) {
.table-list-hd .el-form-item__label {
display: none;
}
.table-list-filter {
width: 100%;
.el-form,
.el-form--inline {
width: 100%;
}
.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 {
width: auto;
}
}
// 移动端分页
.table-list-ft {
flex-direction: column;
gap: 8px;
padding: 8px 0;
}
.table-list-pagination {
.el-pagination {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 2px;
}
.el-pagination__total,
.el-pagination__sizes,
.el-pagination__jump {
display: none;
}
.el-pager {
.number {
min-width: 28px;
height: 28px;
line-height: 28px;
font-size: 12px;
}
}
.btn-prev,
.btn-next {
height: 28px;
min-width: 28px;
}
}
}
</style>
......@@ -5,12 +5,16 @@ export default { name: 'AppAside' }
<script setup lang="ts">
import type { IMenuItem } from '@/types'
import { useMenuStore } from '@/stores/menu'
import { ArrowLeft, ArrowRight } from '@element-plus/icons-vue'
const menus = useMenuStore().menus
const route = useRoute()
const collapsed = ref(false)
const currentMenu = computed(() => {
return findMenu(route.path, menus)
return findMenu(route.fullPath, menus)
})
const currentSubmenu = computed(() => {
......@@ -21,21 +25,43 @@ function findMenu(path: string, menus: IMenuItem[]) {
return menus.find((item) => {
if (
item.children &&
item.children.find((item) => {
const regExp = new RegExp(`^${item.path.replaceAll('?', '\\?')}`)
return regExp.test(path)
item.children.find((child) => {
// 匹配 path + query,不要求 query 参数顺序(如 /material?type=2 应匹配 /material?experiment_id=xxx&type=2)
const [p, qs] = child.path.split('?')
if (!path.startsWith(p)) return false
if (!qs) return true
return qs.split('&').every((param) => path.includes(param))
})
) {
return item
}
const regExp = new RegExp(`^${item.path.replaceAll('?', '\\?')}`)
return regExp.test(path)
const [p, qs] = item.path.split('?')
if (!path.startsWith(p)) return false
if (!qs) return true
return qs.split('&').every((param) => path.includes(param))
})
}
function toggleCollapse() {
collapsed.value = !collapsed.value
}
// 处理菜单路径中的 query 参数(如 /material?type=1),与 experiment_id 合并
function menuLinkTo(item: { path: string }) {
const baseQuery: Record<string, any> = { experiment_id: route.query.experiment_id }
const idx = item.path.indexOf('?')
if (idx === -1) return { path: item.path, query: baseQuery }
const path = item.path.slice(0, idx)
const params = new URLSearchParams(item.path.slice(idx + 1))
for (const [key, value] of params) {
baseQuery[key] = value
}
return { path, query: baseQuery }
}
</script>
<template>
<aside class="app-aside">
<aside class="app-aside" :class="{ 'is-collapsed': collapsed }">
<nav class="menu">
<ul>
<li
......@@ -45,12 +71,12 @@ function findMenu(path: string, menus: IMenuItem[]) {
v-permission="item.tag">
<div class="menu-item">
<template v-if="item.children">
<RouterLink :to="{ path: item.path, query: { experiment_id: $route.query.experiment_id } }">
<RouterLink :to="menuLinkTo(item)">
<component :is="item.icon" class="menu-icon"></component>
</RouterLink>
</template>
<el-tooltip :content="item.name" placement="right" v-else>
<RouterLink :to="{ path: item.path, query: { experiment_id: $route.query.experiment_id } }">
<RouterLink :to="menuLinkTo(item)">
<component :is="item.icon" class="menu-icon"></component>
</RouterLink>
</el-tooltip>
......@@ -63,7 +89,7 @@ function findMenu(path: string, menus: IMenuItem[]) {
:key="submenu.path"
:class="{ 'is-active': submenu.path === currentSubmenu?.path }"
v-permission="submenu.tag">
<RouterLink :to="{ path: submenu.path, query: { experiment_id: $route.query.experiment_id } }">
<RouterLink :to="menuLinkTo(submenu)">
<component :is="submenu.icon" class="submenu-icon" v-if="submenu.icon"></component>
{{ submenu.name }}
</RouterLink>
......@@ -72,7 +98,15 @@ function findMenu(path: string, menus: IMenuItem[]) {
</nav>
</li>
</ul>
<!-- 展开时的收起按钮 -->
<div class="menu-toggle" @click="toggleCollapse" v-if="!collapsed">
<el-icon><ArrowLeft /></el-icon>
</div>
</nav>
<!-- 收起后的展开胶囊按钮 -->
<div class="aside-float-btn" @click="toggleCollapse" v-if="collapsed">
<el-icon><ArrowRight /></el-icon>
</div>
</aside>
</template>
......@@ -82,15 +116,19 @@ function findMenu(path: string, menus: IMenuItem[]) {
top: 0;
display: flex;
height: 100vh;
// overflow-x: hidden;
// overflow-y: auto;
box-sizing: content-box;
z-index: 2001;
transition: width 0.25s ease;
.menu {
width: 60px;
flex-shrink: 0;
height: 100%;
background: #fff;
box-sizing: border-box;
position: relative;
z-index: 1;
.menu-item {
height: 44px;
text-align: center;
......@@ -106,19 +144,18 @@ function findMenu(path: string, menus: IMenuItem[]) {
}
}
}
> ul > li {
> ul {
padding-bottom: 80px;
> li {
padding: 8px;
&.is-active {
.menu-item {
background-color: var(--main-color);
svg {
fill: #fff;
}
svg { fill: #fff; }
}
}
&:hover {
.submenu {
display: block;
.submenu { display: block; }
}
}
}
......@@ -165,5 +202,67 @@ function findMenu(path: string, menus: IMenuItem[]) {
line-height: 1;
color: var(--main-color);
}
// 底部收起按钮
.menu-toggle {
position: absolute;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
width: 28px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #999;
font-size: 14px;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 28px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
z-index: 10;
&:hover {
background: var(--main-color);
color: #fff;
border-color: var(--main-color);
}
}
// 收起状态
&.is-collapsed {
width: 0;
overflow: hidden;
.menu {
transform: translateX(-60px);
}
}
}
// 悬浮胶囊按钮
.aside-float-btn {
position: fixed;
left: 0;
bottom: 20px;
width: 28px;
height: 56px;
background: #fff;
border: 1px solid #e0e0e0;
border-left: none;
border-radius: 0 28px 28px 0;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 2000;
box-shadow: 2px 0 6px rgba(0, 0, 0, 0.08);
color: #666;
font-size: 14px;
transition: all 0.2s;
&:hover {
background: var(--main-color);
color: #fff;
border-color: var(--main-color);
}
}
</style>
......@@ -5,7 +5,7 @@ export default { name: 'AppFooter' }
<template>
<footer class="app-footer">
<p>实验室版本:V2.1.0</p>
<p>Copyright © 2017 Zijing Education. All rights reserved. 清控紫荆(北京)教育科技股份有限公司</p>
<p>Copyright © 2026 Zijing Education. All rights reserved. 清控紫荆(北京)教育科技股份有限公司</p>
</footer>
</template>
......
......@@ -5,16 +5,13 @@ export default { name: 'AppLayout' }
<script setup lang="ts">
import AppAside from './Aside.vue'
import AppMain from './Main.vue'
import AppFooter from './Footer.vue'
interface Props {
sidebar?: boolean
footer?: boolean
}
withDefaults(defineProps<Props>(), {
sidebar: true,
footer: true
})
</script>
......@@ -23,7 +20,6 @@ withDefaults(defineProps<Props>(), {
<AppAside v-if="sidebar"></AppAside>
<div class="app-layout-container">
<AppMain></AppMain>
<AppFooter v-if="footer"></AppFooter>
</div>
</div>
</template>
......
......@@ -4,7 +4,9 @@ const isIframe = computed(() => {
})
const padding = computed(() => {
return isIframe.value ? '0 10px' : '10px'
if (isIframe.value) return '0 10px'
if (typeof window !== 'undefined' && window.innerWidth < 768) return '6px'
return '10px'
})
</script>
......
......@@ -242,4 +242,9 @@ const eventTimeOption = computed(() => {
margin-top: 20px;
}
}
@media (max-width: 767px) {
.row {
flex-direction: column;
}
}
</style>
......@@ -315,4 +315,9 @@ const groupMemberTopOption = computed(() => {
margin-top: 20px;
}
}
@media (max-width: 767px) {
.row {
flex-direction: column;
}
}
</style>
......@@ -39,7 +39,12 @@ async function fetchEventAction() {
if (!userValue.value || !eventValue.value || !startDate || !endDate) return
loading1.value = true
try {
const res = await api.getEventAction({ sso_id: userValue.value, start_date: startDate, end_date: endDate, event_id: eventValue.value })
const res = await api.getEventAction({
sso_id: userValue.value,
start_date: startDate,
end_date: endDate,
event_id: eventValue.value,
})
eventAction.value = res.data
} finally {
loading1.value = false
......@@ -52,14 +57,14 @@ const eventActionOption = computed(() => {
tooltip: { trigger: 'axis' },
legend: {
bottom: '10',
data: ['行为数量', '用户数量']
data: ['行为数量', '用户数量'],
},
xAxis: {
type: 'category',
boundaryGap: ['20%', '20%'],
data: eventAction.value.event_action_items.map(item => {
data: eventAction.value.event_action_items.map((item) => {
return item.group_name
})
}),
},
yAxis: { type: 'value' },
series: [
......@@ -68,16 +73,16 @@ const eventActionOption = computed(() => {
type: 'line',
smooth: true,
label: { show: true, position: 'top' },
data: eventAction.value.event_action_items.map(item => item.total)
data: eventAction.value.event_action_items.map((item) => item.total),
},
{
name: '用户数量',
type: 'line',
smooth: true,
label: { show: true, position: 'top' },
data: eventAction.value.event_member_items.map(item => item.total)
}
]
data: eventAction.value.event_member_items.map((item) => item.total),
},
],
}
})
......@@ -123,7 +128,11 @@ async function fetchUser() {
if (!userValue.value || !userStartDate.value || !userEndDate.value) return
loading3.value = true
try {
const res = await api.getMemberList({ sso_id: userValue.value, start_date: userStartDate.value, end_date: userEndDate.value })
const res = await api.getMemberList({
sso_id: userValue.value,
start_date: userStartDate.value,
end_date: userEndDate.value,
})
user.value = res.data.items
} finally {
loading3.value = false
......@@ -149,7 +158,7 @@ function format(date) {
</el-form-item>
</el-form>
<el-divider />
<el-row :gutter="20">
<el-row :gutter="20" class="main-row">
<el-col :span="16">
<ChartCard title="事件行为分析" :options="eventActionOption" :loading="loading1">
<template #tools>
......@@ -326,3 +335,28 @@ function format(date) {
color: var(--main-color);
}
</style>
<style lang="scss">
@media (max-width: 767px) {
.main-row .el-col {
max-width: 100% !important;
width: 100% !important;
flex: 0 0 100% !important;
}
.el-form--inline .el-form-item {
width: 100%;
margin-right: 0;
}
.chart-card .tools {
flex-wrap: wrap;
gap: 6px;
}
.chart-card .el-date-editor,
.chart-card .el-select {
width: 100% !important;
}
.el-table {
min-width: 600px;
}
}
</style>
......@@ -236,4 +236,9 @@ const statusOption = computed(() => {
margin-top: 20px;
}
}
@media (max-width: 767px) {
.row {
flex-direction: column;
}
}
</style>
......@@ -63,4 +63,10 @@ function handleChange(item: PlatformItem) {
box-shadow: 2px 2px 10px 0px rgba(0, 0, 0, 0.2);
}
}
@media (max-width: 767px) {
.connect-from_icon__box {
grid-template-columns: repeat(2, 1fr);
row-gap: 12px;
}
}
</style>
......@@ -302,4 +302,9 @@ const sendChat = function () {
}
}
}
@media (max-width: 767px) {
.step1-btn {
padding: 20px 0 !important;
}
}
</style>
......@@ -170,9 +170,11 @@ const handleStudentFollow = function (experimentId: string, id: string, type: st
grid-template-columns: repeat(5, 1fr);
gap: 20px;
padding: 20px;
// background-color: #efefef;
border-radius: 5px;
}
@media (max-width: 1200px) { .connect-list { grid-template-columns: repeat(4, 1fr); } }
@media (max-width: 992px) { .connect-list { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 640px) { .connect-list { grid-template-columns: repeat(2, 1fr); gap: 12px; padding: 12px; } }
.connect-add-button {
display: flex;
align-items: center;
......
......@@ -679,4 +679,20 @@ const surveyKingDialogVisible = ref<boolean>(false)
.tabs-box {
margin-bottom: 20px;
}
@media (max-width: 767px) {
.view-info {
flex-direction: column;
align-items: center;
text-align: center;
.view-info_content {
margin-left: 0;
margin-top: 12px;
flex-direction: column;
p { margin-right: 0; margin-bottom: 6px; }
}
}
.tag, .tag-null {
width: 100%;
}
}
</style>
......@@ -364,6 +364,7 @@ onMounted(() => {
flex-direction: column;
.row {
flex: 1;
gap: 10px;
}
}
.card {
......@@ -381,6 +382,28 @@ onMounted(() => {
::v-deep(.chart-card) {
background-color: #fff;
}
@media (max-width: 767px) {
> .row {
flex-direction: column;
}
.row {
flex-wrap: wrap;
.col {
width: 100%;
.row {
flex-wrap: wrap;
.card {
flex: 0 0 calc(50% - 5px);
p {
line-height: 60px;
font-size: 20px;
}
}
}
}
}
}
}
.content-user {
background: #efefef;
......
......@@ -112,4 +112,9 @@ function handleUpdate() {
box-shadow: rgb(0 0 0 / 40%) 0px 2px 6px 0px;
}
}
@media (max-width: 767px) {
.label-type-icon-list {
grid-template-columns: repeat(4, 50px);
}
}
</style>
......@@ -237,4 +237,21 @@ const handleRemoves = async function () {
overflow-x: hidden;
overflow-y: auto;
}
@media (max-width: 767px) {
.label-wrap {
flex-direction: column;
height: auto;
min-height: calc(100vh - 130px);
}
.label-left {
flex: none;
width: 100%;
padding-right: 0;
margin-right: 0;
border-right: none;
border-bottom: 1px solid rgb(187, 187, 187);
padding-bottom: 12px;
margin-bottom: 12px;
}
}
</style>
......@@ -23,7 +23,7 @@ const listOptions = computed(() => {
},
filters: [
{ label: '直播主题品类', prop: 'live_commodity_type_id', slots: 'filter-category' },
{ label: '直播主题名称', prop: 'live_commodity_title', type: 'input' },
{ label: '直播主题名称', prop: 'live_commodity_title', type: 'input', placeholder: '请输入直播主题名称' },
],
columns: [
{ label: '序号', type: 'index', width: 60 },
......@@ -35,18 +35,17 @@ const listOptions = computed(() => {
try {
const info = JSON.parse(row.commodity_info)
const [picture] = JSON.parse(info.picture_addreses) || []
return `<div style="display: flex; align-items: center; gap: 10px;"><img src="${picture.url}" width="100" />${info.title}</div>`
} catch (e) {
console.log(e)
}
return `<div style="display:flex;align-items:center;gap:10px"><img src="${picture.url}" width="80" style="border-radius:4px"/>${info.title}</div>`
} catch {
return '--'
}
},
},
{
label: '单价/数量',
prop: 'num',
computed({ row }) {
return `¥${row.price}<br/>x${row.num}`
return `¥${row.price} × ${row.num}`
},
},
{ label: '订单编号', prop: 'id' },
......@@ -67,7 +66,10 @@ const listOptions = computed(() => {
<AppCard title="订单管理">
<AppList v-bind="listOptions" ref="appList">
<template #filter-category>
<LiveProductCategory v-model="listParams.live_commodity_type_id" @change="handleRefresh"></LiveProductCategory>
<LiveProductCategory
v-model="listParams.live_commodity_type_id"
@change="handleRefresh"
placeholder="请选择直播主题品类"></LiveProductCategory>
</template>
</AppList>
</AppCard>
......
......@@ -37,26 +37,14 @@ const listOptions = computed(() => {
{ label: '序号', type: 'index', width: 60 },
{ label: '商品属性名称', prop: 'name' },
{ label: '所属商品品类', prop: 'live_commodity_type_full_name' },
{
label: '重要性',
prop: 'is_importance',
computed({ row }) {
return getNameByValue(row.is_importance, importType)
},
},
{
label: '必要性',
prop: 'is_essential',
computed({ row }) {
return getNameByValue(row.is_essential, requiredType)
},
},
{ label: '重要性', prop: 'is_importance', computed: ({ row }) => getNameByValue(row.is_importance, importType) },
{ label: '必要性', prop: 'is_essential', computed: ({ row }) => getNameByValue(row.is_essential, requiredType) },
{
label: '状态',
prop: 'status',
computed({ row }) {
computed: ({ row }) => {
const color = row.status === '1' ? 'var(--main-success-color)' : 'var(--main-color)'
return `<span style="color: ${color}">${getNameByValue(row.status, statusList)}</span>`
return `<span style="color:${color}">${getNameByValue(row.status, statusList)}</span>`
},
},
{ label: '创建人', prop: 'created_operator', computed: ({ row }) => getOperatorName(row.created_operator) },
......@@ -65,6 +53,7 @@ const listOptions = computed(() => {
],
}
})
const action = ref(null)
const formVisible = ref(false)
const currentRow = ref(null)
......@@ -99,11 +88,12 @@ const handleRemove = async (row) => {
<template>
<AppCard title="商品属性管理">
<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>
<LiveProductCategory v-model="listParams.live_commodity_type_id" @change="handleRefresh"></LiveProductCategory>
<LiveProductCategory
v-model="listParams.live_commodity_type_id"
@change="handleRefresh"
placeholder="请选择商品品类"></LiveProductCategory>
</template>
<template #table-x="{ row }">
<el-button text type="primary" @click="handleView(row)">查看</el-button>
......@@ -112,6 +102,5 @@ const handleRemove = async (row) => {
</template>
</AppList>
</AppCard>
<!-- 新建/修改 -->
<FormDialog v-model="formVisible" :action="action" :data="currentRow" @update="handleRefresh" v-if="formVisible" />
</template>
......@@ -67,7 +67,7 @@ const handleRemove = async (row) => {
<template>
<AppCard title="商品品类管理">
<AppList v-bind="listOptions" row-key="id" ref="appList">
<AppList v-bind="listOptions" row-key="id" ref="appList" :mobile-cards="false">
<template #header-buttons>
<el-button type="primary" @click="handleAdd()">新增类别</el-button>
</template>
......
......@@ -105,7 +105,7 @@ const unimportanceTotal = computed(() => {
<div>
<el-form-item label="商品标题" prop="title" :rules="[{ required: true, message: '请输入', trigger: 'blur' }]">
<div class="form-tips">标题不规范会有可能引起商品下架,影响您的正常销售,请点击学习商品发布规范认真填写</div>
<div style="display: flex; width: 100%; align-items: center; gap: 20px">
<div class="form-inline-row">
<el-input
placeholder="至少输入8个字(16个字符)以上,30个字(60个字符)以下"
size="large"
......@@ -115,12 +115,11 @@ const unimportanceTotal = computed(() => {
</el-form-item>
<el-form-item label="导购短标题" prop="shopping_guide_short_title">
<div class="form-tips">短标题可用于商品搜索、首页推荐、物流单等场景,请提炼商品关键信息,客观准确填写</div>
<div style="display: flex; width: 100%; align-items: center; gap: 20px">
<div class="form-inline-row">
<el-input
placeholder="建议填写简明准确的标题内容,避免重复表达"
size="large"
v-model="form.shopping_guide_short_title"
style="flex: 1" />
v-model="form.shopping_guide_short_title" />
<el-button type="primary" plain @click="handleAIGenerate" :loading="descLoading">一键智能推荐</el-button>
</div>
</el-form-item>
......@@ -129,14 +128,11 @@ const unimportanceTotal = computed(() => {
prop="live_commodity_attrs.importance_attrs">
<div class="form-tips">错误填写属性,会引起商品下架,请认真准确填写。</div>
<div style="display: flex; flex-direction: column; gap: 16px">
<div
v-for="(item, index) in form.live_commodity_attrs.importance_attrs"
:key="index"
style="display: flex; align-items: flex-start; gap: 12px">
<div v-for="(item, index) in form.live_commodity_attrs.importance_attrs" :key="index" class="attr-row">
<el-select
v-model="item.id"
placeholder="请选择属性"
style="width: 200px"
class="attr-select"
@change="(value) => handleAttrSelect(value, item, 'importance_attrs')">
<el-option
v-for="attr in getAvailableAttrs('importance_attrs', item.id)"
......@@ -145,7 +141,7 @@ const unimportanceTotal = computed(() => {
:value="attr.id">
</el-option>
</el-select>
<el-input v-model="item.value" placeholder="请输入属性值" style="flex: 1; max-width: 300px"> </el-input>
<el-input v-model="item.value" placeholder="请输入属性值" class="attr-value"></el-input>
<el-button type="danger" plain :icon="Delete" circle @click="removeAttrItem('importance_attrs', index)">
</el-button>
</div>
......@@ -156,14 +152,11 @@ const unimportanceTotal = computed(() => {
:label="`非重要属性 ${unimportanceTotal}/${attrs.unimportance_attrs.total}`"
prop="live_commodity_attrs.unimportance_attrs">
<div style="display: flex; flex-direction: column; gap: 16px">
<div
v-for="(item, index) in form.live_commodity_attrs.unimportance_attrs"
:key="index"
style="display: flex; align-items: flex-start; gap: 12px">
<div v-for="(item, index) in form.live_commodity_attrs.unimportance_attrs" :key="index" class="attr-row">
<el-select
v-model="item.id"
placeholder="请选择属性"
style="width: 200px"
class="attr-select"
@change="(value) => handleAttrSelect(value, item, 'unimportance_attrs')">
<el-option
v-for="attr in getAvailableAttrs('unimportance_attrs', item.id)"
......@@ -172,7 +165,7 @@ const unimportanceTotal = computed(() => {
:value="attr.id">
</el-option>
</el-select>
<el-input v-model="item.value" placeholder="请输入属性值" style="flex: 1; max-width: 300px"> </el-input>
<el-input v-model="item.value" placeholder="请输入属性值" class="attr-value"></el-input>
<el-button type="danger" plain :icon="Delete" circle @click="removeAttrItem('unimportance_attrs', index)">
</el-button>
</div>
......@@ -181,3 +174,36 @@ const unimportanceTotal = computed(() => {
</el-form-item>
</div>
</template>
<style scoped>
.form-inline-row {
display: flex;
width: 100%;
align-items: center;
gap: 12px;
.el-input { flex: 1; }
}
.attr-row {
display: flex;
align-items: flex-start;
gap: 12px;
}
.attr-select {
width: 200px;
flex-shrink: 0;
}
.attr-value { flex: 1; }
@media (max-width: 767px) {
.form-inline-row {
flex-direction: column;
align-items: stretch;
.el-button { width: 100%; }
}
.attr-row {
flex-direction: column;
align-items: stretch;
}
.attr-select { width: 100%; }
.attr-value { max-width: none; }
}
</style>
......@@ -57,6 +57,7 @@ const handleSelect = (item) => {
<span @click="form.live_commodity_type_id = item.id">{{ item.name }}</span>
</template>
</p>
<div class="cascader-wrap">
<el-cascader-panel
v-model="form.live_commodity_type_id"
filterable
......@@ -68,12 +69,12 @@ const handleSelect = (item) => {
label: 'name',
value: 'id',
emitPath: false,
expandTrigger: 'hover',
expandTrigger: 'click',
checkStrictly: true,
checkOnClickNode: true,
showPrefix: false,
}"
style="width: 100%" />
}" />
</div>
</el-form-item>
</div>
</template>
......@@ -90,4 +91,12 @@ const handleSelect = (item) => {
}
}
}
.cascader-wrap {
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
.el-cascader-panel {
display: inline-flex;
}
}
</style>
......@@ -28,7 +28,7 @@ const handleCopy = () => {
<el-form-item label="">
<template #label>
<span>主图3:4</span>
<el-button type="primary" plain size="small" style="margin-left: 430px" @click="handleCopy"
<el-button type="primary" plain size="small" class="copy-btn" @click="handleCopy"
>从主图一键填入</el-button
>
</template>
......@@ -68,4 +68,14 @@ const handleCopy = () => {
color: #999;
line-height: 24px;
}
.copy-btn {
margin-left: 430px;
}
@media (max-width: 767px) {
.copy-btn {
margin-left: 0;
margin-top: 8px;
width: 100%;
}
}
</style>
......@@ -313,6 +313,7 @@ function batchSetReferencePrice() {
</el-button>
</div>
</div>
<div class="sku-table">
<el-table :data="form.info.sku" border :header-cell-style="{ backgroundColor: '#f5f7fa' }" style="width: 100%" :row-key="Date.now() + Math.random()">
<el-table-column v-if="hasSpecs" label="规格组合" align="center" width="200" column-key="specs">
<template #default="{ row }">
......@@ -341,6 +342,7 @@ function batchSetReferencePrice() {
</template>
</el-table-column>
</el-table>
</div>
</el-form-item>
<el-form-item label="参考价" prop="info.reference_price" v-if="false">
<template #label>
......@@ -362,7 +364,7 @@ function batchSetReferencePrice() {
</div>
</template>
<style scoped>
<style lang="scss" scoped>
.spec-list {
display: flex;
flex-wrap: wrap;
......@@ -433,5 +435,20 @@ function batchSetReferencePrice() {
.batch-set-item .el-input {
flex: 1;
}
// SKU 表格横向滚动
.sku-table {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
.el-table {
min-width: 600px;
}
}
// 参考价区域
.reference-price-row {
flex-direction: column;
align-items: stretch !important;
}
}
</style>
......@@ -6,12 +6,12 @@ const form = inject('form')
<template>
<div>
<el-form-item label="运费模板" prop="info.shipping_template" :rules="[{ required: true, message: '请选择' }]">
<el-select v-model="form.info.shipping_template" style="width: 300px">
<el-select v-model="form.info.shipping_template" style="width: 300px; max-width: 100%">
<el-option v-for="item in shippingTemplate" :key="item.value" v-bind="item"></el-option>
</el-select>
</el-form-item>
<el-form-item label="售后政策" prop="info.after_sales_policy" :rules="[{ required: true, message: '请选择' }]">
<el-select v-model="form.info.after_sales_policy" style="width: 300px" multiple>
<el-select v-model="form.info.after_sales_policy" style="width: 300px; max-width: 100%" multiple>
<el-option v-for="item in afterSalesPolicy" :key="item.value" v-bind="item"></el-option>
</el-select>
</el-form-item>
......
......@@ -2,6 +2,9 @@
import { getChuanKitDesignList } from '../api'
import { useUserStore } from '@/stores/user'
import { uploadFileByUrl } from '@/utils/upload'
import { useMediaQuery } from '@vueuse/core'
const isMobile = useMediaQuery('(max-width: 767px)')
const ChuangKitDesign = defineAsyncComponent(() => import('@/components/ChuangKitDesign.vue'))
......@@ -71,7 +74,7 @@ async function handleSubmit() {
</script>
<template>
<el-dialog title="图库选择" :close-on-click-modal="false" @closed="$emit('update:modelValue', false)" width="860px">
<el-dialog title="图库选择" :close-on-click-modal="false" @closed="$emit('update:modelValue', false)" :width="isMobile ? '95%' : '860px'" destroy-on-close>
<div class="image-design">
<el-button type="primary" @click="designVisible = true" v-if="false">打开编辑器</el-button>
<ul>
......@@ -97,10 +100,10 @@ async function handleSubmit() {
<style lang="scss">
.image-design {
ul {
margin: 30px auto;
margin: 20px auto;
display: flex;
flex-wrap: wrap;
gap: 30px;
gap: 16px;
}
li {
width: 140px;
......@@ -117,5 +120,17 @@ async function handleSubmit() {
height: 100%;
object-fit: cover;
}
@media (max-width: 767px) {
ul {
justify-content: space-around;
gap: 12px;
}
li {
width: calc(33.33% - 12px);
min-width: 80px;
border-radius: 12px;
}
}
}
</style>
......@@ -92,9 +92,9 @@ const handleRemove = (i) => {
<div class="upload-wrapper">
<template v-if="Array.isArray(props.modelValue)">
<div v-for="(item, i) in props.limit" :key="i">
<el-popover placement="top" width="220px" trigger="hover">
<el-popover placement="top" width="220px" trigger="click">
<ul class="upload-popover">
<li @click="handleOpen(i)" v-if="false">
<li @click="handleOpen(i)">
<i class="el-icon"><UploadFilled /></i>本地上传
</li>
<li @click="handleOpenDesign(i)" v-if="!isVideo">
......@@ -113,7 +113,7 @@ const handleRemove = (i) => {
<i class="el-icon delete-icon" @click.stop="handleRemove(i)"><CircleClose /></i>
</template>
<template v-else>
<div class="upload-item-uploader" @click="handleOpen(i)">
<div class="upload-item-uploader">
<div class="upload-item__title">{{ getTitle(i) }}</div>
<i class="el-icon"><Plus /></i>
<div class="upload-item__tips">{{ getTips(i) }}</div>
......@@ -126,9 +126,9 @@ const handleRemove = (i) => {
</template>
<template v-else>
<el-popover placement="top" width="160px" trigger="hover">
<el-popover placement="top" width="160px" trigger="click">
<ul class="upload-popover">
<li @click="handleOpen" v-if="false">
<li @click="handleOpen">
<i class="el-icon"><UploadFilled /></i>本地上传
</li>
<li @click="handleOpenDesign" v-if="!isVideo">
......@@ -148,7 +148,7 @@ const handleRemove = (i) => {
<i class="el-icon delete-icon" @click.stop="handleRemove()"><CircleClose /></i>
</template>
<template v-else>
<div class="upload-item-uploader" @click="handleOpen">
<div class="upload-item-uploader">
<div class="upload-item__title">{{ props.firstTitle || props.title }}</div>
<i class="el-icon"><Plus /></i>
<div class="upload-item__tips">{{ props.firstTips || props.tips }}</div>
......@@ -168,6 +168,10 @@ const handleRemove = (i) => {
display: flex;
flex-wrap: wrap;
gap: 20px;
@media (max-width: 767px) {
gap: 12px;
justify-content: center;
}
}
.upload-popover {
display: flex;
......
......@@ -35,7 +35,7 @@ const listOptions = computed(() => {
},
filters: [
{ label: '商品品类', prop: 'live_commodity_type_id', slots: 'filter-category' },
{ label: '商品名称', prop: 'title', type: 'input' },
{ label: '商品名称', prop: 'title', type: 'input', placeholder: '请输入商品名称' },
],
columns: [
{ label: '序号', type: 'index', width: 60 },
......@@ -49,6 +49,7 @@ const listOptions = computed(() => {
}
})
// 删除
const handleRemove = async (row) => {
await ElMessageBox.confirm('此操作将永久删除该数据, 是否继续?', '提示')
await deleteProduct({ id: row.id })
......@@ -61,30 +62,22 @@ const handleRemove = async (row) => {
<AppCard title="商品管理">
<AppList v-bind="listOptions" ref="appList">
<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 #filter-category>
<LiveProductCategory v-model="listParams.live_commodity_type_id" @change="handleRefresh"></LiveProductCategory>
<LiveProductCategory v-model="listParams.live_commodity_type_id" @change="handleRefresh" placeholder="请选择商品品类"></LiveProductCategory>
</template>
<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>
</template>
<template #table-x="{ row }">
<el-button text type="primary">
<router-link :to="{ path: 'management/view', query: { id: row.id } }">查看</router-link>
</el-button>
<el-button text type="primary">
<router-link :to="{ path: 'management/update', query: { id: row.id } }">编辑</router-link>
</el-button>
<el-button text type="primary"><router-link :to="{ path: 'management/view', query: { id: row.id } }">查看</router-link></el-button>
<el-button text type="primary"><router-link :to="{ path: 'management/update', query: { id: row.id } }">编辑</router-link></el-button>
<el-button text type="primary" @click="handleRemove(row)">删除</el-button>
</template>
</AppList>
</AppCard>
</template>
<style scoped>
</style>
......@@ -149,6 +149,26 @@ async function handleUpdate(params) {
.el-tabs__nav-wrap::after {
display: none;
}
// 移动端 tab 横向滚动
@media (max-width: 767px) {
.el-tabs__header {
width: 100%;
}
.el-tabs__nav-wrap,
.el-tabs__nav-scroll {
overflow-x: auto;
overflow-y: hidden;
}
.el-tabs__nav {
display: inline-flex;
flex-wrap: nowrap;
}
.el-tabs__item {
flex-shrink: 0;
font-size: 13px;
padding: 0 8px;
}
}
}
.product {
.el-card {
......@@ -163,5 +183,24 @@ async function handleUpdate(params) {
line-height: 22px;
color: #92939a;
}
@media (max-width: 767px) {
.form-tips {
position: static;
margin-bottom: 8px;
}
.el-row[style*="margin: 100px"] {
margin: 24px 0 16px !important;
flex-direction: column;
gap: 8px;
.el-button {
width: 100%;
margin-left: 0 !important;
}
}
.el-row[style*="min-height: 400px"] {
min-height: auto !important;
}
}
}
</style>
......@@ -2,6 +2,9 @@
import { ElMessage } from 'element-plus'
import { createReport, getReport, getTestList } from '../api'
import AppUpload from '@/components/base/AppUpload.vue'
import { useMediaQuery } from '@vueuse/core'
const isMobile = useMediaQuery('(max-width: 767px)')
const props = defineProps(['data', 'action'])
......@@ -73,13 +76,18 @@ function handleSuccess(file) {
</script>
<template>
<el-dialog :title="title" :close-on-click-modal="false" @closed="$emit('update:modelValue', false)" width="500px">
<el-dialog
:title="title"
:close-on-click-modal="false"
@closed="$emit('update:modelValue', false)"
:width="isMobile ? '95%' : '500px'"
destroy-on-close>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="right"
label-width="110px"
:label-position="isMobile ? 'top' : 'right'"
:label-width="isMobile ? undefined : '110px'"
:disabled="action === 'view'">
<el-form-item label="关联直播" prop="live_practice_id">
<el-select v-model="form.live_practice_id" style="width: 100%" placeholder="请选择" filterable>
......
......@@ -26,7 +26,7 @@ const listOptions = computed(() => {
},
filters: [
{ label: '直播主题品类', prop: 'live_commodity_type_id', slots: 'filter-category' },
{ label: '直播主题名称', prop: 'live_commodity_title', type: 'input' },
{ label: '直播主题名称', prop: 'live_commodity_title', type: 'input', placeholder: '请输入直播主题名称' },
],
columns: [
{ label: '序号', type: 'index', width: 60 },
......@@ -58,6 +58,7 @@ const handleView = (row) => {
currentRow.value = row
formVisible.value = true
}
// 删除
const handleRemove = async (row) => {
await ElMessageBox.confirm('此操作将永久删除该数据, 是否继续?', '提示')
......@@ -74,7 +75,7 @@ const handleRemove = async (row) => {
<el-button type="primary" @click="handleAdd">上传总结报告</el-button>
</template>
<template #filter-category>
<LiveProductCategory v-model="listParams.live_commodity_type_id" @change="handleRefresh"></LiveProductCategory>
<LiveProductCategory v-model="listParams.live_commodity_type_id" @change="handleRefresh" placeholder="请选择直播主题品类"></LiveProductCategory>
</template>
<template #table-x="{ row }">
<el-button text type="primary" @click="handleView(row)">查看</el-button>
......@@ -82,6 +83,8 @@ const handleRemove = async (row) => {
</template>
</AppList>
</AppCard>
<!-- 新建/修改 -->
<FormDialog v-model="formVisible" :action="action" :data="currentRow" @update="handleRefresh" v-if="formVisible" />
</template>
<style scoped>
</style>
......@@ -5,6 +5,10 @@ import { createTalk, updateTalk, getTalk, getProduct } from '../api'
import { useChat } from '@/composables/useChat'
import Editor from '@tinymce/tinymce-vue'
import { useMapStore } from '@/stores/map'
import { useMediaQuery } from '@vueuse/core'
const isMobile = useMediaQuery('(max-width: 767px)')
const editorHeight = computed(() => (isMobile.value ? 250 : 420))
const props = defineProps(['data', 'action'])
......@@ -200,8 +204,13 @@ function handleAIGenerate(index) {
</script>
<template>
<el-dialog :title="title" :close-on-click-modal="false" @closed="$emit('update:modelValue', false)" width="1000px">
<el-row justify="center" :gutter="40">
<el-dialog
:title="title"
:close-on-click-modal="false"
@closed="$emit('update:modelValue', false)"
:width="isMobile ? '95%' : '1000px'"
destroy-on-close>
<el-row justify="center" :gutter="40" class="talk-dialog-row">
<el-col :sm="24" :md="12">
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="action === 'view'">
<el-form-item label="直播话术名称" prop="name">
......@@ -257,7 +266,7 @@ function handleAIGenerate(index) {
</el-form-item>
</el-form>
</el-col>
<el-col :sm="24" :md="12" style="border-left: 1px solid #dcdfe6">
<el-col :sm="24" :md="12" class="talk-dialog-right">
<div style="text-align: center; margin-bottom: 20px">
<h2 style="margin-bottom: 20px">直播话术</h2>
<el-button
......@@ -276,7 +285,7 @@ function handleAIGenerate(index) {
quickbars_insert_toolbar: false,
menubar: false,
statusbar: false,
height: 420,
height: editorHeight,
content_style: 'body { font-family:Helvetica,Arial,sans-serif; font-size:13px }',
}" />
</el-col>
......@@ -307,4 +316,19 @@ function handleAIGenerate(index) {
line-height: 22px;
color: #92939a;
}
@media (max-width: 767px) {
.talk-dialog-row {
flex-direction: column;
}
.talk-dialog-right {
border-left: none !important;
border-bottom: 1px solid #dcdfe6;
padding-bottom: 16px;
margin-bottom: 16px;
}
.form-tips {
position: static;
margin-bottom: 8px;
}
}
</style>
......@@ -32,8 +32,13 @@ const listOptions = computed(() => {
},
filters: [
{ label: '直播主题品类', prop: 'live_commodity_type_id', slots: 'filter-category' },
{ label: '直播主题名称', prop: 'live_commodity_title', type: 'input' },
{ label: '话术名称', prop: 'name', type: 'input' },
{
label: '直播主题名称',
prop: 'live_commodity_title',
type: 'input',
placeholder: '请输入直播主题名称',
},
{ label: '话术名称', prop: 'name', type: 'input', placeholder: '请输入话术名称' },
],
columns: [
{ label: '序号', type: 'index', width: 60 },
......@@ -82,10 +87,13 @@ const handleRemove = async (row) => {
<AppCard title="直播话术管理">
<AppList v-bind="listOptions" ref="appList">
<template #header-buttons>
<el-button type="primary" @click="handleAdd" v-if="!listLength">添加话术</el-button>
<el-button type="primary" @click="handleAdd">添加话术</el-button>
</template>
<template #filter-category>
<LiveProductCategory v-model="listParams.live_commodity_type_id" @change="handleRefresh"></LiveProductCategory>
<LiveProductCategory
v-model="listParams.live_commodity_type_id"
@change="handleRefresh"
placeholder="请选择直播主题品类"></LiveProductCategory>
</template>
<template #table-x="{ row }">
<el-button text type="primary" @click="handleView(row)">查看</el-button>
......@@ -97,3 +105,5 @@ const handleRemove = async (row) => {
<!-- 新建/修改 -->
<FormDialog v-model="formVisible" :action="action" :data="currentRow" @update="handleRefresh" v-if="formVisible" />
</template>
<style scoped></style>
......@@ -263,4 +263,34 @@ const onStatsChange = (stats) => {
overflow-y: auto;
line-height: 24px;
}
@media (max-width: 767px) {
.live-row {
flex-direction: column;
gap: 12px;
height: auto;
}
.live-col {
width: 100% !important;
padding: 12px;
border-radius: 0;
border-left: none;
border-right: none;
}
.live-time {
height: auto;
font-size: 42px;
margin: 8px 0;
}
.live-data2 dd {
font-size: 32px;
}
.live-tag {
margin: 12px 0;
padding: 12px;
}
.live-talk-content {
max-height: none;
}
}
</style>
......@@ -3,6 +3,9 @@ import { ElMessage } from 'element-plus'
import LiveProductSelect from '@/components/LiveProductSelect.vue'
import { createTest, updateTest, getTalkList } from '../api'
import { useMapStore } from '@/stores/map'
import { useMediaQuery } from '@vueuse/core'
const isMobile = useMediaQuery('(max-width: 767px)')
const props = defineProps(['data'])
......@@ -62,7 +65,7 @@ async function handleUpdate() {
</script>
<template>
<el-dialog :title="title" :close-on-click-modal="false" @closed="$emit('update:modelValue', false)">
<el-dialog :title="title" :close-on-click-modal="false" @closed="$emit('update:modelValue', false)" :width="isMobile ? '90%' : undefined">
<el-row justify="center">
<el-col :sm="24" :md="16">
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
......
......@@ -80,6 +80,8 @@ const {
endTime,
duration,
currentTime: currentLiveTime,
switchCamera,
isMobile,
} = useLive({
onStart: () => {
console.log('开始直播')
......@@ -215,15 +217,28 @@ defineExpose({ enabled, start, stop })
<img :src="enabled ? '/live/live2.png' : '/live/live1.png'" alt="直播状态" class="live-icon" />
</div>
<div class="live-bd">
<video muted autoplay ref="video"></video>
<video muted autoplay ref="video" playsinline></video>
<LiveCover :messages="messages" :stats="stats" :viewers="viewers" v-if="enabled" />
<!-- 移动端摄像头切换按钮 -->
<el-button
class="camera-switch-btn"
circle
size="small"
@click="switchCamera"
v-if="isMobile && !enabled && !recordId">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
<circle cx="12" cy="13" r="4"/>
</svg>
</el-button>
</div>
<div class="live-ft">
<div class="live-ft-row">
<el-button
type="primary"
:type="enabled ? 'danger' : 'primary'"
size="large"
round
style="width: 80%"
class="live-ft-btn"
@click="enabled ? stop() : start()"
v-if="!recordId">
{{ enabled ? '结束直播' : '开始直播' }}
......@@ -232,7 +247,7 @@ defineExpose({ enabled, start, stop })
type="primary"
size="large"
round
style="width: 80%; margin: 10px 0 0 0"
class="live-ft-btn"
:loading="uploading"
@click="handleUpload"
v-if="isLocalUpload && recordId"
......@@ -240,6 +255,7 @@ defineExpose({ enabled, start, stop })
>
</div>
</div>
</div>
<el-dialog title="提示" :model-value="dialogVisible" :showClose="false" width="500px">
<div class="modal-content">
<template v-if="isPolling">
......@@ -271,6 +287,7 @@ defineExpose({ enabled, start, stop })
<style lang="scss">
.live {
width: 375px;
max-width: 100%;
&-hd {
display: flex;
......@@ -285,6 +302,7 @@ defineExpose({ enabled, start, stop })
&-bd {
position: relative;
height: 667px;
max-height: 70vh;
border-radius: 8px;
overflow: hidden;
background-color: #000;
......@@ -298,6 +316,60 @@ defineExpose({ enabled, start, stop })
&-ft {
margin-top: 10px;
text-align: center;
&-row {
display: flex;
justify-content: center;
gap: 10px;
}
&-btn {
min-width: 160px;
}
}
// 摄像头切换按钮
.camera-switch-btn {
position: absolute;
top: 10px;
right: 10px;
z-index: 130;
background: rgba(0, 0, 0, 0.4);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.3);
&:hover {
background: rgba(0, 0, 0, 0.6);
}
}
// 移动端适配
@media (max-width: 768px) {
width: 100%;
&-bd {
height: 50vh;
max-height: 50vh;
border-radius: 0;
}
&-hd {
padding: 0 4px;
font-size: 14px;
}
&-ft {
&-btn {
width: 80%;
min-width: auto;
}
}
}
@media (max-width: 480px) {
&-bd {
height: 40vh;
max-height: 40vh;
}
}
}
.modal-content {
......
......@@ -110,7 +110,7 @@ const handleInput = (value) => {
</defs>
</svg>
</div>
<div class="live-slider" v-show="hover">
<div class="live-slider" :class="{ 'is-visible': hover }">
<el-slider
v-model="currentTime"
:step="0.1"
......@@ -127,6 +127,7 @@ const handleInput = (value) => {
<style lang="scss">
.live {
width: 375px;
max-width: 100%;
&-hd {
display: flex;
......@@ -141,6 +142,7 @@ const handleInput = (value) => {
&-bd {
position: relative;
height: 667px;
max-height: 70vh;
border-radius: 8px;
overflow: hidden;
background-color: #000;
......@@ -155,6 +157,38 @@ const handleInput = (value) => {
margin-top: 10px;
text-align: center;
}
@media (max-width: 768px) {
width: 100%;
&-bd {
height: 50vh;
max-height: 50vh;
border-radius: 0;
}
&-hd {
padding: 0 4px;
font-size: 14px;
}
.live-play-button {
display: flex;
}
.live-slider {
display: block;
}
.live-slider.is-visible {
display: block;
}
}
@media (max-width: 480px) {
&-bd {
height: 40vh;
max-height: 40vh;
}
}
}
.live-play-button {
position: absolute;
......
......@@ -4,6 +4,7 @@ import Demo from '../components/Demo.vue'
import ImprovementPlan from '../components/ImprovementPlan.vue'
import RecordViewText from './RecordViewText.vue'
import { useLiveRecord } from '../composables/useLiveRecord'
import { useMediaQuery } from '@vueuse/core'
const props = defineProps({
isView: { type: Boolean, default: true },
......@@ -12,6 +13,8 @@ const props = defineProps({
const { liveRecord: detail, handleAIScore, isLoading } = useLiveRecord({ id: props.recordId, immediate: true })
const isMobile = useMediaQuery('(max-width: 767px)')
const timelines = computed(() => {
return detail.value ? JSON.parse(detail.value.subtitle)?.Result?.Sentences || [] : []
})
......@@ -225,8 +228,8 @@ function formatDuration(seconds) {
</el-step>
</el-steps>
</AppCard>
<el-dialog v-model="dialogVisible" title="直播回放" width="1200px">
<div style="height: 740px">
<el-dialog v-model="dialogVisible" title="直播回放" :width="isMobile ? '95%' : '1200px'" destroy-on-close>
<div :style="{ height: isMobile ? 'auto' : '740px', maxHeight: isMobile ? '80vh' : 'none', overflowY: isMobile ? 'auto' : 'hidden' }">
<Demo :id="id" :recordId="recordId" :isView="true" />
</div>
</el-dialog>
......@@ -272,6 +275,38 @@ function formatDuration(seconds) {
width: 200px;
}
}
@media (max-width: 767px) {
flex-direction: column;
.live-info-header {
border-right: none;
border-bottom: 1px solid #e5e5e5;
padding-right: 0;
padding-bottom: 12px;
h2 {
font-size: 20px;
}
h6 {
font-size: 16px;
}
}
.live-info-item {
div {
width: calc(50% - 12px);
min-width: 120px;
}
}
}
@media (max-width: 480px) {
.live-info-item {
div {
width: 100%;
}
}
}
}
.live-title {
font-size: 14px;
......
......@@ -36,10 +36,25 @@ export function useLive({ enabledUserMedia = true, onStart, onRecord, onStop }:
const currentCamera = computed(() => cameras.value[0]?.deviceId)
const currentMicrophone = computed(() => microphones.value[0]?.deviceId)
// 移动端摄像头切换
const facingMode = ref<'user' | 'environment'>('user')
const isFrontCamera = computed(() => facingMode.value === 'user')
// 检测移动端
const isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent) || window.innerWidth < 768
// 响应式媒体约束:移动端使用 facingMode,桌面端使用 deviceId
const constraints = computed(() => {
if (isMobile) {
return { video: { facingMode: { ideal: facingMode.value } }, audio: true }
}
return { video: { deviceId: currentCamera.value as any }, audio: { deviceId: currentMicrophone.value as any } }
})
// 创建媒体流
const { stream } = useUserMedia({
enabled: enabledUserMedia,
constraints: { video: { deviceId: currentCamera as any }, audio: { deviceId: currentMicrophone as any } },
constraints: constraints as any,
})
// 录像设置
......@@ -102,5 +117,13 @@ export function useLive({ enabledUserMedia = true, onStart, onRecord, onStop }:
if (mediaRecorder) mediaRecorder.stop()
}
return { stream, start, stop, startTime, endTime, duration, currentTime }
// 切换摄像头(移动端前置/后置)
const switchCamera = () => {
if (mediaRecorder?.state === 'recording') return
facingMode.value = facingMode.value === 'user' ? 'environment' : 'user'
// 流自动更新后重新初始化 MediaRecorder
nextTick(() => initializeMediaRecorder())
}
return { stream, start, stop, startTime, endTime, duration, currentTime, switchCamera, facingMode, isFrontCamera, isMobile }
}
......@@ -34,7 +34,7 @@ const listOptions = computed(() => {
},
filters: [
{ label: '直播主题品类', prop: 'live_commodity_type_id', slots: 'filter-category' },
{ label: '直播主题标题', prop: 'live_commodity_title', type: 'input' },
{ label: '直播主题标题', prop: 'live_commodity_title', type: 'input', placeholder: '请输入直播主题标题' },
],
columns: [
{ label: '序号', type: 'index', width: 60 },
......@@ -42,13 +42,7 @@ const listOptions = computed(() => {
{ label: '直播主题标题', prop: 'live_commodity_title' },
{ label: '直播话术', prop: 'live_commodity_speeches_name' },
{ label: '所属直播主题品类', prop: 'live_commodity_type_full_name' },
{
label: '上传方式',
prop: 'upload_way',
computed({ row }) {
return getNameByValue(row.upload_way, liveUploadWay)
},
},
{ label: '上传方式', prop: 'upload_way', computed: ({ row }) => getNameByValue(row.upload_way, liveUploadWay) },
{ label: '创建人', prop: 'created_operator', computed: ({ row }) => getOperatorName(row.created_operator) },
{ label: '更新时间', prop: 'updated_time' },
{ label: '操作', slots: 'table-x', width: 200 },
......@@ -60,6 +54,7 @@ const formVisible = ref(false)
const recordVisible = ref(false)
const currentRow = ref(null)
// 查看
const handelView = (row) => {
recordVisible.value = true
currentRow.value = row
......@@ -88,7 +83,10 @@ const handleStart = async (row) => {
<el-button type="primary" @click="formVisible = true">添加直播</el-button>
</template>
<template #filter-category>
<LiveProductCategory v-model="listParams.live_commodity_type_id" @change="handleRefresh"></LiveProductCategory>
<LiveProductCategory
v-model="listParams.live_commodity_type_id"
@change="handleRefresh"
placeholder="请选择直播主题品类"></LiveProductCategory>
</template>
<template #table-x="{ row }">
<el-button text type="primary" @click="handleStart(row)" v-if="row.practice_count == 0">开始直播</el-button>
......@@ -97,8 +95,6 @@ const handleStart = async (row) => {
</template>
</AppList>
</AppCard>
<!-- 新建/修改 -->
<FormDialog v-model="formVisible" @update="handleRefresh" v-if="formVisible" />
<!-- 查看 -->
<RecordDialog v-model="recordVisible" :data="currentRow" v-if="recordVisible"></RecordDialog>
</template>
......@@ -135,4 +135,9 @@ async function handleNext() {
align-items: center;
justify-content: space-between;
}
@media (max-width: 767px) {
.remove {
display: block !important;
}
}
</style>
......@@ -77,4 +77,5 @@ const handleView = row => {
display: flex;
justify-content: space-between;
}
@media (max-width: 767px) { .info { flex-direction: column; gap: 8px; } }
</style>
......@@ -114,4 +114,10 @@ async function handleNext(data, isCheck = false) {
margin: 0;
}
}
@media (max-width: 767px) {
.el-row[style*="padding: 60px"] {
padding: 24px 0 !important;
gap: 20px !important;
}
}
</style>
......@@ -139,4 +139,7 @@ function isComplete(is: boolean) {
display: flex;
justify-content: space-between;
}
@media (max-width: 767px) {
.info { flex-direction: column; gap: 8px; }
}
</style>
......@@ -112,4 +112,5 @@ const handleSubmit = function () {
display: flex;
justify-content: space-between;
}
@media (max-width: 767px) { .info { flex-direction: column; gap: 8px; } }
</style>
......@@ -50,4 +50,5 @@ const data = $computed(() => {
display: flex;
justify-content: space-between;
}
@media (max-width: 767px) { .info { flex-direction: column; gap: 8px; } }
</style>
......@@ -171,7 +171,7 @@ const popoverText = function (row: any) {
</el-table-column>
<el-table-column width="30">
<template #default="{ row }">
<el-popover placement="top-start" :width="300" trigger="hover" :title="row.type_name">
<el-popover placement="top-start" :width="300" trigger="click" :title="row.type_name">
<template #reference>
<div style="display: flex; justify-content: center; cursor: pointer">
<el-icon size="20"><QuestionFilled /></el-icon>
......
......@@ -169,7 +169,7 @@ const popoverText = function (type: any) {
v-else
v-model="form.format"
:placeholder="formatPlaceholder" />
<el-popover placement="top-start" :width="300" trigger="hover" :title="form.type_name">
<el-popover placement="top-start" :width="300" trigger="click" :title="form.type_name">
<template #reference>
<div style="display: flex; justify-content: center; cursor: pointer">
<el-icon size="20"><QuestionFilled /></el-icon>
......
......@@ -161,4 +161,46 @@ const handleDelete = (url) => {
color: #000;
line-height: 30px;
}
@media (max-width: 767px) {
li .el-icon {
display: block !important;
}
.operations-tabs :deep(.el-tabs__header) {
width: 100%;
overflow-x: auto;
}
.operations-tabs :deep(.el-tabs__nav-wrap),
.operations-tabs :deep(.el-tabs__nav-scroll) {
overflow-x: auto;
overflow-y: hidden;
}
.operations-tabs :deep(.el-tabs__nav) {
display: inline-flex;
flex-wrap: nowrap;
}
.operations-tabs :deep(.el-tabs__item) {
flex-shrink: 0;
font-size: 13px;
padding: 0 8px;
}
:deep(.el-card__body) > div[style*="padding: 40px"] {
padding: 16px !important;
min-height: auto !important;
}
.image-list {
gap: 12px;
li {
width: calc(50% - 6px);
height: 160px;
}
}
.el-row {
flex-wrap: wrap;
justify-content: center;
gap: 8px;
.el-button {
margin-left: 0 !important;
}
}
}
</style>
......@@ -244,7 +244,7 @@ const handleNext = () => {
<div class="table-expand-box">
<el-form label-position="top">
<el-row>
<el-col :span="12">
<el-col :xs="24" :sm="12">
<el-form-item label="主图">
<el-image
:src="item.url"
......@@ -266,7 +266,7 @@ const handleNext = () => {
style="width: 100px; height: 134px; margin-right: 10px"></el-image>
</el-form-item>
</el-col>
<el-col :span="12">
<el-col :xs="24" :sm="12">
<el-form-item label="主图视频">
<video
:src="row.video_url"
......@@ -385,6 +385,25 @@ const handleNext = () => {
:deep(.el-tabs__nav-wrap::after) {
display: none;
}
@media (max-width: 767px) {
:deep(.el-tabs__header) {
width: 100%;
}
:deep(.el-tabs__nav-wrap),
:deep(.el-tabs__nav-scroll) {
overflow-x: auto;
overflow-y: hidden;
}
:deep(.el-tabs__nav) {
display: inline-flex;
flex-wrap: nowrap;
}
:deep(.el-tabs__item) {
flex-shrink: 0;
font-size: 13px;
padding: 0 8px;
}
}
}
.score-header {
display: flex;
......
......@@ -279,7 +279,7 @@ const disableTabPanel = (type) => {
<div class="table-expand-box">
<el-form label-position="top">
<el-row>
<el-col :span="12">
<el-col :xs="24" :sm="12">
<el-form-item label="主图">
<el-image
:src="item.url"
......@@ -301,7 +301,7 @@ const disableTabPanel = (type) => {
style="width: 100px; height: 134px; margin-right: 10px"></el-image>
</el-form-item>
</el-col>
<el-col :span="12">
<el-col :xs="24" :sm="12">
<el-form-item label="主图视频">
<video
:src="row.video_url"
......@@ -397,6 +397,14 @@ const disableTabPanel = (type) => {
:deep(.el-tabs__nav-wrap::after) {
display: none;
}
@media (max-width: 767px) {
:deep(.el-tabs__header) { width: 100%; }
:deep(.el-tabs__nav-wrap),
:deep(.el-tabs__nav-scroll) { overflow-x: auto; overflow-y: hidden; }
:deep(.el-tabs__nav) { display: inline-flex; flex-wrap: nowrap; }
:deep(.el-tabs__item) { flex-shrink: 0; font-size: 13px; padding: 0 8px; }
}
}
.score-header {
......
......@@ -245,7 +245,7 @@ const handleNext = () => {
<div class="table-expand-box">
<el-form label-position="top">
<el-row>
<el-col :span="12">
<el-col :xs="24" :sm="12">
<el-form-item label="主图">
<el-image
:src="item.url"
......@@ -267,7 +267,7 @@ const handleNext = () => {
style="width: 100px; height: 134px; margin-right: 10px"></el-image>
</el-form-item>
</el-col>
<el-col :span="12">
<el-col :xs="24" :sm="12">
<el-form-item label="主图视频">
<video
:src="row.video_url"
......@@ -386,6 +386,13 @@ const handleNext = () => {
:deep(.el-tabs__nav-wrap::after) {
display: none;
}
@media (max-width: 767px) {
:deep(.el-tabs__header) { width: 100%; }
:deep(.el-tabs__nav-wrap),
:deep(.el-tabs__nav-scroll) { overflow-x: auto; overflow-y: hidden; }
:deep(.el-tabs__nav) { display: inline-flex; flex-wrap: nowrap; }
:deep(.el-tabs__item) { flex-shrink: 0; font-size: 13px; padding: 0 8px; }
}
}
.score-header {
display: flex;
......
......@@ -204,6 +204,15 @@ const handleNext = () => {
}
}
}
@media (max-width: 767px) {
.image-list {
gap: 12px;
li {
width: calc(50% - 6px);
height: 160px;
}
}
}
.video-player {
max-height: 400px;
}
......
......@@ -24,11 +24,12 @@ const listOptions = computed(() => {
return {
remote: { httpRequest: getScoreList, params: listParams },
filters: [
{ label: '姓名', prop: 'name', type: 'input' },
{ label: '姓名', prop: 'name', type: 'input', placeholder: '请输入姓名' },
{
label: '状态',
prop: 'check_status',
type: 'select',
placeholder: '请选择状态',
options: [
{ label: '暂未评分', value: '0' },
{ label: '评分中', value: '1' },
......@@ -101,8 +102,12 @@ useIntervalFn(() => {
</template>
<template #filter-category>
<LiveProductCategory v-model="listParams.live_commodity_type_id" @change="handleRefresh"></LiveProductCategory>
<LiveProductCategory
v-model="listParams.live_commodity_type_id"
@change="handleRefresh"
placeholder="请选择直播主题品类"></LiveProductCategory>
</template>
<template #table-x="{ row }">
<el-button text type="primary" v-if="row.check_status != '1'">
<router-link :to="{ path: 'score/view', query: { ...$route.query, id: row.id } }">评分</router-link>
......
......@@ -197,6 +197,13 @@ useEventListener(window, 'pagehide', () => {
:deep(.el-tabs__nav-wrap::after) {
display: none;
}
@media (max-width: 767px) {
:deep(.el-tabs__header) { width: 100%; }
:deep(.el-tabs__nav-wrap),
:deep(.el-tabs__nav-scroll) { overflow-x: auto; overflow-y: hidden; }
:deep(.el-tabs__nav) { display: inline-flex; flex-wrap: nowrap; }
:deep(.el-tabs__item) { flex-shrink: 0; font-size: 13px; padding: 0 8px; }
}
}
.score-header {
display: flex;
......
......@@ -311,6 +311,42 @@ watch(currentConnection, () => {
}
}
}
@media (max-width: 767px) {
.info-box {
flex-direction: column;
align-items: stretch;
.info-name {
flex-direction: column;
min-width: auto !important;
margin-bottom: 12px;
}
h1 {
font-size: 22px;
margin: 10px 0 0 0;
}
.line {
display: none;
}
.info-box > div {
flex-direction: column;
gap: 8px;
}
}
.card-box {
flex-direction: column;
height: auto;
.card {
height: auto;
min-height: 300px;
margin-bottom: 10px;
.demo-tabs {
height: auto;
min-height: 200px;
}
}
}
}
.event-box {
border-bottom: 1px solid #ccc;
padding: 20px 0 5px;
......
......@@ -328,3 +328,16 @@ const handleClear = async () => {
<!-- 用户事件数据 -->
<ViewGenerateEventData v-model="generateEventVisible" v-if="generateEventVisible"></ViewGenerateEventData>
</template>
<style scoped>
@media (max-width: 767px) {
.el-row[style*="space-between"] {
flex-direction: column;
gap: 8px;
}
.el-space {
flex-wrap: wrap;
gap: 6px;
}
}
</style>
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论