Compare commits

..

2 Commits

Author SHA1 Message Date
69659a1746 refactor travel 2025-12-13 18:44:37 +08:00
880e702288 merge journal-creater, journal-editor 2025-12-12 17:49:20 +08:00
46 changed files with 4659 additions and 1258 deletions

3
.gitignore vendored
View File

@ -61,4 +61,5 @@ ehthumbs.db
[Tt]humbs.db
.claude/
CLAUDE.md
CLAUDE.md
/docs

View File

@ -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<Travel> {
return Network.get<Travel>(`/journal/travel/${id}`);
}
/**
* 旅行分页列表
*
* @param pageParams - 分页参数
*/
static getList(pageParams: QueryPage): Promise<QueryPageResult<Travel>> {
return Network.page<Travel>("/journal/travel/list", pageParams);
}
/**
* 创建旅行
*
* @param data - 旅行数据
*/
static create(data: Partial<Travel>): Promise<Travel> {
return Network.post<Travel>("/journal/travel/create", data, {
showLoading: true,
loadingText: "正在保存.."
});
}
/**
* 更新旅行
*
* @param data - 旅行数据(必须包含 id
*/
static update(data: Partial<Travel> & { id: number }): Promise<Travel> {
return Network.post<Travel>("/journal/travel/update", data, {
showLoading: true,
loadingText: "正在保存.."
});
}
/**
* 删除旅行
*
* @param id - 旅行 ID
*/
static delete(id: number): Promise<void> {
return Network.post<void>("/journal/travel/delete", { id });
}
}

View File

@ -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<TravelLocation> {
return Network.get<TravelLocation>(`/journal/travel/location/${id}`);
}
/**
* 获取旅行地点分页列表
*
* @param pageParams - 分页参数(通常包含 travelId 筛选)
*/
static getList(pageParams: QueryPage): Promise<QueryPageResult<TravelLocation>> {
return Network.page<TravelLocation>("/journal/travel/location/list", pageParams);
}
/**
* 创建旅行地点
*
* @param data - 地点数据
*/
static create(data: Partial<TravelLocation>): Promise<TravelLocation> {
return Network.post<TravelLocation>("/journal/travel/location/create", data, {
showLoading: true,
loadingText: "正在保存.."
});
}
/**
* 更新旅行地点
*
* @param data - 地点数据(必须包含 id
*/
static update(data: Partial<TravelLocation> & { id: number }): Promise<TravelLocation> {
return Network.post<TravelLocation>("/journal/travel/location/update", data, {
showLoading: true,
loadingText: "正在保存.."
});
}
/**
* 删除旅行地点
*
* @param id - 地点 ID
*/
static delete(id: number): Promise<void> {
return Network.post<void>("/journal/travel/location/delete", { id });
}
/**
* 批量获取旅行地点
*
* @param ids - 地点 ID 数组
*/
static getListByIds(ids: number[]): Promise<TravelLocation[]> {
return Network.post<TravelLocation[]>("/journal/travel/location/list/ids", ids);
}
}

View File

@ -2,15 +2,17 @@
"pages": [
"pages/index/index",
"pages/main/journal/index",
"pages/main/journal-creater/index",
"pages/main/journal-search/index",
"pages/main/journal-editor/index",
"pages/main/journal-map/index",
"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,

View File

@ -1,3 +1,3 @@
/**app.wxss**/
@import "./theme.wxss";
@import "./tdesign.wxss";
@import "./tdesign.wxss";
@import "./theme.wxss";

View File

@ -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"
}
}

View File

@ -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;
}
}
}
}
}
}
}

View File

@ -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: <TravelLocationPopupData>{
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
});
}
}
});

View File

@ -0,0 +1,69 @@
<!-- components/travel-location-popup/index.wxml -->
<t-popup
class="popup"
visible="{{visible}}"
placement="bottom"
bind:visible-change="closeDetail"
>
<view class="detail-panel">
<view class="detail-content">
<view class="header">
<view class="info">
<view class="title-row">
<t-icon wx:if="{{locations[currentLocationIndex].typeIcon}}" class="type-icon" name="{{locations[currentLocationIndex].typeIcon}}" />
<text class="title">{{locations[currentLocationIndex].title || '未命名地点'}}</text>
</view>
<view wx:if="{{locations[currentLocationIndex].location}}" class="location" catchtap="openLocation">
<t-icon class="icon" name="location-filled" />
<text class="text">{{locations[currentLocationIndex].location}}</text>
</view>
</view>
<view class="actions">
<view wx:if="{{locations.length > 1}}" class="indicator">
{{currentLocationIndex + 1}}/{{locations.length}}
</view>
<t-icon name="close" catchtap="closeDetail" size="48rpx" />
</view>
</view>
<swiper class="locations-swiper" current="{{currentLocationIndex}}" bindchange="onSwiperChange">
<block wx:for="{{locations}}" wx:key="id">
<swiper-item class="swiper-item-wrapper">
<scroll-view scroll-y class="location-scroll">
<view class="location-item">
<view class="tags">
<t-tag wx:if="{{item.typeLabel}}" theme="primary" variant="outline">
{{item.typeLabel}}
</t-tag>
<t-tag wx:if="{{item.amount}}" theme="warning" variant="outline">
¥{{item.amount}}
</t-tag>
<t-tag wx:if="{{item.score}}" theme="success" variant="outline">
评分 {{item.score}}
</t-tag>
<t-tag wx:if="{{item.requireIdCard}}" theme="danger" variant="outline">
需要身份证
</t-tag>
</view>
<view wx:if="{{item.description}}" class="description">{{item.description}}</view>
<view wx:if="{{item.columnedItems && item.columnedItems.length > 0}}" class="items">
<view wx:for="{{item.columnedItems}}" wx:for-item="column" wx:for-index="columnIndex" wx:key="columnIndex" class="column">
<block wx:for="{{column}}" wx:key="attachmentId" wx:for-item="media" wx:for-index="itemIndex">
<image
class="item thumbnail {{media.type === 1 ? 'video' : 'image'}}"
src="{{media.thumbURL}}"
mode="widthFix"
catchtap="previewMedia"
data-item-index="{{media.originalIndex}}"
/>
</block>
</view>
</view>
<view wx:if="{{!item.description && (!item.columnedItems || item.columnedItems.length === 0)}}" class="empty">暂无详细说明</view>
</view>
</scroll-view>
</swiper-item>
</block>
</swiper>
</view>
</view>
</t-popup>

View File

@ -1,10 +0,0 @@
{
"component": true,
"usingComponents": {
"t-icon": "tdesign-miniprogram/icon/icon",
"t-radio": "tdesign-miniprogram/radio/radio",
"t-button": "tdesign-miniprogram/button/button",
"t-navbar": "tdesign-miniprogram/navbar/navbar",
"t-radio-group": "tdesign-miniprogram/radio-group/radio-group"
}
}

View File

@ -1,146 +0,0 @@
/* pages/main/journal-creater/index.wxss */
.container {
.content {
width: calc(100% - 64px);
padding: 0 32px 32px 32px;
display: flex;
align-items: center;
flex-direction: column;
.label {
color: var(--theme-text-secondary);
}
.section {
width: 100%;
margin-top: 1.5rem;
&.type {
display: flex;
.radio {
margin-right: 1em;
}
}
&.time {
display: flex;
.picker {
margin-right: .25rem;
}
}
&.media {
.ctrl {
display: flex;
.clear {
width: 100px;
padding-left: 0;
padding-right: 0;
}
}
.gallery {
gap: 10rpx;
display: grid;
grid-template-columns: repeat(3, 1fr);
.item {
height: 200rpx;
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: 200rpx;
height: 200rpx;
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%;
}
}
}
}
}
}
.uploading {
width: 100%;
margin-top: 1rem;
.progress {
margin-bottom: 8rpx;
}
.text {
color: var(--theme-text-secondary);
display: flex;
font-size: .8rem;
justify-content: space-between;
}
}
.ctrl {
width: 100%;
display: flex;
margin-top: 1rem;
align-items: center;
.clear {
width: 200rpx;
}
.submit {
flex: 1;
margin-left: 12rpx;
}
}
}
}

View File

