hookehuyr

feat(list): 新增资料列表操作组件并统一页面样式

- 新增 ListItemActions 组件,支持查看、收藏、删除操作
- 修复资料列表页布局问题(图标和文字水平排列)
- 统一首页和资料列表页的资料项样式和交互
- 更新收藏页、知识库页、计划页使用新组件
- 添加完整的组件文档和类型定义

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
......@@ -10,14 +10,10 @@ declare module 'vue' {
DocumentPreview: typeof import('./src/components/DocumentPreview/index.vue')['default']
IconFont: typeof import('./src/components/IconFont.vue')['default']
IndexNav: typeof import('./src/components/indexNav.vue')['default']
ListItemActions: typeof import('./src/components/ListItemActions/index.vue')['default']
NavHeader: typeof import('./src/components/NavHeader.vue')['default']
NutAvatar: typeof import('@nutui/nutui-taro')['Avatar']
NutButton: typeof import('@nutui/nutui-taro')['Button']
NutCheckbox: typeof import('@nutui/nutui-taro')['Checkbox']
NutCheckboxGroup: typeof import('@nutui/nutui-taro')['CheckboxGroup']
NutForm: typeof import('@nutui/nutui-taro')['Form']
NutFormItem: typeof import('@nutui/nutui-taro')['FormItem']
NutIcon: typeof import('@nutui/nutui-taro')['Icon']
NutInput: typeof import('@nutui/nutui-taro')['Input']
NutPicker: typeof import('@nutui/nutui-taro')['Picker']
NutPopup: typeof import('@nutui/nutui-taro')['Popup']
......@@ -26,7 +22,6 @@ declare module 'vue' {
NutSearchbar: typeof import('@nutui/nutui-taro')['Searchbar']
NutTabPane: typeof import('@nutui/nutui-taro')['TabPane']
NutTabs: typeof import('@nutui/nutui-taro')['Tabs']
NutTextarea: typeof import('@nutui/nutui-taro')['Textarea']
NutUploader: typeof import('@nutui/nutui-taro')['Uploader']
OfficeViewer: typeof import('./src/components/OfficeViewer.vue')['default']
PdfPreview: typeof import('./src/components/PdfPreview.vue')['default']
......
<!--
列表项操作按钮组件
@description 统一的列表项操作按钮组件,支持查看、收藏、删除三种操作
@example
<ListItemActions
:viewable="true"
:collectable="true"
:deletable="true"
:collected="item.collected"
@view="onView(item)"
@collect="onCollect(item)"
@delete="onDelete(item)"
/>
-->
<template>
<view class="flex justify-end gap-[24rpx]">
<!-- 查看按钮 -->
<view v-if="viewable" class="flex items-center text-blue-600" @tap="handleView">
<IconFont name="eye" size="14" class="mr-[8rpx]" />
<text class="text-[24rpx]">查看</text>
</view>
<!-- 收藏按钮 -->
<view v-if="collectable" class="flex items-center" :class="isCollected ? 'text-red-500' : 'text-gray-400'" @tap="handleCollect">
<IconFont :name="isCollected ? 'heart-fill' : 'heart'" size="14" class="mr-[8rpx]" />
<text class="text-[24rpx]">{{ isCollected ? '已收藏' : '收藏' }}</text>
</view>
<!-- 删除按钮 -->
<view v-if="deletable" class="flex items-center text-red-500" @tap="handleDelete">
<IconFont name="del" size="14" class="mr-[8rpx]" />
<text class="text-[24rpx]">删除</text>
</view>
</view>
</template>
<script setup>
import { computed } from 'vue'
import IconFont from '@/components/IconFont.vue'
/**
* 组件属性
*/
const props = defineProps({
/**
* 是否显示查看按钮
* @type {boolean}
* @default true
*/
viewable: {
type: Boolean,
default: true
},
/**
* 是否显示收藏按钮
* @type {boolean}
* @default false
*/
collectable: {
type: Boolean,
default: false
},
/**
* 是否显示删除按钮
* @type {boolean}
* @default false
*/
deletable: {
type: Boolean,
default: false
},
/**
* 是否已收藏
* @type {boolean}
* @default false
*/
collected: {
type: Boolean,
default: false
}
})
/**
* 组件事件
*/
const emit = defineEmits({
/**
* 点击查看按钮时触发
*/
view: null,
/**
* 点击收藏按钮时触发
*/
collect: null,
/**
* 点击删除按钮时触发
*/
delete: null
})
const isCollected = computed(() => props.collected)
/**
* 处理查看点击
*/
const handleView = () => {
emit('view')
}
/**
* 处理收藏点击
*/
const handleCollect = () => {
emit('collect')
}
/**
* 处理删除点击
*/
const handleDelete = () => {
emit('delete')
}
</script>
<style lang="less" scoped>
</style>
......@@ -43,16 +43,12 @@
<view class="h-[1rpx] bg-gray-100 mb-[20rpx]"></view>
<!-- Actions -->
<view class="flex justify-end gap-[24rpx]">
<view class="flex items-center text-blue-600" @tap="viewFile({...item, fileName: item.title})">
<IconFont name="eye" size="14" class="mr-[8rpx]" />
<text class="text-[24rpx]">查看</text>
</view>
<view class="flex items-center text-red-500" @tap="onDelete(item)">
<IconFont name="del" size="14" class="mr-[8rpx]" />
<text class="text-[24rpx]">删除</text>
</view>
</view>
<ListItemActions
:viewable="true"
:deletable="true"
@view="viewFile({...item, fileName: item.title})"
@delete="onDelete(item)"
/>
</view>
<!-- Empty State -->
......@@ -77,6 +73,7 @@ import { getDocumentIcon } from '@/utils/documentIcons'
import IconFont from '@/components/IconFont.vue'
import TabBar from '@/components/TabBar.vue'
import NavHeader from '@/components/NavHeader.vue'
import ListItemActions from '@/components/ListItemActions/index.vue'
const go = useGo()
const { viewFile } = useFileOperation()
......
......@@ -126,61 +126,40 @@
</view>
<!-- Material List -->
<view class="flex flex-col gap-[32rpx]">
<!-- Item 1 -->
<view class="flex gap-[24rpx]" @tap="handleMaterialClick(hotMaterials[0])">
<view class="w-[80rpx] h-[88rpx] flex-shrink-0 flex items-center justify-center bg-blue-50 rounded-[12rpx]">
<image :src="getDocumentIcon(hotMaterials[0].fileName)" class="w-[48rpx] h-[48rpx]" mode="aspectFit" />
</view>
<view class="flex-1 flex flex-col justify-between py-[4rpx]">
<text class="text-gray-800 text-[28rpx] leading-[40rpx] line-clamp-2 mb-1">{{ hotMaterials[0].title }}</text>
<view class="flex items-center gap-2 mb-1">
<view class="bg-blue-50 rounded px-2 py-0.5">
<text class="text-blue-600 text-[22rpx]">{{ getDocumentLabel(hotMaterials[0].fileName) }}</text>
</view>
</view>
<view class="flex justify-between items-end">
<text class="text-gray-400 text-[24rpx]">{{ hotMaterials[0].learners }}</text>
<text class="text-blue-600 text-[26rpx]">{{ hotMaterials[0].progress }}</text>
</view>
</view>
</view>
<view class="h-[2rpx] bg-gray-100"></view>
<!-- Item 2 -->
<view class="flex gap-[24rpx]" @tap="handleMaterialClick(hotMaterials[1])">
<view class="w-[80rpx] h-[88rpx] flex-shrink-0 flex items-center justify-center bg-blue-50 rounded-[12rpx]">
<image :src="getDocumentIcon(hotMaterials[1].fileName)" class="w-[48rpx] h-[48rpx]" mode="aspectFit" />
</view>
<view class="flex-1 flex flex-col justify-between py-[4rpx]">
<text class="text-gray-800 text-[28rpx] leading-[40rpx] line-clamp-2 mb-1">{{ hotMaterials[1].title }}</text>
<view class="flex items-center gap-2 mb-1">
<view class="bg-blue-50 rounded px-2 py-0.5">
<text class="text-blue-600 text-[22rpx]">{{ getDocumentLabel(hotMaterials[1].fileName) }}</text>
</view>
</view>
<view class="flex justify-between items-end">
<text class="text-gray-400 text-[24rpx]">{{ hotMaterials[1].learners }}</text>
<text class="text-blue-600 text-[26rpx]">{{ hotMaterials[1].progress }}</text>
</view>
</view>
</view>
<view class="h-[2rpx] bg-gray-100"></view>
<!-- Item 3 -->
<view class="flex gap-[24rpx]" @tap="handleMaterialClick(hotMaterials[2])">
<view class="w-[80rpx] h-[88rpx] flex-shrink-0 flex items-center justify-center bg-blue-50 rounded-[12rpx]">
<image :src="getDocumentIcon(hotMaterials[2].fileName)" class="w-[48rpx] h-[48rpx]" mode="aspectFit" />
<view class="flex flex-col gap-[24rpx]">
<!-- Material Items -->
<view v-for="(item, index) in hotMaterials" :key="index"
class="flex flex-row bg-white rounded-[24rpx] p-[24rpx] border border-gray-50">
<!-- 左侧图标 -->
<view class="w-[88rpx] h-[88rpx] mr-[24rpx] flex-shrink-0 flex items-center justify-center bg-gradient-to-br from-blue-50 to-blue-100 rounded-[20rpx] shadow-inner self-start">
<image :src="getDocumentIcon(item.fileName)" class="w-[48rpx] h-[48rpx]" mode="aspectFit" />
</view>
<view class="flex-1 flex flex-col justify-between py-[4rpx]">
<text class="text-gray-800 text-[28rpx] leading-[40rpx] line-clamp-2 mb-1">{{ hotMaterials[2].title }}</text>
<view class="flex items-center gap-2 mb-1">
<view class="bg-blue-50 rounded px-2 py-0.5">
<text class="text-blue-600 text-[22rpx]">{{ getDocumentLabel(hotMaterials[2].fileName) }}</text>
<!-- 内容区域 -->
<view class="flex-1 min-w-0">
<text class="text-[#1F2937] text-[30rpx] font-bold leading-[1.4] line-clamp-2 mb-[8rpx]">
{{ item.title }}
</text>
<view class="flex items-center gap-[12rpx] mb-[16rpx]">
<view class="inline-flex items-center justify-center px-[12rpx] py-[4rpx] bg-gray-100 text-gray-500 text-[20rpx] font-medium rounded-[8rpx]">
<text>{{ getDocumentLabel(item.fileName) }}</text>
</view>
<text class="text-[#9CA3AF] text-[22rpx]">{{ item.learners }}</text>
</view>
<view class="flex justify-between items-end">
<text class="text-gray-400 text-[24rpx]">{{ hotMaterials[2].learners }}</text>
<text class="text-blue-600 text-[26rpx]">{{ hotMaterials[2].progress }}</text>
</view>
<!-- 分割线 -->
<view class="h-[1rpx] bg-gray-100 my-[20rpx]"></view>
<!-- 操作按钮 -->
<ListItemActions
:viewable="true"
:collectable="true"
:deletable="false"
:collected="item.collected"
@view="onViewMaterial(item)"
@collect="toggleMaterialCollect(item)"
/>
</view>
</view>
</view>
......@@ -217,6 +196,7 @@ import IconFont from '@/components/IconFont.vue';
import PlanPopup from '@/components/PlanPopup/index.vue';
import SchemeA from '@/components/PlanSchemes/SchemeA.vue';
import SchemeB from '@/components/PlanSchemes/SchemeB.vue';
import ListItemActions from '@/components/ListItemActions/index.vue';
// Plan Popup State
const showPlanPopup = ref(false);
......@@ -256,6 +236,7 @@ const hotMaterials = ref([
title: '2024年保险市场趋势分析报告',
learners: '256人学习',
progress: '78%',
collected: false,
// PDF 文件
fileName: '2024年保险市场趋势分析报告.pdf',
downloadUrl: 'https://cdn.ipadbiz.cn/manulife/document/1_%E7%BE%8E%E4%B9%90%E7%88%B1%E8%A7%89%E6%95%99%E8%82%B22024%E9%A1%B9%E7%9B%AE%E5%9B%BE%E5%BD%B1%E4%BB%8B%E7%BB%8D_.pdf'
......@@ -264,6 +245,7 @@ const hotMaterials = ref([
title: '高净值客户产品配置方案模板',
learners: '189人学习',
progress: '65%',
collected: true,
// Word 文件
fileName: '高净值客户产品配置方案模板.docx',
downloadUrl: 'https://cdn.ipadbiz.cn/manulife/document/1_%E7%BE%8E%E4%B9%90%E7%88%B1%E8%A7%89%E6%95%99%E8%82%B22024%E9%A1%B9%E7%9B%AE%E5%9B%BE%E5%BD%B1%E4%BB%8B%E7%BB%8D_.pdf'
......@@ -272,6 +254,7 @@ const hotMaterials = ref([
title: '产品收益率测算表(2024版)',
learners: '142人学习',
progress: '52%',
collected: false,
// Excel 文件
fileName: '产品收益率测算表.xlsx',
downloadUrl: 'https://cdn.ipadbiz.cn/manulife/document/1_%E7%BE%8E%E4%B9%90%E7%88%B1%E8%A7%89%E6%95%99%E8%82%B22024%E9%A1%B9%E7%9B%AE%E5%9B%BE%E5%BD%B1%E4%BB%8B%E7%BB%8D_.pdf'
......@@ -286,13 +269,28 @@ const go = useGo();
*
* @description 配置为文件类型列表,点击时打开文件预览
*/
const { handleClick: handleMaterialClick } = useListItemClick({
const { handleClick: onViewMaterial } = useListItemClick({
listType: ListType.FILE,
onAfterClick: (item) => {
console.log('用户打开了资料:', item.title);
}
});
/**
* 切换资料收藏状态
*
* @description 切换热门资料的收藏状态
* @param {Object} item - 资料项
*/
const toggleMaterialCollect = (item) => {
item.collected = !item.collected;
Taro.showToast({
title: item.collected ? '已收藏' : '已取消收藏',
icon: 'success',
duration: 1000
});
};
// Handle grid navigation click
const handleGridNav = (item) => {
if (!item.route) {
......
......@@ -45,7 +45,8 @@
</div>
<!-- Desc -->
<div class="mt-auto self-start bg-[#F3F4F6] text-[#6B7280] text-[22rpx] px-[12rpx] py-[4rpx] rounded-full">
<div class="mt-auto self-start text-[22rpx] px-[12rpx] py-[4rpx] rounded-full"
:class="[getDescColor(item.id).bg, getDescColor(item.id).text]">
{{ item.desc }}
</div>
</div>
......@@ -68,6 +69,34 @@ const activeTab = ref(0)
const tabs = ['全部产品', '人寿保险', '医疗保险', '意外保险']
/**
* Desc 颜色调色板
*
* @description 为不同描述提供柔和的背景色和对应的文字颜色
*/
const descColorPalette = [
{ bg: 'bg-blue-50', text: 'text-blue-600' }, // 蓝色
{ bg: 'bg-green-50', text: 'text-green-600' }, // 绿色
{ bg: 'bg-purple-50', text: 'text-purple-600' }, // 紫色
{ bg: 'bg-orange-50', text: 'text-orange-600' }, // 橙色
{ bg: 'bg-pink-50', text: 'text-pink-600' }, // 粉色
{ bg: 'bg-teal-50', text: 'text-teal-600' }, // 青色
{ bg: 'bg-indigo-50', text: 'text-indigo-600' }, // 靛蓝色
{ bg: 'bg-red-50', text: 'text-red-600' }, // 红色
]
/**
* 获取 desc 的颜色样式
*
* @description 根据 ID 获取固定的背景色和文字颜色
* @param {number} id - 产品 ID
* @returns {Object} 包含 bg 和 text 属性的颜色对象
*/
const getDescColor = (id) => {
const index = id % descColorPalette.length
return descColorPalette[index]
}
/**
* 生成产品数据
*
* @description 生成模拟产品列表数据,每个产品包含唯一 id
......
This diff is collapsed. Click to expand it.
......@@ -23,7 +23,7 @@
</view>
<!-- Arrow -->
<IconFont name="rectRight" size="20" color="#9CA3AF" />
<IconFont name="rect-right" size="20" color="#9CA3AF" />
</view>
<!-- Menu List -->
......
......@@ -53,16 +53,12 @@
<view class="h-[1rpx] bg-gray-100 my-[20rpx]"></view>
<!-- Actions -->
<view class="flex justify-end gap-[24rpx]">
<view class="flex items-center text-blue-600" @tap="onView(item)">
<IconFont name="eye" size="14" class="mr-[8rpx]" />
<text class="text-[24rpx]">查看</text>
</view>
<view class="flex items-center text-red-500" @tap="onDelete(item)">
<IconFont name="del" size="14" class="mr-[8rpx]" />
<text class="text-[24rpx]">删除</text>
</view>
</view>
<ListItemActions
:viewable="true"
:deletable="true"
@view="onView(item)"
@delete="onDelete(item)"
/>
</view>
<!-- Empty State -->
......@@ -85,6 +81,7 @@ import { useFileOperation } from '@/composables/useFileOperation'
import IconFont from '@/components/IconFont.vue'
import TabBar from '@/components/TabBar.vue'
import NavHeader from '@/components/NavHeader.vue'
import ListItemActions from '@/components/ListItemActions/index.vue'
import Taro from '@tarojs/taro'
const go = useGo()
......