feat(search): 实现首页搜索功能与模板市场组件

- 新增首页搜索功能,支持关键词搜索问卷
- 新增布局组件(CommonLayout),优化页面结构
- 新增问卷列表组件(MineSurvey),展示用户问卷
- 新增模板市场组件(TemplateMarketItem),使用Tailwind CSS样式
- 优化路由配置,添加模板市场路由
- 修复CSS样式问题,使用Tailwind CSS替代自定义样式
- 改进组件间通信,使用Vue3 defineModel API

相关任务: #TASK-2025-05-13
This commit is contained in:
Huangzhe
2025-05-13 01:17:26 +08:00
parent 6cfa35f666
commit 37e6280d57
19 changed files with 362 additions and 65 deletions

View File

@@ -32,7 +32,7 @@
"editor.formatOnSave": true
},
"[vue]": {
"editor.defaultFormatter": "Vue.volar",
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"css.validate": false, //用来校验CSS文件中的语法错误和潜在的问题

2
components.d.ts vendored
View File

@@ -7,6 +7,7 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
CommonLayout: typeof import('./src/components/Layout/CommonLayout.vue')['default']
Contenteditable: typeof import('./src/components/contenteditable.vue')['default']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
@@ -31,6 +32,7 @@ declare module 'vue' {
RichText: typeof import('./src/components/RichText.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SimpleLayout: typeof import('./src/components/Layout/SimpleLayout.vue')['default']
VanActionSheet: typeof import('vant/es')['ActionSheet']
VanButton: typeof import('vant/es')['Button']
VanCard: typeof import('vant/es')['Card']

View File

@@ -18,18 +18,21 @@
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"@tailwindcss/vite": "^4.1.6",
"axios": "^1.8.2",
"core-js": "^3.41.0",
"cos-js-sdk-v5": "^1.8.7",
"dotenv": "^16.4.7",
"echarts": "^5.6.0",
"element-plus": "^2.7.8",
"install": "^0.13.0",
"js-base64": "^3.7.7",
"lodash": "^4.17.21",
"pinia": "^3.0.2",
"regenerator-runtime": "^0.14.1",
"shrinkpng": "^1.2.0-beta.1",
"sortablejs": "^1.15.6",
"tailwindcss": "^4.1.6",
"uuid": "^11.1.0",
"vant": "^4.9.17",
"vconsole": "^3.15.1",

1
src/assets/css/main.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

View File

@@ -1,5 +1,3 @@
// main.scss
/* eslint-disable */
@import 'theme';

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import { ref } from 'vue';
const title = defineModel<string>('title', { default: '' });
const show = ref<boolean>(true);
</script>
<template>
<div class="common-layout px-2 pt-1">
<!-- title 部分 -->
<section v-if="title" class="pb-1">
<header>{{ title }}</header>
</section>
<slot class="" name="content" />
</div>
</template>
<style scoped lang="scss">
.common-layout {
margin-bottom: 20px;
}
</style>

View File

@@ -1,25 +1,27 @@
<template>
<section class="search-container">
<van-search v-model="value" :placeholder="placeholder" @search="searchMethod" @cancel="emitCancel"
style="--van-search-padding: 0;">
<van-search
v-model="value"
:placeholder="placeholder"
style="--van-search-padding: 0"
@search="searchMethod"
>
</van-search>
<el-text>
搜索
</el-text>
<el-text @click="searchMethod">搜索</el-text>
</section>
</template>
<script setup>
import { ref } from 'vue';
const value = ref('');
<script setup lang="ts">
// 搜索的关键词
const value = defineModel<string>('value', { required: true });
/**
* @description 搜索方法
*/
const searchMethod = defineModel('search', {
type: Function,
default: () => void 0
default: () => ({})
});
const placeholder = defineModel('placeholder', {
type: String,
default: '请输入搜索关键词'
@@ -44,7 +46,7 @@ const placeholder = defineModel('placeholder', {
}
border-radius: 18px;
border : solid 2px var(--primary-color);
border: solid 2px var(--primary-color);
background-color: #f5f5f5;
//width: 100vw;

View File

@@ -0,0 +1,60 @@
<template>
<section class="search-container">
<van-search
v-model="value"
:placeholder="placeholder"
style="--van-search-padding: 0"
@search="searchMethod"
@update:model-value="updateValue"
>
</van-search>
<el-text @click="searchMethod">搜索</el-text>
</section>
</template>
<script setup lang="ts">
// 搜索的关键词
const value = defineModel<string>('value', { required: true });
/**
* @description 搜索方法
*/
const searchMethod = defineModel('search', {
type: Function,
default: () => ({})
});
const placeholder = defineModel('placeholder', {
type: String,
default: '请输入搜索关键词'
});
</script>
<style scoped lang="scss">
.search-container {
:deep(.van-search) {
padding: 0;
margin: 0 10px;
border-radius: 0;
background-color: #f5f5f5;
}
:deep(.el-text) {
margin: 0 10px;
font-size: 14px;
color: #999;
cursor: pointer;
justify-self: center;
}
border-radius: 18px;
border: solid 2px var(--primary-color);
background-color: #f5f5f5;
//width: 100vw;
display: grid;
grid-template-columns: 1fr 60px;
padding: 0 10px;
margin: 0 10px;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,22 @@
<template>
<div class="flex flex-col">
<h1>{{ survey.project_name }}</h1>
<el-space spacer="|" direction="horizontal">
<section class="flex items-center pt-1">
<fc-businessman /> <el-text>{{ survey.created_user }}</el-text>
</section>
<section class="flex items-center pt-1">
<fc-clock /> <el-text>创建时间{{ survey.created_at }}</el-text>
</section>
</el-space>
</div>
</template>
<script setup lang="ts">
import { FcClock, FcBusinessman } from 'vue-icons-plus/fc';
const survey = defineModel<object>('survey', { required: true });
// console.log(survey.value);
</script>
<style scoped></style>

View File

@@ -0,0 +1,74 @@
<template>
<section class="w-full">
<van-list :finished="finished" @load="onLoad">
<van-card
v-for="template in templates"
:key="template.id"
class="mb-3 rounded-sm overflow-hidden shadow-md"
>
<template #tags>
<div class="flex flex-col">
<div class="flex justify-between items-center mb-2">
<h3 class="m-0 font-bold text-gray-800">{{ template.title }}</h3>
<el-tag size="small" type="success">{{ template.type }}</el-tag>
</div>
<el-space spacer="|" direction="horizontal" class="mb-3 text-sm text-gray-600">
<section class="flex items-center">
<el-icon><User /></el-icon>
<el-text class="ml-1">{{ template.author }}</el-text>
</section>
<section class="flex items-center">
<el-icon><View /></el-icon>
<el-text class="ml-1">{{ template.views }}次使用</el-text>
</section>
</el-space>
</div>
</template>
</van-card>
</van-list>
</section>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { User, View } from '@element-plus/icons-vue';
// 模拟模板数据
const templates = ref([
{
id: 1,
title: '报名登到模板A',
type: '报名表',
author: '张强',
views: 4,
created_at: '2025-05-01'
},
{
id: 2,
title: '客户满意度调查',
type: '满意度',
author: '李明',
views: 12,
created_at: '2025-04-28'
},
{
id: 3,
title: '产品反馈收集',
type: '反馈',
author: '王华',
views: 8,
created_at: '2025-04-25'
}
]);
// 列表加载状态
const loading = ref(false);
const finished = ref(true);
// 加载更多数据
const onLoad = () => {
loading.value = false;
finished.value = true;
};
</script>

View File

@@ -6,14 +6,14 @@ import { createPinia } from 'pinia';
import App from './App.vue';
import router from './router';
import utils from '@/assets/js/common';
import 'core-js/stable';
import 'regenerator-runtime/runtime';
// 2. 引入组件样式
import 'vant/lib/index.css';
// 2. 引入组件样式, 由于引入了自动引入插件,无需手动引入
// import 'vant/lib/index.css';
import '@/style/utils.scss';
import appBridge from '@/assets/js/appBridge';
import VConsole from 'vconsole';
import './assets/css/main.scss';
import './assets/css/main.css';
const app = createApp(App);
if (import.meta.env.VITE_APP_ENV !== 'production') {

View File

@@ -92,7 +92,11 @@ const router = createRouter({
path: '/search',
name: 'search',
meta: {},
component: () => import("@/views/HomeSearch/Index.vue")
component: () => import('@/views/HomeSearch/Index.vue')
}, {
path: '/template-market',
name: 'templateMarket',
component: () => import('@/views/Home/components/Market/Index.vue')
}
]
},

View File

@@ -60,7 +60,7 @@ onMounted(() => {
background-color: #70b937;
}
:deep(.van-tab--active ) {
:deep(.van-tab--active) {
color: #000;
font-size: 13px;
}

View File

@@ -0,0 +1,8 @@
import {ref} from 'vue';
const visible = ref<{ [key: string]: boolean }>({
mineSurvey: true,
templateMarket: true
});
export { visible };

View File

@@ -0,0 +1,80 @@
import { nextTick, ref, watch } from 'vue';
import { getSurveysPage } from '@/api/home';
// 问卷
const surveys = ref([]);
// 搜索关键词
const keyword = ref<string>();
// 搜索页面索引, 默认第一页
const index = ref<number>(1);
// 是否到达最后的列表
const lastIndex = ref<boolean>(false);
// 每页的结果个数
const pageCount = ref<number>(5);
// 数组是否是脏数据,当 keyword 变动的时候,数组里面的数据已经不能用了, 需要重新获取
// 这个是判断问卷数据是否重新获取的关键字
const dirty = ref(true);
// loading 状态
const loading = ref(false);
async function handleSearch() {
// loading 状态开启
// loading.value = true;
const params = {
page: index.value,
per_page: pageCount.value,
group_id: 0,
project_name: keyword.value
};
const res = await getSurveysPage(params);
// 排除返回出错的异常
if (res.data.code !== 0) return;
// 更新问卷信息
// surveys.value = dirty ? res.data.data : surveys.value.concat(res.data.data);
surveys.value = surveys.value.concat(res.data.data);
// 数据更新完成之后,脏数据标记清空
dirty.value = false;
// 检查是否是最后一页
lastIndex.value = res.data.meta.last_page === index.value;
}
/**
* 更新 搜索关键字信息
*/
async function updateKeyword() {
// 排除边界条件
if (!keyword.value) return;
// 重新获取数据
await handleSearch();
// 处理后置条件,点击搜索之后,索引更新,
index.value = 1;
}
/**
*
* @param value
*/
function updatePageCount(value: number) {
pageCount.value = value;
}
// 当 keyword 变动的时候,标记脏数据
watch(keyword, async () => {
dirty.value = true;
// 重新获取数据
// await handleSearch();
});
// 索引变动之后,立刻进行搜索 (没有进行防抖)
watch(index, async () => {
console.log(`index add`, index.value, loading.value);
// 当不在 loading 的状态时候再开始获取数据
await nextTick(() => {
handleSearch();
});
});
export { keyword, updateKeyword as handleSearch, lastIndex, loading, surveys, index };

View File

@@ -1,48 +1,34 @@
<script setup lang="ts">
import { ref } from 'vue';
import Search from '@/components/Search/Index.vue';
import { ElMessage } from 'element-plus';
import TemplateMarket from "@/views/HomeSearch/components/TemplateMarket/Index.vue"
interface SearchItem {
id: number;
name: string;
}
const searchResult = ref<SearchItem[]>([]);
const handleSearch = (value: string) => {
if (!value.trim()) {
ElMessage.warning('请输入搜索关键词');
return;
}
// 这里模拟搜索结果,实际应用中应该调用 API
searchResult.value = [
{ id: 1, name: `搜索结果1: ${value}` },
{ id: 2, name: `搜索结果2: ${value}` },
{ id: 3, name: `搜索结果3: ${value}` }
];
};
const emitCancel = () => {
searchResult.value = [];
};
import TemplateMarket from '@/views/HomeSearch/components/TemplateMarket/Index.vue';
import MineSurvey from '@/views/HomeSearch/components/MineSurvey/Index.vue';
import { handleSearch, keyword } from '@/views/HomeSearch/Hooks/useSurveySearch';
import Layout from '@/components/Layout/CommonLayout.vue';
import { visible } from '@/views/HomeSearch/Hooks/useHomeSearch';
</script>
<template>
<section>
<Search placeholder="请输入搜索关键词" :search="handleSearch" />
<search v-model:value="keyword" :search="handleSearch" />
<!-- 广告区域 -->
<!-- 我的问卷区域 -->
<layout v-if="visible.mineSurvey" title="我的任务">
<template #content>
<mine-survey />
</template>
</layout>
<!-- 更多模板区域 -->
<template-market />
<div class="search-result">
<div class="search-result-item" v-for="item in searchResult" :key="item.id">
{{ item.name }}
</div>
</div>
<layout v-if="visible.templateMarket" title="问卷模板">
<template #content>
<template-market />
</template>
</layout>
<!-- <div class="search-result">-->
<!-- <div class="search-result-item" v-for="item in searchResult" :key="item.id">-->
<!-- {{ item.name }}-->
<!-- </div>-->
<!-- </div>-->
</section>
</template>

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
import { index, surveys, lastIndex, keyword } from '@/views/HomeSearch/Hooks/useSurveySearch';
import SurveyItem from '@/components/SurveyItem/Index.vue';
/**
* van-list 触发 loading 状态函数
*/
function handleLoadStatus() {
// 关键字为空时不进行搜索
if (!keyword.value) return;
index.value += 1;
}
</script>
<template>
<section class="survey-container">
<van-list :offset="10" :finished="lastIndex" @load="handleLoadStatus">
<van-card v-for="survey in surveys" :key="survey" class="rounded-xs">
<template #tags>
<survey-item :survey="survey" />
</template>
</van-card>
</van-list>
</section>
</template>
<style scoped lang="scss">
.survey-container {
min-height: 300px;
height: 30vh;
overflow: scroll;
padding: 0;
}
</style>

View File

@@ -1,14 +1,12 @@
<script setup lang="ts">
import MarketItem from '@/components/MarketItem/MarketItem.vue';
// import MarketItem from '@/components/MarketItem/MarketItem.vue';
import MarketItem from '@/components/TemplateMarketItem/Index.vue';
</script>
<template>
<section>
<market-item info=""></market-item>
<market-item></market-item>
</section>
</template>
<style scoped lang="scss">
</style>
<style scoped lang="scss"></style>

View File

@@ -10,6 +10,7 @@ import postCssPxToRem from 'postcss-pxtorem';
import legacy from '@vitejs/plugin-legacy';
// shift + alt 快速定位到对应组件
import { codeInspectorPlugin } from 'code-inspector-plugin';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig(({ mode }) => {
// 接收 mode 参数
@@ -74,8 +75,9 @@ export default defineConfig(({ mode }) => {
additionalLegacyPolyfills: ['regenerator-runtime/runtime']
}),
codeInspectorPlugin({
bundler: 'vite',
bundler: 'vite'
}),
tailwindcss()
],
resolve: {
alias: {