@ -1,376 +0,0 @@
// pages/main/journal-creater/index.ts
import Events from "../../../utils/Events";
import Time from "../../../utils/Time";
import Toolkit from "../../../utils/Toolkit";
import config from "../../../config/index";
import { JournalType } from "../../../types/Journal";
import IOSize, { Unit } from "../../../utils/IOSize";
enum MediaItemType {
IMAGE,
VIDEO
}
type MediaItem = {
type: MediaItemType;
path: string;
thumbPath: string;
size: number;
duration: number | undefined;
raw: any;
}
export type Location = {
lat: number;
lng: number;
text?: string;
}
interface JournalEditorData {
idea: string;
date: string;
time: string;
type: JournalType;
mediaList: MediaItem[];
location?: Location;
qqMapSDK?: any;
isAuthLocation: boolean;
uploaded: string;
uploadTotal: string;
uploadSpeed: string;
uploadProgress: number;
isSubmitting: boolean;
}
Page({
data: <JournalEditorData>{
idea: "",
date: "2025-06-28",
time: "16:00",
type: JournalType.NORMAL,
mediaList: [],
location: undefined,
isAuthLocation: false,
uploaded: "0",
uploadTotal: "0 MB",
uploadSpeed: "0 MB / s",
uploadProgress: 0,
isSubmitting: false,
mediaItemTypeEnum: {
...MediaItemType
}
},
async onLoad() {
// 授权定位
const setting = await wx.getSetting();
wx.setStorageSync("isAuthLocation", setting.authSetting["scope.userLocation"]);
let isAuthLocation = JSON.parse(wx.getStorageSync("isAuthLocation"));
this.setData({ isAuthLocation });
if (!isAuthLocation) {
wx.authorize({
scope: "scope.userLocation"
}).then(() => {
isAuthLocation = true;
this.setData({ isAuthLocation });
});
}
const unixTime = new Date().getTime();
this.setData({
date: Time.toDate(unixTime),
time: Time.toTime(unixTime)
});
// 获取默认定位
wx.getLocation({
type: "gcj02"
}).then(resp => {
this.setData({
location: {
lat: resp.latitude,
lng: resp.longitude
},
});
const argLoc = `location=${this.data.location!.lat},${this.data.location!.lng}`;
const argKey = "key=WW5BZ-J4LCM-UIT6I-65MXY-Z5HDT-VRFFU";
wx.request({
url: `https://apis.map.qq.com/ws/geocoder/v1/?${argLoc}&${argKey}`,
success: res => {
if (res.statusCode === 200) {
this.setData({
location: {
lat: this.data.location!.lat,
lng: this.data.location!.lng,
text: (res.data as any).result?.formatted_addresses?.recommend
}
});
}
}
});
});
},
onChangeType(e: any) {
const { value } = e.detail;
this.setData({ type: value });
},
async chooseLocation() {
const location = await wx.chooseLocation({});
this.setData({
location: {
lat: location.latitude,
lng: location.longitude,
text: location.name
}
});
},
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 mediaList = tempFiles.map(item => {
return {
type: (<any>MediaItemType)[item.fileType.toUpperCase()],
path: item.tempFilePath,
thumbPath: item.thumbTempFilePath,
size: item.size,
duration: item.duration,
raw: item
} as MediaItem;
});
that.setData({
mediaList: [...that.data.mediaList, ...mediaList]
});
wx.hideLoading();
}
})
},
clearMedia() {
wx.showModal({
title: "提示",
content: "确认清空已选照片或视频吗?",
confirmText: "清空",
confirmColor: "#E64340",
cancelText: "取消",
success: res => {
if (res.confirm) {
this.setData({
mediaList: []
});
}
}
})
},
preview(e: WechatMiniprogram.BaseEvent) {
const itemIndex = e.currentTarget.dataset.index;
const total = this.data.mediaList.length;
const startIndex = Math.max(0, itemIndex - 25);
const endIndex = Math.min(total, startIndex + 50);
const newCurrentIndex = itemIndex - startIndex;
const sources = this.data.mediaList.slice(startIndex, endIndex).map(item => {
return {
url: item.path,
type: MediaItemType[item.type].toLowerCase()
} as WechatMiniprogram.MediaSource;
});
wx.previewMedia({
current: newCurrentIndex,
sources
});
},
deleteMedia(e: WechatMiniprogram.BaseEvent) {
const index = e.currentTarget.dataset.index;
const mediaList = [...this.data.mediaList];
mediaList.splice(index, 1);
this.setData({
mediaList
});
},
cancel() {
wx.switchTab({
url: "/pages/main/journal/index",
})
},
submit() {
const handleFail = () => {
wx.showToast({ title: "上传失败", icon: "error" });
wx.hideLoading();
this.setData({
isSubmitting: false
});
};
this.setData({
isSubmitting: true
});
// 获取 openId
const getOpenId = new Promise<string>((resolve, reject) => {
wx.login({
success: (res) => {
if (res.code) {
wx.request({
url: `${config.url}/journal/openid`,
method: "POST",
header: {
Key: wx.getStorageSync("key")
},
data: {
code: res.code
},
success: (resp) => {
const data = resp.data as any;
if (data.code === 20000) {
resolve(data.data);
} else {
reject(new Error("获取 openId 失败"));
}
},
fail: () => reject(new Error("获取 openId 请求失败"))
});
} else {
reject(new Error("获取登录凭证失败"));
}
},
fail: () => reject(new Error("登录失败"))
});
});
// 文件上传
const uploadFiles = new Promise<string[]>((resolve, reject) => {
const mediaList = this.data.mediaList || [];
if (mediaList.length === 0) {
resolve([]);
return;
}
wx.showLoading({ title: "正在上传..", mask: true });
// 计算总大小
const sizePromises = mediaList.map(item => {
return new Promise<number>((sizeResolve, sizeReject) => {
wx.getFileSystemManager().getFileInfo({
filePath: item.path,
success: (res) => sizeResolve(res.size),
fail: (err) => sizeReject(err)
});
});
});
Promise.all(sizePromises).then(fileSizes => {
const totalSize = fileSizes.reduce((acc, size) => acc + size, 0);
const uploadTasks: WechatMiniprogram.UploadTask[] = [];
let uploadedSize = 0;
let lastUploadedSize = 0;
this.setData({
uploadTotal: IOSize.format(totalSize, 2, Unit.MB)
});
// 计算上传速度
const speedUpdateInterval = setInterval(() => {
const chunkSize = uploadedSize - lastUploadedSize;
this.setData({
uploadSpeed: `${IOSize.format(chunkSize)} / s`
});
lastUploadedSize = uploadedSize;
}, 1000);
// 上传文件
const uploadPromises = mediaList.map(item => {
return new Promise<string>((uploadResolve, uploadReject) => {
const task = wx.uploadFile({
url: `${config.url}/temp/file/upload`,
filePath: item.path,
name: "file",
success: (resp) => {
const result = JSON.parse(resp.data);
if (result && result.code === 20000) {
uploadResolve(result.data[0].id);
} else {
uploadReject(new Error(`文件上传失败: ${result?.message || "未知错误"}`));
}
},
fail: (err) => uploadReject(new Error(`文件上传失败: ${err.errMsg}`))
});
// 监听上传进度
let prevProgress = 0;
task.onProgressUpdate((res) => {
const fileUploaded = (res.totalBytesExpectedToSend * res.progress) / 100;
const delta = fileUploaded - prevProgress;
uploadedSize += delta;
prevProgress = fileUploaded;
// 更新进度条
this.setData({
uploaded: IOSize.formatWithoutUnit(uploadedSize, 2, Unit.MB),
uploadProgress: Math.round((uploadedSize / totalSize) * 10000) / 100
});
});
uploadTasks.push(task);
});
});
Promise.all(uploadPromises).then((tempFileIds) => {
// 清除定时器
clearInterval(speedUpdateInterval);
uploadTasks.forEach(task => task.offProgressUpdate());
this.setData({
uploadProgress: 100,
uploadSpeed: "0 MB / s"
});
resolve(tempFileIds);
}).catch((e: Error) => {
// 取消所有上传任务
uploadTasks.forEach(task => task.abort());
clearInterval(speedUpdateInterval);
reject(e);
});
}).catch(reject);
});
// 并行执行获取 openId 和文件上传
Promise.all([getOpenId, uploadFiles]).then(([openId, tempFileIds]) => {
wx.showLoading({ title: "正在保存..", mask: true });
wx.request({
url: `${config.url}/journal/create`,
method: "POST",
header: {
Key: wx.getStorageSync("key")
},
data: {
idea: this.data.idea,
type: this.data.type,
lat: this.data.location?.lat,
lng: this.data.location?.lng,
location: this.data.location?.text,
pusher: openId,
createdAt: Date.parse(`${this.data.date} ${this.data.time}`),
tempFileIds
},
success: async () => {
Events.emit("JOURNAL_REFRESH");
wx.showToast({ title: "提交成功", icon: "success" });
this.setData({
idea: "",
mediaList: [],
isSubmitting: false,
uploaded: "0",
uploadTotal: "0 MB",
uploadProgress: 0
});
await Toolkit.sleep(1000);
wx.switchTab({
url: "/pages/main/journal/index"
});
},
fail: handleFail
});
}).catch(handleFail);
}
});

View File

