feat: 优化组件和页面功能

- 新增 YlSwiper 轮播组件,基于 swiper 库实现,支持自定义渲染和多种配置项
- 优化 YlTable 组件,提升表格渲染性能和使用体验
- 优化 LogicInfo 组件,修复数据为空时的显示问题,使用 currentTabs 替代重复的计算属性
- 优化 AnalysisInfo 组件,移除冗余类型转换
- 新增问卷列表组件 QuestionList,用于展示任务相关问卷
- 更新 vite 配置,支持 swiper 自定义元素
- 添加 swiper 依赖包
This commit is contained in:
Huangzhe
2025-05-21 11:05:00 +08:00
parent b15beb91ee
commit bff8dda1d4
13 changed files with 477 additions and 46 deletions

View File

@@ -32,6 +32,7 @@
"regenerator-runtime": "^0.14.1",
"shrinkpng": "^1.2.0-beta.1",
"sortablejs": "^1.15.6",
"swiper": "^11.2.7",
"tailwindcss": "^4.1.6",
"uuid": "^11.1.0",
"vant": "^4.9.17",

View File

@@ -0,0 +1,286 @@
<script setup lang="ts">
import { register } from 'swiper/element/bundle';
import { ref, computed, defineEmits, onMounted, defineModel, useSlots } from 'vue';
import { ArrowLeft, ArrowRight } from '@element-plus/icons-vue';
// 定义 Swiper 元素类型
interface SwiperEvent {
detail: [any, any?];
}
interface SwiperElement extends HTMLElement {
swiper: any;
}
// 注册 Swiper 元素
register();
// Swiper 实例引用
const swiperRef = ref<SwiperElement | null>(null);
const swiperInstance = ref<any>(null);
// 使用 defineModel 定义可双向绑定的属性
// 轮播图数据
const slides = defineModel<any[]>('slides', { default: () => [] });
// 每个幻灯片的渲染函数
const renderSlide = defineModel<Function | null>('renderSlide', { default: null });
// 每页显示的幻灯片数
const slidesPerView = defineModel<number | string>('slidesPerView', { default: 1 });
// 幻灯片之间的间距
const spaceBetween = defineModel<number | string>('spaceBetween', { default: 10 });
// 是否居中显示
const centeredSlides = defineModel<boolean>('centeredSlides', { default: false });
// 是否循环播放
const loop = defineModel<boolean>('loop', { default: false });
// 是否自动播放
const autoplay = defineModel<boolean | object>('autoplay', { default: false });
// 是否显示分页器
const pagination = defineModel<boolean | object>('pagination', { default: false });
// 是否显示导航按钮
const navigation = defineModel<boolean | object>('navigation', { default: false });
// 断点配置
const breakpoints = defineModel<object>('breakpoints', { default: () => ({}) });
// 初始幻灯片索引
const initialSlide = defineModel<number>('initialSlide', { default: 0 });
// 高度
const height = defineModel<string>('height', { default: '' });
// 宽度
const width = defineModel<string>('width', { default: '100%' });
// 是否允许触摸滑动(设置为 false 则禁用滑动)
const allowTouchMove = defineModel<boolean>('allowTouchMove', { default: true });
// 定义事件
const emit = defineEmits([
'progress',
'slideChange',
'slideChangeTransitionStart',
'slideChangeTransitionEnd',
'update'
]);
// 计算属性:自动播放配置
const autoplayConfig = computed(() => {
if (autoplay.value === true) {
return { delay: 3000, disableOnInteraction: false };
}
return autoplay.value || false;
});
// 计算属性:分页器配置
const paginationConfig = computed(() => {
// 始终返回 false禁用原生分页指示器
return false;
});
// 获取插槽
const slots = useSlots();
// 计算属性:是否有自定义导航按钮
const hasCustomNavButtons = computed(() => {
return !!(slots.prevButton || slots.nextButton);
});
// 计算属性:导航按钮配置
const navigationConfig = computed(() => {
// 如果有自定义导航按钮,则禁用原生导航按钮
if (hasCustomNavButtons.value) {
return false;
}
// 否则使用配置的导航按钮设置
if (navigation.value === true) {
return {};
}
return navigation.value || false;
});
// 事件处理函数
const onProgress = (e: SwiperEvent) => {
const [swiper, progress] = e.detail;
emit('progress', { swiper, progress });
};
const onSlideChange = (e: SwiperEvent) => {
const [swiper] = e.detail;
emit('slideChange', { swiper, activeIndex: swiper.activeIndex });
};
const onSlideChangeTransitionStart = (e: SwiperEvent) => {
const [swiper] = e.detail;
emit('slideChangeTransitionStart', { swiper });
};
const onSlideChangeTransitionEnd = (e: SwiperEvent) => {
const [swiper] = e.detail;
emit('slideChangeTransitionEnd', { swiper });
};
// 初始化 Swiper
onMounted(() => {
// 使用 setTimeout 确保 DOM 已完全渲染
setTimeout(() => {
if (swiperRef.value && swiperRef.value.swiper) {
swiperInstance.value = swiperRef.value.swiper;
emit('update', swiperInstance.value);
}
}, 0);
});
// 提供方法:切换到下一张幻灯片
const slideNext = () => {
if (swiperInstance.value && typeof swiperInstance.value.slideNext === 'function') {
swiperInstance.value.slideNext();
}
};
// 提供方法:切换到上一张幻灯片
const slidePrev = () => {
if (swiperInstance.value && typeof swiperInstance.value.slidePrev === 'function') {
swiperInstance.value.slidePrev();
}
};
// 提供方法:切换到指定幻灯片
const slideTo = (index: number) => {
if (swiperInstance.value && typeof swiperInstance.value.slideTo === 'function') {
swiperInstance.value.slideTo(index);
}
};
// 暴露方法给父组件
defineExpose({
slideNext,
slidePrev,
slideTo,
swiper: swiperInstance
});
</script>
<template>
<div class="yl-swiper" :style="{ width, height }">
<!-- 导航按钮 -->
<div v-if="navigation" class="yl-swiper-button-prev" @click="slidePrev">
<slot name="prevButton">
<el-icon :size="24"><arrow-left /></el-icon>
</slot>
</div>
<swiper-container
ref="swiperRef"
:slides-per-view="slidesPerView"
:space-between="spaceBetween"
:centered-slides="centeredSlides"
:initial-slide="initialSlide"
:loop="loop"
:autoplay="autoplayConfig"
:pagination="paginationConfig"
:navigation="navigationConfig"
:breakpoints="breakpoints"
:allow-touch-move="allowTouchMove"
@swiperprogress="onProgress"
@swiperslidechange="onSlideChange"
@sliderslidetransitionstart="onSlideChangeTransitionStart"
@sliderslidetransitionend="onSlideChangeTransitionEnd"
>
<!-- 使用传入的渲染函数渲染幻灯片 -->
<template v-if="renderSlide && slides && slides.length">
<swiper-slide v-for="(item, index) in slides" :key="index">
<component :is="renderSlide(item, index)" />
</swiper-slide>
</template>
<!-- 使用默认插槽 -->
<template v-else>
<slot></slot>
</template>
</swiper-container>
<!-- 导航按钮 -->
<div v-if="navigation" class="yl-swiper-button-next" @click="slideNext">
<slot name="nextButton">
<el-icon :size="24"><arrow-right /></el-icon>
</slot>
</div>
<!-- 自定义分页指示器 -->
<div v-if="pagination" class="yl-swiper-pagination">
<slot name="pagination">
<div
v-for="(_, index) in slides && slides.length ? slides : (swiperInstance?.value?.slides || [])"
:key="index"
class="yl-swiper-pagination-bullet"
:class="{ 'is-active': swiperInstance?.value?.activeIndex === index }"
@click="slideTo(index)"
></div>
</slot>
</div>
</div>
</template>
<style lang="scss" scoped>
.yl-swiper {
width: 100%;
position: relative;
overflow: hidden;
.yl-swiper-button-prev,
.yl-swiper-button-next {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 10;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.yl-swiper-button-prev {
left: 10px;
}
.yl-swiper-button-next {
right: 10px;
}
.yl-swiper-button-prev,
.yl-swiper-button-next {
width: 32px;
height: 32px;
background-color: rgba(255, 255, 255, 0.8);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15);
color: #409EFF;
}
.yl-swiper-pagination {
position: absolute;
bottom: 10px;
left: 0;
right: 0;
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
z-index: 10;
.yl-swiper-pagination-bullet {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.2);
cursor: pointer;
transition: all 0.3s;
&.is-active {
background-color: #409EFF;
width: 16px;
border-radius: 4px;
}
}
}
}
</style>

