| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811 |
- <template>
- <view class="timeline-container">
- <!-- 加载状态 -->
- <view v-if="loading && items.length === 0" class="loading-mask">
- <view class="loading-content">
- <uni-icons type="spinner-cycle" size="30" color="#007aff" class="spin"></uni-icons>
- <text class="loading-text">{{ t('common.loading') }}</text>
- </view>
- </view>
- <!-- 时间线列表 -->
- <scroll-view v-else class="timeline-scroll" scroll-y :scroll-top="scrollTop" @scrolltolower="handleLoadMore"
- :lower-threshold="lowerThreshold" :refresher-enabled="enableRefresh" :refresher-triggered="refreshing"
- @refresherrefresh="handleRefresh">
- <view class="card">
- <view class="timeline" :style="{ '--size': size }">
- <view v-for="(item, index) in items" :key="item.id || index" class="timeline-item"
- @click="handleItemClick(item)">
- <!-- 时间点图标 -->
- <view class="timeline-item-point" :class="[
- `text-${getItemColor(item)}`,
- { 'bg-white': !getItemSolid(item), 'solid': getItemSolid(item) }
- ]">
- <view class="icon-wrapper"
- :style="{ backgroundColor: getItemSolid(item) ? getColorValue(getItemColor(item)) : 'transparent' }">
- <uni-icons :type="getItemIcon(item)" :size="14"
- :color="getItemSolid(item) ? '#fff' : getColorValue(getItemColor(item))"></uni-icons>
- </view>
- </view>
- <!-- 内容区域 -->
- <view class="timeline-item-content">
- <view class="timeline-item-header">
- <text class="title">{{ getItemTitle(item) }}</text>
- <text class="time">{{ formatTime(getItemTime(item)) }}</text>
- </view>
- <view class="content-description">{{ getItemDescription(item) }}</view>
- <!-- 标签区域 -->
- <view v-if="getItemTags(item) && getItemTags(item).length" class="tags-container"
- @click.stop>
- <view class="tags-label">{{ t('common.category') }}:</view>
- <view v-for="(tag, tagIndex) in getItemTags(item)" :key="tagIndex" class="tag"
- @click="handleTagClick(tag, item)">
- #{{ tag }}
- </view>
- </view>
- <!-- 额外信息插槽 -->
- <slot name="extra" :item="item" :index="index"></slot>
- </view>
- </view>
- </view>
- <!-- 加载更多状态 -->
- <view v-if="loadingMore" class="loading-more">
- <uni-icons type="spinner-cycle" size="20" color="#999" class="spin"></uni-icons>
- <text>{{ t('common.loadingMore') }}</text>
- </view>
- <!-- 没有更多数据 -->
- <view v-if="!hasMore && items.length > 0" class="no-more">
- <text>{{ t('common.noMore') }}</text>
- </view>
- <!-- 空数据 -->
- <view v-if="items.length === 0 && !loading" class="empty-data">
- <uni-icons type="info" size="40" color="#999"></uni-icons>
- <text>{{ t('common.noData') }}</text>
- </view>
- </view>
- </scroll-view>
- <!-- 回到顶部按钮 -->
- <view v-if="showBackToTop && scrollTop > 300" class="back-to-top" @click="scrollToTop">
- <uni-icons type="arrow-up" size="20" color="#fff"></uni-icons>
- </view>
- </view>
- </template>
- <script setup lang="ts">
- import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
- import { useI18n } from 'vue-i18n'
- const { t } = useI18n()
- const props = defineProps({
- // API 请求函数
- api: {
- type: Function,
- required: true
- },
- // 查询参数
- queryParams: {
- type: Object,
- default: () => ({})
- },
- // 每页条数
- pageSize: {
- type: Number,
- default: 10
- },
- // 初始页码
- initialPage: {
- type: Number,
- default: 1
- },
- // 是否立即加载
- immediate: {
- type: Boolean,
- default: true
- },
- // 时间点大小
- size: {
- type: String,
- default: '1.5rem'
- },
- // 滚动阈值
- lowerThreshold: {
- type: Number,
- default: 100
- },
- // 是否启用下拉刷新
- enableRefresh: {
- type: Boolean,
- default: true
- },
- // 标题
- title: {
- type: String,
- default: ''
- },
- // 是否显示标题栏
- showHeader: {
- type: Boolean,
- default: false
- },
- // 是否显示更多链接
- showMoreLink: {
- type: Boolean,
- default: false
- },
- // 更多链接跳转地址
- moreUrl: {
- type: String,
- default: ''
- },
- // 是否显示回到顶部按钮
- showBackToTop: {
- type: Boolean,
- default: true
- },
- // 数据映射函数(用于自定义数据格式)
- dataMapper: {
- type: Function,
- default: null
- },
- // 图标映射
- iconMap: {
- type: Object,
- default: () => ({
- 'user-edit': 'person',
- 'image': 'image',
- 'leaf': 'leaf',
- 'project-diagram': 'grid',
- 'history': 'clock',
- 'default': 'circle'
- })
- },
- // 颜色映射
- colorMap: {
- type: Object,
- default: () => ({
- 'primary': '#007aff',
- 'secondary': '#6c757d',
- 'success': '#4cd964',
- 'warning': '#ffcc00',
- 'error': '#ff3b30',
- 'info': '#5ac8fa'
- })
- }
- })
- const emit = defineEmits([
- 'tag-click',
- 'item-click',
- 'more-click',
- 'load-success',
- 'load-error',
- 'page-change'
- ])
- // ==================== 数据状态 ====================
- const items = ref<any[]>([])
- const currentPage = ref(props.initialPage)
- const hasMore = ref(true)
- const loading = ref(false)
- const loadingMore = ref(false)
- const refreshing = ref(false)
- const scrollTop = ref(0)
- const total = ref(0)
- // ==================== 工具函数 ====================
- // 获取项目图标
- const getItemIcon = (item: any): string => {
- if (props.dataMapper) {
- const mapped = props.dataMapper(item)
- return props.iconMap[mapped.icon] || props.iconMap.default
- }
- return props.iconMap[item.icon] || props.iconMap.default
- }
- // 获取项目颜色
- const getItemColor = (item: any): string => {
- if (props.dataMapper) {
- const mapped = props.dataMapper(item)
- return mapped.color || 'secondary'
- }
- return item.color || 'secondary'
- }
- // 获取项目是否实心
- const getItemSolid = (item: any): boolean => {
- if (props.dataMapper) {
- const mapped = props.dataMapper(item)
- return mapped.solid || false
- }
- return item.solid || false
- }
- // 获取项目标题
- const getItemTitle = (item: any): string => {
- if (props.dataMapper) {
- const mapped = props.dataMapper(item)
- return mapped.title || ''
- }
- return item.title || ''
- }
- // 获取项目描述
- const getItemDescription = (item: any): string => {
- if (props.dataMapper) {
- const mapped = props.dataMapper(item)
- return mapped.description || ''
- }
- return item.description || ''
- }
- // 获取项目时间
- const getItemTime = (item: any): string | Date | number => {
- if (props.dataMapper) {
- const mapped = props.dataMapper(item)
- return mapped.time || new Date()
- }
- return item.time || new Date()
- }
- // 获取项目标签
- const getItemTags = (item: any): string[] => {
- if (props.dataMapper) {
- const mapped = props.dataMapper(item)
- return mapped.tags || []
- }
- return item.tags || []
- }
- // 获取颜色值
- const getColorValue = (color: string): string => {
- return props.colorMap[color] || props.colorMap.secondary
- }
- // 格式化时间
- const formatTime = (time: string | Date | number): string => {
- if (!time) return ''
- const date = new Date(time)
- const now = new Date()
- const diff = now.getTime() - date.getTime()
- // 1分钟内
- if (diff < 60 * 1000) {
- return t('time.justNow')
- }
- // 1小时内
- if (diff < 60 * 60 * 1000) {
- const minutes = Math.floor(diff / (60 * 1000))
- return t('time.minutesAgo', { minutes })
- }
- // 24小时内
- if (diff < 24 * 60 * 60 * 1000) {
- const hours = Math.floor(diff / (60 * 60 * 1000))
- return t('time.hoursAgo', { hours })
- }
- // 7天内
- if (diff < 7 * 24 * 60 * 60 * 1000) {
- const days = Math.floor(diff / (24 * 60 * 60 * 1000))
- return t('time.daysAgo', { days })
- }
- // 超过7天显示具体日期
- const year = date.getFullYear()
- const month = String(date.getMonth() + 1).padStart(2, '0')
- const day = String(date.getDate()).padStart(2, '0')
- const hour = String(date.getHours()).padStart(2, '0')
- const minute = String(date.getMinutes()).padStart(2, '0')
- return `${year}-${month}-${day} ${hour}:${minute}`
- }
- // API 请求函数
- const fetchTimelineData = async (params: any) => {
- console.log('请求参数:', params)
- return new Promise((resolve) => {
- setTimeout(() => {
- // 模拟数据
- const mockData = [
- {
- id: 1,
- title: 'Dollar Index (ICE)',
- description: 'Dollar Index (ICE) Intraday: caution.',
- time: new Date(Date.now() - 12 * 60 * 1000),
- icon: 'user-edit',
- color: 'secondary',
- url: '/pages/detail/1', // 跳转链接
- tags: ['美元', '指数']
- },
- {
- id: 2,
- title: 'USD/CHF',
- description: 'USD/CHF Intraday: under pressure.',
- time: new Date(Date.now() - 60 * 60 * 1000),
- icon: 'image',
- color: 'primary',
- solid: true,
- page: '/pages/detail/2', // 内部页面
- tags: ['外汇', '瑞士法郎']
- },
- {
- id: 3,
- title: 'USD/CAD',
- description: 'USD/CAD Intraday: intraday support around 1.3545.',
- time: new Date(Date.now() - 3 * 60 * 60 * 1000),
- icon: 'leaf',
- color: 'success'
- },
- {
- id: 4,
- title: 'AUD/USD',
- description: 'AUD/USD Intraday: the downside prevails.',
- time: new Date(Date.now() - 24 * 60 * 60 * 1000),
- icon: 'project-diagram',
- color: 'warning'
- },
- {
- id: 5,
- title: 'Weekly Report',
- description: 'The weekly report was uploaded',
- time: new Date(Date.now() - 24 * 60 * 60 * 1000),
- icon: 'history',
- color: 'error'
- }
- ]
- // 分页处理
- const { page, pageSize } = params
- const start = (page - 1) * pageSize
- const end = start + pageSize
- const pageData = mockData.slice(start, end)
- resolve({
- code: 200,
- data: {
- list: pageData,
- total: mockData.length
- }
- })
- }, 800)
- })
- }
- // ==================== 数据加载 ====================
- const loadData = async (isRefresh: boolean = false) => {
- if (loading.value || loadingMore.value) return
- if (isRefresh) {
- refreshing.value = true
- currentPage.value = 1
- } else {
- loadingMore.value = true
- }
- loading.value = true
- try {
- const params = {
- page: currentPage.value,
- pageSize: props.pageSize,
- ...props.queryParams
- }
- const res = await fetchTimelineData(params)
- // 处理不同格式的返回数据
- let newItems: any[] = []
- let totalCount = 0
- if (res.code === 200 || res.code === 0) {
- const data = res.data || res
- if (Array.isArray(data)) {
- newItems = data
- totalCount = data.length
- } else if (data.list || data.records) {
- newItems = data.list || data.records
- totalCount = data.total || newItems.length
- } else {
- newItems = []
- }
- // 更新数据
- if (isRefresh) {
- items.value = newItems
- } else {
- items.value = [...items.value, ...newItems]
- }
- total.value = totalCount
- // 判断是否还有更多
- hasMore.value = newItems.length === props.pageSize
- emit('load-success', { data: res, isRefresh, page: currentPage.value })
- } else {
- throw new Error(res.message || '加载失败')
- }
- } catch (error: any) {
- console.error('加载失败:', error)
- uni.showToast({
- title: error.message || '加载失败',
- icon: 'none'
- })
- emit('load-error', { error, page: currentPage.value })
- } finally {
- loading.value = false
- loadingMore.value = false
- refreshing.value = false
- }
- }
- // 加载更多
- const handleLoadMore = () => {
- if (!hasMore.value || loadingMore.value || loading.value) return
- currentPage.value++
- emit('page-change', currentPage.value)
- loadData(false)
- }
- // 下拉刷新
- const handleRefresh = () => {
- loadData(true)
- }
- // ==================== 事件处理 ====================
- // 项目点击
- const handleItemClick = (item: any) => {
- emit('item-click', item)
- // 如果项目有跳转链接,自动跳转
- if (item.url) {
- // #ifdef H5
- window.open(item.url, item.target || '_self')
- // #endif
- // #ifndef H5
- if (item.url.startsWith('http')) {
- plus.runtime.openURL(item.url)
- } else {
- uni.navigateTo({ url: item.url })
- }
- // #endif
- } else if (item.page) {
- // 内部页面跳转
- uni.navigateTo({
- url: item.page,
- success: () => {
- console.log('跳转成功')
- },
- fail: (err) => {
- console.error('跳转失败:', err)
- uni.showToast({
- title: '页面不存在',
- icon: 'none'
- })
- }
- })
- }
- }
- // 标签点击
- const handleTagClick = (tag: string, item: any) => {
- emit('tag-click', { tag, item })
- // 可以在这里添加标签搜索等功能
- uni.showToast({
- title: `标签: ${tag}`,
- icon: 'none'
- })
- }
- // 更多点击
- const handleMoreClick = () => {
- emit('more-click')
- if (props.moreUrl) {
- // #ifdef H5
- window.open(props.moreUrl, '_self')
- // #endif
- // #ifndef H5
- if (props.moreUrl.startsWith('http')) {
- plus.runtime.openURL(props.moreUrl)
- } else {
- uni.navigateTo({ url: props.moreUrl })
- }
- // #endif
- }
- }
- // ==================== 滚动控制 ====================
- // 监听滚动
- const onScroll = (e: any) => {
- scrollTop.value = e.detail.scrollTop
- }
- // 回到顶部
- const scrollToTop = () => {
- scrollTop.value = 0
- uni.pageScrollTo({
- scrollTop: 0,
- duration: 300
- })
- }
- // ==================== 外部方法 ====================
- // 刷新数据
- const refresh = () => {
- loadData(true)
- }
- // 重置并刷新
- const reset = () => {
- items.value = []
- currentPage.value = 1
- hasMore.value = true
- loadData(true)
- }
- // 添加项目
- const addItem = (item: any) => {
- items.value.unshift(item)
- }
- // 删除项目
- const removeItem = (id: string | number) => {
- const index = items.value.findIndex(item => item.id === id)
- if (index !== -1) {
- items.value.splice(index, 1)
- }
- }
- // 更新项目
- const updateItem = (id: string | number, newData: any) => {
- const index = items.value.findIndex(item => item.id === id)
- if (index !== -1) {
- items.value[index] = { ...items.value[index], ...newData }
- }
- }
- // ==================== 生命周期 ====================
- // 监听查询参数变化
- watch(() => props.queryParams, () => {
- refresh()
- }, { deep: true })
- // 初始化加载
- onMounted(() => {
- if (props.immediate) {
- loadData(true)
- }
- })
- // 暴露方法给父组件
- defineExpose({
- refresh,
- reset,
- addItem,
- removeItem,
- updateItem,
- items,
- loading,
- hasMore,
- total,
- currentPage
- })
- </script>
- <style scoped lang="scss">
- @import "@/uni.scss";
- .timeline-container {
- max-width: px2rpx(576);
- min-width: px2rpx(20);
- padding: px2rpx(12);
- box-sizing: border-box;
- margin: 0 auto;
- background-color: rgba(var(--bs-body-bg-rgb), var(--bs-bg-opacity)) !important;
- .timeline-scroll {
- height: 100%;
- }
- .timeline-item {
- position: relative;
- display: flex;
- padding-bottom: px2rpx(32);
- &::before {
- content: '';
- position: absolute;
- left: px2rpx(12);
- top: 0;
- bottom: 0;
- width: px2rpx(2);
- background-color: var(--color-slate-300);
- transform: translateX(-50%);
- }
- &:last-child {
- padding-bottom: 0;
- }
- .timeline-item-point {
- position: relative;
- width: px2rpx(24);
- height: px2rpx(24);
- display: flex;
- align-items: center;
- justify-content: center;
- background-color: rgba(var(--bs-body-bg-rgb), var(--bs-bg-opacity)) !important;
- border: 1px solid currentColor;
- flex-shrink: 0;
- z-index: 1;
- &.solid {
- border: none;
- .icon-wrapper {
- width: 100%;
- height: 100%;
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- }
- // 颜色变体
- &.text-primary {
- color: #cf1322;
- border-color: #cf1322;
- }
- &.text-secondary {
- color: #6c757d;
- border-color: #6c757d;
- }
- &.text-success {
- color: #4cd964;
- border-color: #4cd964;
- }
- &.text-warning {
- color: #ffcc00;
- border-color: #ffcc00;
- }
- &.text-error {
- color: #ff3b30;
- border-color: #ff3b30;
- }
- &.text-info {
- color: #5ac8fa;
- border-color: #5ac8fa;
- }
- }
- .timeline-item-content {
- margin-left: px2rpx(32);
- flex: 1;
- .timeline-item-header {
- display: flex;
- justify-content: space-between;
- margin-bottom: px2rpx(8);
- .title {
- color: var(--color-slate-600);
- font-weight: 500;
- font-size: px2rpx(14);
- }
- .time {
- font-size: px2rpx(12);
- color: var(--color-slate-600);
- }
- }
- .content-description {
- color: var(--color-slate-900);
- font-size: px2rpx(14);
- padding: px2rpx(4) 0;
- }
- .tags-container {
- display: inline-flex;
- margin-top: px2rpx(16);
- flex-wrap: wrap;
- gap: px2rpx(8);
- font-size: px2rpx(14);
- .tag {
- color: var(--color-primary);
- cursor: pointer;
- &:hover {
- text-decoration: underline;
- }
- }
- }
- @media (min-width: 640px) {
- margin-left: px2rpx(40);
- }
- }
- }
- }
- // 加载状态
- .loading-mask {
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- background-color: rgba(255, 255, 255, 0.9);
- z-index: 100;
- }
- .loading-content {
- display: flex;
- flex-direction: column;
- align-items: center;
- padding: px2rpx(30);
- background-color: rgba(var(--bs-body-bg-rgb), var(--bs-bg-opacity)) !important;
- border-radius: px2rpx(16);
- box-shadow: 0 px2rpx(4) px2rpx(20) rgba(0, 0, 0, 0.1);
- }
- .loading-text {
- margin-top: px2rpx(20);
- font-size: px2rpx(28);
- color: var(--bs-heading-color);
- }
- .loading-more,
- .no-more,
- .empty-data {
- display: flex;
- align-items: center;
- justify-content: center;
- padding: px2rpx(30);
- font-size: px2rpx(28);
- color: var(--bs-heading-color);
- }
- .empty-data {
- flex-direction: column;
- gap: px2rpx(20);
- }
- .spin {
- animation: spin 1s linear infinite;
- margin-right: px2rpx(10);
- }
- @keyframes spin {
- from {
- transform: rotate(0deg);
- }
- to {
- transform: rotate(360deg);
- }
- }
- </style>
|