From 69659a1746aa1514796a6054f66a3797dfb613c7 Mon Sep 17 00:00:00 2001 From: Timi Date: Sat, 13 Dec 2025 18:44:37 +0800 Subject: [PATCH] refactor travel --- .gitignore | 3 +- miniprogram/api/TravelApi.ts | 61 +++ miniprogram/api/TravelLocationApi.ts | 70 +++ miniprogram/app.json | 5 +- miniprogram/app.wxss | 4 +- .../travel-location-popup/index.json | 8 + .../travel-location-popup/index.less | 173 ++++++ .../components/travel-location-popup/index.ts | 141 +++++ .../travel-location-popup/index.wxml | 69 +++ .../luggage => travel-detail}/index.json | 13 +- .../pages/main/travel-detail/index.less | 308 +++++++++++ miniprogram/pages/main/travel-detail/index.ts | 233 ++++++++ .../pages/main/travel-detail/index.wxml | 208 ++++++++ .../pages/main/travel-editor/index.json | 17 + .../pages/main/travel-editor/index.less | 92 ++++ miniprogram/pages/main/travel-editor/index.ts | 292 ++++++++++ .../pages/main/travel-editor/index.wxml | 150 ++++++ .../main/travel-location-editor/index.json | 16 + .../main/travel-location-editor/index.less | 226 ++++++++ .../main/travel-location-editor/index.ts | 501 ++++++++++++++++++ .../main/travel-location-editor/index.wxml | 242 +++++++++ .../pages/main/travel-location-map/index.json | 8 + .../pages/main/travel-location-map/index.less | 70 +++ .../pages/main/travel-location-map/index.ts | 181 +++++++ .../pages/main/travel-location-map/index.wxml | 32 ++ miniprogram/pages/main/travel/index.json | 12 +- miniprogram/pages/main/travel/index.less | 126 ++++- miniprogram/pages/main/travel/index.ts | 254 +++++---- miniprogram/pages/main/travel/index.wxml | 132 ++++- .../pages/main/travel/luggage/index.less | 88 --- .../pages/main/travel/luggage/index.ts | 86 --- .../pages/main/travel/luggage/index.wxml | 39 -- miniprogram/theme.wxss | 71 +-- miniprogram/types/Journal.ts | 2 +- miniprogram/types/Model.ts | 16 +- miniprogram/types/Travel.ts | 165 ++++++ miniprogram/utils/Network.ts | 440 +++++++++++++++ 37 files changed, 4154 insertions(+), 400 deletions(-) create mode 100644 miniprogram/api/TravelApi.ts create mode 100644 miniprogram/api/TravelLocationApi.ts create mode 100644 miniprogram/components/travel-location-popup/index.json create mode 100644 miniprogram/components/travel-location-popup/index.less create mode 100644 miniprogram/components/travel-location-popup/index.ts create mode 100644 miniprogram/components/travel-location-popup/index.wxml rename miniprogram/pages/main/{travel/luggage => travel-detail}/index.json (52%) create mode 100644 miniprogram/pages/main/travel-detail/index.less create mode 100644 miniprogram/pages/main/travel-detail/index.ts create mode 100644 miniprogram/pages/main/travel-detail/index.wxml create mode 100644 miniprogram/pages/main/travel-editor/index.json create mode 100644 miniprogram/pages/main/travel-editor/index.less create mode 100644 miniprogram/pages/main/travel-editor/index.ts create mode 100644 miniprogram/pages/main/travel-editor/index.wxml create mode 100644 miniprogram/pages/main/travel-location-editor/index.json create mode 100644 miniprogram/pages/main/travel-location-editor/index.less create mode 100644 miniprogram/pages/main/travel-location-editor/index.ts create mode 100644 miniprogram/pages/main/travel-location-editor/index.wxml create mode 100644 miniprogram/pages/main/travel-location-map/index.json create mode 100644 miniprogram/pages/main/travel-location-map/index.less create mode 100644 miniprogram/pages/main/travel-location-map/index.ts create mode 100644 miniprogram/pages/main/travel-location-map/index.wxml delete mode 100644 miniprogram/pages/main/travel/luggage/index.less delete mode 100644 miniprogram/pages/main/travel/luggage/index.ts delete mode 100644 miniprogram/pages/main/travel/luggage/index.wxml create mode 100644 miniprogram/types/Travel.ts create mode 100644 miniprogram/utils/Network.ts diff --git a/.gitignore b/.gitignore index 77921f3..0c17b9c 100644 --- a/.gitignore +++ b/.gitignore @@ -61,4 +61,5 @@ ehthumbs.db [Tt]humbs.db .claude/ -CLAUDE.md \ No newline at end of file +CLAUDE.md +/docs diff --git a/miniprogram/api/TravelApi.ts b/miniprogram/api/TravelApi.ts new file mode 100644 index 0000000..614387b --- /dev/null +++ b/miniprogram/api/TravelApi.ts @@ -0,0 +1,61 @@ +import { Network } from "../utils/Network"; +import { Travel } from "../types/Travel"; +import { QueryPage, QueryPageResult } from "../types/Model"; + +/** + * Travel 旅行计划 API + * + * 按业务模块封装网络请求,使代码更清晰、可维护 + */ +export class TravelApi { + /** + * 获取旅行详情 + * + * @param id - 旅行 ID + */ + static getDetail(id: number | string): Promise { + return Network.get(`/journal/travel/${id}`); + } + + /** + * 旅行分页列表 + * + * @param pageParams - 分页参数 + */ + static getList(pageParams: QueryPage): Promise> { + return Network.page("/journal/travel/list", pageParams); + } + + /** + * 创建旅行 + * + * @param data - 旅行数据 + */ + static create(data: Partial): Promise { + return Network.post("/journal/travel/create", data, { + showLoading: true, + loadingText: "正在保存.." + }); + } + + /** + * 更新旅行 + * + * @param data - 旅行数据(必须包含 id) + */ + static update(data: Partial & { id: number }): Promise { + return Network.post("/journal/travel/update", data, { + showLoading: true, + loadingText: "正在保存.." + }); + } + + /** + * 删除旅行 + * + * @param id - 旅行 ID + */ + static delete(id: number): Promise { + return Network.post("/journal/travel/delete", { id }); + } +} diff --git a/miniprogram/api/TravelLocationApi.ts b/miniprogram/api/TravelLocationApi.ts new file mode 100644 index 0000000..49719c7 --- /dev/null +++ b/miniprogram/api/TravelLocationApi.ts @@ -0,0 +1,70 @@ +import { Network } from "../utils/Network"; +import { TravelLocation } from "../types/Travel"; +import { QueryPage, QueryPageResult } from "../types/Model"; + +/** + * TravelLocation 旅行地点 API + * + * 按业务模块封装网络请求,使代码更清晰、可维护 + */ +export class TravelLocationApi { + /** + * 获取旅行地点详情 + * + * @param id - 地点 ID + */ + static getDetail(id: number | string): Promise { + return Network.get(`/journal/travel/location/${id}`); + } + + /** + * 获取旅行地点分页列表 + * + * @param pageParams - 分页参数(通常包含 travelId 筛选) + */ + static getList(pageParams: QueryPage): Promise> { + return Network.page("/journal/travel/location/list", pageParams); + } + + /** + * 创建旅行地点 + * + * @param data - 地点数据 + */ + static create(data: Partial): Promise { + return Network.post("/journal/travel/location/create", data, { + showLoading: true, + loadingText: "正在保存.." + }); + } + + /** + * 更新旅行地点 + * + * @param data - 地点数据(必须包含 id) + */ + static update(data: Partial & { id: number }): Promise { + return Network.post("/journal/travel/location/update", data, { + showLoading: true, + loadingText: "正在保存.." + }); + } + + /** + * 删除旅行地点 + * + * @param id - 地点 ID + */ + static delete(id: number): Promise { + return Network.post("/journal/travel/location/delete", { id }); + } + + /** + * 批量获取旅行地点 + * + * @param ids - 地点 ID 数组 + */ + static getListByIds(ids: number[]): Promise { + return Network.post("/journal/travel/location/list/ids", ids); + } +} diff --git a/miniprogram/app.json b/miniprogram/app.json index 01c178a..022ccf9 100644 --- a/miniprogram/app.json +++ b/miniprogram/app.json @@ -8,8 +8,11 @@ "pages/main/journal-date/index", "pages/main/portfolio/index", "pages/main/travel/index", + "pages/main/travel-location-map/index", + "pages/main/travel-detail/index", + "pages/main/travel-editor/index", + "pages/main/travel-location-editor/index", "pages/main/about/index", - "pages/main/travel/luggage/index", "pages/main/moment/index" ], "darkmode": true, diff --git a/miniprogram/app.wxss b/miniprogram/app.wxss index a4856d8..7bea78a 100644 --- a/miniprogram/app.wxss +++ b/miniprogram/app.wxss @@ -1,3 +1,3 @@ /**app.wxss**/ -@import "./theme.wxss"; -@import "./tdesign.wxss"; \ No newline at end of file +@import "./tdesign.wxss"; +@import "./theme.wxss"; \ No newline at end of file diff --git a/miniprogram/components/travel-location-popup/index.json b/miniprogram/components/travel-location-popup/index.json new file mode 100644 index 0000000..bc6740c --- /dev/null +++ b/miniprogram/components/travel-location-popup/index.json @@ -0,0 +1,8 @@ +{ + "component": true, + "usingComponents": { + "t-icon": "tdesign-miniprogram/icon/icon", + "t-popup": "tdesign-miniprogram/popup/popup", + "t-tag": "tdesign-miniprogram/tag/tag" + } +} diff --git a/miniprogram/components/travel-location-popup/index.less b/miniprogram/components/travel-location-popup/index.less new file mode 100644 index 0000000..35b6c09 --- /dev/null +++ b/miniprogram/components/travel-location-popup/index.less @@ -0,0 +1,173 @@ +/* components/travel-location-popup/index.less */ +.detail-panel { + width: 100%; + height: 70vh; + display: flex; + border-radius: 16rpx 16rpx 0 0; + flex-direction: column; + + .detail-content { + flex: 1; + display: flex; + overflow: hidden; + flex-direction: column; + + > .header { + display: flex; + padding: 32rpx 32rpx 0 32rpx; + flex-shrink: 0; + align-items: flex-start; + margin-bottom: 24rpx; + justify-content: space-between; + + .info { + flex: 1; + display: flex; + overflow: hidden; + flex-direction: column; + + .title-row { + gap: 8rpx; + display: flex; + overflow: hidden; + align-items: center; + margin-bottom: 8rpx; + + .type-icon { + color: var(--theme-wx); + font-size: 36rpx; + flex-shrink: 0; + } + + .title { + flex: 1; + overflow: hidden; + font-size: 32rpx; + font-weight: 600; + white-space: nowrap; + text-overflow: ellipsis; + } + } + + .location { + gap: 4rpx; + display: flex; + align-items: center; + + .icon { + color: var(--theme-wx); + font-size: 28rpx; + flex-shrink: 0; + } + + .text { + flex: 1; + overflow: hidden; + font-size: 24rpx; + white-space: nowrap; + text-overflow: ellipsis; + } + } + } + + .actions { + gap: 16rpx; + display: flex; + flex-shrink: 0; + align-items: center; + + .indicator { + color: var(--theme-wx); + padding: 4rpx 12rpx; + font-size: 24rpx; + font-weight: bold; + background: var(--theme-bg-journal); + border-radius: 12rpx; + } + } + } + + .locations-swiper { + flex: 1; + height: 100%; + + .swiper-item-wrapper { + height: 100%; + + .location-scroll { + height: 100%; + + .location-item { + display: flex; + padding: 0 32rpx 128rpx 32rpx; + flex-direction: column; + + .tags { + gap: 12rpx; + display: flex; + flex-wrap: wrap; + margin-bottom: 24rpx; + } + + .description { + color: var(--td-text-color-primary); + font-size: 28rpx; + line-height: 1.6; + white-space: pre-wrap; + word-break: break-word; + margin-bottom: 16rpx; + } + + .items { + gap: 8rpx; + display: flex; + align-items: flex-start; + + .column { + flex: 1; + display: flex; + flex-direction: column; + + .item { + overflow: hidden; + background: var(--theme-bg-card); + margin-bottom: 8rpx; + + &.thumbnail { + width: 100%; + display: block; + } + + &.video { + position: relative; + + &::after { + content: ""; + top: 50%; + left: 53%; + width: 0; + height: 0; + position: absolute; + transform: translate(-50%, -50%); + border-top: 16px solid transparent; + border-left: 24px solid var(--theme-video-play); + border-bottom: 16px solid transparent; + pointer-events: none; + } + } + } + } + } + + .empty { + color: var(--td-text-color-placeholder); + padding: 64rpx 0; + font-size: 28rpx; + text-align: center; + } + } + } + } + } + } +} diff --git a/miniprogram/components/travel-location-popup/index.ts b/miniprogram/components/travel-location-popup/index.ts new file mode 100644 index 0000000..6273616 --- /dev/null +++ b/miniprogram/components/travel-location-popup/index.ts @@ -0,0 +1,141 @@ +// components/travel-location-popup/index.ts +import { TravelLocation, TravelLocationTypeLabel, TravelLocationTypeIcon } from "../../types/Travel"; +import { TravelLocationApi } from "../../api/TravelLocationApi"; +import { MediaAttachExt, MediaAttachType } from "../../types/Attachment"; +import { MediaItem, MediaItemType } from "../../types/UI"; +import config from "../../config/index"; +import Toolkit from "../../utils/Toolkit"; + +interface TravelLocationPopupData { + locations: TravelLocation[]; + currentLocationIndex: number; +} + +Component({ + properties: { + visible: { + type: Boolean, + value: false + }, + ids: { + type: Array, + value: [] + } + }, + data: { + locations: [], + currentLocationIndex: 0, + }, + observers: { + async 'ids, visible'(ids: number[], visible: boolean) { + if (visible && ids && 0 < ids.length) { + wx.showLoading({ title: "加载中...", mask: true }); + try { + const locations = await TravelLocationApi.getListByIds(ids); + + locations.forEach(location => { + location.typeLabel = location.type ? TravelLocationTypeLabel[location.type] : ""; + location.typeIcon = location.type ? TravelLocationTypeIcon[location.type] : ""; + + // 处理附件数据 + const attachments = location.items || []; + const thumbItems = attachments.filter((item: any) => item.attachType === MediaAttachType.THUMB); + + if (0 < thumbItems.length) { + const mediaItems: MediaItem[] = thumbItems.map((thumbItem: any, index: number) => { + const metadata = thumbItem.metadata; + const ext = typeof thumbItem.ext === "string" ? JSON.parse(thumbItem.ext) : thumbItem.ext; + const thumbURL = `${config.url}/attachment/read/${thumbItem.mongoId}`; + const sourceURL = `${config.url}/attachment/read/${ext.sourceMongoId}`; + return { + type: ext.isVideo ? MediaItemType.VIDEO : MediaItemType.IMAGE, + thumbURL, + sourceURL, + size: thumbItem.size || 0, + attachmentId: thumbItem.id, + width: metadata?.width, + height: metadata?.height, + originalIndex: index + } as MediaItem; + }); + + location.mediaItems = mediaItems; + location.columnedItems = Toolkit.splitItemsIntoColumns(mediaItems, 3, (item) => { + if (item.width && item.height && 0 < item.width) { + return item.height / item.width; + } + return 1; + }); + } + }); + + this.setData({ + locations, + currentLocationIndex: 0, + }); + wx.hideLoading(); + } catch (err: any) { + wx.hideLoading(); + wx.showToast({ + title: err.message || "加载失败", + icon: "error" + }); + } + } + } + }, + + methods: { + /** 关闭详情 */ + closeDetail() { + this.triggerEvent("close"); + }, + /** swiper 切换事件 */ + onSwiperChange(e: WechatMiniprogram.SwiperChange) { + this.setData({ + currentLocationIndex: e.detail.current + }); + }, + /** 打开位置 */ + openLocation() { + const location = this.data.locations[this.data.currentLocationIndex]; + if (location && location.lat && location.lng) { + wx.openLocation({ + latitude: location.lat, + longitude: location.lng, + name: location.title || "地点", + address: location.location || "" + }); + } + }, + /** 预览媒体 */ + previewMedia(e: WechatMiniprogram.BaseEvent) { + const locations = this.data.locations; + if (!locations || locations.length === 0) { + return; + } + const { itemIndex } = e.currentTarget.dataset; + const location = locations[this.data.currentLocationIndex]; + const items = (location as any).mediaItems; + if (!items || items.length === 0) { + return; + } + const total = items.length; + + const startIndex = Math.max(0, itemIndex - 25); + const endIndex = Math.min(total, startIndex + 50); + const newCurrentIndex = itemIndex - startIndex; + + const sources = items.slice(startIndex, endIndex).map((item: MediaItem) => { + return { + url: item.sourceURL, + type: item.type === MediaItemType.IMAGE ? "image" : "video" + }; + }) as any; + wx.previewMedia({ + current: newCurrentIndex, + sources + }); + } + } +}); diff --git a/miniprogram/components/travel-location-popup/index.wxml b/miniprogram/components/travel-location-popup/index.wxml new file mode 100644 index 0000000..6990c4d --- /dev/null +++ b/miniprogram/components/travel-location-popup/index.wxml @@ -0,0 +1,69 @@ + + + + + + + + + {{locations[currentLocationIndex].title || '未命名地点'}} + + + + {{locations[currentLocationIndex].location}} + + + + + {{currentLocationIndex + 1}}/{{locations.length}} + + + + + + + + + + + + {{item.typeLabel}} + + + ¥{{item.amount}} + + + 评分 {{item.score}} + + + 需要身份证 + + + {{item.description}} + + + + + + + + 暂无详细说明 + + + + + + + + diff --git a/miniprogram/pages/main/travel/luggage/index.json b/miniprogram/pages/main/travel-detail/index.json similarity index 52% rename from miniprogram/pages/main/travel/luggage/index.json rename to miniprogram/pages/main/travel-detail/index.json index 701fecb..f40451d 100644 --- a/miniprogram/pages/main/travel/luggage/index.json +++ b/miniprogram/pages/main/travel-detail/index.json @@ -1,10 +1,13 @@ { + "component": true, "usingComponents": { + "t-tag": "tdesign-miniprogram/tag/tag", "t-icon": "tdesign-miniprogram/icon/icon", "t-input": "tdesign-miniprogram/input/input", - "t-navbar": "tdesign-miniprogram/navbar/navbar", "t-button": "tdesign-miniprogram/button/button", - "t-checkbox": "tdesign-miniprogram/checkbox/checkbox", - "t-checkbox-group": "tdesign-miniprogram/checkbox-group/checkbox-group" - } -} \ No newline at end of file + "t-dialog": "tdesign-miniprogram/dialog/dialog", + "t-navbar": "tdesign-miniprogram/navbar/navbar", + "t-loading": "tdesign-miniprogram/loading/loading" + }, + "styleIsolation": "shared" +} diff --git a/miniprogram/pages/main/travel-detail/index.less b/miniprogram/pages/main/travel-detail/index.less new file mode 100644 index 0000000..d1dd47b --- /dev/null +++ b/miniprogram/pages/main/travel-detail/index.less @@ -0,0 +1,308 @@ +// pages/main/travel-detail/index.less + +.detail-container { + width: 100vw; + min-height: 100vh; + box-sizing: border-box; + background: var(--theme-bg-page); + + .content { + gap: 24rpx; + display: flex; + padding-top: 48rpx; + flex-direction: column; + + .status-section { + display: flex; + padding: 16rpx 0; + justify-content: center; + } + + .title-section { + padding: 24rpx; + text-align: center; + background: var(--theme-bg-card); + box-shadow: 0 2px 12px var(--theme-shadow-light); + + .title { + color: var(--theme-text-primary); + font-size: 40rpx; + font-weight: bold; + line-height: 1.5; + } + } + + .info-card, + .content-card, + .locations-card { + overflow: hidden; + background: var(--theme-bg-card); + box-shadow: 0 2px 12px var(--theme-shadow-light); + + .card-title { + display: flex; + padding: 24rpx; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--theme-border-light); + + .title-left { + gap: 12rpx; + display: flex; + align-items: center; + + .icon { + color: var(--theme-primary); + } + + .text { + color: var(--theme-text-primary); + font-size: 32rpx; + font-weight: bold; + } + } + + .title-right { + gap: 16rpx; + display: flex; + align-items: center; + + .icon-btn { + display: flex; + padding: 8rpx; + align-items: center; + justify-content: center; + transition: all .2s; + + &:active { + transform: scale(1.1); + } + } + } + } + + .info-list { + padding: 24rpx; + + .info-item { + display: flex; + padding: 20rpx 0; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--theme-border-light); + + &:last-child { + border-bottom: none; + } + + .label { + gap: 12rpx; + flex: 1; + display: flex; + align-items: center; + + .icon { + color: var(--theme-text-secondary); + } + + .text { + color: var(--theme-text-secondary); + font-size: 28rpx; + } + } + + .value { + color: var(--theme-text-primary); + font-size: 28rpx; + font-weight: 500; + } + } + } + + .content-text { + color: var(--theme-text-primary); + padding: 24rpx; + font-size: 28rpx; + line-height: 1.8; + white-space: pre-wrap; + word-break: break-all; + } + + .loading-container { + display: flex; + padding: 48rpx; + align-items: center; + justify-content: center; + } + + .locations-list { + padding: 24rpx; + + .location-item { + gap: 24rpx; + display: flex; + padding: 24rpx; + margin-bottom: 20rpx; + border-radius: 12rpx; + background: var(--theme-bg-page); + border: 1px solid var(--theme-border-light); + transition: all .3s; + + &:last-child { + margin-bottom: 0; + } + + &:active { + transform: scale(.98); + background: var(--theme-bg-card); + } + + .location-icon { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + } + + .location-content { + gap: 16rpx; + flex: 1; + display: flex; + overflow: hidden; + flex-direction: column; + + .location-header { + display: flex; + align-items: center; + justify-content: space-between; + + .location-title { + flex: 1; + color: var(--theme-text-primary); + overflow: hidden; + font-size: 30rpx; + font-weight: bold; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .location-description { + color: var(--theme-text-secondary); + font-size: 26rpx; + line-height: 1.6; + word-break: break-all; + } + + .location-info { + gap: 24rpx; + display: flex; + flex-wrap: wrap; + + .info-item { + gap: 8rpx; + display: flex; + align-items: center; + color: var(--theme-text-secondary); + font-size: 24rpx; + } + } + } + + .location-arrow { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + } + } + } + + .empty-state { + gap: 16rpx; + display: flex; + padding: 64rpx 24rpx; + align-items: center; + flex-direction: column; + justify-content: center; + + .empty-text { + color: var(--theme-text-secondary); + font-size: 26rpx; + } + } + } + + .time-card { + gap: 16rpx; + display: flex; + padding: 24rpx; + flex-direction: column; + background: var(--theme-bg-card); + box-shadow: 0 2px 12px var(--theme-shadow-light); + + .time-item { + display: flex; + align-items: center; + justify-content: space-between; + + .label { + color: var(--theme-text-secondary); + font-size: 24rpx; + } + + .value { + color: var(--theme-text-secondary); + font-size: 24rpx; + } + } + } + + .action-section { + gap: 24rpx; + display: flex; + padding: 24rpx 16rpx 48rpx 16rpx; + + .edit-btn { + flex: 2; + } + + .delete-btn { + flex: 1; + } + } + } +} + +.delete-dialog-content { + gap: 32rpx; + display: flex; + flex-direction: column; + + .delete-warning { + gap: 16rpx; + display: flex; + padding: 24rpx; + align-items: center; + border-radius: 12rpx; + flex-direction: column; + background: #FFF4F4; + + .warning-text { + color: #E34D59; + font-size: 28rpx; + text-align: center; + } + } + + .delete-confirm { + gap: 16rpx; + display: flex; + flex-direction: column; + + .confirm-label { + color: var(--theme-text-primary); + font-size: 28rpx; + } + } +} diff --git a/miniprogram/pages/main/travel-detail/index.ts b/miniprogram/pages/main/travel-detail/index.ts new file mode 100644 index 0000000..89aad09 --- /dev/null +++ b/miniprogram/pages/main/travel-detail/index.ts @@ -0,0 +1,233 @@ +// pages/main/travel-detail/index.ts + +import Time from "../../../utils/Time"; +import { TravelApi } from "../../../api/TravelApi"; +import { TravelLocationApi } from "../../../api/TravelLocationApi"; +import { Travel, TravelStatusLabel, TravelStatusIcon, TransportationTypeLabel, TravelLocation, TravelLocationTypeLabel, TravelLocationTypeIcon } from "../../../types/Travel"; + +interface TravelDetailData { + /** 旅行详情 */ + travel: Travel | null; + /** 旅行 ID */ + travelId: string; + /** 是否正在加载 */ + isLoading: boolean; + /** 地点列表 */ + locations: TravelLocation[]; + /** 是否正在加载地点 */ + isLoadingLocations: boolean; + /** 状态标签映射 */ + statusLabels: typeof TravelStatusLabel; + /** 状态图标映射 */ + statusIcons: typeof TravelStatusIcon; + /** 交通类型标签映射 */ + transportLabels: typeof TransportationTypeLabel; + /** 地点类型标签映射 */ + locationTypeLabels: typeof TravelLocationTypeLabel; + /** 地点类型图标映射 */ + locationTypeIcons: typeof TravelLocationTypeIcon; + /** 删除对话框可见性 */ + deleteDialogVisible: boolean; + /** 删除确认文本 */ + deleteConfirmText: string; +} + +Page({ + data: { + travel: null, + travelId: "", + isLoading: true, + locations: [], + isLoadingLocations: false, + statusLabels: TravelStatusLabel, + statusIcons: TravelStatusIcon, + transportLabels: TransportationTypeLabel, + locationTypeLabels: TravelLocationTypeLabel, + locationTypeIcons: TravelLocationTypeIcon, + deleteDialogVisible: false, + deleteConfirmText: "" + }, + + onLoad(options: any) { + const { id } = options; + if (id) { + this.setData({ travelId: id }); + this.fetchDetail(id); + } else { + wx.showToast({ + title: "参数错误", + icon: "error" + }); + setTimeout(() => { + wx.navigateBack(); + }, 1500); + } + }, + + onShow() { + // 页面显示时刷新地点列表(从编辑页返回时) + if (this.data.travelId && !this.data.isLoading) { + this.fetchLocations(this.data.travelId); + } + }, + + /** 获取旅行详情 */ + async fetchDetail(id: string) { + this.setData({ isLoading: true }); + + try { + const travel = await TravelApi.getDetail(id); + + // 格式化数据 + if (travel.travelAt) { + travel.travelDate = Time.toDate(travel.travelAt); + travel.travelTime = Time.toTime(travel.travelAt); + } + + // 格式化创建和更新时间 + if (travel.createdAt) { + (travel as any).createdAtFormatted = Time.toDateTime(travel.createdAt); + } + if (travel.updatedAt) { + (travel as any).updatedAtFormatted = Time.toDateTime(travel.updatedAt); + } + + this.setData({ travel }); + + // 获取地点列表 + this.fetchLocations(id); + } catch (error) { + console.error("获取旅行详情失败:", error); + wx.showToast({ + title: "加载失败", + icon: "error" + }); + setTimeout(() => { + wx.navigateBack(); + }, 1500); + } finally { + this.setData({ isLoading: false }); + } + }, + + /** 获取地点列表 */ + async fetchLocations(travelId: string) { + this.setData({ isLoadingLocations: true }); + + try { + const result = await TravelLocationApi.getList({ + index: 0, + size: 100, + equalsExample: { + travelId: Number(travelId) + } + }); + + this.setData({ locations: result.list }); + } catch (error) { + console.error("获取地点列表失败:", error); + } finally { + this.setData({ isLoadingLocations: false }); + } + }, + + /** 编辑旅行 */ + toEdit() { + const { travel } = this.data; + if (travel && travel.id) { + wx.navigateTo({ + url: `/pages/main/travel-editor/index?id=${travel.id}` + }); + } + }, + + /** 新增地点 */ + toAddLocation() { + const { travel } = this.data; + if (travel && travel.id) { + wx.navigateTo({ + url: `/pages/main/travel-location-editor/index?travelId=${travel.id}` + }); + } + }, + + /** 编辑地点 */ + toEditLocation(e: WechatMiniprogram.BaseEvent) { + const { id } = e.currentTarget.dataset; + const { travel } = this.data; + if (id && travel && travel.id) { + wx.navigateTo({ + url: `/pages/main/travel-location-editor/index?id=${id}&travelId=${travel.id}` + }); + } + }, + + /** 跳转地图 */ + toMap() { + const { travel } = this.data; + if (travel && travel.id) { + wx.navigateTo({ + url: `/pages/main/travel-location-map/index?travelId=${travel.id}` + }); + } + }, + + /** 删除旅行 */ + deleteTravel() { + this.setData({ + deleteDialogVisible: true, + deleteConfirmText: "" + }); + }, + + /** 取消删除 */ + cancelDelete() { + this.setData({ + deleteDialogVisible: false, + deleteConfirmText: "" + }); + }, + + /** 确认删除 */ + confirmDelete() { + const inputText = this.data.deleteConfirmText.trim(); + if (inputText !== "确认删除") { + wx.showToast({ + title: "输入不匹配", + icon: "error" + }); + return; + } + this.setData({ + deleteDialogVisible: false + }); + this.executeDelete(); + }, + + /** 执行删除 */ + async executeDelete() { + if (!this.data.travel || !this.data.travel.id) return; + + wx.showLoading({ title: "删除中...", mask: true }); + + try { + await TravelApi.delete(this.data.travel.id); + wx.showToast({ + title: "删除成功", + icon: "success" + }); + setTimeout(() => { + wx.navigateBack(); + }, 1500); + } catch (error) { + // 错误已由 Network 类处理 + } finally { + wx.hideLoading(); + } + }, + + /** 返回 */ + goBack() { + wx.navigateBack(); + } +}); diff --git a/miniprogram/pages/main/travel-detail/index.wxml b/miniprogram/pages/main/travel-detail/index.wxml new file mode 100644 index 0000000..48da43c --- /dev/null +++ b/miniprogram/pages/main/travel-detail/index.wxml @@ -0,0 +1,208 @@ + + + + + + + + + + + + + + + + + + + {{statusLabels[travel.status]}} + + + + + + {{travel.title || '未命名旅行'}} + + + + + + + 基本信息 + + + + + + + 出行时间 + + {{travel.travelDate}} {{travel.travelTime}} + + + + + + 旅行天数 + + {{travel.days}} 天 + + + + + + 交通方式 + + {{transportLabels[travel.transportationType]}} + + + + + + + + + 详细说明 + + {{travel.content}} + + + + + + + + 地点列表 + + + + + + + + + + + + + + + + + + + + + + + + + {{item.title || '未命名地点'}} + {{locationTypeLabels[item.type]}} + + {{item.description}} + + + + {{item.location}} + + + + ¥{{item.amount}} + + + + 需要身份证 + + + + 必要度 {{item.score}} + + + + + + + + + + + + + 暂无地点信息 + + + + + + + 创建时间 + {{travel.createdAtFormatted || ''}} + + + 更新时间 + {{travel.updatedAtFormatted || ''}} + + + + + + + 删除 + + + 编辑旅行计划 + + + + + + + + + + + 删除后无法恢复,请谨慎操作! + + + 请输入"确认删除"以继续: + + + + diff --git a/miniprogram/pages/main/travel-editor/index.json b/miniprogram/pages/main/travel-editor/index.json new file mode 100644 index 0000000..572b2ff --- /dev/null +++ b/miniprogram/pages/main/travel-editor/index.json @@ -0,0 +1,17 @@ +{ + "component": true, + "usingComponents": { + "t-cell": "tdesign-miniprogram/cell/cell", + "t-icon": "tdesign-miniprogram/icon/icon", + "t-image": "tdesign-miniprogram/image/image", + "t-input": "tdesign-miniprogram/input/input", + "t-button": "tdesign-miniprogram/button/button", + "t-dialog": "tdesign-miniprogram/dialog/dialog", + "t-navbar": "tdesign-miniprogram/navbar/navbar", + "t-loading": "tdesign-miniprogram/loading/loading", + "t-stepper": "tdesign-miniprogram/stepper/stepper", + "t-textarea": "tdesign-miniprogram/textarea/textarea", + "t-cell-group": "tdesign-miniprogram/cell-group/cell-group" + }, + "styleIsolation": "shared" +} diff --git a/miniprogram/pages/main/travel-editor/index.less b/miniprogram/pages/main/travel-editor/index.less new file mode 100644 index 0000000..86c5916 --- /dev/null +++ b/miniprogram/pages/main/travel-editor/index.less @@ -0,0 +1,92 @@ +// pages/main/travel-editor/index.less + +.container { + width: 100vw; + min-height: 100vh; + background: var(--theme-bg-secondary); + + .content { + padding-bottom: 64rpx; + + .loading { + display: flex; + padding: 128rpx 0; + align-items: center; + flex-direction: column; + + .loading-text { + color: var(--theme-text-secondary); + margin-top: 24rpx; + font-size: 28rpx; + } + } + + .section { + margin-top: 48rpx; + + .picker .slot { + gap: 16rpx; + display: flex; + align-items: center; + } + + .days-stepper { + display: flex; + align-items: center; + } + } + + .submit-section { + gap: 24rpx; + display: flex; + padding: 24rpx 16rpx 48rpx 16rpx; + margin-top: 64rpx; + flex-direction: column; + + &.horizontal { + flex-direction: row; + + .save-btn { + flex: 2; + } + + .delete-btn { + flex: 1; + } + } + } + } +} + +.delete-dialog-content { + gap: 32rpx; + display: flex; + flex-direction: column; + + .delete-warning { + gap: 16rpx; + display: flex; + padding: 24rpx; + align-items: center; + border-radius: 12rpx; + flex-direction: column; + background: #FFF4F4; + + .warning-text { + color: #E34D59; + font-size: 28rpx; + text-align: center; + } + } + + .delete-confirm { + gap: 16rpx; + display: flex; + flex-direction: column; + + .confirm-label { + color: var(--theme-text-primary); + font-size: 28rpx; + } + } +} diff --git a/miniprogram/pages/main/travel-editor/index.ts b/miniprogram/pages/main/travel-editor/index.ts new file mode 100644 index 0000000..f177fcf --- /dev/null +++ b/miniprogram/pages/main/travel-editor/index.ts @@ -0,0 +1,292 @@ +// pages/main/travel-editor/index.ts + +import Time from "../../../utils/Time"; +import { TravelApi } from "../../../api/TravelApi"; +import { TravelStatus, TransportationType } from "../../../types/Travel"; + +interface TravelEditorData { + /** 模式:create 或 edit */ + mode: "create" | "edit"; + /** 旅行 ID(编辑模式) */ + id?: number; + /** 标题 */ + title: string; + /** 内容 */ + content: string; + /** 出行日期 */ + date: string; + /** 出行时间 */ + time: string; + /** 天数 */ + days: number; + /** 交通类型 */ + transportationType: TransportationType; + /** 状态 */ + status: TravelStatus; + /** 是否正在加载(编辑模式) */ + isLoading: boolean; + /** 是否正在保存 */ + isSaving: boolean; + /** 交通类型选项 */ + transportationTypes: { label: string; value: TransportationType }[]; + /** 交通类型选中索引 */ + transportationTypeIndex: number; + /** 状态选项 */ + statuses: { label: string; value: TravelStatus }[]; + /** 状态选中索引 */ + statusIndex: number; + /** 删除对话框可见性 */ + deleteDialogVisible: boolean; + /** 删除确认文本 */ + deleteConfirmText: string; +} + +Page({ + data: { + mode: "create", + id: undefined, + title: "", + content: "", + date: "2025-06-28", + time: "16:00", + days: 1, + transportationType: TransportationType.PLANE, + transportationTypeIndex: 0, + status: TravelStatus.PLANNING, + statusIndex: 0, + isLoading: false, + isSaving: false, + transportationTypes: [ + { label: "飞机", value: TransportationType.PLANE }, + { label: "火车", value: TransportationType.TRAIN }, + { label: "汽车", value: TransportationType.CAR }, + { label: "轮船", value: TransportationType.SHIP }, + { label: "自驾", value: TransportationType.SELF_DRIVING }, + { label: "其他", value: TransportationType.OTHER } + ], + statuses: [ + { label: "计划中", value: TravelStatus.PLANNING }, + { label: "进行中", value: TravelStatus.ONGOING }, + { label: "已完成", value: TravelStatus.COMPLETED } + ], + deleteDialogVisible: false, + deleteConfirmText: "" + }, + onLoad(options: any) { + // 判断模式:有 ID 是编辑,无 ID 是创建 + const id = options.id ? parseInt(options.id) : undefined; + + if (id) { + // 编辑模式 + this.setData({ + mode: "edit", + id, + isLoading: true + }); + this.loadTravelDetail(id); + } else { + // 创建模式 + this.setData({ + mode: "create", + isLoading: false + }); + + // 设置当前时间 + const unixTime = new Date().getTime(); + this.setData({ + date: Time.toDate(unixTime), + time: Time.toTime(unixTime) + }); + } + }, + /** 加载旅行详情(编辑模式) */ + async loadTravelDetail(id: number) { + wx.showLoading({ title: "加载中...", mask: true }); + try { + const travel = await TravelApi.getDetail(id); + + // 格式化数据 + let date = ""; + let time = ""; + if (travel.travelAt) { + date = Time.toDate(travel.travelAt); + time = Time.toTime(travel.travelAt); + } + + // 计算交通类型索引 + const transportationType = travel.transportationType || TransportationType.PLANE; + const transportationTypeIndex = this.data.transportationTypes.findIndex( + item => item.value === transportationType + ); + + // 计算状态索引 + const status = travel.status || TravelStatus.PLANNING; + const statusIndex = this.data.statuses.findIndex(item => item.value === status); + + this.setData({ + title: travel.title || "", + content: travel.content || "", + date, + time, + days: travel.days || 1, + transportationType, + transportationTypeIndex: transportationTypeIndex >= 0 ? transportationTypeIndex : 0, + status, + statusIndex: statusIndex >= 0 ? statusIndex : 0, + isLoading: false + }); + } catch (error) { + wx.showToast({ + title: "加载失败", + icon: "error" + }); + setTimeout(() => { + wx.navigateBack(); + }, 1500); + } finally { + wx.hideLoading(); + } + }, + /** 改变交通类型 */ + onChangeTransportationType(e: any) { + const index = e.detail.value; + this.setData({ + transportationTypeIndex: index, + transportationType: this.data.transportationTypes[index].value + }); + }, + /** 改变状态 */ + onChangeStatus(e: any) { + const index = e.detail.value; + this.setData({ + statusIndex: index, + status: this.data.statuses[index].value + }); + }, + /** 取消 */ + cancel() { + if (this.data.mode === "create") { + wx.navigateBack(); + } else { + wx.navigateBack(); + } + }, + /** 提交/保存 */ + submit() { + // 验证必填字段 + if (!this.data.title.trim()) { + wx.showToast({ + title: "请输入标题", + icon: "error" + }); + return; + } + if (this.data.mode === "create") { + this.createTravel(); + } else { + this.updateTravel(); + } + }, + /** 创建旅行 */ + async createTravel() { + this.setData({ isSaving: true }); + + try { + await TravelApi.create({ + title: this.data.title.trim(), + content: this.data.content.trim(), + travelAt: new Date(`${this.data.date}T${this.data.time}:00`).getTime(), + days: this.data.days, + transportationType: this.data.transportationType, + status: this.data.status + }); + wx.showToast({ + title: "创建成功", + icon: "success" + }); + setTimeout(() => { + wx.navigateBack(); + }, 1000); + } catch (error) { + this.setData({ isSaving: false }); + } + }, + /** 更新旅行 */ + async updateTravel() { + this.setData({ isSaving: true }); + try { + await TravelApi.update({ + id: this.data.id!, + title: this.data.title.trim(), + content: this.data.content.trim(), + travelAt: new Date(`${this.data.date}T${this.data.time}:00`).getTime(), + days: this.data.days, + transportationType: this.data.transportationType, + status: this.data.status + }); + + wx.showToast({ + title: "保存成功", + icon: "success" + }); + setTimeout(() => { + wx.navigateBack(); + }, 1000); + } catch (error) { + this.setData({ isSaving: false }); + } + }, + /** 删除旅行 */ + deleteTravel() { + this.setData({ + deleteDialogVisible: true, + deleteConfirmText: "" + }); + }, + + /** 取消删除 */ + cancelDelete() { + this.setData({ + deleteDialogVisible: false, + deleteConfirmText: "" + }); + }, + + /** 确认删除 */ + confirmDelete() { + const inputText = this.data.deleteConfirmText.trim(); + if (inputText !== "确认删除") { + wx.showToast({ + title: "输入不匹配", + icon: "error" + }); + return; + } + this.setData({ + deleteDialogVisible: false + }); + this.executeDelete(); + }, + + /** 执行删除 */ + async executeDelete() { + if (!this.data.id) return; + + wx.showLoading({ title: "删除中...", mask: true }); + + try { + await TravelApi.delete(this.data.id); + wx.showToast({ + title: "删除成功", + icon: "success" + }); + setTimeout(() => { + wx.navigateBack({ delta: 2 }); + }, 1500); + } catch (error) { + // 错误已由 Network 类处理 + } finally { + wx.hideLoading(); + } + } +}); diff --git a/miniprogram/pages/main/travel-editor/index.wxml b/miniprogram/pages/main/travel-editor/index.wxml new file mode 100644 index 0000000..bb05af5 --- /dev/null +++ b/miniprogram/pages/main/travel-editor/index.wxml @@ -0,0 +1,150 @@ + + + 取消 + + + + + + + 加载中... + + + + + 标题 + + + 内容 + + + + + + + + + {{date}} + + + + + + + + + + + + + + {{transportationTypes[transportationTypeIndex].label}} + + + + + + + + + + {{statuses[statusIndex].label}} + + + + + + + + + 创建旅行 + + + + + 删除 + + + 保存修改 + + + + + + + + + + + + 删除后无法恢复,请谨慎操作! + + + 请输入"确认删除"以继续: + + + + diff --git a/miniprogram/pages/main/travel-location-editor/index.json b/miniprogram/pages/main/travel-location-editor/index.json new file mode 100644 index 0000000..d5bb092 --- /dev/null +++ b/miniprogram/pages/main/travel-location-editor/index.json @@ -0,0 +1,16 @@ +{ + "component": true, + "usingComponents": { + "t-cell": "tdesign-miniprogram/cell/cell", + "t-icon": "tdesign-miniprogram/icon/icon", + "t-rate": "tdesign-miniprogram/rate/rate", + "t-input": "tdesign-miniprogram/input/input", + "t-button": "tdesign-miniprogram/button/button", + "t-navbar": "tdesign-miniprogram/navbar/navbar", + "t-loading": "tdesign-miniprogram/loading/loading", + "t-stepper": "tdesign-miniprogram/stepper/stepper", + "t-textarea": "tdesign-miniprogram/textarea/textarea", + "t-cell-group": "tdesign-miniprogram/cell-group/cell-group" + }, + "styleIsolation": "shared" +} diff --git a/miniprogram/pages/main/travel-location-editor/index.less b/miniprogram/pages/main/travel-location-editor/index.less new file mode 100644 index 0000000..2e25b36 --- /dev/null +++ b/miniprogram/pages/main/travel-location-editor/index.less @@ -0,0 +1,226 @@ +// pages/main/travel-location-editor/index.less + +.container { + width: 100vw; + min-height: 100vh; + background: var(--theme-bg-secondary); + + .content { + padding-bottom: 64rpx; + + .loading { + display: flex; + padding: 128rpx 0; + align-items: center; + flex-direction: column; + + .loading-text { + color: var(--theme-text-secondary); + margin-top: 24rpx; + font-size: 28rpx; + } + } + + .section { + margin-top: 48rpx; + + .picker .slot { + gap: 16rpx; + display: flex; + align-items: center; + } + + .location-slot { + gap: 16rpx; + display: flex; + align-items: center; + + .location-text { + color: var(--theme-text-primary); + font-size: 28rpx; + } + + .location-placeholder { + color: var(--theme-text-placeholder); + font-size: 28rpx; + } + } + + &.media { + + .gallery { + gap: 10rpx; + display: grid; + grid-template-columns: repeat(3, 1fr); + + .item { + width: 240rpx; + height: 240rpx; + position: relative; + overflow: hidden; + background: var(--theme-bg-card); + box-shadow: 1px 1px 6px var(--theme-shadow-light); + border-radius: 2rpx; + + &.add { + color: var(--theme-wx); + margin: 0; + font-size: 80rpx; + background: transparent; + } + + .thumbnail { + width: 240rpx; + height: 240rpx; + display: block; + } + + .video-container { + position: relative; + + + .play-icon { + top: 50%; + left: 50%; + color: rgba(255, 255, 255, .8); + z-index: 2; + position: absolute; + font-size: 128rpx; + transform: translate(-50%, -50%); + text-shadow: 4rpx 4rpx 0 rgba(0, 0, 0, .5); + pointer-events: none; + } + } + + .delete { + top: 10rpx; + right: 10rpx; + color: rgba(255, 255, 255, .6); + z-index: 3; + position: absolute; + font-size: 45rpx; + + &::after { + content: ""; + top: 0; + width: 100%; + height: 100%; + z-index: -1; + position: absolute; + background: rgba(0, 0, 0, .6); + border-radius: 50%; + } + } + + .new-badge { + top: 10rpx; + left: 10rpx; + color: var(--theme-wx); + z-index: 3; + display: flex; + position: absolute; + font-size: 45rpx; + text-shadow: 4rpx 4rpx 0 rgba(0, 0, 0, .5); + } + } + } + } + } + + .media-section { + margin-top: 48rpx; + padding: 32rpx; + background: var(--theme-bg-card); + + .section-title { + color: var(--theme-text-primary); + margin-bottom: 24rpx; + font-size: 32rpx; + font-weight: bold; + } + + .media-grid { + gap: 24rpx; + display: grid; + grid-template-columns: repeat(3, 1fr); + + .media-item { + width: 100%; + height: 200rpx; + overflow: hidden; + position: relative; + border-radius: 12rpx; + + .media-img { + width: 100%; + height: 100%; + } + + .video-badge { + top: 50%; + left: 50%; + display: flex; + position: absolute; + transform: translate(-50%, -50%); + align-items: center; + justify-content: center; + } + + .media-delete { + top: 8rpx; + right: 8rpx; + width: 48rpx; + height: 48rpx; + display: flex; + position: absolute; + background: rgba(0, 0, 0, .5); + align-items: center; + border-radius: 50%; + justify-content: center; + } + } + + .media-add { + width: 100%; + height: 200rpx; + display: flex; + align-items: center; + border-radius: 12rpx; + justify-content: center; + background: var(--theme-bg-page); + border: 2rpx dashed var(--theme-border); + } + } + } + + .upload-info { + gap: 16rpx; + display: flex; + padding: 24rpx 32rpx; + margin-top: 24rpx; + align-items: center; + border-radius: 12rpx; + background: var(--theme-bg-card); + + .upload-text { + color: var(--theme-text-secondary); + font-size: 28rpx; + } + } + + .submit-section { + gap: 24rpx; + display: flex; + padding: 24rpx 16rpx 48rpx 16rpx; + margin-top: 64rpx; + + .delete-btn { + flex: .6; + } + + .submit-btn { + flex: 1; + } + } + } +} diff --git a/miniprogram/pages/main/travel-location-editor/index.ts b/miniprogram/pages/main/travel-location-editor/index.ts new file mode 100644 index 0000000..973ac8d --- /dev/null +++ b/miniprogram/pages/main/travel-location-editor/index.ts @@ -0,0 +1,501 @@ +// pages/main/travel-location-editor/index.ts + +import { Network, WechatMediaItem } from "../../../utils/Network"; +import { TravelLocationApi } from "../../../api/TravelLocationApi"; +import { TravelLocationType } from "../../../types/Travel"; +import { MediaAttachExt, MediaAttachType } from "../../../types/Attachment"; +import config from "../../../config/index"; +import { MediaItem, MediaItemType } from "../../../types/UI"; + +interface TravelLocationEditorData { + /** 模式:create 或 edit */ + mode: "create" | "edit"; + /** 旅行地点 ID(编辑模式) */ + id?: number; + /** 关联的旅行计划 ID */ + travelId: number; + /** 地点类型 */ + type: TravelLocationType; + /** 标题 */ + title: string; + /** 说明 */ + description: string; + /** 位置描述 */ + location: string; + /** 纬度 */ + lat: number; + /** 经度 */ + lng: number; + /** 费用 */ + amount: number; + /** 是否需要身份证 */ + requireIdCard: boolean; + /** 必要评分 */ + score: number; + /** 媒体列表(创建和编辑模式使用) */ + mediaList: (MediaItem | WechatMediaItem)[]; + /** 新媒体列表(编辑模式使用) */ + newMediaList: WechatMediaItem[]; + /** 是否正在加载(编辑模式) */ + isLoading: boolean; + /** 是否正在保存 */ + isSaving: boolean; + /** 是否正在上传 */ + isUploading: boolean; + /** 上传进度信息 */ + uploadInfo: string; + /** 地点类型选项 */ + locationTypes: { label: string; value: TravelLocationType }[]; + /** 地点类型选中索引 */ + locationTypeIndex: number; + /** 媒体类型枚举 */ + mediaItemTypeEnum: any; +} + +Page({ + data: { + mode: "create", + id: undefined, + travelId: 0, + type: TravelLocationType.ATTRACTION, + title: "", + description: "", + location: "", + lat: 0, + lng: 0, + amount: 0, + requireIdCard: false, + score: 3, + mediaList: [], + newMediaList: [], + isLoading: false, + isSaving: false, + isUploading: false, + uploadInfo: "", + mediaItemTypeEnum: { + ...MediaItemType + }, + locationTypes: [ + { label: "景点", value: TravelLocationType.ATTRACTION }, + { label: "酒店", value: TravelLocationType.HOTEL }, + { label: "餐厅", value: TravelLocationType.RESTAURANT }, + { label: "交通站点", value: TravelLocationType.TRANSPORT }, + { label: "购物", value: TravelLocationType.SHOPPING }, + { label: "其他", value: TravelLocationType.OTHER } + ], + locationTypeIndex: 0 + }, + + onLoad(options: any) { + // 获取 travelId(必填) + const travelId = options.travelId ? parseInt(options.travelId) : 0; + if (!travelId) { + wx.showToast({ + title: "缺少旅行计划 ID", + icon: "error" + }); + setTimeout(() => { + wx.navigateBack(); + }, 1500); + return; + } + + // 判断模式:有 ID 是编辑,无 ID 是创建 + const id = options.id ? parseInt(options.id) : undefined; + + if (id) { + // 编辑模式 + this.setData({ + mode: "edit", + id, + travelId, + isLoading: true + }); + this.loadLocationDetail(id); + } else { + // 创建模式 + this.setData({ + mode: "create", + travelId, + isLoading: false + }); + } + }, + + /** 加载地点详情(编辑模式) */ + async loadLocationDetail(id: number) { + wx.showLoading({ title: "加载中...", mask: true }); + + try { + const location = await TravelLocationApi.getDetail(id); + + // 计算地点类型索引 + const type = location.type || TravelLocationType.ATTRACTION; + const locationTypeIndex = this.data.locationTypes.findIndex( + item => item.value === type + ); + + const items = location.items || []; + const thumbItems = items.filter((item) => item.attachType === MediaAttachType.THUMB); + + const mediaList: MediaItem[] = thumbItems.map((thumbItem) => { + const ext = thumbItem.ext = JSON.parse(thumbItem.ext!.toString()) as MediaAttachExt; + const thumbURL = `${config.url}/attachment/read/${thumbItem.mongoId}`; + const sourceURL = `${config.url}/attachment/read/${ext.sourceMongoId}`; + return { + type: ext.isVideo ? MediaItemType.VIDEO : MediaItemType.IMAGE, + thumbURL, + sourceURL, + size: thumbItem.size || 0, + attachmentId: thumbItem.id + } as MediaItem; + }); + + this.setData({ + type, + locationTypeIndex: locationTypeIndex >= 0 ? locationTypeIndex : 0, + title: location.title || "", + description: location.description || "", + location: location.location || "", + lat: location.lat || 0, + lng: location.lng || 0, + amount: location.amount || 0, + requireIdCard: location.requireIdCard || false, + score: location.score !== undefined ? location.score : 3, + mediaList, + isLoading: false + }); + } catch (error) { + wx.showToast({ + title: "加载失败", + icon: "error" + }); + setTimeout(() => { + wx.navigateBack(); + }, 1500); + } finally { + wx.hideLoading(); + } + }, + + /** 改变地点类型 */ + onChangeLocationType(e: any) { + const index = e.detail.value; + this.setData({ + locationTypeIndex: index, + type: this.data.locationTypes[index].value + }); + }, + + /** 改变是否需要身份证 */ + onChangeRequireIdCard(e: any) { + this.setData({ requireIdCard: e.detail.value }); + }, + + /** 改变评分 */ + onChangeScore(e: any) { + this.setData({ score: e.detail.value }); + }, + + /** 选择位置 */ + chooseLocation() { + wx.chooseLocation({ + success: (res) => { + const locationName = res.address || res.name; + const updateData: any = { + location: locationName, + lat: res.latitude, + lng: res.longitude + }; + + // 如果标题为空,使用选择的地点名称作为标题 + if (!this.data.title.trim()) { + updateData.title = res.name || locationName; + } + + this.setData(updateData); + }, + fail: (err) => { + if (err.errMsg.includes("auth deny")) { + wx.showToast({ + title: "请授权位置权限", + icon: "error" + }); + } + } + }); + }, + + /** 添加媒体 */ + addMedia() { + const that = this; + wx.chooseMedia({ + mediaType: ["mix"], + sourceType: ["album", "camera"], + camera: "back", + success(res) { + wx.showLoading({ + title: "加载中..", + mask: true + }); + const tempFiles = res.tempFiles; + const newMedia = tempFiles.map(item => { + return { + type: (MediaItemType)[item.fileType.toUpperCase()], + path: item.tempFilePath, + thumbPath: item.thumbTempFilePath, + size: item.size, + duration: item.duration, + raw: item + } as WechatMediaItem; + }); + if (that.data.mode === "create") { + that.setData({ + mediaList: [...that.data.mediaList, ...newMedia] + }); + } else { + that.setData({ + newMediaList: [...that.data.newMediaList, ...newMedia] + }); + } + wx.hideLoading(); + } + }); + }, + + /** 删除媒体(创建模式) */ + deleteMedia(e: any) { + const { index } = e.currentTarget.dataset; + const mediaList = this.data.mediaList; + mediaList.splice(index, 1); + this.setData({ mediaList }); + }, + + /** 删除新媒体(编辑模式) */ + deleteNewMedia(e: any) { + const { index } = e.currentTarget.dataset; + const newMediaList = this.data.newMediaList; + newMediaList.splice(index, 1); + this.setData({ newMediaList }); + }, + + /** 删除已有媒体(编辑模式) */ + deleteExistingMedia(e: any) { + const { index } = e.currentTarget.dataset; + const existingMedia = this.data.mediaList; + existingMedia.splice(index, 1); + this.setData({ existingMedia }); + }, + + /** 预览媒体 */ + preview(e: WechatMiniprogram.BaseEvent) { + const isNewMedia = e.currentTarget.dataset.newMedia; + const index = e.currentTarget.dataset.index; + + if (this.data.mode === "create") { + // 创建模式:只有 mediaList + const sources = (this.data.mediaList as WechatMediaItem[]).map(item => ({ + url: item.path, + type: MediaItemType[item.type].toLowerCase() + })); + + const total = sources.length; + const startIndex = Math.max(0, index - 25); + const endIndex = Math.min(total, startIndex + 50); + const newCurrentIndex = index - startIndex; + + wx.previewMedia({ + current: newCurrentIndex, + sources: sources.slice(startIndex, endIndex) as WechatMiniprogram.MediaSource[] + }); + } else { + // 编辑模式:mediaList + newMediaList + const sources = (this.data.mediaList as MediaItem[]).map(item => ({ + url: item.sourceURL, + type: MediaItemType[item.type].toLowerCase() + })); + const newSources = this.data.newMediaList.map(item => ({ + url: item.path, + type: MediaItemType[item.type].toLowerCase() + })); + const allSources = [...sources, ...newSources]; + const itemIndex = isNewMedia ? this.data.mediaList.length + index : index; + const total = allSources.length; + + const startIndex = Math.max(0, itemIndex - 25); + const endIndex = Math.min(total, startIndex + 50); + const newCurrentIndex = itemIndex - startIndex; + + wx.previewMedia({ + current: newCurrentIndex, + sources: allSources.slice(startIndex, endIndex) as WechatMiniprogram.MediaSource[] + }); + } + }, + + /** 取消 */ + cancel() { + wx.navigateBack(); + }, + + /** 删除地点 */ + deleteLocation() { + wx.showModal({ + title: "确认删除", + content: "确定要删除这个地点吗?删除后无法恢复。", + success: async (res) => { + if (res.confirm && this.data.id) { + try { + await TravelLocationApi.delete(this.data.id); + wx.showToast({ + title: "删除成功", + icon: "success" + }); + setTimeout(() => { + wx.navigateBack(); + }, 1000); + } catch (error) { + wx.showToast({ + title: "删除失败", + icon: "error" + }); + } + } + } + }); + }, + + /** 提交/保存 */ + submit() { + // 验证必填字段 + if (!this.data.title.trim()) { + wx.showToast({ + title: "请输入标题", + icon: "error" + }); + return; + } + + if (!this.data.location || !this.data.lat || !this.data.lng) { + wx.showToast({ + title: "请选择位置", + icon: "error" + }); + return; + } + + if (this.data.mode === "create") { + this.createLocation(); + } else { + this.updateLocation(); + } + }, + + /** 创建地点 */ + async createLocation() { + this.setData({ isSaving: true }); + + try { + // 上传媒体文件 + this.setData({ + isUploading: true, + uploadInfo: "正在上传 0%" + }); + const tempFileIds = await Network.uploadFiles({ + mediaList: this.data.mediaList as WechatMediaItem[], + onProgress: ({ percent }) => { + this.setData({ + uploadInfo: `正在上传 ${percent}%` + }); + } + }); + this.setData({ + isUploading: false, + uploadInfo: "" + }); + + // 创建地点 + await TravelLocationApi.create({ + travelId: this.data.travelId, + type: this.data.type, + title: this.data.title.trim(), + description: this.data.description.trim(), + location: this.data.location, + lat: this.data.lat, + lng: this.data.lng, + amount: this.data.amount, + requireIdCard: this.data.requireIdCard, + score: this.data.score, + tempFileIds + }); + wx.showToast({ + title: "创建成功", + icon: "success" + }); + setTimeout(() => { + wx.navigateBack(); + }, 1000); + } catch (error) { + this.setData({ + isSaving: false, + isUploading: false, + uploadInfo: "" + }); + } + }, + + /** 更新地点 */ + async updateLocation() { + this.setData({ isSaving: true }); + try { + // 保留的附件 ID 列表 + const attachmentIds = (this.data.mediaList as MediaItem[]).map(item => item.attachmentId); + // 上传新媒体文件 + this.setData({ + isUploading: true, + uploadInfo: "正在上传 0%" + }); + const tempFileIds = await Network.uploadFiles({ + mediaList: this.data.newMediaList, + onProgress: ({ percent }) => { + this.setData({ + uploadInfo: `正在上传 ${percent}%` + }); + } + }); + this.setData({ + isUploading: false, + uploadInfo: "" + }); + // 更新地点 + await TravelLocationApi.update({ + id: this.data.id!, + travelId: this.data.travelId, + type: this.data.type, + title: this.data.title.trim(), + description: this.data.description.trim(), + location: this.data.location, + lat: this.data.lat, + lng: this.data.lng, + amount: this.data.amount, + requireIdCard: this.data.requireIdCard, + score: this.data.score, + attachmentIds, + tempFileIds + }); + wx.showToast({ + title: "保存成功", + icon: "success" + }); + setTimeout(() => { + wx.navigateBack(); + }, 1000); + } catch (error) { + this.setData({ + isSaving: false, + isUploading: false, + uploadInfo: "" + }); + } + } +}); diff --git a/miniprogram/pages/main/travel-location-editor/index.wxml b/miniprogram/pages/main/travel-location-editor/index.wxml new file mode 100644 index 0000000..5559d00 --- /dev/null +++ b/miniprogram/pages/main/travel-location-editor/index.wxml @@ -0,0 +1,242 @@ + + + 取消 + + + + + + + 加载中... + + + + + + + + {{locationTypes[locationTypeIndex].label}} + + + + + + + + + {{location}} + 点击选择位置 + + + + + + + + + 说明 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{uploadInfo}} + + + + + + 删除 + + + {{mode === 'create' ? '创建地点' : '保存修改'}} + + + + + diff --git a/miniprogram/pages/main/travel-location-map/index.json b/miniprogram/pages/main/travel-location-map/index.json new file mode 100644 index 0000000..4e1c413 --- /dev/null +++ b/miniprogram/pages/main/travel-location-map/index.json @@ -0,0 +1,8 @@ +{ + "component": true, + "usingComponents": { + "t-navbar": "tdesign-miniprogram/navbar/navbar", + "travel-location-popup": "/components/travel-location-popup/index" + }, + "styleIsolation": "shared" +} diff --git a/miniprogram/pages/main/travel-location-map/index.less b/miniprogram/pages/main/travel-location-map/index.less new file mode 100644 index 0000000..b55fea5 --- /dev/null +++ b/miniprogram/pages/main/travel-location-map/index.less @@ -0,0 +1,70 @@ +/* pages/main/travel-location-map/index.less */ +.container { + width: 100%; + height: 100vh; + position: fixed; + overflow: hidden; + + .map { + width: 100%; + height: 100%; + } + + .custom-callout { + width: fit-content; + padding: 12rpx 16rpx; + display: flex; + min-width: 300rpx; + max-width: 400rpx; + background: #fff; + box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, .15); + border-radius: 6rpx; + flex-direction: column; + + .location-item { + display: flex; + padding: 6rpx 0; + align-items: center; + + &:not(:last-child) { + border-bottom: 1px solid #eee; + } + + .type { + color: #fff; + padding: 2rpx 8rpx; + font-size: 24rpx; + flex-shrink: 0; + background: var(--theme-wx, #07c160); + margin-right: 12rpx; + border-radius: 4rpx; + } + + .title { + flex: 1; + color: #333; + overflow: hidden; + font-size: 28rpx; + font-weight: bold; + white-space: nowrap; + text-overflow: ellipsis; + } + } + } + + .loading { + top: 50%; + left: 50%; + z-index: 1000; + position: fixed; + transform: translate(-50%, -50%); + + .loading-text { + color: #666; + padding: 24rpx 48rpx; + background: #FFF; + border-radius: 8rpx; + box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, .15); + } + } +} diff --git a/miniprogram/pages/main/travel-location-map/index.ts b/miniprogram/pages/main/travel-location-map/index.ts new file mode 100644 index 0000000..a9ce72e --- /dev/null +++ b/miniprogram/pages/main/travel-location-map/index.ts @@ -0,0 +1,181 @@ +// pages/main/travel-location-map/index.ts + +import { TravelLocationApi } from "../../../api/TravelLocationApi"; +import { TravelLocation, TravelLocationTypeLabel } from "../../../types/Travel"; + +interface MapMarker { + id: number; + latitude: number; + longitude: number; + width: number; + height: number; + customCallout: { + anchorY: number; + anchorX: number; + display: string; + }; + /** 该位置的所有地点 */ + locations: TravelLocation[]; +} + +interface TravelMapData { + travelId: number; + centerLat: number; + centerLng: number; + scale: number; + markers: MapMarker[]; + locations: TravelLocation[]; + includePoints: Array<{ latitude: number; longitude: number }>; + isLoading: boolean; + detailVisible: boolean; + detailIds: number[]; +} + +Page({ + data: { + travelId: 0, + centerLat: 39.908823, + centerLng: 116.397470, + scale: 13, + markers: [], + locations: [], + includePoints: [], + isLoading: true, + detailVisible: false, + detailIds: [] + }, + + onLoad(options: any) { + const travelId = options.travelId ? parseInt(options.travelId) : 0; + if (travelId) { + this.setData({ travelId }); + this.loadLocations(travelId); + } else { + wx.showToast({ + title: "参数错误", + icon: "error" + }); + setTimeout(() => { + wx.navigateBack(); + }, 1500); + } + }, + + /** 加载所有地点 */ + async loadLocations(travelId: number) { + this.setData({ isLoading: true }); + + try { + const result = await TravelLocationApi.getList({ + index: 0, + size: 100, + equalsExample: { + travelId + } + }); + + // 过滤有位置信息的地点 + const locations = result.list.filter(loc => loc.lat && loc.lng); + + if (locations.length === 0) { + wx.showToast({ + title: "暂无位置记录", + icon: "none" + }); + this.setData({ isLoading: false }); + return; + } + + // 为每个地点添加类型标签 + locations.forEach(location => { + (location as any).typeLabel = location.type ? TravelLocationTypeLabel[location.type] : ""; + }); + + // 按位置分组,处理重叠地点 + const locationMap = new Map(); + locations.forEach(location => { + const key = `${location.lat},${location.lng}`; + if (!locationMap.has(key)) { + locationMap.set(key, []); + } + locationMap.get(key)!.push(location); + }); + + // 生成地图标记(每个唯一位置一个标记) + const markers: MapMarker[] = Array.from(locationMap.values()).map((locs, index) => ({ + id: index, + latitude: locs[0].lat!, + longitude: locs[0].lng!, + width: 24, + height: 30, + customCallout: { + anchorY: -2, + anchorX: 0, + display: "ALWAYS" + }, + locations: locs + })); + + // 计算中心点(所有标记的平均位置) + const centerLat = locations.reduce((sum, l) => sum + l.lat!, 0) / locations.length; + const centerLng = locations.reduce((sum, l) => sum + l.lng!, 0) / locations.length; + + // 缩放视野以包含所有标记点 + const includePoints = locations.map((l) => ({ + latitude: l.lat!, + longitude: l.lng! + })); + + this.setData({ + locations, + markers, + centerLat, + centerLng, + includePoints, + isLoading: false + }); + } catch (err: any) { + wx.showToast({ + title: "加载失败", + icon: "error" + }); + this.setData({ isLoading: false }); + } + }, + + /** 标记点击事件 */ + onMarkerTap(e: any) { + const markerId = e.detail.markerId || e.markerId; + this.loadLocationDetail(markerId); + }, + + /** 气泡点击事件 */ + onCalloutTap(e: any) { + const markerId = e.detail.markerId || e.markerId; + this.loadLocationDetail(markerId); + }, + + /** 加载位置详情 */ + loadLocationDetail(markerId: number) { + const marker = this.data.markers[markerId]; + if (!marker || !marker.locations || marker.locations.length === 0) { + return; + } + // 获取该标记点的所有地点 ID + const locationIds = marker.locations.map(loc => loc.id!).filter(id => id); + if (locationIds.length === 0) { + return; + } + this.setData({ + detailVisible: true, + detailIds: locationIds + }); + }, + + /** 关闭详情 */ + closeDetail() { + this.setData({ + detailVisible: false + }); + } +}); diff --git a/miniprogram/pages/main/travel-location-map/index.wxml b/miniprogram/pages/main/travel-location-map/index.wxml new file mode 100644 index 0000000..d2d5315 --- /dev/null +++ b/miniprogram/pages/main/travel-location-map/index.wxml @@ -0,0 +1,32 @@ + + + + + + + + + + {{location.typeLabel}} + {{location.title || '未命名地点'}} + + + + + + + + 加载中... + + + diff --git a/miniprogram/pages/main/travel/index.json b/miniprogram/pages/main/travel/index.json index 1796681..1623fe3 100644 --- a/miniprogram/pages/main/travel/index.json +++ b/miniprogram/pages/main/travel/index.json @@ -1,9 +1,13 @@ { "component": true, "usingComponents": { + "t-tag": "tdesign-miniprogram/tag/tag", "t-cell": "tdesign-miniprogram/cell/cell", + "t-icon": "tdesign-miniprogram/icon/icon", + "t-empty": "tdesign-miniprogram/empty/empty", "t-navbar": "tdesign-miniprogram/navbar/navbar", - "t-collapse": "tdesign-miniprogram/collapse/collapse", - "t-collapse-panel": "tdesign-miniprogram/collapse-panel/collapse-panel" - } -} \ No newline at end of file + "t-cell-group": "tdesign-miniprogram/cell-group/cell-group" + }, + "styleIsolation": "shared", + "enablePullDownRefresh": true +} diff --git a/miniprogram/pages/main/travel/index.less b/miniprogram/pages/main/travel/index.less index a3a74d9..0aeb2a3 100644 --- a/miniprogram/pages/main/travel/index.less +++ b/miniprogram/pages/main/travel/index.less @@ -1,25 +1,119 @@ -/* pages/main/travel/travel.wxss */ +// pages/main/travel/index.less -.travel { +.filter-menu { + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 999; + position: fixed; + background: var(--theme-bg-overlay); - .collapse { + .content { + z-index: 1000; + position: fixed; + background: var(--theme-bg-menu); + box-shadow: 0 0 12px var(--theme-shadow-medium); + border-radius: 8rpx; + } +} - .panel { +.travel-list { + width: 100vw; + padding: 16rpx; + min-height: 100vh; + box-sizing: border-box; + padding-bottom: 120rpx; - .images { - column-gap: .25rem; - column-count: 3; - padding-bottom: 2rem; + .travel-card { + background: var(--theme-bg-card); + margin-bottom: 24rpx; + border-radius: 16rpx; + overflow: hidden; + box-shadow: 0 2px 12px var(--theme-shadow-light); + transition: all .3s; - .image { - width: 100%; - display: block; - overflow: hidden; - background: var(--theme-bg-card); - break-inside: avoid; - margin-bottom: .25rem; + &:active { + transform: scale(.98); + box-shadow: 0 2px 8px var(--theme-shadow-light); + } + + .card-header { + padding: 24rpx; + border-bottom: 1px solid var(--theme-border-light); + } + + .card-body { + padding: 24rpx; + + .title { + color: var(--theme-text-primary); + font-size: 32rpx; + font-weight: bold; + margin-bottom: 16rpx; + line-height: 1.5; + } + + .content { + color: var(--theme-text-secondary); + font-size: 28rpx; + line-height: 1.6; + margin-bottom: 24rpx; + display: -webkit-box; + overflow: hidden; + -webkit-line-clamp: 2; + text-overflow: ellipsis; + -webkit-box-orient: vertical; + } + + .meta { + gap: 16rpx; + display: flex; + flex-wrap: wrap; + + .meta-item { + gap: 8rpx; + display: flex; + align-items: center; + + .icon { + color: var(--theme-text-secondary); + } + + .text { + color: var(--theme-text-secondary); + font-size: 24rpx; + } } } } } -} \ No newline at end of file + + .finished { + color: var(--theme-text-secondary); + padding: 32rpx 0; + font-size: 24rpx; + text-align: center; + } +} + +.fab { + right: 32rpx; + width: 112rpx; + bottom: 120rpx; + height: 112rpx; + display: flex; + z-index: 9999; + position: fixed; + background: var(--theme-wx); + align-items: center; + border-radius: 50%; + justify-content: center; + box-shadow: 0 8px 24px rgba(102, 126, 234, .4); + transition: all .3s ease; + + &:active { + transform: scale(.9); + box-shadow: 0 4px 12px rgba(102, 126, 234, .3); + } +} diff --git a/miniprogram/pages/main/travel/index.ts b/miniprogram/pages/main/travel/index.ts index 59cf258..96831f8 100644 --- a/miniprogram/pages/main/travel/index.ts +++ b/miniprogram/pages/main/travel/index.ts @@ -1,106 +1,174 @@ -// pages/main/travel/travel.ts +// pages/main/travel/index.ts -import config from "../../../config/index"; +import Time from "../../../utils/Time"; +import { TravelApi } from "../../../api/TravelApi"; +import { Travel, TravelPage, TravelStatus, TravelStatusLabel, TravelStatusIcon, TransportationTypeLabel } from "../../../types/Travel"; +import { OrderType } from "../../../types/Model"; -export type Luggage = { - gao: LuggageItem[]; - yu: LuggageItem[]; -} - -export type LuggageItem = { - name: string; - isTaken: boolean; -} - -type Guide = { - title: string; - images: string[]; -} - -interface ITravelData { - luggage?: Luggage; - guides: Guide[]; - guidesDB: Guide[]; - activeCollapse?: number; +interface TravelData { + /** 分页参数 */ + page: TravelPage; + /** 旅行列表 */ + list: Travel[]; + /** 当前筛选状态 */ + currentStatus: TravelStatus | "ALL"; + /** 是否正在加载 */ + isFetching: boolean; + /** 是否已加载完成 */ + isFinished: boolean; + /** 是否显示筛选菜单 */ + isShowFilterMenu: boolean; + /** 菜单位置 */ + menuTop: number; + menuLeft: number; + /** 状态标签映射 */ + statusLabels: typeof TravelStatusLabel; + /** 状态图标映射 */ + statusIcons: typeof TravelStatusIcon; + /** 交通类型标签映射 */ + transportLabels: typeof TransportationTypeLabel; } Page({ - data: { - luggage: undefined, - guides: [], - guidesDB: [], - activeCollapse: undefined + data: { + page: { + index: 0, + size: 10, + orderMap: { + travelAt: OrderType.DESC + } + }, + list: [], + currentStatus: "ALL", + isFetching: false, + isFinished: false, + isShowFilterMenu: false, + menuTop: 0, + menuLeft: 0, + statusLabels: TravelStatusLabel, + statusIcons: TravelStatusIcon, + transportLabels: TransportationTypeLabel }, onLoad() { - wx.request({ - url: `${config.url}/journal/travel`, - method: "GET", - header: { - Key: wx.getStorageSync("key") - }, - success: async (resp: any) => { - this.setData({ - luggage: resp.data.data.luggage, - guides: resp.data.data.guides.map((item: any) => { - return { - title: item.title, - images: [] // 留空分批加载 - } - }), - guidesDB: resp.data.data.guides - }) - } - }); + this.resetAndFetch(); }, - onShow() { - wx.request({ - url: `${config.url}/journal/travel`, - method: "GET", - header: { - Key: wx.getStorageSync("key") - }, - success: async (resp: any) => { - this.setData({ - luggage: resp.data.data.luggage - }); - } - }); - }, - toLuggageList(e: WechatMiniprogram.BaseEvent) { - const { name } = e.currentTarget.dataset; - wx.setStorageSync("luggage", { - name, - luggage: this.data.luggage - }); - wx.navigateTo({ - "url": "/pages/main/travel/luggage/index" - }) - }, - onCollapseChange(e: any) { - const index = e.detail.value; - if (this.data.guides[index].images.length === 0) { - this.data.guides[index].images = this.data.guidesDB[index].images.map((item: any) => { - return `${config.url}/attachment/read/${item}`; - }); - this.setData({ - guides: this.data.guides - }) - } + onHide() { this.setData({ - activeCollapse: index - }) + isShowFilterMenu: false + }); }, - preview(e: WechatMiniprogram.BaseEvent) { - const { index, imageIndex } = e.currentTarget.dataset; - const images = this.data.guides[index].images; - wx.previewMedia({ - current: imageIndex, - sources: images.map((image: any) => { - return { - url: image, - type: "image" + onPullDownRefresh() { + this.resetAndFetch(); + wx.stopPullDownRefresh(); + }, + onReachBottom() { + this.fetch(); + }, + /** 重置并获取数据 */ + resetAndFetch() { + this.setData({ + page: { + index: 0, + size: 10, + orderMap: { + travelAt: OrderType.DESC + }, + equalsExample: this.data.currentStatus === "ALL" ? undefined : { + status: this.data.currentStatus as TravelStatus } - }) - }) + }, + list: [], + isFetching: false, + isFinished: false + }); + this.fetch(); + }, + /** 获取旅行列表 */ + async fetch() { + if (this.data.isFetching || this.data.isFinished) { + return; + } + + this.setData({ isFetching: true }); + + try { + const pageResult = await TravelApi.getList(this.data.page); + const list = pageResult.list || []; + + if (list.length === 0) { + this.setData({ isFinished: true }); + return; + } + + // 格式化数据 + list.forEach(travel => { + if (travel.travelAt) { + travel.travelDate = Time.toDate(travel.travelAt); + travel.travelTime = Time.toTime(travel.travelAt); + } + }); + + this.setData({ + page: { + ...this.data.page, + index: this.data.page.index + 1 + }, + list: this.data.list.concat(list), + isFinished: list.length < this.data.page.size + }); + } catch (error) { + console.error("获取旅行列表失败:", error); + } finally { + this.setData({ isFetching: false }); + } + }, + /** 切换筛选菜单 */ + toggleFilterMenu() { + if (!this.data.isShowFilterMenu) { + // 打开菜单时计算位置 + const query = wx.createSelectorQuery(); + query.select(".filter-btn").boundingClientRect(); + query.exec((res) => { + if (res[0]) { + const { top, left, height } = res[0]; + this.setData({ + isShowFilterMenu: true, + menuTop: top + height + 16, + menuLeft: left + }); + } + }); + } else { + // 关闭菜单 + this.setData({ + isShowFilterMenu: false + }); + } + }, + /** 阻止事件冒泡 */ + stopPropagation() { + // 空函数,仅用于阻止事件冒泡 + }, + /** 筛选状态 */ + filterByStatus(e: WechatMiniprogram.BaseEvent) { + const status = e.currentTarget.dataset.status as TravelStatus | "ALL"; + this.setData({ + currentStatus: status, + isShowFilterMenu: false + }); + this.resetAndFetch(); + }, + /** 新建旅行 */ + toCreate() { + wx.navigateTo({ + url: "/pages/main/travel-editor/index" + }); + }, + /** 查看详情 */ + toDetail(e: WechatMiniprogram.BaseEvent) { + const { id } = e.currentTarget.dataset; + wx.navigateTo({ + url: `/pages/main/travel-detail/index?id=${id}` + }); }, }); diff --git a/miniprogram/pages/main/travel/index.wxml b/miniprogram/pages/main/travel/index.wxml index a336e41..82b75fc 100644 --- a/miniprogram/pages/main/travel/index.wxml +++ b/miniprogram/pages/main/travel/index.wxml @@ -1,30 +1,108 @@ - + - + + + + + - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + {{statusLabels[travel.status]}} + + + + + {{travel.title || '未命名旅行'}} + + {{travel.content}} + + + + + {{travel.travelDate}} {{travel.travelTime}} + + + + + {{travel.days}} 天 + + + + + {{transportLabels[travel.transportationType]}} + - - - \ No newline at end of file + + + + + 没有更多了 + + + + + diff --git a/miniprogram/pages/main/travel/luggage/index.less b/miniprogram/pages/main/travel/luggage/index.less deleted file mode 100644 index 1ad6ed1..0000000 --- a/miniprogram/pages/main/travel/luggage/index.less +++ /dev/null @@ -1,88 +0,0 @@ -.luggage { - - .tips { - color: var(--theme-text-secondary); - margin: .5rem 0; - font-size: 12px; - text-align: center; - } - - .items { - gap: 8px; - width: calc(100% - 64rpx); - margin: 12rpx auto; - display: grid; - grid-template-columns: repeat(3, 1fr); - - .item { - --td-checkbox-vertical-padding: 12rpx 24rpx; - - flex: 1; - margin: 0; - border: 3rpx solid var(--theme-text-disabled); - position: relative; - overflow: hidden; - box-sizing: border-box; - word-break: break-all; - border-radius: 12rpx; - - &:first-child { - margin-left: 0; - } - - &.active { - border-color: var(--td-brand-color, #0052d9); - - &::after { - content: ""; - top: 0; - left: 0; - width: 0; - height: 0; - display: block; - position: absolute; - border-width: 24px 24px 24px 0; - border-style: solid; - border-color: var(--td-brand-color); - border-bottom-color: transparent; - border-right-color: transparent; - } - } - - .icon { - top: 1px; - left: 1px; - color: var(--td-bg-color-container, #fff); - z-index: 1; - position: absolute; - font-size: 12px; - } - - .checkbox { - height: calc(100% - 24rpx); - } - } - } -} - -.add-container { - left: 0; - right: 0; - color: var(--theme-text-primary); - bottom: 0; - display: flex; - padding: 20rpx; - position: fixed; - border-top: 1px solid var(--theme-border-light); - background: var(--theme-bg-secondary); - box-sizing: border-box; - align-items: center; - padding-bottom: calc(20rpx + env(safe-area-inset-bottom)); - backdrop-filter: blur(10px); - - .input { - --td-input-vertical-padding: 8rpx; - margin-right: .5rem; - border-radius: 5px; - } -} \ No newline at end of file diff --git a/miniprogram/pages/main/travel/luggage/index.ts b/miniprogram/pages/main/travel/luggage/index.ts deleted file mode 100644 index c680216..0000000 --- a/miniprogram/pages/main/travel/luggage/index.ts +++ /dev/null @@ -1,86 +0,0 @@ -// pages/main/travel/luggage/index.ts - -import { LuggageItem } from ".."; -import config from "../../../../config/index" - -interface ILuggageData { - name: string; - value: LuggageItem[]; - keyboardHeight: number; - addValue: string; -} - -Page({ - data: { - name: "", - value: [], - keyboardHeight: 0, - addValue: "" - }, - onLoad() { - wx.onKeyboardHeightChange(res => { - this.setData({ - keyboardHeight: res.height - }) - }) - }, - onShow() { - const store = wx.getStorageSync("luggage"); - const value = store.luggage[store.name]; - this.setData({ - value, - name: store.name === "gao" ? "小糕" : "夜雨" - }); - }, - doBack() { - const store = wx.getStorageSync("luggage"); - store.luggage[store.name] = this.data.value; - wx.request({ - url: `${config.url}/journal/travel/luggage/update`, - method: "POST", - header: { - Key: wx.getStorageSync("key") - }, - data: store.luggage, - success: () => { - wx.navigateBack(); - } - }); - }, - onTapItem(e: WechatMiniprogram.BaseEvent) { - const index = e.currentTarget.dataset.index; - this.data.value[index].isTaken = !this.data.value[index].isTaken; - this.setData({ value: this.data.value }); - }, - showMenu(e: WechatMiniprogram.BaseEvent) { - const index = e.currentTarget.dataset.index; - wx.showActionSheet({ - itemList: ["删除"], - itemColor: "red", - success: () => { - this.data.value.splice(index, 1); - this.setData({ value: this.data.value }) - } - }); - }, - onInputFocus() { - this.setData({ - keyboardHeight: this.data.keyboardHeight - }) - }, - onInputBlur() { - this.setData({ - keyboardHeight: 0 - }) - }, - add() { - this.data.value.push({ - name: this.data.addValue, - isTaken: false - }) - this.setData({ - value: this.data.value, - addValue: "" - }) - } -}) \ No newline at end of file diff --git a/miniprogram/pages/main/travel/luggage/index.wxml b/miniprogram/pages/main/travel/luggage/index.wxml deleted file mode 100644 index 88c3a74..0000000 --- a/miniprogram/pages/main/travel/luggage/index.wxml +++ /dev/null @@ -1,39 +0,0 @@ - - module.exports.contain = function(arr, key) { return arr.indexOf(key) > -1 } - - - - - - tips: 勾选表示已携带,返回自动保存 - - - - - - - - - - 添加 - \ No newline at end of file diff --git a/miniprogram/theme.wxss b/miniprogram/theme.wxss index a83caf9..833d2a2 100644 --- a/miniprogram/theme.wxss +++ b/miniprogram/theme.wxss @@ -12,7 +12,7 @@ page { --theme-bg-primary: #FFF; --theme-bg-secondary: #F5F5F5; --theme-bg-card: #FFF; - --theme-bg-journal: #fff2C8; + --theme-bg-journal: #FFF2C8; --theme-bg-overlay: rgba(0, 0, 0, .1); --theme-bg-menu: rgba(255, 255, 255, .95); @@ -56,46 +56,49 @@ page { } /* 深色模式变量 */ -page[data-weui-theme="dark"] { - /* 微信标准色 */ - --theme-wx: #07C160; +@media (prefers-color-scheme: dark) { + page { + + /* 微信标准色 */ + --theme-wx: #07C160; - /* === 背景色 === */ - --theme-bg-primary: #1A1A1A; - --theme-bg-secondary: #2A2A2A; - --theme-bg-card: #2C2C2C; - --theme-bg-journal: #3A3A2E; - --theme-bg-overlay: rgba(0, 0, 0, .3); - --theme-bg-menu: rgba(40, 40, 40, .95); + /* === 背景色 === */ + --theme-bg-primary: #1A1A1A; + --theme-bg-secondary: #2A2A2A; + --theme-bg-card: #2C2C2C; + --theme-bg-journal: #3A3A2E; + --theme-bg-overlay: rgba(0, 0, 0, .3); + --theme-bg-menu: rgba(40, 40, 40, .95); - /* === 文字颜色 === */ - --theme-text-primary: #FFF; - --theme-text-secondary: #AAA; - --theme-text-tertiary: #888; - --theme-text-disabled: #666; + /* === 文字颜色 === */ + --theme-text-primary: #FFF; + --theme-text-secondary: #AAA; + --theme-text-tertiary: #888; + --theme-text-disabled: #666; - /* === 边框颜色 === */ - --theme-border-light: rgba(255, 255, 255, .1); - --theme-border-medium: rgba(255, 255, 255, .2); - --theme-border-dark: rgba(255, 255, 255, .6); + /* === 边框颜色 === */ + --theme-border-light: rgba(255, 255, 255, .1); + --theme-border-medium: rgba(255, 255, 255, .2); + --theme-border-dark: rgba(255, 255, 255, .6); - /* === 阴影颜色 === */ - --theme-shadow-light: rgba(0, 0, 0, .3); - --theme-shadow-medium: rgba(0, 0, 0, .5); - --theme-shadow-dark: rgba(0, 0, 0, .7); + /* === 阴影颜色 === */ + --theme-shadow-light: rgba(0, 0, 0, .3); + --theme-shadow-medium: rgba(0, 0, 0, .5); + --theme-shadow-dark: rgba(0, 0, 0, .7); - /* === 品牌色保持不变 === */ + /* === 品牌色保持不变 === */ - /* === 功能色保持不变 === */ + /* === 功能色保持不变 === */ - /* === 纸张纹理效果(深色模式调整) === */ - --theme-texture-light: rgba(0, 0, 0, 0); - --theme-texture-bright: rgba(255, 255, 255, .05); - --theme-texture-line: rgba(255, 255, 255, .02); + /* === 纸张纹理效果(深色模式调整) === */ + --theme-texture-light: rgba(0, 0, 0, 0); + --theme-texture-bright: rgba(255, 255, 255, .05); + --theme-texture-line: rgba(255, 255, 255, .02); - /* === 视频播放按钮 === */ - --theme-video-play: rgba(200, 200, 200, .8); + /* === 视频播放按钮 === */ + --theme-video-play: rgba(200, 200, 200, .8); - /* 内容颜色 */ - --theme-content-rain: rgba(235, 250, 255, .7); + /* 内容颜色 */ + --theme-content-rain: rgba(235, 250, 255, .7); + } } diff --git a/miniprogram/types/Journal.ts b/miniprogram/types/Journal.ts index 1d6f844..1bb0625 100644 --- a/miniprogram/types/Journal.ts +++ b/miniprogram/types/Journal.ts @@ -23,7 +23,7 @@ export type Journal = { /** 天气 */ weatcher?: string; - // ---------- 以下为 VO 字段 ---------- + // ---------- 视图属性 ---------- /** 日期 */ date?: string; diff --git a/miniprogram/types/Model.ts b/miniprogram/types/Model.ts index 6c93c71..e157452 100644 --- a/miniprogram/types/Model.ts +++ b/miniprogram/types/Model.ts @@ -8,10 +8,20 @@ export type Model = { } /** 基本返回对象 */ -export type Response = { +export type Response = { code: number; msg?: string; - data: object; + data: T; +} + +/** 临时文件返回 */ +export type TempFileResponse = { + + /** 临时文件 ID */ + id: string; + + /** 过期于 */ + expireAt: number; } /** 基本页面查询对象 */ @@ -27,7 +37,7 @@ export type QueryPage = { orderMap?: { [key: string]: OrderType }; /** 全等比较条件(AND 连接) */ - equalsExample?: { [key: string]: string | undefined | null }; + equalsExample?: { [key: string]: string | number | undefined | null }; /** 模糊查询条件(OR 连接) */ likeExample?: { [key: string]: string | undefined | null }; diff --git a/miniprogram/types/Travel.ts b/miniprogram/types/Travel.ts new file mode 100644 index 0000000..ab70e16 --- /dev/null +++ b/miniprogram/types/Travel.ts @@ -0,0 +1,165 @@ +import { Attachment } from "./Attachment"; +import { Model, QueryPage } from "./Model"; +import { MediaItem } from "./UI"; + +/** 交通类型 */ +export enum TransportationType { + PLANE = "PLANE", + TRAIN = "TRAIN", + CAR = "CAR", + SHIP = "SHIP", + SELF_DRIVING = "SELF_DRIVING", + OTHER = "OTHER" +} + +/** 交通类型中文映射 */ +export const TransportationTypeLabel: Record = { + [TransportationType.PLANE]: "飞机", + [TransportationType.TRAIN]: "火车", + [TransportationType.CAR]: "汽车", + [TransportationType.SHIP]: "轮船", + [TransportationType.SELF_DRIVING]: "自驾", + [TransportationType.OTHER]: "其他" +}; + +/** 旅行状态 */ +export enum TravelStatus { + PLANNING = "PLANNING", + ONGOING = "ONGOING", + COMPLETED = "COMPLETED" +} + +/** 旅行状态中文映射 */ +export const TravelStatusLabel: Record = { + [TravelStatus.PLANNING]: "计划中", + [TravelStatus.ONGOING]: "进行中", + [TravelStatus.COMPLETED]: "已完成" +}; + +/** 旅行状态图标映射 */ +export const TravelStatusIcon: Record = { + [TravelStatus.PLANNING]: "calendar", + [TravelStatus.ONGOING]: "play-circle", + [TravelStatus.COMPLETED]: "check-circle" +}; + +/** 旅行计划实体 */ +export interface Travel extends Model { + /** 交通类型 */ + transportationType?: TransportationType; + + /** 标题 */ + title?: string; + + /** 内容 */ + content?: string; + + /** 出行时间戳 */ + travelAt?: number; + + /** 天数 */ + days?: number; + + /** 状态 */ + status?: TravelStatus; + + /** 格式化的出行日期 */ + travelDate?: string; + + /** 格式化的出行时间 */ + travelTime?: string; +} + +/** 旅行分页查询 */ +export interface TravelPage extends QueryPage { + /** 条件过滤 */ + equalsExample?: { + status?: TravelStatus; + }; +} + +/** 地点类型 */ +export enum TravelLocationType { + ATTRACTION = "ATTRACTION", + HOTEL = "HOTEL", + RESTAURANT = "RESTAURANT", + TRANSPORT = "TRANSPORT", + SHOPPING = "SHOPPING", + OTHER = "OTHER" +} + +/** 地点类型中文映射 */ +export const TravelLocationTypeLabel: Record = { + [TravelLocationType.ATTRACTION]: "景点", + [TravelLocationType.HOTEL]: "酒店", + [TravelLocationType.RESTAURANT]: "餐厅", + [TravelLocationType.TRANSPORT]: "交通站点", + [TravelLocationType.SHOPPING]: "购物", + [TravelLocationType.OTHER]: "其他" +}; + +/** 地点类型图标映射 */ +export const TravelLocationTypeIcon: Record = { + [TravelLocationType.ATTRACTION]: "location", + [TravelLocationType.HOTEL]: "home", + [TravelLocationType.RESTAURANT]: "shop", + [TravelLocationType.TRANSPORT]: "map-route", + [TravelLocationType.SHOPPING]: "cart", + [TravelLocationType.OTHER]: "ellipsis" +}; + +/** 旅行地点实体 */ +export interface TravelLocation extends Model { + /** 关联的旅行计划 ID */ + travelId?: number; + + /** 地点类型 */ + type?: TravelLocationType; + + /** 标题 */ + title?: string; + + /** 说明 */ + description?: string; + + /** 纬度 */ + lat?: number; + + /** 经度 */ + lng?: number; + + /** 位置描述 */ + location?: string; + + /** 费用 */ + amount?: number; + + /** 是否需要身份证 */ + requireIdCard?: boolean; + + /** 必要评分 */ + score?: number; + + /** 附件 */ + items?: Attachment[]; + + /** 保留的附件 ID 列表(更新时使用) */ + attachmentIds?: number[]; + + /** 临时文件 ID 列表(创建/更新时上传附件用) */ + tempFileIds?: string[]; + + // ---------- 视图属性 ---------- + + /** 类型标签 */ + typeLabel?: string; + + /** 类型图标 */ + typeIcon?: string; + + /** 分列后的 items,用于瀑布流展示 */ + columnedItems?: MediaItem[][]; + + /** 媒体项(由附件转) */ + mediaItems?: MediaItem[]; +} diff --git a/miniprogram/utils/Network.ts b/miniprogram/utils/Network.ts new file mode 100644 index 0000000..756767a --- /dev/null +++ b/miniprogram/utils/Network.ts @@ -0,0 +1,440 @@ +import config from "../config/index"; +import { Response, QueryPage, QueryPageResult, TempFileResponse } from "../types/Model"; + +/** 微信媒体项(用于上传) */ +export interface WechatMediaItem { + + /** 媒体路径或 URL */ + path?: string; + + url?: string; + + /** 文件大小 */ + size?: number; + + /** 媒体类型 */ + type?: string; +} + +/** 请求选项 */ +export interface RequestOptions + +extends Omit { + + /** 接口路径(相对于 baseURL) */ + url: string; + + /** 请求数据 */ + data?: T; + + /** 是否显示加载提示 */ + showLoading?: boolean; + + /** 加载提示文字 */ + loadingText?: string; + + /** 是否自动处理错误提示 */ + autoHandleError?: boolean; +} + +/** 上传进度信息 */ +export interface UploadProgress { + + /** 总大小(字节) */ + total: number; + + /** 已上传大小(字节) */ + uploaded: number; + + /** 当前每秒上传大小(字节/秒) */ + speed: number; + + /** 上传进度百分比 (0-100) */ + percent: number; +} + +/** 上传选项 */ +export interface UploadOptions { + + /** 文件路径 */ + filePath: string; + + /** 文件字段名 */ + name?: string; + + /** 上传进度回调 */ + onProgress?: (progress: UploadProgress) => void; +} + +/** 批量上传选项 */ +export interface UploadFilesOptions { + + /** 媒体文件列表 */ + mediaList: WechatMediaItem[]; + + /** 上传进度回调 */ + onProgress?: (progress: UploadProgress) => void; + + /** 是否显示加载提示 */ + showLoading?: boolean; +} + +/** + * 网络请求工具类 + * + * 设计原则: + * 1. 简单直接 - 不过度封装,保持 API 清晰 + * 2. 类型安全 - 充分利用 TypeScript 泛型 + * 3. 统一处理 - 自动添加 header、统一错误处理 + * 4. Promise 化 - 提供现代化的异步 API + */ +export class Network { + /** 基础 URL */ + private static baseURL = config.url; + + /** 获取通用请求头 */ + private static getHeaders(): Record { + return { + Key: wx.getStorageSync("key") || "" + }; + } + + /** + * 通用请求方法 + * + * @template T - 响应数据类型 + * @param options - 请求选项 + * @returns Promise - 返回业务数据 + */ + static request(options: RequestOptions): Promise { + const { + url, + method = "GET", + data, + header = {}, + showLoading = false, + loadingText = "加载中...", + autoHandleError = true, + ...restOptions + } = options; + // 显示加载提示 + if (showLoading) { + wx.showLoading({ title: loadingText, mask: true }); + } + return new Promise((resolve, reject) => { + wx.request({ + url: `${this.baseURL}${url}`, + method: method as any, + data, + header: { + ...this.getHeaders(), + ...header + }, + ...restOptions, + success: (res: WechatMiniprogram.RequestSuccessCallbackResult) => { + if (showLoading) { + wx.hideLoading(); + } + const response = res.data as Response; + // 业务成功 + if (response.code === 20000) { + resolve(response.data as T); + } else { + // 业务失败 + const error = new Error(response.msg || "请求失败"); + if (autoHandleError) { + wx.showToast({ + title: response.msg || "请求失败", + icon: "error" + }); + } + reject(error); + } + }, + fail: (err) => { + if (showLoading) { + wx.hideLoading(); + } + if (autoHandleError) { + wx.showToast({ + title: "网络请求失败", + icon: "error" + }); + } + reject(err); + } + }); + }); + } + + /** + * GET 请求 + * + * @template T - 响应数据类型 + * @param url - 接口路径 + * @param data - 请求参数 + * @param options - 其他选项 + */ + static get(url: string, data?: any, options?: Partial): Promise { + return this.request({ + url, + method: "GET", + data, + ...options + }); + } + + /** + * POST 请求 + * + * @template T - 响应数据类型 + * @param url - 接口路径 + * @param data - 请求数据 + * @param options - 其他选项 + */ + static post(url: string, data?: any, options?: Partial): Promise { + return this.request({ + url, + method: "POST", + data, + ...options + }); + } + + /** + * DELETE 请求 + * + * @template T - 响应数据类型 + * @param url - 接口路径 + * @param data - 请求数据 + * @param options - 其他选项 + */ + static delete(url: string, data?: any, options?: Partial): Promise { + return this.request({ + url, + method: "DELETE", + data, + ...options + }); + } + + /** + * 分页查询请求 + * + * @template T - 列表项类型 + * @param url - 接口路径 + * @param pageParams - 分页参数 + * @param options - 其他选项 + */ + static page( + url: string, + pageParams: QueryPage, + options?: Partial + ): Promise> { + return this.post>(url, pageParams, options); + } + + /** + * 上传单个文件 + * + * @param options - 上传选项 + * @returns Promise - 返回临时文件 ID + */ + static uploadFile(options: UploadOptions): Promise { + const { filePath, name = "file", onProgress } = options; + + return new Promise((resolve, reject) => { + // 先获取文件大小 + wx.getFileSystemManager().getFileInfo({ + filePath, + success: (fileInfo) => { + const total = fileInfo.size; + let uploaded = 0; + let lastuploaded = 0; + let speed = 0; + + // 每秒计算一次上传速度 + const speedUpdateInterval = setInterval(() => { + const chunkSize = uploaded - lastuploaded; + speed = chunkSize; + lastuploaded = uploaded; + + if (onProgress) { + onProgress({ + total, + uploaded, + speed, + percent: Math.round((uploaded / total) * 10000) / 100 + }); + } + }, 1000); + + const uploadTask = wx.uploadFile({ + url: `${this.baseURL}/temp/file/upload`, + filePath, + name, + header: this.getHeaders(), + success: (res) => { + // 清除定时器 + clearInterval(speedUpdateInterval); + try { + const response = JSON.parse(res.data) as Response; + if (response.code === 20000) { + resolve(response.data[0].id); + } else { + reject(new Error(response.msg || "文件上传失败")); + } + } catch (error) { + reject(new Error("解析上传响应失败")); + } + }, + fail: (err) => { + // 清除定时器 + clearInterval(speedUpdateInterval); + reject(err); + } + }); + + // 监听上传进度 + uploadTask.onProgressUpdate((res) => { + uploaded = Math.floor((res.totalBytesExpectedToSend * res.progress) / 100); + + if (onProgress) { + onProgress({ + total, + uploaded, + speed, + percent: res.progress + }); + } + }); + }, + fail: (err) => { + reject(new Error(`获取文件信息失败: ${err.errMsg}`)); + } + }); + }); + } + + /** + * 批量上传文件 + * + * @param options - 批量上传选项 + * @returns Promise - 返回临时文件 ID 列表 + */ + static uploadFiles(options: UploadFilesOptions): Promise { + const { mediaList, onProgress, showLoading = true } = options; + + return new Promise((resolve, reject) => { + // 空列表直接返回 + if (mediaList.length === 0) { + resolve([]); + return; + } + + if (showLoading) { + wx.showLoading({ title: "正在上传..", mask: true }); + } + + // 先获取所有文件的大小 + const sizePromises = mediaList.map(item => { + const filePath = item.path || item.url || ""; + return new Promise((sizeResolve, sizeReject) => { + wx.getFileSystemManager().getFileInfo({ + filePath, + success: (res) => sizeResolve(res.size), + fail: (err) => sizeReject(err) + }); + }); + }); + + Promise.all(sizePromises).then(fileSizes => { + const total = fileSizes.reduce((acc, size) => acc + size, 0); + const tempFileIds: string[] = []; + let uploaded = 0; + let lastuploaded = 0; + let speed = 0; + + // 每秒计算一次上传速度 + const speedUpdateInterval = setInterval(() => { + const chunkSize = uploaded - lastuploaded; + speed = chunkSize; + lastuploaded = uploaded; + + if (onProgress) { + onProgress({ + total, + uploaded, + speed, + percent: Math.round((uploaded / total) * 10000) / 100 + }); + } + }, 1000); + + // 串行上传(避免并发过多) + const uploadNext = (index: number) => { + if (index >= mediaList.length) { + // 清除定时器 + clearInterval(speedUpdateInterval); + if (showLoading) { + wx.hideLoading(); + } + resolve(tempFileIds); + return; + } + + const media = mediaList[index]; + const filePath = media.path || media.url || ""; + let prevProgress = 0; + + this.uploadFile({ + filePath, + onProgress: (progressResult) => { + // 计算当前文件的增量上传大小 + const fileUploaded = (progressResult.total * progressResult.percent) / 100; + const delta = fileUploaded - prevProgress; + uploaded += delta; + prevProgress = fileUploaded; + + if (onProgress) { + onProgress({ + total, + uploaded, + speed, + percent: Math.round((uploaded / total) * 10000) / 100 + }); + } + } + }).then((tempFileId) => { + tempFileIds.push(tempFileId); + // 继续上传下一个 + uploadNext(index + 1); + }).catch((err) => { + // 清除定时器 + clearInterval(speedUpdateInterval); + if (showLoading) { + wx.hideLoading(); + } + wx.showToast({ + title: "文件上传失败", + icon: "error" + }); + reject(err); + }); + }; + + // 开始上传第一个文件 + uploadNext(0); + }).catch((err) => { + if (showLoading) { + wx.hideLoading(); + } + wx.showToast({ + title: "获取文件信息失败", + icon: "error" + }); + reject(err); + }); + }); + } +}