@ -1,118 +0,0 @@
<!--pages/main/journal-creater/index.wxml-->
<t-navbar title="新纪录">
<text slot="left" bindtap="cancel">取消</text>
</t-navbar>
<scroll-view
class="container"
type="custom"
scroll-y
show-scrollbar="{{false}}"
scroll-into-view="{{intoView}}"
>
<view class="content">
<view class="section">
<textarea
class="idea"
placeholder="这一刻的想法..."
model:value="{{idea}}"
/>
</view>
<view class="section type">
<text class="label">类型:</text>
<t-radio-group bind:change="onChangeType" default-value="NORMAL" borderless t-class="box">
<t-radio class="radio" block="{{false}}" label="日常" value="NORMAL" />
<t-radio class="radio" block="{{false}}" label="专拍" value="PORTFOLIO" />
</t-radio-group>
</view>
<view class="section time">
<text class="label">时间:</text>
<picker class="picker" mode="date" model:value="{{date}}">
<view class="picker">
{{date}}
</view>
</picker>
<picker class="picker" mode="time" model:value="{{time}}">
<view class="picker">
{{time}}
</view>
</picker>
</view>
<view class="section location">
<text class="label">位置:</text>
<text wx:if="{{location}}" bind:tap="chooseLocation">{{location.text}}</text>
<text wx:else bind:tap="chooseLocation">选择位置..</text>
</view>
<view class="section media">
<view class="gallery">
<block wx:for="{{mediaList}}" wx:key="index">
<view class="item">
<!-- 图片 -->
<image
wx:if="{{item.type === mediaItemTypeEnum.IMAGE}}"
src="{{item.path}}"
class="thumbnail"
mode="aspectFill"
bindtap="preview"
data-index="{{index}}"
></image>
<!-- 视频 -->
<view wx:if="{{item.type === mediaItemTypeEnum.VIDEO}}" class="video-container">
<image
src="{{item.thumbPath}}"
class="thumbnail"
mode="aspectFill"
bindtap="preview"
data-index="{{index}}"
></image>
<t-icon class="play-icon" name="play" />
</view>
<!-- 删除 -->
<t-icon
class="delete"
name="close"
bindtap="deleteMedia"
data-index="{{index}}"
data-new-media="{{true}}"
/>
</view>
</block>
<t-button
class="item add"
theme="primary"
plain="true"
disabled="{{isSubmitting}}"
bind:tap="addMedia"
>
<t-icon class="icon" name="add" />
</t-button>
</view>
</view>
<view wx:if="{{isSubmitting}}" class="uploading">
<progress
class="progress"
percent="{{uploadProgress}}"
stroke-width="6"
/>
<view class="text">
<view>{{uploaded}} / {{uploadTotal}}</view>
<view>{{uploadSpeed}}</view>
</view>
</view>
<view class="ctrl">
<t-button
class="clear"
theme="danger"
variant="outline"
disabled="{{isSubmitting}}"
bind:tap="clearMedia"
disabled="{{mediaList.length === 0}}"
>清空已选</t-button>
<t-button
class="submit"
theme="primary"
bind:tap="submit"
disabled="{{(!idea && mediaList.length === 0) || isSubmitting}}"
>提交</t-button>
</view>
</view>
</scroll-view>

View File

