hookehuyr

refactor(search): 将搜索结果中的资料改为文章展示

- 替换 MaterialCard 为 ArticleCard 组件
- 更新 Tab 名称:资料 → 文章
- 更新数据字段:data.files → data.article
- 映射 API 字段:post_title, post_excerpt, post_date, is_favorite
- 同步更新 mock 数据使用 generateArticleItem

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
......@@ -3,6 +3,71 @@
记录项目开发过程中的重要变更和完成任务。
## 2026-02-28
### 19:54:54 - docs
- 更新搜索 API 响应字段:files → article
- 字段名规范化:name/value/extension → post_title/post_excerpt/post_link
- 文章详情 API 添加 file_list 字段类型定义
- 计划书 API 完善参数文档说明
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>(api
- 更新搜索 API 响应字段:files → article
- 字段名规范化:name/value/extension → post_title/post_excerpt/post_link
- 文章详情 API 添加 file_list 字段类型定义
- 计划书 API 完善参数文档说明
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>): 同步搜索 API 文档,将 file 类型改为 article
- 更新搜索 API 响应字段:files → article
- 字段名规范化:name/value/extension → post_title/post_excerpt/post_link
- 文章详情 API 添加 file_list 字段类型定义
- 计划书 API 完善参数文档说明
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
**影响文件**:
- `docs/api-specs/search/search.md`
- `src/api/article.js`
- `src/api/plan.js`
**变更摘要**:
- 同步搜索 API 文档,将 file 类型改为 article
- 更新搜索 API 响应字段:files → article
- 字段名规范化:name/value/extension → post_title/post_excerpt/post_link
- 文章详情 API 添加 file_list 字段类型定义
- 计划书 API 完善参数文档说明
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
**相关提交**:
- `a4ef54a` - docs
- 更新搜索 API 响应字段:files → article
- 字段名规范化:name/value/extension → post_title/post_excerpt/post_link
- 文章详情 API 添加 file_list 字段类型定义
- 计划书 API 完善参数文档说明
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>(api
- 更新搜索 API 响应字段:files → article
- 字段名规范化:name/value/extension → post_title/post_excerpt/post_link
- 文章详情 API 添加 file_list 字段类型定义
- 计划书 API 完善参数文档说明
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>): 同步搜索 API 文档,将 file 类型改为 article
- 更新搜索 API 响应字段:files → article
- 字段名规范化:name/value/extension → post_title/post_excerpt/post_link
- 文章详情 API 添加 file_list 字段类型定义
- 计划书 API 完善参数文档说明
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
### 17:11:05 - fix
- 删除 if/else 块外部的重复 mv 命令
......
<!--
* @Date: 2026-02-08
* @Description: 搜索页面 - 使用 LoadMoreList 组件重构版本
* @description 支持产品和资料搜索,实时查询API,自动切换分类
* @description 支持产品和文章搜索,实时查询API,自动切换分类
-->
<template>
<view class="bg-[#FFF] search-page-container">
......@@ -27,7 +27,7 @@
<view class="px-[40rpx] mt-[32rpx]">
<SearchBar
v-model="searchKeyword"
placeholder="搜索培训资料、案例、产品..."
placeholder="搜索文章、产品..."
variant="rounded"
:show-border="true"
:show-clear="true"
......@@ -80,18 +80,18 @@
)"
/>
<!-- File Results -->
<MaterialCard
v-else-if="activeTab === 'file'"
<!-- Article Results -->
<ArticleCard
v-else-if="activeTab === 'article'"
:id="item.id"
:title="item.title"
:file-name="item.fileName"
:file-size="item.fileSize"
:excerpt="item.excerpt"
:cover-url="item.coverUrl"
:date="item.date"
:learners="item.learners"
:read-people-percent="item.readPeoplePercent"
:collected="item.collected"
:extension="item.extension"
:download-url="item.downloadUrl"
:show-cover="!!item.coverUrl"
class="search-result-item"
@collect-changed="handleCollectChanged(item, $event)"
/>
......@@ -102,7 +102,7 @@
<!-- Initial State (从未搜索过) -->
<view v-if="!hasSearched" class="flex flex-col items-center justify-center py-[120rpx]">
<IconFont name="search" class="text-gray-300 mb-[24rpx]" size="64" />
<view class="text-[#6B7280] text-[28rpx]">搜索产品或资料</view>
<view class="text-[#6B7280] text-[28rpx]">搜索产品或文章</view>
<view class="text-[#9CA3AF] text-[24rpx] mt-[12rpx]">输入关键词开始搜索,自动切换分类</view>
</view>
......@@ -136,7 +136,7 @@ import NavHeader from '@/components/navigation/NavHeader.vue'
import IconFont from '@/components/icons/IconFont.vue'
import SearchBar from '@/components/forms/SearchBar.vue'
import ProductCard from '@/components/cards/ProductCard.vue'
import MaterialCard from '@/components/cards/MaterialCard.vue'
import ArticleCard from '@/components/cards/ArticleCard.vue'
import PlanFormContainer from '@/components/plan/PlanFormContainer.vue'
import { searchAPI } from '@/api/search'
import { mockSearchAPI } from '@/utils/mockData'
......@@ -169,7 +169,7 @@ onMounted(() => {
/**
* 搜索页面状态管理
* @description 支持双类型(产品/资料)搜索,自动切换分类
* @description 支持双类型(产品/文章)搜索,自动切换分类
*/
// Plan Popup State
......@@ -183,9 +183,9 @@ const hasSearched = ref(false) // 是否已经搜索过
// 数据状态 - 双列表系统
const products = ref([]) // 产品列表
const files = ref([]) // 资料列表
const articles = ref([]) // 文章列表
const productsTotal = ref(0) // 产品总数
const filesTotal = ref(0) // 资料总数
const articlesTotal = ref(0) // 文章总数
// 分页状态
const loading = ref(false) // 首次加载状态
......@@ -195,11 +195,11 @@ const currentPage = ref(0) // 当前页码(从0开始)
const pageSize = 20 // 每页数量
/**
* Tab 数据源(只保留产品和资料
* Tab 数据源(只保留产品和文章
*/
const tabsData = ref([
{ id: 'product', name: '产品' },
{ id: 'file', name: '资料' },
{ id: 'article', name: '文章' },
])
/**
......@@ -213,7 +213,7 @@ const currentList = computed(() => {
if (activeTab.value === 'product') {
return products.value
} else {
return files.value
return articles.value
}
})
......@@ -227,7 +227,7 @@ const currentTotal = computed(() => {
if (activeTab.value === 'product') {
return productsTotal.value
} else {
return filesTotal.value
return articlesTotal.value
}
})
......@@ -244,7 +244,7 @@ const shouldEnableScrollLoad = computed(() => {
* 执行搜索
*
* @param {string} keyword - 搜索关键字
* @param {string} type - 可选,'product' | 'file' | undefined
* @param {string} type - 可选,'product' | 'article' | undefined
* @param {number} page - 页码(从0开始)
* @param {number} limit - 每页数量
* @param {boolean} isLoadMore - 是否为加载更多
......@@ -273,43 +273,39 @@ const performSearch = async (keyword, type, page = 0, limit = pageSize, isLoadMo
// 映射产品列表
const newProducts = res.data.products.list || []
// 映射资料列表(进行字段映射,与首页保持一致)
const newFiles = (res.data.files.list || []).map(item => {
return {
id: item.meta_id || item.id,
title: item.name,
fileName: item.name || '未命名文件',
fileSize: item.size || item.file_size,
downloadUrl: item.src || item.value,
// 不手动提取 extension,让 MaterialCard 内部使用 extractExtensionFromFile 自动从 URL 解析
learners: item.read_people_count ? `${item.read_people_count }人学习` : '',
readPeoplePercent: item.read_people_percent,
is_favorite: item.is_favorite, // 保留原始字段
collected: Boolean(item.is_favorite) // 转换为 Boolean 供 MaterialCard 使用
}
})
// 映射文章列表(参考本周热门文章的映射方式)
const newArticles = (res.data.article.list || []).map(item => ({
id: item.id,
title: item.post_title || '未命名文章',
excerpt: item.post_excerpt || '',
coverUrl: '', // API 未返回封面图,留空
date: item.post_date || '',
collected: item.is_favorite === 1 || item.is_favorite === '1' || item.is_favorite === true,
learners: '', // API 未返回学习人数,留空
readPeoplePercent: null // API 未返回热度百分比,留空
}))
// 根据是否为加载更多来处理数据
if (isLoadMore) {
// 加载更多:追加数据
products.value = [...products.value, ...newProducts]
files.value = [...files.value, ...newFiles]
articles.value = [...articles.value, ...newArticles]
} else {
// 首次加载或刷新:替换数据
products.value = newProducts
files.value = newFiles
articles.value = newArticles
}
productsTotal.value = res.data.products.total || 0
filesTotal.value = res.data.files.total || 0
articlesTotal.value = res.data.article.total || 0
// ⚠️ 重要:必须先自动选择 tab,然后再计算 hasMore
// 如果不传 type,自动选择有数据的 tab(仅首次搜索时)
if (!type && !isLoadMore) {
if (productsTotal.value > 0) {
activeTab.value = 'product'
} else if (filesTotal.value > 0) {
activeTab.value = 'file'
} else if (articlesTotal.value > 0) {
activeTab.value = 'article'
}
// 如果都为 0,默认 product
}
......@@ -320,8 +316,8 @@ const performSearch = async (keyword, type, page = 0, limit = pageSize, isLoadMo
const actualTab = type || activeTab.value
if (actualTab === 'product') {
hasMore.value = products.value.length < productsTotal.value
} else if (actualTab === 'file') {
hasMore.value = files.value.length < filesTotal.value
} else if (actualTab === 'article') {
hasMore.value = articles.value.length < articlesTotal.value
} else {
// 如果都没有选中,保守设置为false
hasMore.value = false
......@@ -331,7 +327,7 @@ const performSearch = async (keyword, type, page = 0, limit = pageSize, isLoadMo
console.log('[Search] 搜索成功', {
productsTotal: productsTotal.value,
filesTotal: filesTotal.value,
articlesTotal: articlesTotal.value,
activeTab: activeTab.value,
isLoadMore,
hasMore: hasMore.value
......@@ -437,9 +433,9 @@ const clearSearch = () => {
searchKeyword.value = ''
hasSearched.value = false
products.value = []
files.value = []
articles.value = []
productsTotal.value = 0
filesTotal.value = 0
articlesTotal.value = 0
activeTab.value = '' // 重置为空,不选中任何tab
currentPage.value = 0
hasMore.value = true
......@@ -500,15 +496,15 @@ const { handlePlanSubmit } = usePlanSubmit({
/**
* 处理收藏状态改变
*
* @param {Object} item - 资料对象
* @param {Object} item - 文章对象
* @param {Object} newStatus - 新的状态 { collected: boolean }
*/
const handleCollectChanged = (item, newStatus) => {
console.log('[Search] 收藏状态改变:', item.title, newStatus.collected)
// 找到对应的项并更新状态
const file = files.value.find(f => f.id === item.id)
if (file) {
file.collected = newStatus.collected
const article = articles.value.find(a => a.id === item.id)
if (article) {
article.collected = newStatus.collected
}
}
</script>
......
......@@ -516,9 +516,9 @@ export async function mockSearchAPI(params) {
if (!keyword) {
// 🔧 优化:如果没有关键词,返回更多推荐数据用于测试
// 生成20个产品和20个资料作为默认搜索结果
// 生成20个产品和20个文章作为默认搜索结果
const defaultProducts = []
const defaultFiles = []
const defaultArticles = []
for (let i = 0; i < 20; i++) {
const productItem = generateProductItem(i + 1)
......@@ -528,32 +528,17 @@ export async function mockSearchAPI(params) {
cover_image: productItem.cover_image
})
const materialItem = generateMaterialItem(i + 1)
// 确保使用真实的测试文件地址
const testFileUrl = getTestFileUrl(materialItem.extension, i)
defaultFiles.push({
...materialItem,
id: i + 1,
fileName: materialItem.fileName,
fileSize: materialItem.size,
learners: `${materialItem.read_people_count}人学习`,
readPeoplePercent: materialItem.read_people_percent,
collected: materialItem.collected,
extension: materialItem.extension,
downloadUrl: testFileUrl, // 覆盖为真实的测试文件地址
title: materialItem.title,
src: materialItem.src
})
const articleItem = generateArticleItem(i + 1)
defaultArticles.push(articleItem)
}
console.log(`[Mock] searchAPI - 无关键词,返回默认数据:产品${defaultProducts.length}条,资料${defaultFiles.length}条`)
console.log(`[Mock] searchAPI - 无关键词,返回默认数据:产品${defaultProducts.length}条,文章${defaultArticles.length}条`)
return {
code: 1,
msg: 'success',
data: {
products: { list: defaultProducts, total: defaultProducts.length * 5 },
files: { list: defaultFiles, total: defaultFiles.length * 5 }
article: { list: defaultArticles, total: defaultArticles.length * 5 }
}
}
}
......@@ -566,13 +551,13 @@ export async function mockSearchAPI(params) {
msg: 'success',
data: {
products: { list: [], total: 0 },
files: { list: [], total: 0 }
article: { list: [], total: 0 }
}
}
}
const products = []
const files = []
const articles = []
const startIndex = page * limit
// 🔧 优化:每次循环都生成数据和尝试匹配,增加命中率
......@@ -598,35 +583,20 @@ export async function mockSearchAPI(params) {
})
}
// 资料
const materialItem = generateMaterialItem(startIndex + i + 100)
const materialName = materialItem.name.toLowerCase()
const hasAnyCharMaterial = keywords.length > 0 && keywords.some(k => materialName.includes(k))
const hasBigramMaterial = searchKeyword.length >= 2 && keywords.slice(0, -1).some((k, idx) => materialName.includes(k + keywords[idx + 1]))
if (materialName.includes(searchKeyword) || hasAnyCharMaterial || hasBigramMaterial) {
// 确保使用真实的测试文件地址
const testFileUrl = getTestFileUrl(materialItem.extension, startIndex + i + 100)
files.push({
...materialItem,
id: startIndex + i + 100,
fileName: materialItem.fileName,
fileSize: materialItem.size,
learners: `${materialItem.read_people_count}人学习`,
readPeoplePercent: materialItem.read_people_percent,
collected: materialItem.collected,
extension: materialItem.extension,
downloadUrl: testFileUrl, // 覆盖为真实的测试文件地址
title: materialItem.title,
src: materialItem.src
})
// 文章
const articleItem = generateArticleItem(startIndex + i + 100)
const articleTitle = articleItem.post_title.toLowerCase()
const hasAnyCharArticle = keywords.length > 0 && keywords.some(k => articleTitle.includes(k))
const hasBigramArticle = searchKeyword.length >= 2 && keywords.slice(0, -1).some((k, idx) => articleTitle.includes(k + keywords[idx + 1]))
if (articleTitle.includes(searchKeyword) || hasAnyCharArticle || hasBigramArticle) {
articles.push(articleItem)
}
}
// 🔧 优化:如果没有匹配到任何数据,返回一些推荐数据
if (products.length === 0 && files.length === 0) {
if (products.length === 0 && articles.length === 0) {
console.log(`[Mock] searchAPI - 无匹配结果,返回推荐数据`)
// 生成 5 个推荐产品
for (let i = 0; i < 5; i++) {
......@@ -637,34 +607,19 @@ export async function mockSearchAPI(params) {
cover_image: productItem.cover_image
})
const materialItem = generateMaterialItem(startIndex + i + 100)
// 确保使用真实的测试文件地址
const testFileUrl = getTestFileUrl(materialItem.extension, startIndex + i + 100)
files.push({
...materialItem,
id: startIndex + i + 100,
fileName: materialItem.fileName,
fileSize: materialItem.size,
learners: `${materialItem.read_people_count}人学习`,
readPeoplePercent: materialItem.read_people_percent,
collected: materialItem.collected,
extension: materialItem.extension,
downloadUrl: testFileUrl, // 覆盖为真实的测试文件地址
title: materialItem.title,
src: materialItem.src
})
const articleItem = generateArticleItem(startIndex + i + 100)
articles.push(articleItem)
}
}
console.log(`[Mock] searchAPI - 第${page}页,关键词"${keyword}",产品${products.length}条,资料${files.length}条`)
console.log(`[Mock] searchAPI - 第${page}页,关键词"${keyword}",产品${products.length}条,文章${articles.length}条`)
return {
code: 1,
msg: 'success',
data: {
products: { list: products, total: products.length * totalPages },
files: { list: files, total: files.length * totalPages }
article: { list: articles, total: articles.length * totalPages }
}
}
}
......