View File

@@ -1,6 +1,5 @@
<script setup lang="ts">
import { RowAlign, type ColumnStyle } from 'element-plus';
import type { CSSProperties } from 'vue';
const data = defineModel<unknown[]>('data', { default: [] });
const props = defineModel<
@@ -16,6 +15,9 @@ const singleLine = defineModel<boolean>('singleLine', { default: false });
const rowStyle = defineModel<ColumnStyle<any>>('rowStyle', {
default: {}
});
// 显示表格的高度, 默认的高度是 200px
const tableHeight = defineModel<string | number>('height', { default: '300px' });
const headerStyle = defineModel<ColumnStyle<any>>('headerStyle', {
default: {
background: 'red'
@@ -34,6 +36,7 @@ function setStripeColor(rowData: { row: any; rowIndex: number }): string {
<template>
<div :style="{ borderRadius: rounded ? '10px' : '0', overflow: 'hidden' }">
<el-table
:max-height="tableHeight"
:header-cell-style="{ background: '#f2f8ee' }"
:row-class-name="setStripeColor"
:data="data"
@@ -49,7 +52,7 @@ function setStripeColor(rowData: { row: any; rowIndex: number }): string {
</div>
</template>
<style lang="scss" scoped>
<style lang="scss" scoped module="table">
:deep(.even-row) {
background-color: white;
}

View File

@@ -11,6 +11,11 @@ import '@/style/utils.scss';
import appBridge from '@/assets/js/appBridge';
import VConsole from 'vconsole';
import './assets/css/main.scss';
// 引入 swiper 样式
import 'swiper/css';
import 'swiper/css/navigation';
import 'swiper/css/pagination';
const app = createApp(App);
if (import.meta.env.VITE_APP_ENV !== 'production') {

View File

@@ -28,7 +28,8 @@ service.interceptors.request.use(
let plantToken = localStorage.getItem('plantToken');
// 如果 token 不存在, 试图尝试让用户输入 token ,然后放入本地 token 内容中
// 仅限开发环境中
if (!plantToken && !import.meta.env.PROD) {
// if (!plantToken && !import.meta.env.PROD) {
if (!plantToken) {
plantToken = prompt('token 不存在,请输入 plant token');
plantToken && localStorage.setItem('plantToken', plantToken);
}

View File

@@ -52,7 +52,7 @@ function handleSearchClick() {
<div class="container-body">
<!-- 搜索栏 -->
<section class="search">
<search-bar placeholder="请输入关键" :value="keyword" @click="handleSearchClick" />
<search-bar placeholder="请输入关键" :value="keyword" @click="handleSearchClick" />
</section>
<!-- 首页轮播图 -->
<section class="slider">

View File

@@ -34,7 +34,6 @@ const createdQuestion = (item) => {
remarks: '为优化活动服务品质,烦请完成问卷,感谢配合',
scene_code: item.parentCode,
scene_code_info: item.code,
// 很迷茫 模板新增 tag 空数组 非模板 就是k
tags: ''
};
if (createdNewPage.value) {
@@ -116,7 +115,7 @@ onMounted(() => {
<template>
<div class="create_survey">
<div class="create_survey_title" style="color: #000; text-align: left">新建问卷</div>
<div class="create_survey_title" style="color: #000; text-align: left">新建任务</div>
<div class="home-pen">
<img :src="homePen" alt="" />
</div>

View File

@@ -1,35 +1,57 @@
<script setup lang="ts">
import SurveyAnalysis from '@/views/Survey/views/Analysis/Index.vue';
import { fetchSurveys } from '@/hooks/request/useSurvey';
import { cellWithoutPadding } from '@/utils/theme/cell';
import QuestionList from './components/QuestionList.vue';
import YlSwiper from '@/components/YlSwiper/Index.vue';
const { surveys } = fetchSurveys();
</script>
<template>
<van-cell :style="cellWithoutPadding" class="swipe-container">
<template #extra>
<van-swipe class="my-swipe" indicator-color="white" :loop="false">
<van-swipe-item :key="survey.sn" v-for="survey in surveys">
<section style="width: 90vw">
<h3 style="margin: 10px 0 -10px 10px">我的任务</h3>
<survey-analysis :sn="survey.sn" :disable-search="true" :disable-insight="true" />
</section>
</van-swipe-item>
<!-- 指示器 -->
<!-- <template #indicator="{ active, total }">
<div class="custom-indicator">{{ active + 1 }}/{{ total }}</div>
</template> -->
</van-swipe>
</template>
</van-cell>
<div class="carousel-container">
<!-- 方式一使用默认插槽手动添加 swiper-slide 元素 -->
<yl-swiper
:slides-per-view="1"
:centered-slides="true"
:pagination="true"
:navigation="true"
:loop="false"
:space-between="0"
:allow-touch-move="false"
>
<swiper-slide v-for="question in surveys" :key="question.sn">
<question-list :survey="question" style="max-width: 100vw; overflow: hidden" />
</swiper-slide>
</yl-swiper>
</div>
</template>
<style lang="scss" scoped>
@use '@/assets/css/theme';
.swipe-container {
.carousel-container {
background-color: #fff;
overflow: hidden;
margin-top: theme.$gap;
border-radius: theme.$card-radius;
padding: 10px;
.carousel-item {
.swipe-container {
margin-top: theme.$gap;
border-radius: theme.$card-radius;
}
}
.slide-content {
height: 150px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: bold;
background-color: #f5f5f5;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
}
</style>

View File

@@ -0,0 +1,95 @@
<script setup lang="ts">
import YlSwiper from '@/components/YlSwiper/Index.vue';
import { useFetchAnalysis } from '@/hooks/request/useSurvey';
import { ref } from 'vue';
import SurveyItem from '@/views/Survey/components/SurveyItem.vue';
import LogicInfo from '@/views/Survey/views/Analysis/components/LogicInfo/Index.vue';
import { useRoute } from 'vue-router';
import Wait from '@/views/Survey/views/Analysis/components/Wait/Index.vue';
import AnalysisInfo from '@/views/Survey/views/Analysis/components/AnalysisInfo/Index.vue';
import SearchBar from '@/components/Search/Index.vue';
import { fetchSingleSurvey } from '@/hooks/request/useSurvey';
const survey = defineModel<SurveyItem>('survey');
// 获取问卷分析数据
const { questionAnalysis } = useFetchAnalysis(survey.value?.sn as string);
const { currentSurvey } = fetchSingleSurvey(survey.value?.sn as string);
const disableInsight = ref(true);
const aiInsightsConfig = ref({
visible: false,
message: ''
});
const postAnalysis = (sn: string) => {
aiInsightsConfig.value.visible = true;
};
const height = ref(`200px`);
</script>
<template>
<section class="question-item-container">
<section class="survey-item">
<!-- 问卷详情部分 -->
<survey-item
v-if="currentSurvey"
:is-analysis="disableInsight"
:disable-action-button="true"
:survey="currentSurvey as SurveyItem"
></survey-item>
</section>
<!-- 问卷分析 -->
<section class="analysis-info">
<!-- van swiper -->
<van-swipe>
<van-swipe-item v-for="analysis in questionAnalysis">
<analysis-info :sn="survey?.sn" :questionAnalysis="[analysis]" />
</van-swipe-item>
</van-swipe>
<!-- <swiper navigation :slides-per-view="3" :modules="modules" :space-between="50">
<swiper-slide v-for="analysis in questionAnalysis">
{{ analysis }}
</swiper-slide>
</swiper> -->
<!-- swiper -->
<!-- <yl-swiper> -->
<!-- <swiper-slide v-for="item in questionAnalysis" :key="item.stem"> -->
<!-- item 解决内部数据错误的问题 -->
<!-- <analysis-info :sn="survey?.sn" :questionAnalysis="[item]" /> -->
<!-- </swiper-slide> -->
<!-- </yl-swiper> -->
<!-- el carousel -->
<!-- <el-carousel arrow="always" :loop="false" :autoplay="false"> -->
<!-- <el-carousel-item v-for="item in questionAnalysis"> -->
<!-- item 解决内部数据错误的问题 -->
<!-- <analysis-info :sn="survey?.sn" :questionAnalysis="[item]" /> -->
<!-- </el-carousel-item> -->
<!-- </el-carousel> -->
</section>
</section>
</template>
<style lang="scss" scoped>
@use '@/assets/css/theme';
.question-item-container {
width: 90vw;
// display: flex;
// justify-content: center;
// flex-flow: column nowrap;
// align-items: center;
.survey-item {
width: 95%;
}
.analysis-info {
}
}
</style>

View File

@@ -1,6 +1,7 @@
<template>
<div style="width: 100%">
<section v-for="analysis in questionAnalysis" :key="analysis.stem" class="mt10">
<!-- 优先去上级传递的数值 -->
<section v-for="analysis in analysis" :key="analysis.stem" class="mt10">
<!-- {{ analysis }} -->
<!-- 问题标题 -->
<el-tag type="success" size="small">{{
@@ -17,16 +18,21 @@
<!-- 问题表格部分 -->
<yl-table
class="mt10"
class="mt10"
:props="getTableHeadProps(analysis.head, analysis.option)"
:data="getTableData(analysis)"
v-if="analysis.head"
/>
</section>
<!-- <section v-else>
<empty-container />
</section> -->
</div>
</template>
<script setup lang="ts">
// 空白容器
import EmptyContainer from '@/views/Survey/components/EmptyContainer.vue';
import { useFetchAnalysis } from '../../hooks/useAnalysis';
import { questionTypeMap } from '@/utils/question/typeMapping';
import ChartMsg from '@/components/Analysis/Index.vue';
@@ -35,12 +41,20 @@ import YlTable from '@/components/YlTable/Index.vue';
import { ref } from 'vue';
// questionTypeMap 自己去对应
const showChart = ref([1, 2, 5, 106, 9, 10]);
const sn = defineModel('sn');
const { questionAnalysis } = useFetchAnalysis(sn.value as string);
// const showTable = ref([1,2,4])
const sn = defineModel('sn', { required: true });
// 接受上级传递的 questionAnalysis 数据
const analysis = defineModel<any[]>('questionAnalysis');
// 如果没有接收到数据,那么就去请求
if (!analysis.value) {
console.log('repeat fetch analysis');
const { questionAnalysis } = useFetchAnalysis(sn.value as string);
analysis.value = questionAnalysis.value;
}
// 构建表头
const getTableHeadProps = (values: any[], option) => {
const getTableHeadProps = (values: any[], option: any[]) => {
const head = [];
if (values && values.length > 0) {
@@ -63,7 +77,6 @@ const getTableHeadProps = (values: any[], option) => {
}
return head;
};
// 构建表格数据
</script>
<style scoped lang="scss">
.mt10 {

View File

@@ -4,7 +4,7 @@ export const series = ref({
name: 'yl form chart',
type: 'pie',
radius: ['30%', '50%'],
center: ['50%', '30%'],
center: ['50%', '35%'],
data: [
{ value: 1048, name: 'Search Engine' },
{ value: 735, name: 'Direct' },

View File

@@ -14,7 +14,8 @@ sn.value = sn.value ?? (route.query.sn as string);
const { quota, random, cycle, loading } = fetchLogicInfo(sn.value);
const activeTab = ref(0);
const tabs = ref<LogicInfoTab[]>([
const tabs = computed(() => [
{
title: '逻辑配额',
props: [
@@ -23,7 +24,7 @@ const tabs = ref<LogicInfoTab[]>([
{ prop: 'sample_number', label: '样本量', width: 90 },
{ prop: 'percent', label: '进度', width: 90 }
],
data: quota.value as any
data: quota.value
},
{
title: '随机题组配额',
@@ -33,7 +34,7 @@ const tabs = ref<LogicInfoTab[]>([
{ prop: 'sample_num', label: '样本量', width: 120 },
{ prop: 'precent', label: '进度', width: 120 }
],
data: random.value as any
data: random.value
},
{
title: '循环题组配额',
@@ -43,25 +44,23 @@ const tabs = ref<LogicInfoTab[]>([
{ prop: 'sample_num', label: '样本量', width: 80 },
{ prop: 'precent', label: '进度', width: 120 }
],
data: cycle.value as any
data: cycle.value
}
]);
// 自动计算是否有 card 区域,只显示有数据的标签页
const currentTabs = computed(() => {
return tabs.value.filter((item) => {
return item.data.length > 0;
return item.data && item.data.length > 0;
});
});
// 自动计算是否有 card 区域
const visibleQuestionConfig = computed(() => {
return tabs.value.filter((item) => {
return item.data.length > 0;
});
});
// 调试信息
const debug = ref(false);
</script>
<template>
<van-cell class="logic-info" v-if="visibleQuestionConfig.length > 0">
<van-cell class="logic-info" v-if="currentTabs.length > 0">
<template #extra>
<section style="width: 86vw" v-loading="loading">
<!-- tabs 选项列表 -->

View File

@@ -69,7 +69,14 @@ export default defineConfig(({ mode }) => {
cacheDir: '.tmp',
plugins: [
vueDevTools(),
vue(),
vue({
template: {
compilerOptions: {
// 将 swiper 相关标签注册为自定义元素
isCustomElement: (tag) => tag.startsWith('swiper-')
}
}
}),
vueJsx(),
AutoImport({ resolvers: [VantResolver(), ElementPlusResolver()] }),
Components({ resolvers: [VantResolver(), ElementPlusResolver()] }),