@ -141,10 +141,12 @@
margin-top: 1rem;
align-items: center;
.clear,
.delete {
width: 200rpx;
}
.submit,
.save {
flex: 1;
margin-left: 12rpx;

View File

@ -9,28 +9,49 @@ import { MediaAttachExt, MediaAttachType } from "../../../types/Attachment";
import IOSize, { Unit } from "../../../utils/IOSize";
interface JournalEditorData {
/** 模式create 或 edit */
mode: "create" | "edit";
/** 日记 ID编辑模式 */
id?: number;
/** 想法 */
idea: string;
/** 日期 */
date: string;
/** 时间 */
time: string;
/** 类型 */
type: JournalType;
mediaList: MediaItem[];
/** 媒体列表(创建模式:新选择的媒体;编辑模式:现有附件) */
mediaList: (MediaItem | WechatMediaItem)[];
/** 新媒体列表(仅编辑模式使用) */
newMediaList: WechatMediaItem[];
/** 位置 */
location?: Location;
/** 是否授权定位 */
isAuthLocation: boolean;
/** 是否正在加载(编辑模式) */
isLoading: boolean;
/** 是否正在保存/提交 */
isSaving: boolean;
/** 已上传大小 */
uploaded: string;
/** 总大小 */
uploadTotal: string;
/** 上传速度 */
uploadSpeed: string;
/** 上传进度 */
uploadProgress: number;
/** 媒体类型枚举 */
mediaItemTypeEnum: any;
/** 删除对话框可见性 */
deleteDialogVisible: boolean;
/** 删除确认文本 */
deleteConfirmText: string;
}
Page({
data: <JournalEditorData>{
mode: "create",
id: undefined,
idea: "",
date: "2025-06-28",
@ -40,7 +61,7 @@ Page({
newMediaList: [],
location: undefined,
isSaving: false,
isLoading: true,
isLoading: false,
uploaded: "0",
uploadTotal: "0 MB",
uploadSpeed: "0 MB / s",
@ -52,6 +73,7 @@ Page({
deleteDialogVisible: false,
deleteConfirmText: ""
},
async onLoad(options: any) {
// 授权定位
const setting = await wx.getSetting();
@ -66,22 +88,65 @@ Page({
this.setData({ isAuthLocation });
});
}
// 获取日记 ID
// 判断模式:有 ID 是编辑,无 ID 是创建
const id = options.id ? parseInt(options.id) : undefined;
if (!id) {
wx.showToast({
title: "缺少日志 ID",
icon: "error"
if (id) {
// 编辑模式
this.setData({
mode: "edit",
id,
isLoading: true
});
setTimeout(() => {
wx.navigateBack();
}, 1500);
return;
await this.loadJournalDetail(id);
} else {
// 创建模式
this.setData({
mode: "create",
isLoading: false
});
// 设置当前时间
const unixTime = new Date().getTime();
this.setData({
date: Time.toDate(unixTime),
time: Time.toTime(unixTime)
});
// 获取默认定位
this.getDefaultLocation();
}
this.setData({ id });
await this.loadJournalDetail(id);
},
/** 加载日记详情 */
/** 获取默认定位(创建模式) */
getDefaultLocation() {
wx.getLocation({
type: "gcj02"
}).then(resp => {
this.setData({
location: {
lat: resp.latitude,
lng: resp.longitude
}
});
const argLoc = `location=${this.data.location!.lat},${this.data.location!.lng}`;
const argKey = "key=WW5BZ-J4LCM-UIT6I-65MXY-Z5HDT-VRFFU";
wx.request({
url: `https://apis.map.qq.com/ws/geocoder/v1/?${argLoc}&${argKey}`,
success: res => {
if (res.statusCode === 200) {
this.setData({
location: {
lat: this.data.location!.lat,
lng: this.data.location!.lng,
text: (res.data as any).result?.formatted_addresses?.recommend
}
});
}
}
});
}).catch(() => {
// 忽略定位失败
});
},
/** 加载日记详情(编辑模式) */
async loadJournalDetail(id: number) {
wx.showLoading({ title: "加载中...", mask: true });
try {
@ -102,6 +167,7 @@ Page({
fail: reject
});
});
const items = journal.items || [];
const thumbItems = items.filter((item) => item.attachType === MediaAttachType.THUMB);
@ -117,6 +183,7 @@ Page({
attachmentId: thumbItem.id
} as MediaItem;
});
this.setData({
idea: journal.idea || "",
date: Time.toDate(journal.createdAt),
@ -142,20 +209,25 @@ Page({
}, 1500);
}
},
/** 改变类型 */
onChangeType(e: any) {
const { value } = e.detail;
this.setData({ type: value });
},
/** 选择位置 */
async chooseLocation() {
const location = await wx.chooseLocation({});
this.setData({
location: {
lat: location.latitude,
lng: location.longitude,
text: location.name
}
});
try {
const location = await wx.chooseLocation({});
this.setData({
location: {
lat: location.latitude,
lng: location.longitude,
text: location.name
}
});
} catch (err) {
// 用户取消选择
}
},
/** 新增附件 */
addMedia() {
@ -180,58 +252,117 @@ Page({
raw: item
} as WechatMediaItem;
});
that.setData({
newMediaList: [...that.data.newMediaList, ...newMedia]
});
if (that.data.mode === "create") {
// 创建模式:直接添加到 mediaList
that.setData({
mediaList: [...that.data.mediaList, ...newMedia]
});
} else {
// 编辑模式:添加到 newMediaList
that.setData({
newMediaList: [...that.data.newMediaList, ...newMedia]
});
}
wx.hideLoading();
}
});
},
/** 清空媒体(仅创建模式) */
clearMedia() {
wx.showModal({
title: "提示",
content: "确认清空已选照片或视频吗?",
confirmText: "清空",
confirmColor: "#E64340",
cancelText: "取消",
success: res => {
if (res.confirm) {
this.setData({
mediaList: []
});
}
}
});
},
/** 预览附件 */
preview(e: WechatMiniprogram.BaseEvent) {
const isNewMedia = e.currentTarget.dataset.newMedia;
const index = e.currentTarget.dataset.index;
const sources = this.data.mediaList.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;
if (this.data.mode === "create") {
// 创建模式:只有 mediaList
const sources = (this.data.mediaList as WechatMediaItem[]).map(item => ({
url: item.path,
type: MediaItemType[item.type].toLowerCase()
}));
const startIndex = Math.max(0, itemIndex - 25);
const endIndex = Math.min(total, startIndex + 50);
const newCurrentIndex = itemIndex - startIndex;
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: allSources.slice(startIndex, endIndex) as WechatMiniprogram.MediaSource[]
});
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[]
});
}
},
/** 删除附件 */
deleteMedia(e: WechatMiniprogram.BaseEvent) {
const isNewMedia = e.currentTarget.dataset.newMedia;
const index = e.currentTarget.dataset.index;
if (isNewMedia) {
if (this.data.mode === "create") {
// 创建模式:从 mediaList 删除
const mediaList = [...this.data.mediaList];
mediaList.splice(index, 1);
this.setData({ mediaList });
} else {
const newMediaList = [...this.data.newMediaList];
newMediaList.splice(index, 1);
this.setData({ newMediaList });
// 编辑模式:根据标识删除
if (isNewMedia) {
const newMediaList = [...this.data.newMediaList];
newMediaList.splice(index, 1);
this.setData({ newMediaList });
} else {
const mediaList = [...this.data.mediaList];
mediaList.splice(index, 1);
this.setData({ mediaList });
}
}
},
/** 取消编辑 */
/** 取消 */
cancel() {
wx.navigateBack();
if (this.data.mode === "create") {
wx.switchTab({
url: "/pages/main/journal/index"
});
} else {
wx.navigateBack();
}
},
/** 删除记录 */
/** 删除记录(仅编辑模式) */
deleteJournal() {
this.setData({
deleteDialogVisible: true,
@ -301,8 +432,100 @@ Page({
}
});
},
/** 保存 */
save() {
/** 提交/保存 */
submit() {
if (this.data.mode === "create") {
this.createJournal();
} else {
this.updateJournal();
}
},
/** 创建日记 */
createJournal() {
const handleFail = () => {
wx.showToast({ title: "上传失败", icon: "error" });
wx.hideLoading();
this.setData({
isSaving: false
});
};
this.setData({
isSaving: true
});
// 获取 openId
const getOpenId = new Promise<string>((resolve, reject) => {
wx.login({
success: (res) => {
if (res.code) {
wx.request({
url: `${config.url}/journal/openid`,
method: "POST",
header: {
Key: wx.getStorageSync("key")
},
data: {
code: res.code
},
success: (resp) => {
const data = resp.data as any;
if (data.code === 20000) {
resolve(data.data);
} else {
reject(new Error("获取 openId 失败"));
}
},
fail: () => reject(new Error("获取 openId 请求失败"))
});
} else {
reject(new Error("获取登录凭证失败"));
}
},
fail: () => reject(new Error("登录失败"))
});
});
// 文件上传
const uploadFiles = this.uploadMediaFiles(this.data.mediaList as WechatMediaItem[]);
// 并行执行获取 openId 和文件上传
Promise.all([getOpenId, uploadFiles]).then(([openId, tempFileIds]) => {
wx.showLoading({ title: "正在保存..", mask: true });
wx.request({
url: `${config.url}/journal/create`,
method: "POST",
header: {
Key: wx.getStorageSync("key")
},
data: {
idea: this.data.idea,
type: this.data.type,
lat: this.data.location?.lat,
lng: this.data.location?.lng,
location: this.data.location?.text,
pusher: openId,
createdAt: Date.parse(`${this.data.date} ${this.data.time}`),
tempFileIds
},
success: async () => {
Events.emit("JOURNAL_REFRESH");
wx.showToast({ title: "提交成功", icon: "success" });
this.setData({
idea: "",
mediaList: [],
isSaving: false,
uploaded: "0",
uploadTotal: "0 MB",
uploadProgress: 0
});
await Toolkit.sleep(1000);
wx.switchTab({
url: "/pages/main/journal/index"
});
},
fail: handleFail
});
}).catch(handleFail);
},
/** 更新日记 */
updateJournal() {
const handleFail = () => {
wx.showToast({ title: "保存失败", icon: "error" });
wx.hideLoading();
@ -314,99 +537,10 @@ Page({
isSaving: true
});
// 收集保留的附件 ID缩略图 ID
const attachmentIds = this.data.mediaList.map(item => item.attachmentId);
const attachmentIds = (this.data.mediaList as MediaItem[]).map(item => item.attachmentId);
// 上传新媒体文件
const uploadFiles = new Promise<string[]>((resolve, reject) => {
if (this.data.newMediaList.length === 0) {
resolve([]);
return;
}
const uploadFiles = this.uploadMediaFiles(this.data.newMediaList);
wx.showLoading({ title: "正在上传..", mask: true });
// 计算总大小
const sizePromises = this.data.newMediaList.map(item => {
return new Promise<number>((sizeResolve, sizeReject) => {
wx.getFileSystemManager().getFileInfo({
filePath: item.path,
success: (res) => sizeResolve(res.size),
fail: (err) => sizeReject(err)
});
});
});
Promise.all(sizePromises).then(fileSizes => {
const totalSize = fileSizes.reduce((acc, size) => acc + size, 0);
const uploadTasks: WechatMiniprogram.UploadTask[] = [];
let uploadedSize = 0;
let lastUploadedSize = 0;
this.setData({
uploadTotal: IOSize.format(totalSize, 2, Unit.MB)
});
// 计算上传速度
const speedUpdateInterval = setInterval(() => {
const chunkSize = uploadedSize - lastUploadedSize;
this.setData({
uploadSpeed: `${IOSize.format(chunkSize)} / s`
});
lastUploadedSize = uploadedSize;
}, 1000);
// 上传文件
const uploadPromises = this.data.newMediaList.map(item => {
return new Promise<string>((uploadResolve, uploadReject) => {
const task = wx.uploadFile({
url: `${config.url}/temp/file/upload`,
filePath: item.path,
name: "file",
success: (resp) => {
const result = JSON.parse(resp.data);
if (result && result.code === 20000) {
uploadResolve(result.data[0].id);
} else {
uploadReject(new Error(`文件上传失败: ${result?.message || "未知错误"}`));
}
},
fail: (err) => uploadReject(new Error(`文件上传失败: ${err.errMsg}`))
});
// 监听上传进度
let prevProgress = 0;
task.onProgressUpdate((res) => {
const fileUploaded = (res.totalBytesExpectedToSend * res.progress) / 100;
const delta = fileUploaded - prevProgress;
uploadedSize += delta;
prevProgress = fileUploaded;
// 更新进度条
this.setData({
uploaded: IOSize.formatWithoutUnit(uploadedSize, 2, Unit.MB),
uploadProgress: Math.round((uploadedSize / totalSize) * 10000) / 100
});
});
uploadTasks.push(task);
});
});
Promise.all(uploadPromises).then((tempFileIds) => {
// 清除定时器
clearInterval(speedUpdateInterval);
uploadTasks.forEach(task => task.offProgressUpdate());
this.setData({
uploadProgress: 100,
uploadSpeed: "0 MB / s"
});
resolve(tempFileIds);
}).catch((e: Error) => {
// 取消所有上传任务
uploadTasks.forEach(task => task.abort());
clearInterval(speedUpdateInterval);
reject(e);
});
}).catch(reject);
});
// 提交保存
uploadFiles.then((tempFileIds) => {
wx.showLoading({ title: "正在保存..", mask: true });
@ -449,5 +583,97 @@ Page({
fail: handleFail
});
}).catch(handleFail);
},
/** 上传媒体文件 */
uploadMediaFiles(mediaList: WechatMediaItem[]): Promise<string[]> {
return new Promise<string[]>((resolve, reject) => {
if (mediaList.length === 0) {
resolve([]);
return;
}
wx.showLoading({ title: "正在上传..", mask: true });
// 计算总大小
const sizePromises = mediaList.map(item => {
return new Promise<number>((sizeResolve, sizeReject) => {
wx.getFileSystemManager().getFileInfo({
filePath: item.path,
success: (res) => sizeResolve(res.size),
fail: (err) => sizeReject(err)
});
});
});
Promise.all(sizePromises).then(fileSizes => {
const totalSize = fileSizes.reduce((acc, size) => acc + size, 0);
const uploadTasks: WechatMiniprogram.UploadTask[] = [];
let uploadedSize = 0;
let lastUploadedSize = 0;
this.setData({
uploadTotal: IOSize.format(totalSize, 2, Unit.MB)
});
// 计算上传速度
const speedUpdateInterval = setInterval(() => {
const chunkSize = uploadedSize - lastUploadedSize;
this.setData({
uploadSpeed: `${IOSize.format(chunkSize)} / s`
});
lastUploadedSize = uploadedSize;
}, 1000);
// 上传文件
const uploadPromises = mediaList.map(item => {
return new Promise<string>((uploadResolve, uploadReject) => {
const task = wx.uploadFile({
url: `${config.url}/temp/file/upload`,
filePath: item.path,
name: "file",
success: (resp) => {
const result = JSON.parse(resp.data);
if (result && result.code === 20000) {
uploadResolve(result.data[0].id);
} else {
uploadReject(new Error(`文件上传失败: ${result?.message || "未知错误"}`));
}
},
fail: (err) => uploadReject(new Error(`文件上传失败: ${err.errMsg}`))
});
// 监听上传进度
let prevProgress = 0;
task.onProgressUpdate((res) => {
const fileUploaded = (res.totalBytesExpectedToSend * res.progress) / 100;
const delta = fileUploaded - prevProgress;
uploadedSize += delta;
prevProgress = fileUploaded;
// 更新进度条
this.setData({
uploaded: IOSize.formatWithoutUnit(uploadedSize, 2, Unit.MB),
uploadProgress: Math.round((uploadedSize / totalSize) * 10000) / 100
});
});
uploadTasks.push(task);
});
});
Promise.all(uploadPromises).then((tempFileIds) => {
// 清除定时器
clearInterval(speedUpdateInterval);
uploadTasks.forEach(task => task.offProgressUpdate());
this.setData({
uploadProgress: 100,
uploadSpeed: "0 MB / s"
});
resolve(tempFileIds);
}).catch((e: Error) => {
// 取消所有上传任务
uploadTasks.forEach(task => task.abort());
clearInterval(speedUpdateInterval);
reject(e);
});
}).catch(reject);
});
}
});

View File

@ -1,5 +1,5 @@
<!--pages/main/journal-editor/index.wxml-->
<t-navbar title="编辑记录">
<t-navbar title="{{mode === 'create' ? '新纪录' : '编辑记录'}}">
<text slot="left" bindtap="cancel">取消</text>
</t-navbar>
<scroll-view
@ -48,22 +48,51 @@
</view>
<view class="section media">
<view class="gallery">
<!-- 现有附件 -->
<block wx:for="{{mediaList}}" wx:key="attachmentId">
<view class="item">
<!-- 图片 -->
<image
wx:if="{{item.type === mediaItemTypeEnum.IMAGE}}"
src="{{item.thumbURL}}"
class="thumbnail"
mode="aspectFill"
bindtap="preview"
data-index="{{index}}"
data-new-media="{{false}}"
></image>
<!-- 视频 -->
<view wx:if="{{item.type === mediaItemTypeEnum.VIDEO}}" class="video-container">
<!-- 创建模式mediaList 显示新选择的媒体 -->
<block wx:if="{{mode === 'create'}}">
<block wx:for="{{mediaList}}" wx:key="index">
<view class="item">
<!-- 图片 -->
<image
wx:if="{{item.type === mediaItemTypeEnum.IMAGE}}"
src="{{item.path}}"
class="thumbnail"
mode="aspectFill"
bindtap="preview"
data-index="{{index}}"
data-new-media="{{true}}"
></image>
<!-- 视频 -->
<view wx:if="{{item.type === mediaItemTypeEnum.VIDEO}}" class="video-container">
<image
src="{{item.thumbPath}}"
class="thumbnail"
mode="aspectFill"
bindtap="preview"
data-index="{{index}}"
data-new-media="{{true}}"
></image>
<t-icon class="play-icon" name="play" />
</view>
<!-- 删除 -->
<t-icon
class="delete"
name="close"
bindtap="deleteMedia"
data-index="{{index}}"
data-new-media="{{true}}"
/>
</view>
</block>
</block>
<!-- 编辑模式mediaList 显示现有附件newMediaList 显示新添加的附件 -->
<block wx:else>
<!-- 现有附件 -->
<block wx:for="{{mediaList}}" wx:key="attachmentId">
<view class="item">
<!-- 图片 -->
<image
wx:if="{{item.type === mediaItemTypeEnum.IMAGE}}"
src="{{item.thumbURL}}"
class="thumbnail"
mode="aspectFill"
@ -71,55 +100,68 @@
data-index="{{index}}"
data-new-media="{{false}}"
></image>
<t-icon class="play-icon" name="play" />
<!-- 视频 -->
<view wx:if="{{item.type === mediaItemTypeEnum.VIDEO}}" class="video-container">
<image
src="{{item.thumbURL}}"
class="thumbnail"
mode="aspectFill"
bindtap="preview"
data-index="{{index}}"
data-new-media="{{false}}"
></image>
<t-icon class="play-icon" name="play" />
</view>
<!-- 删除 -->
<t-icon
class="delete"
name="close"
bindtap="deleteMedia"
data-index="{{index}}"
data-new-media="{{false}}"
/>
</view>
<!-- 删除 -->
<t-icon
class="delete"
name="close"
bindtap="deleteMedia"
data-index="{{index}}"
data-new-media="{{true}}"
/>
</view>
</block>
<!-- 新选择附件 -->
<block wx:for="{{newMediaList}}" wx:key="index">
<view class="item new-item">
<!-- 图片 -->
<image
wx:if="{{item.type === mediaItemTypeEnum.IMAGE}}"
src="{{item.path}}"
class="thumbnail"
mode="aspectFill"
bindtap="preview"
data-index="{{index}}"
data-new-media="{{true}}"
></image>
<!-- 视频 -->
<view wx:if="{{item.type === mediaItemTypeEnum.VIDEO}}" class="video-container">
</block>
<!-- 新选择附件 -->
<block wx:for="{{newMediaList}}" wx:key="index">
<view class="item new-item">
<!-- 图片 -->
<image
src="{{item.thumbPath}}"
wx:if="{{item.type === mediaItemTypeEnum.IMAGE}}"
src="{{item.path}}"
class="thumbnail"
mode="aspectFill"
bindtap="preview"
data-index="{{index}}"
data-new-media="{{true}}"
></image>
<t-icon class="play-icon" name="play" />
<!-- 视频 -->
<view wx:if="{{item.type === mediaItemTypeEnum.VIDEO}}" class="video-container">
<image
src="{{item.thumbPath}}"
class="thumbnail"
mode="aspectFill"
bindtap="preview"
data-index="{{index}}"
data-new-media="{{true}}"
></image>
<t-icon class="play-icon" name="play" />
</view>
<!-- 新增标识 -->
<t-icon class="new-badge" name="add" />
<!-- 删除 -->
<t-icon
class="delete"
name="close"
bindtap="deleteMedia"
data-index="{{index}}"
data-new-media="{{true}}"
/>
</view>
<!-- 新增标识 -->
<t-icon class="new-badge" name="add" />
<!-- 删除 -->
<t-icon
class="delete"
name="close"
bindtap="deleteMedia"
data-index="{{index}}"
data-new-media="{{true}}"
/>
</view>
</block>
</block>
<!-- 添加按钮 -->
<t-button
class="item add"
theme="primary"
@ -142,19 +184,47 @@
<view>{{uploadSpeed}}</view>
</view>
</view>
<!-- 控制按钮 -->
<view class="ctrl">
<t-button class="delete" theme="danger" bind:tap="deleteJournal" disabled="{{isSaving}}">删除记录</t-button>
<t-button
class="save"
theme="primary"
bind:tap="save"
disabled="{{(!idea && mediaList.length === 0 && newMediaList.length === 0) || isSaving}}"
>保存</t-button>
<!-- 创建模式 -->
<block wx:if="{{mode === 'create'}}">
<t-button
class="clear"
theme="danger"
variant="outline"
disabled="{{isSaving}}"
bind:tap="clearMedia"
disabled="{{mediaList.length === 0}}"
>清空已选</t-button>
<t-button
class="submit"
theme="primary"
bind:tap="submit"
disabled="{{(!idea && mediaList.length === 0) || isSaving}}"
>提交</t-button>
</block>
<!-- 编辑模式 -->
<block wx:else>
<t-button
class="delete"
theme="danger"
bind:tap="deleteJournal"
disabled="{{isSaving}}"
>删除记录</t-button>
<t-button
class="save"
theme="primary"
bind:tap="submit"
disabled="{{(!idea && mediaList.length === 0 && newMediaList.length === 0) || isSaving}}"
>保存</t-button>
</block>
</view>
</block>
</view>
</scroll-view>
<!-- 删除对话框(仅编辑模式) -->
<t-dialog
wx:if="{{mode === 'edit'}}"
visible="{{deleteDialogVisible}}"
title="删除记录"
confirm-btn="{{ {content: '删除', variant: 'text', theme: 'danger'} }}"

View File

@ -125,7 +125,7 @@ Page({
},
toCreater() {
wx.navigateTo({
"url": "/pages/main/journal-creater/index?from=journal"
"url": "/pages/main/journal-editor/index?from=journal"
})
},
toSearch() {

View File

@ -4,7 +4,7 @@ import Events from "../../../utils/Events";
import IOSize, { Unit } from "../../../utils/IOSize";
import Time from "../../../utils/Time";
import Toolkit from "../../../utils/Toolkit";
import type { Location } from "../journal-creater/index";
import type { Location } from "../../../types/UI";
type Item = {
id: number;

View File

@ -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"
}
}
"t-dialog": "tdesign-miniprogram/dialog/dialog",
"t-navbar": "tdesign-miniprogram/navbar/navbar",
"t-loading": "tdesign-miniprogram/loading/loading"
},
"styleIsolation": "shared"
}

View File

@ -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;
}
}
}

