Timeline.vue 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811
  1. <template>
  2. <view class="timeline-container">
  3. <!-- 加载状态 -->
  4. <view v-if="loading && items.length === 0" class="loading-mask">
  5. <view class="loading-content">
  6. <uni-icons type="spinner-cycle" size="30" color="#007aff" class="spin"></uni-icons>
  7. <text class="loading-text">{{ t('common.loading') }}</text>
  8. </view>
  9. </view>
  10. <!-- 时间线列表 -->
  11. <scroll-view v-else class="timeline-scroll" scroll-y :scroll-top="scrollTop" @scrolltolower="handleLoadMore"
  12. :lower-threshold="lowerThreshold" :refresher-enabled="enableRefresh" :refresher-triggered="refreshing"
  13. @refresherrefresh="handleRefresh">
  14. <view class="card">
  15. <view class="timeline" :style="{ '--size': size }">
  16. <view v-for="(item, index) in items" :key="item.id || index" class="timeline-item"
  17. @click="handleItemClick(item)">
  18. <!-- 时间点图标 -->
  19. <view class="timeline-item-point" :class="[
  20. `text-${getItemColor(item)}`,
  21. { 'bg-white': !getItemSolid(item), 'solid': getItemSolid(item) }
  22. ]">
  23. <view class="icon-wrapper"
  24. :style="{ backgroundColor: getItemSolid(item) ? getColorValue(getItemColor(item)) : 'transparent' }">
  25. <uni-icons :type="getItemIcon(item)" :size="14"
  26. :color="getItemSolid(item) ? '#fff' : getColorValue(getItemColor(item))"></uni-icons>
  27. </view>
  28. </view>
  29. <!-- 内容区域 -->
  30. <view class="timeline-item-content">
  31. <view class="timeline-item-header">
  32. <text class="title">{{ getItemTitle(item) }}</text>
  33. <text class="time">{{ formatTime(getItemTime(item)) }}</text>
  34. </view>
  35. <view class="content-description">{{ getItemDescription(item) }}</view>
  36. <!-- 标签区域 -->
  37. <view v-if="getItemTags(item) && getItemTags(item).length" class="tags-container"
  38. @click.stop>
  39. <view class="tags-label">{{ t('common.category') }}:</view>
  40. <view v-for="(tag, tagIndex) in getItemTags(item)" :key="tagIndex" class="tag"
  41. @click="handleTagClick(tag, item)">
  42. #{{ tag }}
  43. </view>
  44. </view>
  45. <!-- 额外信息插槽 -->
  46. <slot name="extra" :item="item" :index="index"></slot>
  47. </view>
  48. </view>
  49. </view>
  50. <!-- 加载更多状态 -->
  51. <view v-if="loadingMore" class="loading-more">
  52. <uni-icons type="spinner-cycle" size="20" color="#999" class="spin"></uni-icons>
  53. <text>{{ t('common.loadingMore') }}</text>
  54. </view>
  55. <!-- 没有更多数据 -->
  56. <view v-if="!hasMore && items.length > 0" class="no-more">
  57. <text>{{ t('common.noMore') }}</text>
  58. </view>
  59. <!-- 空数据 -->
  60. <view v-if="items.length === 0 && !loading" class="empty-data">
  61. <uni-icons type="info" size="40" color="#999"></uni-icons>
  62. <text>{{ t('common.noData') }}</text>
  63. </view>
  64. </view>
  65. </scroll-view>
  66. <!-- 回到顶部按钮 -->
  67. <view v-if="showBackToTop && scrollTop > 300" class="back-to-top" @click="scrollToTop">
  68. <uni-icons type="arrow-up" size="20" color="#fff"></uni-icons>
  69. </view>
  70. </view>
  71. </template>
  72. <script setup lang="ts">
  73. import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
  74. import { useI18n } from 'vue-i18n'
  75. const { t } = useI18n()
  76. const props = defineProps({
  77. // API 请求函数
  78. api: {
  79. type: Function,
  80. required: true
  81. },
  82. // 查询参数
  83. queryParams: {
  84. type: Object,
  85. default: () => ({})
  86. },
  87. // 每页条数
  88. pageSize: {
  89. type: Number,
  90. default: 10
  91. },
  92. // 初始页码
  93. initialPage: {
  94. type: Number,
  95. default: 1
  96. },
  97. // 是否立即加载
  98. immediate: {
  99. type: Boolean,
  100. default: true
  101. },
  102. // 时间点大小
  103. size: {
  104. type: String,
  105. default: '1.5rem'
  106. },
  107. // 滚动阈值
  108. lowerThreshold: {
  109. type: Number,
  110. default: 100
  111. },
  112. // 是否启用下拉刷新
  113. enableRefresh: {
  114. type: Boolean,
  115. default: true
  116. },
  117. // 标题
  118. title: {
  119. type: String,
  120. default: ''
  121. },
  122. // 是否显示标题栏
  123. showHeader: {
  124. type: Boolean,
  125. default: false
  126. },
  127. // 是否显示更多链接
  128. showMoreLink: {
  129. type: Boolean,
  130. default: false
  131. },
  132. // 更多链接跳转地址
  133. moreUrl: {
  134. type: String,
  135. default: ''
  136. },
  137. // 是否显示回到顶部按钮
  138. showBackToTop: {
  139. type: Boolean,
  140. default: true
  141. },
  142. // 数据映射函数(用于自定义数据格式)
  143. dataMapper: {
  144. type: Function,
  145. default: null
  146. },
  147. // 图标映射
  148. iconMap: {
  149. type: Object,
  150. default: () => ({
  151. 'user-edit': 'person',
  152. 'image': 'image',
  153. 'leaf': 'leaf',
  154. 'project-diagram': 'grid',
  155. 'history': 'clock',
  156. 'default': 'circle'
  157. })
  158. },
  159. // 颜色映射
  160. colorMap: {
  161. type: Object,
  162. default: () => ({
  163. 'primary': '#007aff',
  164. 'secondary': '#6c757d',
  165. 'success': '#4cd964',
  166. 'warning': '#ffcc00',
  167. 'error': '#ff3b30',
  168. 'info': '#5ac8fa'
  169. })
  170. }
  171. })
  172. const emit = defineEmits([
  173. 'tag-click',
  174. 'item-click',
  175. 'more-click',
  176. 'load-success',
  177. 'load-error',
  178. 'page-change'
  179. ])
  180. // ==================== 数据状态 ====================
  181. const items = ref<any[]>([])
  182. const currentPage = ref(props.initialPage)
  183. const hasMore = ref(true)
  184. const loading = ref(false)
  185. const loadingMore = ref(false)
  186. const refreshing = ref(false)
  187. const scrollTop = ref(0)
  188. const total = ref(0)
  189. // ==================== 工具函数 ====================
  190. // 获取项目图标
  191. const getItemIcon = (item: any): string => {
  192. if (props.dataMapper) {
  193. const mapped = props.dataMapper(item)
  194. return props.iconMap[mapped.icon] || props.iconMap.default
  195. }
  196. return props.iconMap[item.icon] || props.iconMap.default
  197. }
  198. // 获取项目颜色
  199. const getItemColor = (item: any): string => {
  200. if (props.dataMapper) {
  201. const mapped = props.dataMapper(item)
  202. return mapped.color || 'secondary'
  203. }
  204. return item.color || 'secondary'
  205. }
  206. // 获取项目是否实心
  207. const getItemSolid = (item: any): boolean => {
  208. if (props.dataMapper) {
  209. const mapped = props.dataMapper(item)
  210. return mapped.solid || false
  211. }
  212. return item.solid || false
  213. }
  214. // 获取项目标题
  215. const getItemTitle = (item: any): string => {
  216. if (props.dataMapper) {
  217. const mapped = props.dataMapper(item)
  218. return mapped.title || ''
  219. }
  220. return item.title || ''
  221. }
  222. // 获取项目描述
  223. const getItemDescription = (item: any): string => {
  224. if (props.dataMapper) {
  225. const mapped = props.dataMapper(item)
  226. return mapped.description || ''
  227. }
  228. return item.description || ''
  229. }
  230. // 获取项目时间
  231. const getItemTime = (item: any): string | Date | number => {
  232. if (props.dataMapper) {
  233. const mapped = props.dataMapper(item)
  234. return mapped.time || new Date()
  235. }
  236. return item.time || new Date()
  237. }
  238. // 获取项目标签
  239. const getItemTags = (item: any): string[] => {
  240. if (props.dataMapper) {
  241. const mapped = props.dataMapper(item)
  242. return mapped.tags || []
  243. }
  244. return item.tags || []
  245. }
  246. // 获取颜色值
  247. const getColorValue = (color: string): string => {
  248. return props.colorMap[color] || props.colorMap.secondary
  249. }
  250. // 格式化时间
  251. const formatTime = (time: string | Date | number): string => {
  252. if (!time) return ''
  253. const date = new Date(time)
  254. const now = new Date()
  255. const diff = now.getTime() - date.getTime()
  256. // 1分钟内
  257. if (diff < 60 * 1000) {
  258. return t('time.justNow')
  259. }
  260. // 1小时内
  261. if (diff < 60 * 60 * 1000) {
  262. const minutes = Math.floor(diff / (60 * 1000))
  263. return t('time.minutesAgo', { minutes })
  264. }
  265. // 24小时内
  266. if (diff < 24 * 60 * 60 * 1000) {
  267. const hours = Math.floor(diff / (60 * 60 * 1000))
  268. return t('time.hoursAgo', { hours })
  269. }
  270. // 7天内
  271. if (diff < 7 * 24 * 60 * 60 * 1000) {
  272. const days = Math.floor(diff / (24 * 60 * 60 * 1000))
  273. return t('time.daysAgo', { days })
  274. }
  275. // 超过7天显示具体日期
  276. const year = date.getFullYear()
  277. const month = String(date.getMonth() + 1).padStart(2, '0')
  278. const day = String(date.getDate()).padStart(2, '0')
  279. const hour = String(date.getHours()).padStart(2, '0')
  280. const minute = String(date.getMinutes()).padStart(2, '0')
  281. return `${year}-${month}-${day} ${hour}:${minute}`
  282. }
  283. // API 请求函数
  284. const fetchTimelineData = async (params: any) => {
  285. console.log('请求参数:', params)
  286. return new Promise((resolve) => {
  287. setTimeout(() => {
  288. // 模拟数据
  289. const mockData = [
  290. {
  291. id: 1,
  292. title: 'Dollar Index (ICE)',
  293. description: 'Dollar Index (ICE) Intraday: caution.',
  294. time: new Date(Date.now() - 12 * 60 * 1000),
  295. icon: 'user-edit',
  296. color: 'secondary',
  297. url: '/pages/detail/1', // 跳转链接
  298. tags: ['美元', '指数']
  299. },
  300. {
  301. id: 2,
  302. title: 'USD/CHF',
  303. description: 'USD/CHF Intraday: under pressure.',
  304. time: new Date(Date.now() - 60 * 60 * 1000),
  305. icon: 'image',
  306. color: 'primary',
  307. solid: true,
  308. page: '/pages/detail/2', // 内部页面
  309. tags: ['外汇', '瑞士法郎']
  310. },
  311. {
  312. id: 3,
  313. title: 'USD/CAD',
  314. description: 'USD/CAD Intraday: intraday support around 1.3545.',
  315. time: new Date(Date.now() - 3 * 60 * 60 * 1000),
  316. icon: 'leaf',
  317. color: 'success'
  318. },
  319. {
  320. id: 4,
  321. title: 'AUD/USD',
  322. description: 'AUD/USD Intraday: the downside prevails.',
  323. time: new Date(Date.now() - 24 * 60 * 60 * 1000),
  324. icon: 'project-diagram',
  325. color: 'warning'
  326. },
  327. {
  328. id: 5,
  329. title: 'Weekly Report',
  330. description: 'The weekly report was uploaded',
  331. time: new Date(Date.now() - 24 * 60 * 60 * 1000),
  332. icon: 'history',
  333. color: 'error'
  334. }
  335. ]
  336. // 分页处理
  337. const { page, pageSize } = params
  338. const start = (page - 1) * pageSize
  339. const end = start + pageSize
  340. const pageData = mockData.slice(start, end)
  341. resolve({
  342. code: 200,
  343. data: {
  344. list: pageData,
  345. total: mockData.length
  346. }
  347. })
  348. }, 800)
  349. })
  350. }
  351. // ==================== 数据加载 ====================
  352. const loadData = async (isRefresh: boolean = false) => {
  353. if (loading.value || loadingMore.value) return
  354. if (isRefresh) {
  355. refreshing.value = true
  356. currentPage.value = 1
  357. } else {
  358. loadingMore.value = true
  359. }
  360. loading.value = true
  361. try {
  362. const params = {
  363. page: currentPage.value,
  364. pageSize: props.pageSize,
  365. ...props.queryParams
  366. }
  367. const res = await fetchTimelineData(params)
  368. // 处理不同格式的返回数据
  369. let newItems: any[] = []
  370. let totalCount = 0
  371. if (res.code === 200 || res.code === 0) {
  372. const data = res.data || res
  373. if (Array.isArray(data)) {
  374. newItems = data
  375. totalCount = data.length
  376. } else if (data.list || data.records) {
  377. newItems = data.list || data.records
  378. totalCount = data.total || newItems.length
  379. } else {
  380. newItems = []
  381. }
  382. // 更新数据
  383. if (isRefresh) {
  384. items.value = newItems
  385. } else {
  386. items.value = [...items.value, ...newItems]
  387. }
  388. total.value = totalCount
  389. // 判断是否还有更多
  390. hasMore.value = newItems.length === props.pageSize
  391. emit('load-success', { data: res, isRefresh, page: currentPage.value })
  392. } else {
  393. throw new Error(res.message || '加载失败')
  394. }
  395. } catch (error: any) {
  396. console.error('加载失败:', error)
  397. uni.showToast({
  398. title: error.message || '加载失败',
  399. icon: 'none'
  400. })
  401. emit('load-error', { error, page: currentPage.value })
  402. } finally {
  403. loading.value = false
  404. loadingMore.value = false
  405. refreshing.value = false
  406. }
  407. }
  408. // 加载更多
  409. const handleLoadMore = () => {
  410. if (!hasMore.value || loadingMore.value || loading.value) return
  411. currentPage.value++
  412. emit('page-change', currentPage.value)
  413. loadData(false)
  414. }
  415. // 下拉刷新
  416. const handleRefresh = () => {
  417. loadData(true)
  418. }
  419. // ==================== 事件处理 ====================
  420. // 项目点击
  421. const handleItemClick = (item: any) => {
  422. emit('item-click', item)
  423. // 如果项目有跳转链接,自动跳转
  424. if (item.url) {
  425. // #ifdef H5
  426. window.open(item.url, item.target || '_self')
  427. // #endif
  428. // #ifndef H5
  429. if (item.url.startsWith('http')) {
  430. plus.runtime.openURL(item.url)
  431. } else {
  432. uni.navigateTo({ url: item.url })
  433. }
  434. // #endif
  435. } else if (item.page) {
  436. // 内部页面跳转
  437. uni.navigateTo({
  438. url: item.page,
  439. success: () => {
  440. console.log('跳转成功')
  441. },
  442. fail: (err) => {
  443. console.error('跳转失败:', err)
  444. uni.showToast({
  445. title: '页面不存在',
  446. icon: 'none'
  447. })
  448. }
  449. })
  450. }
  451. }
  452. // 标签点击
  453. const handleTagClick = (tag: string, item: any) => {
  454. emit('tag-click', { tag, item })
  455. // 可以在这里添加标签搜索等功能
  456. uni.showToast({
  457. title: `标签: ${tag}`,
  458. icon: 'none'
  459. })
  460. }
  461. // 更多点击
  462. const handleMoreClick = () => {
  463. emit('more-click')
  464. if (props.moreUrl) {
  465. // #ifdef H5
  466. window.open(props.moreUrl, '_self')
  467. // #endif
  468. // #ifndef H5
  469. if (props.moreUrl.startsWith('http')) {
  470. plus.runtime.openURL(props.moreUrl)
  471. } else {
  472. uni.navigateTo({ url: props.moreUrl })
  473. }
  474. // #endif
  475. }
  476. }
  477. // ==================== 滚动控制 ====================
  478. // 监听滚动
  479. const onScroll = (e: any) => {
  480. scrollTop.value = e.detail.scrollTop
  481. }
  482. // 回到顶部
  483. const scrollToTop = () => {
  484. scrollTop.value = 0
  485. uni.pageScrollTo({
  486. scrollTop: 0,
  487. duration: 300
  488. })
  489. }
  490. // ==================== 外部方法 ====================
  491. // 刷新数据
  492. const refresh = () => {
  493. loadData(true)
  494. }
  495. // 重置并刷新
  496. const reset = () => {
  497. items.value = []
  498. currentPage.value = 1
  499. hasMore.value = true
  500. loadData(true)
  501. }
  502. // 添加项目
  503. const addItem = (item: any) => {
  504. items.value.unshift(item)
  505. }
  506. // 删除项目
  507. const removeItem = (id: string | number) => {
  508. const index = items.value.findIndex(item => item.id === id)
  509. if (index !== -1) {
  510. items.value.splice(index, 1)
  511. }
  512. }
  513. // 更新项目
  514. const updateItem = (id: string | number, newData: any) => {
  515. const index = items.value.findIndex(item => item.id === id)
  516. if (index !== -1) {
  517. items.value[index] = { ...items.value[index], ...newData }
  518. }
  519. }
  520. // ==================== 生命周期 ====================
  521. // 监听查询参数变化
  522. watch(() => props.queryParams, () => {
  523. refresh()
  524. }, { deep: true })
  525. // 初始化加载
  526. onMounted(() => {
  527. if (props.immediate) {
  528. loadData(true)
  529. }
  530. })
  531. // 暴露方法给父组件
  532. defineExpose({
  533. refresh,
  534. reset,
  535. addItem,
  536. removeItem,
  537. updateItem,
  538. items,
  539. loading,
  540. hasMore,
  541. total,
  542. currentPage
  543. })
  544. </script>
  545. <style scoped lang="scss">
  546. @import "@/uni.scss";
  547. .timeline-container {
  548. max-width: px2rpx(576);
  549. min-width: px2rpx(20);
  550. padding: px2rpx(12);
  551. box-sizing: border-box;
  552. margin: 0 auto;
  553. background-color: rgba(var(--bs-body-bg-rgb), var(--bs-bg-opacity)) !important;
  554. .timeline-scroll {
  555. height: 100%;
  556. }
  557. .timeline-item {
  558. position: relative;
  559. display: flex;
  560. padding-bottom: px2rpx(32);
  561. &::before {
  562. content: '';
  563. position: absolute;
  564. left: px2rpx(12);
  565. top: 0;
  566. bottom: 0;
  567. width: px2rpx(2);
  568. background-color: var(--color-slate-300);
  569. transform: translateX(-50%);
  570. }
  571. &:last-child {
  572. padding-bottom: 0;
  573. }
  574. .timeline-item-point {
  575. position: relative;
  576. width: px2rpx(24);
  577. height: px2rpx(24);
  578. display: flex;
  579. align-items: center;
  580. justify-content: center;
  581. background-color: rgba(var(--bs-body-bg-rgb), var(--bs-bg-opacity)) !important;
  582. border: 1px solid currentColor;
  583. flex-shrink: 0;
  584. z-index: 1;
  585. &.solid {
  586. border: none;
  587. .icon-wrapper {
  588. width: 100%;
  589. height: 100%;
  590. border-radius: 50%;
  591. display: flex;
  592. align-items: center;
  593. justify-content: center;
  594. }
  595. }
  596. // 颜色变体
  597. &.text-primary {
  598. color: #cf1322;
  599. border-color: #cf1322;
  600. }
  601. &.text-secondary {
  602. color: #6c757d;
  603. border-color: #6c757d;
  604. }
  605. &.text-success {
  606. color: #4cd964;
  607. border-color: #4cd964;
  608. }
  609. &.text-warning {
  610. color: #ffcc00;
  611. border-color: #ffcc00;
  612. }
  613. &.text-error {
  614. color: #ff3b30;
  615. border-color: #ff3b30;
  616. }
  617. &.text-info {
  618. color: #5ac8fa;
  619. border-color: #5ac8fa;
  620. }
  621. }
  622. .timeline-item-content {
  623. margin-left: px2rpx(32);
  624. flex: 1;
  625. .timeline-item-header {
  626. display: flex;
  627. justify-content: space-between;
  628. margin-bottom: px2rpx(8);
  629. .title {
  630. color: var(--color-slate-600);
  631. font-weight: 500;
  632. font-size: px2rpx(14);
  633. }
  634. .time {
  635. font-size: px2rpx(12);
  636. color: var(--color-slate-600);
  637. }
  638. }
  639. .content-description {
  640. color: var(--color-slate-900);
  641. font-size: px2rpx(14);
  642. padding: px2rpx(4) 0;
  643. }
  644. .tags-container {
  645. display: inline-flex;
  646. margin-top: px2rpx(16);
  647. flex-wrap: wrap;
  648. gap: px2rpx(8);
  649. font-size: px2rpx(14);
  650. .tag {
  651. color: var(--color-primary);
  652. cursor: pointer;
  653. &:hover {
  654. text-decoration: underline;
  655. }
  656. }
  657. }
  658. @media (min-width: 640px) {
  659. margin-left: px2rpx(40);
  660. }
  661. }
  662. }
  663. }
  664. // 加载状态
  665. .loading-mask {
  666. position: absolute;
  667. top: 0;
  668. left: 0;
  669. right: 0;
  670. bottom: 0;
  671. display: flex;
  672. align-items: center;
  673. justify-content: center;
  674. background-color: rgba(255, 255, 255, 0.9);
  675. z-index: 100;
  676. }
  677. .loading-content {
  678. display: flex;
  679. flex-direction: column;
  680. align-items: center;
  681. padding: px2rpx(30);
  682. background-color: rgba(var(--bs-body-bg-rgb), var(--bs-bg-opacity)) !important;
  683. border-radius: px2rpx(16);
  684. box-shadow: 0 px2rpx(4) px2rpx(20) rgba(0, 0, 0, 0.1);
  685. }
  686. .loading-text {
  687. margin-top: px2rpx(20);
  688. font-size: px2rpx(28);
  689. color: var(--bs-heading-color);
  690. }
  691. .loading-more,
  692. .no-more,
  693. .empty-data {
  694. display: flex;
  695. align-items: center;
  696. justify-content: center;
  697. padding: px2rpx(30);
  698. font-size: px2rpx(28);
  699. color: var(--bs-heading-color);
  700. }
  701. .empty-data {
  702. flex-direction: column;
  703. gap: px2rpx(20);
  704. }
  705. .spin {
  706. animation: spin 1s linear infinite;
  707. margin-right: px2rpx(10);
  708. }
  709. @keyframes spin {
  710. from {
  711. transform: rotate(0deg);
  712. }
  713. to {
  714. transform: rotate(360deg);
  715. }
  716. }
  717. </style>