View File

@ -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: <TravelDetailData>{
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();
}
});

View File

@ -0,0 +1,208 @@
<!--pages/main/travel-detail/index.wxml-->
<view class="custom-navbar">
<t-navbar title="旅行详情" leftArrow bind:go-back="goBack">
<view slot="right" class="edit-btn" bind:tap="toEdit">
<t-icon name="edit" size="24px" />
</view>
</t-navbar>
</view>
<view class="detail-container">
<!-- 加载状态 -->
<t-loading wx:if="{{isLoading}}" theme="dots" size="40rpx" />
<!-- 详情内容 -->
<view wx:if="{{!isLoading && travel}}" class="content">
<!-- 状态标签 -->
<view class="status-section">
<t-tag
size="large"
theme="{{travel.status === 'PLANNING' ? 'default' : travel.status === 'ONGOING' ? 'warning' : 'success'}}"
variant="light"
icon="{{statusIcons[travel.status]}}"
>
{{statusLabels[travel.status]}}
</t-tag>
</view>
<!-- 标题 -->
<view class="title-section">
<text class="title">{{travel.title || '未命名旅行'}}</text>
</view>
<!-- 基本信息 -->
<view class="info-card">
<view class="card-title">
<t-icon name="info-circle" size="20px" class="icon" />
<text class="text">基本信息</text>
</view>
<view class="info-list">
<view class="info-item">
<view class="label">
<t-icon name="time" size="18px" class="icon" />
<text class="text">出行时间</text>
</view>
<view class="value">{{travel.travelDate}} {{travel.travelTime}}</view>
</view>
<view wx:if="{{travel.days}}" class="info-item">
<view class="label">
<t-icon name="calendar" size="18px" class="icon" />
<text class="text">旅行天数</text>
</view>
<view class="value">{{travel.days}} 天</view>
</view>
<view wx:if="{{travel.transportationType}}" class="info-item">
<view class="label">
<t-icon name="{{travel.transportationType === 'PLANE' ? 'flight-takeoff' : travel.transportationType === 'TRAIN' ? 'map-route' : travel.transportationType === 'SELF_DRIVING' ? 'control-platform' : 'location'}}" size="18px" class="icon" />
<text class="text">交通方式</text>
</view>
<view class="value">{{transportLabels[travel.transportationType]}}</view>
</view>
</view>
</view>
<!-- 旅行内容 -->
<view wx:if="{{travel.content}}" class="content-card">
<view class="card-title">
<t-icon name="edit" size="20px" class="icon" />
<text class="text">详细说明</text>
</view>
<view class="content-text">{{travel.content}}</view>
</view>
<!-- 地点列表 -->
<view class="locations-card">
<view class="card-title">
<view class="title-left">
<t-icon name="location" size="20px" class="icon" />
<text class="text">地点列表</text>
</view>
<view class="title-right">
<view class="icon-btn" catch:tap="toMap">
<t-icon name="map" size="20px" color="#5E7CE0" />
</view>
<view class="icon-btn" catch:tap="toAddLocation">
<t-icon name="add" size="20px" color="#5E7CE0" />
</view>
</view>
</view>
<!-- 加载中 -->
<view wx:if="{{isLoadingLocations}}" class="loading-container">
<t-loading theme="dots" size="40rpx" />
</view>
<!-- 地点列表 -->
<view wx:elif="{{locations.length > 0}}" class="locations-list">
<view
wx:for="{{locations}}"
wx:key="id"
class="location-item"
bind:tap="toEditLocation"
data-id="{{item.id}}"
>
<view class="location-icon">
<t-icon name="{{locationTypeIcons[item.type]}}" size="24px" color="#5E7CE0" />
</view>
<view class="location-content">
<view class="location-header">
<text class="location-title">{{item.title || '未命名地点'}}</text>
<t-tag size="small" variant="light">{{locationTypeLabels[item.type]}}</t-tag>
</view>
<view wx:if="{{item.description}}" class="location-description">{{item.description}}</view>
<view class="location-info">
<view wx:if="{{item.location}}" class="info-item">
<t-icon name="location" size="14px" />
<text>{{item.location}}</text>
</view>
<view wx:if="{{item.amount}}" class="info-item">
<t-icon name="money-circle" size="14px" />
<text>¥{{item.amount}}</text>
</view>
<view wx:if="{{item.requireIdCard}}" class="info-item">
<t-icon name="user" size="14px" />
<text>需要身份证</text>
</view>
<view wx:if="{{item.score}}" class="info-item">
<t-icon name="star" size="14px" />
<text>必要度 {{item.score}}</text>
</view>
</view>
</view>
<view class="location-arrow">
<t-icon name="chevron-right" size="20px" color="#DCDCDC" />
</view>
</view>
</view>
<!-- 空状态 -->
<view wx:else class="empty-state">
<t-icon name="location" size="48px" color="#DCDCDC" />
<text class="empty-text">暂无地点信息</text>
</view>
</view>
<!-- 时间信息 -->
<view class="time-card">
<view class="time-item">
<text class="label">创建时间</text>
<text class="value">{{travel.createdAtFormatted || ''}}</text>
</view>
<view wx:if="{{travel.updatedAt && travel.updatedAt !== travel.createdAt}}" class="time-item">
<text class="label">更新时间</text>
<text class="value">{{travel.updatedAtFormatted || ''}}</text>
</view>
</view>
<!-- 操作按钮 -->
<view class="action-section">
<t-button
theme="danger"
variant="outline"
size="large"
icon="delete"
t-class="delete-btn"
bind:tap="deleteTravel"
>
删除
</t-button>
<t-button
theme="primary"
size="large"
icon="edit"
t-class="edit-btn"
bind:tap="toEdit"
>
编辑旅行计划
</t-button>
</view>
</view>
</view>
<!-- 删除确认对话框 -->
<t-dialog
visible="{{deleteDialogVisible}}"
title="删除旅行计划"
confirm-btn="删除"
cancel-btn="取消"
bind:confirm="confirmDelete"
bind:cancel="cancelDelete"
>
<view class="delete-dialog-content">
<view class="delete-warning">
<t-icon name="error-circle" size="48rpx" color="#E34D59" />
<text class="warning-text">删除后无法恢复,请谨慎操作!</text>
</view>
<view class="delete-confirm">
<text class="confirm-label">请输入"确认删除"以继续:</text>
<t-input
placeholder="请输入确认删除"
model:value="{{deleteConfirmText}}"
borderless="{{false}}"
/>
</view>
</view>
</t-dialog>

View File

@ -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"
}

View File

@ -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;
}
}
}

View File

@ -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: <TravelEditorData>{
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();
}
}
});

View File

@ -0,0 +1,150 @@
<!--pages/main/travel-editor/index.wxml-->
<t-navbar title="{{mode === 'create' ? '新建旅行' : '编辑旅行'}}">
<text slot="left" bindtap="cancel">取消</text>
</t-navbar>
<scroll-view class="container" type="custom" scroll-y show-scrollbar="{{false}}">
<view class="content">
<view wx:if="{{isLoading}}" class="loading">
<t-loading theme="dots" size="40rpx" />
<text class="loading-text">加载中...</text>
</view>
<block wx:else>
<t-cell-group class="section">
<t-input
class="input"
placeholder="请输入旅行标题"
model:value="{{title}}"
maxlength="50"
borderless
>
<text slot="label">标题</text>
</t-input>
<t-textarea
class="textarea"
placeholder="添加详细说明(选填)"
model:value="{{content}}"
maxlength="500"
indicator
>
<text slot="label">内容</text>
</t-textarea>
</t-cell-group>
<t-cell-group class="section">
<t-cell class="travel-at" title="出行时间">
<view slot="right-icon">
<picker class="picker" mode="date" model:value="{{date}}">
<view slot="content" class="slot">
<t-icon name="calendar" size="20px" class="icon" />
<text>{{date}}</text>
</view>
</picker>
</view>
</t-cell>
<t-cell title="旅行天数" t-class="days-cell">
<view slot="right-icon" class="days-stepper">
<t-stepper
theme="filled"
model:value="{{days}}"
size="medium"
min="{{1}}"
max="{{999}}"
t-class="stepper"
/>
</view>
</t-cell>
<t-cell title="交通方式">
<view slot="right-icon">
<picker
class="picker"
mode="selector"
range="{{transportationTypes}}"
range-key="label"
value="{{transportationTypeIndex}}"
bindchange="onChangeTransportationType"
>
<view class="slot">
<text>{{transportationTypes[transportationTypeIndex].label}}</text>
<t-icon name="chevron-right" size="20px" class="icon" />
</view>
</picker>
</view>
</t-cell>
<t-cell title="状态">
<view slot="right-icon">
<picker
class="picker"
mode="selector"
range="{{statuses}}"
range-key="label"
value="{{statusIndex}}"
bindchange="onChangeStatus"
>
<view class="slot">
<text>{{statuses[statusIndex].label}}</text>
<t-icon name="chevron-right" size="20px" class="icon" />
</view>
</picker>
</view>
</t-cell>
</t-cell-group>
<view wx:if="{{mode === 'create'}}" class="submit-section">
<t-button
theme="primary"
size="large"
block
bind:tap="submit"
disabled="{{isSaving}}"
>
创建旅行
</t-button>
</view>
<view wx:else class="submit-section horizontal">
<t-button
theme="danger"
variant="outline"
size="large"
icon="delete"
t-class="delete-btn"
bind:tap="deleteTravel"
>
删除
</t-button>
<t-button
theme="primary"
size="large"
t-class="save-btn"
bind:tap="submit"
disabled="{{isSaving}}"
>
保存修改
</t-button>
</view>
</block>
</view>
</scroll-view>
<!-- 删除确认对话框 -->
<t-dialog
visible="{{deleteDialogVisible}}"
title="删除旅行计划"
confirm-btn="删除"
cancel-btn="取消"
bind:confirm="confirmDelete"
bind:cancel="cancelDelete"
>
<view class="delete-dialog-content">
<view class="delete-warning">
<t-icon name="error-circle" size="48rpx" color="#E34D59" />
<text class="warning-text">删除后无法恢复,请谨慎操作!</text>
</view>
<view class="delete-confirm">
<text class="confirm-label">请输入"确认删除"以继续:</text>
<t-input
placeholder="请输入确认删除"
model:value="{{deleteConfirmText}}"
borderless="{{false}}"
/>
</view>
</view>
</t-dialog>

View File

@ -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"
}

View File

@ -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;
}
}
}
}

View File

@ -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: <TravelLocationEditorData>{
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: (<any>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: ""
});
}
}
});

View File

@ -0,0 +1,242 @@
<!--pages/main/travel-location-editor/index.wxml-->
<t-navbar title="{{mode === 'create' ? '添加地点' : '编辑地点'}}">
<text slot="left" bindtap="cancel">取消</text>
</t-navbar>
<scroll-view class="container" type="custom" scroll-y show-scrollbar="{{false}}">
<view class="content">
<view wx:if="{{isLoading}}" class="loading">
<t-loading theme="dots" size="40rpx" />
<text class="loading-text">加载中...</text>
</view>
<block wx:else>
<t-cell-group class="section">
<t-cell title="地点类型">
<view slot="right-icon">
<picker
class="picker"
mode="selector"
range="{{locationTypes}}"
range-key="label"
value="{{locationTypeIndex}}"
bindchange="onChangeLocationType"
>
<view class="slot">
<text>{{locationTypes[locationTypeIndex].label}}</text>
<t-icon name="chevron-right" size="20px" class="icon" />
</view>
</picker>
</view>
</t-cell>
<t-cell title="位置" required bind:click="chooseLocation">
<view slot="right-icon">
<view class="location-slot">
<text wx:if="{{location}}" class="location-text">{{location}}</text>
<text wx:else class="location-placeholder">点击选择位置</text>
<t-icon name="chevron-right" size="20px" class="icon" />
</view>
</view>
</t-cell>
</t-cell-group>
<t-cell-group class="section">
<t-input
class="input"
placeholder="请输入地点名称"
model:value="{{title}}"
label="标题"
maxlength="50"
/>
<t-textarea
class="textarea"
placeholder="添加地点说明(选填)"
model:value="{{description}}"
maxlength="500"
indicator
>
<text slot="label">说明</text>
</t-textarea>
</t-cell-group>
<t-cell-group class="section">
<t-input
model:value="{{amount}}"
placeholder="0"
label="费用"
suffix="元"
align="right"
/>
<t-cell title="必要评分">
<view slot="right-icon">
<t-rate
value="{{score}}"
count="{{5}}"
size="24px"
bind:change="onChangeScore"
/>
</view>
</t-cell>
<t-cell title="需要身份证">
<view slot="right-icon">
<switch checked="{{requireIdCard}}" bindchange="onChangeRequireIdCard" />
</view>
</t-cell>
</t-cell-group>
<view class="section media">
<view class="gallery">
<!-- 创建模式mediaList 显示新选择的媒体 -->
<block wx:if="{{mode === 'create'}}">
<block wx:for="{{mediaList}}" wx:key="index">
<view class="item">
<!-- 图片 -->
<image
wx:if="{{item.type === mediaItemTypeEnum.IMAGE}}"
src="{{item.path}}"
class="thumbnail"
mode="aspectFill"
bindtap="preview"
data-index="{{index}}"
data-new-media="{{true}}"
></image>
<!-- 视频 -->
<view wx:if="{{item.type === mediaItemTypeEnum.VIDEO}}" class="video-container">
<image
src="{{item.thumbPath}}"
class="thumbnail"
mode="aspectFill"
bindtap="preview"
data-index="{{index}}"
data-new-media="{{true}}"
></image>
<t-icon class="play-icon" name="play" />
</view>
<!-- 删除 -->
<t-icon
class="delete"
name="close"
bindtap="deleteMedia"
data-index="{{index}}"
data-new-media="{{true}}"
/>
</view>
</block>
</block>
<!-- 编辑模式mediaList 显示现有附件newMediaList 显示新添加的附件 -->
<block wx:else>
<!-- 现有附件 -->
<block wx:for="{{mediaList}}" wx:key="attachmentId">
<view class="item">
<!-- 图片 -->
<image
wx:if="{{item.type === mediaItemTypeEnum.IMAGE}}"
src="{{item.thumbURL}}"
class="thumbnail"
mode="aspectFill"
bindtap="preview"
data-index="{{index}}"
data-new-media="{{false}}"
></image>
<!-- 视频 -->
<view wx:if="{{item.type === mediaItemTypeEnum.VIDEO}}" class="video-container">
<image
src="{{item.thumbURL}}"
class="thumbnail"
mode="aspectFill"
bindtap="preview"
data-index="{{index}}"
data-new-media="{{false}}"
></image>
<t-icon class="play-icon" name="play" />
</view>
<!-- 删除 -->
<t-icon
class="delete"
name="close"
bindtap="deleteMedia"
data-index="{{index}}"
data-new-media="{{false}}"
/>
</view>
</block>
<!-- 新选择附件 -->
<block wx:for="{{newMediaList}}" wx:key="index">
<view class="item new-item">
<!-- 图片 -->
<image
wx:if="{{item.type === mediaItemTypeEnum.IMAGE}}"
src="{{item.path}}"
class="thumbnail"
mode="aspectFill"
bindtap="preview"
data-index="{{index}}"
data-new-media="{{true}}"
></image>
<!-- 视频 -->
<view wx:if="{{item.type === mediaItemTypeEnum.VIDEO}}" class="video-container">
<image
src="{{item.thumbPath}}"
class="thumbnail"
mode="aspectFill"
bindtap="preview"
data-index="{{index}}"
data-new-media="{{true}}"
></image>
<t-icon class="play-icon" name="play" />
</view>
<!-- 新增标识 -->
<t-icon class="new-badge" name="add" />
<!-- 删除 -->
<t-icon
class="delete"
name="close"
bindtap="deleteMedia"
data-index="{{index}}"
data-new-media="{{true}}"
/>
</view>
</block>
</block>
<!-- 添加按钮 -->
<t-button
class="item add"
theme="primary"
plain="true"
disabled="{{isSaving}}"
bind:tap="addMedia"
>
<t-icon name="add" />
</t-button>
</view>
</view>
<!-- 上传进度提示 -->
<view wx:if="{{isUploading}}" class="upload-info">
<t-loading theme="circular" size="32rpx" />
<text class="upload-text">{{uploadInfo}}</text>
</view>
<!-- 按钮 -->
<view class="submit-section">
<t-button
wx:if="{{mode === 'edit'}}"
class="delete-btn"
theme="danger"
variant="outline"
size="large"
bind:tap="deleteLocation"
disabled="{{isSaving || isUploading}}"
>
删除
</t-button>
<t-button
class="submit-btn"
theme="primary"
size="large"
bind:tap="submit"
loading="{{isSaving}}"
disabled="{{isSaving || isUploading}}"
>
{{mode === 'create' ? '创建地点' : '保存修改'}}
</t-button>
</view>
</block>
</view>
</scroll-view>

View File

@ -0,0 +1,8 @@
{
"component": true,
"usingComponents": {
"t-navbar": "tdesign-miniprogram/navbar/navbar",
"travel-location-popup": "/components/travel-location-popup/index"
},
"styleIsolation": "shared"
}

View File

@ -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);
}
}
}

View File

@ -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: <TravelMapData>{
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<string, TravelLocation[]>();
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
});
}
});

View File

@ -0,0 +1,32 @@
<!--pages/main/travel-location-map/index.wxml-->
<t-navbar title="地点地图" left-arrow />
<view class="container">
<map
id="travel-location-map"
class="map"
latitude="{{centerLat}}"
longitude="{{centerLng}}"
scale="{{scale}}"
markers="{{markers}}"
include-points="{{includePoints}}"
bindmarkertap="onMarkerTap"
bindcallouttap="onCalloutTap"
>
<cover-view slot="callout">
<block wx:for="{{markers}}" wx:key="id" wx:for-index="markerIndex">
<cover-view class="custom-callout" marker-id="{{markerIndex}}">
<block wx:for="{{item.locations}}" wx:key="id" wx:for-item="location">
<cover-view class="location-item">
<cover-view wx:if="{{location.typeLabel}}" class="type">{{location.typeLabel}}</cover-view>
<cover-view class="title">{{location.title || '未命名地点'}}</cover-view>
</cover-view>
</block>
</cover-view>
</block>
</cover-view>
</map>
<view wx:if="{{isLoading}}" class="loading">
<view class="loading-text">加载中...</view>
</view>
<travel-location-popup visible="{{detailVisible}}" ids="{{detailIds}}" bind:close="closeDetail" />
</view>

View File

@ -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"
}
}
"t-cell-group": "tdesign-miniprogram/cell-group/cell-group"
},
"styleIsolation": "shared",
"enablePullDownRefresh": true
}

View File

@ -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;
}
}
}
}
}
}
.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);
}
}

View File

@ -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: <ITravelData>{
luggage: undefined,
guides: [],
guidesDB: [],
activeCollapse: undefined
data: <TravelData>{
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}`
});
},
});

View File

@ -1,30 +1,108 @@
<!--pages/main/travel/travel.wxml-->
<!--pages/main/travel/index.wxml-->
<view class="custom-navbar">
<t-navbar title="北海之旅" />
<t-navbar title="旅行计划">
<view slot="left" class="filter-btn" bind:tap="toggleFilterMenu">
<t-icon name="filter" size="24px" />
</view>
</t-navbar>
</view>
<view class="travel">
<t-cell title="小糕的旅行装备" arrow bindtap="toLuggageList" data-name="gao"></t-cell>
<t-cell title="夜雨的旅行装备" arrow bindtap="toLuggageList" data-name="yu"></t-cell>
<t-collapse class="collapse" bind:change="onCollapseChange" expandMutex expandIcon>
<t-collapse-panel
class="panel"
wx:for="{{guides}}"
header="{{item.title}}"
value="{{index}}"
wx:key="index"
>
<view wx:if="{{item.images}}" class="images">
<block wx:for="{{item.images}}" wx:for-item="image" wx:for-index="imageIndex" wx:key="imageIndex">
<image
class="image"
src="{{image}}"
mode="widthFix"
bindtap="preview"
data-index="{{index}}"
data-image-index="{{imageIndex}}"
></image>
</block>
<!-- 筛选菜单 -->
<view wx:if="{{isShowFilterMenu}}" class="filter-menu" catchtap="toggleFilterMenu">
<t-cell-group
class="content"
theme="card"
style="top: {{menuTop}}px; left: {{menuLeft}}px;"
catchtap="stopPropagation"
>
<t-cell
title="全部"
leftIcon="bulletpoint"
bind:tap="filterByStatus"
data-status="ALL"
rightIcon="{{currentStatus === 'ALL' ? 'check' : ''}}"
/>
<t-cell
title="计划中"
leftIcon="calendar"
bind:tap="filterByStatus"
data-status="PLANNING"
rightIcon="{{currentStatus === 'PLANNING' ? 'check' : ''}}"
/>
<t-cell
title="进行中"
leftIcon="play-circle"
bind:tap="filterByStatus"
data-status="ONGOING"
rightIcon="{{currentStatus === 'ONGOING' ? 'check' : ''}}"
/>
<t-cell
title="已完成"
leftIcon="check-circle"
bind:tap="filterByStatus"
data-status="COMPLETED"
rightIcon="{{currentStatus === 'COMPLETED' ? 'check' : ''}}"
/>
</t-cell-group>
</view>
<!-- 旅行列表 -->
<view class="travel-list">
<!-- 空状态 -->
<t-empty
wx:if="{{!isFetching && list.length === 0}}"
icon="travel"
description="暂无旅行计划"
/>
<!-- 列表内容 -->
<view
wx:for="{{list}}"
wx:for-item="travel"
wx:for-index="travelIndex"
wx:key="id"
class="travel-card"
bind:tap="toDetail"
data-id="{{travel.id}}"
>
<view class="card-header">
<t-tag
theme="{{travel.status === 'PLANNING' ? 'default' : travel.status === 'ONGOING' ? 'warning' : 'success'}}"
variant="light"
icon="{{statusIcons[travel.status]}}"
>
{{statusLabels[travel.status]}}
</t-tag>
</view>
<view class="card-body">
<view class="title">{{travel.title || '未命名旅行'}}</view>
<view wx:if="{{travel.content}}" class="content">{{travel.content}}</view>
<view class="meta">
<view class="meta-item">
<t-icon name="time" size="16px" class="icon" />
<text class="text">{{travel.travelDate}} {{travel.travelTime}}</text>
</view>
<view wx:if="{{travel.days}}" class="meta-item">
<t-icon name="calendar" size="16px" class="icon" />
<text class="text">{{travel.days}} 天</text>
</view>
<view wx:if="{{travel.transportationType}}" class="meta-item">
<t-icon name="{{travel.transportationType === 'PLANE' ? 'flight-takeoff' : travel.transportationType === 'TRAIN' ? 'map-route' : travel.transportationType === 'SELF_DRIVING' ? 'control-platform' : 'location'}}" size="16px" class="icon" />
<text class="text">{{transportLabels[travel.transportationType]}}</text>
</view>
</view>
</t-collapse-panel>
</t-collapse>
</view>
</view>
</view>
<!-- 加载完成提示 -->
<view wx:if="{{isFinished && 0 < list.length}}" class="finished">没有更多了</view>
</view>
<!-- 新建按钮 -->
<view class="fab" bind:tap="toCreate">
<t-icon name="add" size="24px" color="#fff" />
</view>

View File

@ -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;
}
}

View File

@ -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: <ILuggageData>{
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: ""
})
}
})

View File

@ -1,39 +0,0 @@
<!--pages/main/travel/luggage/index.wxml-->
<wxs module="_"> module.exports.contain = function(arr, key) { return arr.indexOf(key) > -1 } </wxs>
<view class="custom-navbar">
<t-navbar title="{{name}}的旅行装备" bind:go-back="doBack" delta="0" left-arrow />
</view>
<view class="luggage">
<view class="tips">tips: 勾选表示已携带,返回自动保存</view>
<t-checkbox-group class="items">
<view
class="item {{item.isTaken ? 'active' : ''}}"
wx:for="{{value}}"
wx:key="index"
data-index="{{index}}"
bindtap="onTapItem"
bindlongpress="showMenu"
>
<t-icon wx:if="{{item.isTaken}}" name="check" class="icon" ariaHidden="{{true}}" />
<t-checkbox
class="checkbox"
value="{{item.isTaken}}"
label="{{item.name}}"
icon="none"
borderless
/>
</view>
</t-checkbox-group>
</view>
<view class="add-container" style="transform: translateY(-{{keyboardHeight}}px)">
<t-input
class="input"
placeholder="请输入新的物品"
cursor-spacing="20"
adjust-position="{{false}}"
model:value="{{addValue}}"
borderless
/>
<t-button size="small" theme="primary" bindtap="add" disabled="{{!addValue}}">添加</t-button>
</view>

View File

@ -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);
}
}

View File

@ -23,7 +23,7 @@ export type Journal = {
/** 天气 */
weatcher?: string;
// ---------- 以下为 VO 字段 ----------
// ---------- 视图属性 ----------
/** 日期 */
date?: string;

View File

@ -8,10 +8,20 @@ export type Model = {
}
/** 基本返回对象 */
export type Response = {
export type Response<T> = {
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 };

165
miniprogram/types/Travel.ts Normal file
View File

@ -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, string> = {
[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, string> = {
[TravelStatus.PLANNING]: "计划中",
[TravelStatus.ONGOING]: "进行中",
[TravelStatus.COMPLETED]: "已完成"
};
/** 旅行状态图标映射 */
export const TravelStatusIcon: Record<TravelStatus, string> = {
[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, string> = {
[TravelLocationType.ATTRACTION]: "景点",
[TravelLocationType.HOTEL]: "酒店",
[TravelLocationType.RESTAURANT]: "餐厅",
[TravelLocationType.TRANSPORT]: "交通站点",
[TravelLocationType.SHOPPING]: "购物",
[TravelLocationType.OTHER]: "其他"
};
/** 地点类型图标映射 */
export const TravelLocationTypeIcon: Record<TravelLocationType, string> = {
[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[];
}

View File

@ -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<T = any>
extends Omit<WechatMiniprogram.RequestOption, "url" | "success" | "fail" | "complete"> {
/** 接口路径(相对于 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<string, string> {
return {
Key: wx.getStorageSync("key") || ""
};
}
/**
* 通用请求方法
*
* @template T - 响应数据类型
* @param options - 请求选项
* @returns Promise<T> - 返回业务数据
*/
static request<T = any>(options: RequestOptions): Promise<T> {
const {
url,
method = "GET",
data,
header = {},
showLoading = false,
loadingText = "加载中...",
autoHandleError = true,
...restOptions
} = options;
// 显示加载提示
if (showLoading) {
wx.showLoading({ title: loadingText, mask: true });
}
return new Promise<T>((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<T>;
// 业务成功
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<T = any>(url: string, data?: any, options?: Partial<RequestOptions>): Promise<T> {
return this.request<T>({
url,
method: "GET",
data,
...options
});
}
/**
* POST 请求
*
* @template T - 响应数据类型
* @param url - 接口路径
* @param data - 请求数据
* @param options - 其他选项
*/
static post<T = any>(url: string, data?: any, options?: Partial<RequestOptions>): Promise<T> {
return this.request<T>({
url,
method: "POST",
data,
...options
});
}
/**
* DELETE 请求
*
* @template T - 响应数据类型
* @param url - 接口路径
* @param data - 请求数据
* @param options - 其他选项
*/
static delete<T = any>(url: string, data?: any, options?: Partial<RequestOptions>): Promise<T> {
return this.request<T>({
url,
method: "DELETE",
data,
...options
});
}
/**
* 分页查询请求
*
* @template T - 列表项类型
* @param url - 接口路径
* @param pageParams - 分页参数
* @param options - 其他选项
*/
static page<T = any>(
url: string,
pageParams: QueryPage,
options?: Partial<RequestOptions>
): Promise<QueryPageResult<T>> {
return this.post<QueryPageResult<T>>(url, pageParams, options);
}
/**
* 上传单个文件
*
* @param options - 上传选项
* @returns Promise<string> - 返回临时文件 ID
*/
static uploadFile(options: UploadOptions): Promise<string> {
const { filePath, name = "file", onProgress } = options;
return new Promise<string>((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<TempFileResponse[]>;
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<string[]> - 返回临时文件 ID 列表
*/
static uploadFiles(options: UploadFilesOptions): Promise<string[]> {
const { mediaList, onProgress, showLoading = true } = options;
return new Promise<string[]>((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<number>((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);
});
});
}
}