Compare commits

...

28 Commits

Author SHA1 Message Date
8adc28ae9c update 1.6.6 2026-01-04 19:09:52 +08:00
e31a3432a0 remove PreviewImageMetadata.isImage/isVideo 2026-01-04 19:09:28 +08:00
2c6478c729 merge Attachment metadata/ext 2026-01-04 17:41:02 +08:00
f4232e8752 udpate 1.6.5 2025-12-23 16:18:23 +08:00
41e2959a72 fix travel location type 2025-12-23 16:18:16 +08:00
bf5deff045 update 1.6.4 2025-12-23 14:57:37 +08:00
f94cf05f62 preview travel location attach in list 2025-12-23 14:45:15 +08:00
9538a21e42 add MALL travel type 2025-12-23 14:27:51 +08:00
3b091c4f18 update 1.6.3 2025-12-20 14:45:59 +08:00
b64e2767c2 add fiilter for travel location list 2025-12-20 14:45:42 +08:00
df7cfa95a0 fix miss key for Network.request 2025-12-20 13:53:16 +08:00
84fc382c91 fix travel order 2025-12-18 21:05:08 +08:00
107177d095 fix travel location attachment style 2025-12-18 19:33:31 +08:00
2966289930 fix TravelLocation type 2025-12-18 19:24:10 +08:00
6f74559c01 fix map marker width 2025-12-18 19:21:46 +08:00
186a74bc77 scroe and importance allow nullable 2025-12-18 11:59:01 +08:00
51e679dd83 fix transport icon 2025-12-18 11:57:29 +08:00
1ad1da1c4e fix scroll exception 2025-12-18 11:57:07 +08:00
9f7df3cfed update 1.6.2 2025-12-17 16:56:45 +08:00
62186abdb8 apply Network.ts 2025-12-17 16:56:33 +08:00
369cfe2bf2 update style 2025-12-17 16:16:10 +08:00
423775c255 fix travel style 2025-12-17 10:18:10 +08:00
f0f2815971 fix travel style 2025-12-16 16:57:49 +08:00
e650571563 update 1.6.0 2025-12-13 18:59:39 +08:00
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
cbf804c535 update 1.5.4 2025-12-11 21:01:58 +08:00
0df10f5b35 fix list portfolio fail 2025-12-11 21:01:40 +08:00
82 changed files with 6097 additions and 2043 deletions

4
.gitignore vendored
View File

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

View File

@ -0,0 +1,89 @@
import { Network } from "../utils/Network";
import { Journal, JournalPage } from "../types/Journal";
import { QueryPageResult } from "../types/Model";
/**
* Journal 日记 API
*
* 按业务模块封装网络请求,使代码更清晰、可维护
*/
export class JournalApi {
/**
* 获取日记详情
*
* @param id - 日记 ID
*/
static getDetail(id: number | string): Promise<Journal> {
return Network.post<Journal>(`/journal/${id}`);
}
/**
* 日记分页列表
*
* @param pageParams - 分页参数
*/
static getList(pageParams: JournalPage): Promise<QueryPageResult<Journal>> {
return Network.post<QueryPageResult<Journal>>("/journal/list", pageParams);
}
/**
* 创建日记
*
* @param data - 日记数据
*/
static create(data: Partial<Journal> & {
pusher?: string;
tempFileIds?: string[];
}): Promise<Journal> {
return Network.post<Journal>("/journal/create", data, {
showLoading: true,
loadingText: "正在保存.."
});
}
/**
* 更新日记
*
* @param data - 日记数据(必须包含 id
*/
static update(data: Partial<Journal> & {
id: number;
attachmentIds?: number[];
tempFileIds?: string[];
}): Promise<Journal> {
return Network.post<Journal>("/journal/update", data, {
showLoading: true,
loadingText: "正在保存.."
});
}
/**
* 删除日记
*
* @param id - 日记 ID
*/
static delete(id: number): Promise<void> {
return Network.post<void>("/journal/delete", { id }, {
showLoading: true,
loadingText: "删除中..."
});
}
/**
* 获取 openId用于推送
*
* @param code - 微信登录 code
*/
static getOpenId(code: string): Promise<string> {
return Network.post<string>("/journal/openid", { code });
}
/**
* 根据 ID 列表获取日记
*
* @param ids - 日记 ID 列表
*/
static getListByIds(ids: number[]): Promise<Journal[]> {
return Network.post<Journal[]>("/journal/list/ids", ids);
}
}

View File

@ -0,0 +1,69 @@
import { Network } from "../utils/Network";
import { Attachment } from "../types/Attachment";
/**
* Moment 瞬间 API
*
* 管理临时照片/视频上传、归档到日记等操作
*/
export class MomentApi {
/**
* 获取 moment 列表
*/
static getList(): Promise<Attachment[]> {
return Network.post<Attachment[]>("/journal/moment/list");
}
/**
* MD5 查重过滤
*
* @param md5s - MD5 值数组
* @returns 未重复的 MD5 数组
*/
static filterByMD5(md5s: string[]): Promise<string[]> {
return Network.post<string[]>("/journal/moment/filter", md5s);
}
/**
* 创建 moment 附件
*
* @param tempFileIds - 临时文件 ID 数组
* @returns 创建的附件列表
*/
static create(tempFileIds: string[]): Promise<Attachment[]> {
return Network.post<Attachment[]>("/journal/moment/create", tempFileIds, {
showLoading: true,
loadingText: "正在保存.."
});
}
/**
* 归档 moments 到日记
*
* @param data - 归档数据
*/
static archive(data: {
id?: number;
type: string;
idea: string;
lat?: number;
lng?: number;
location?: string;
pusher: string;
thumbIds: number[];
}): Promise<void> {
return Network.post<void>("/journal/moment/archive", data, {
showLoading: true,
loadingText: "正在归档.."
});
}
/**
* 删除 moments
*
* @param ids - 附件 ID 数组
*/
static delete(ids: number[]): Promise<void> {
return Network.post<void>("/journal/moment/delete", ids);
}
}

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,23 +2,26 @@
"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-detail/index",
"pages/main/travel-editor/index",
"pages/main/travel-location-detail/index",
"pages/main/travel-location-map/index",
"pages/main/travel-location-editor/index",
"pages/main/about/index",
"pages/main/travel/luggage/index",
"pages/main/moment/index"
],
"darkmode": true,
"themeLocation": "theme.json",
"window": {
"navigationStyle": "custom",
"navigationBarTextStyle": "black",
"navigationBarBackgroundColor": "#FFFFFF"
"navigationBarTextStyle": "@navigationBarTextStyle",
"navigationBarBackgroundColor": "@navigationBarBackgroundColor"
},
"lazyCodeLoading": "requiredComponents",
"tabBar": {
@ -46,7 +49,7 @@
"selectedIconPath": "@tabBarIconMomentActive"
},
{
"text": "行",
"text": "行",
"pagePath": "pages/main/travel/index",
"iconPath": "@tabBarIconTravel",
"selectedIconPath": "@tabBarIconTravelActive"
@ -68,4 +71,4 @@
"getLocation",
"chooseLocation"
]
}
}

17
miniprogram/app.less Normal file
View File

@ -0,0 +1,17 @@
/**app.wxss**/
@import "./timi-web.less";
@import "./tdesign.less";
@import "./theme.less";
.setting-bg {
background: var(--theme-bg-wx);
&::before {
content: "";
width: 100vw;
height: 100vh;
z-index: -1;
position: fixed;
background: var(--theme-bg-wx);
}
}

View File

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

View File

@ -1,4 +1,6 @@
{
"component": true,
"usingComponents": {}
"usingComponents": {
"t-icon": "tdesign-miniprogram/icon/icon"
}
}

View File

@ -23,31 +23,9 @@ page {
}
.snowflake {
width: 10px;
height: 10px;
color: var(--theme-brand-gao);
display: block;
position: absolute;
animation: snowflakeFall linear infinite;
&::before,
&::after {
content: '';
position: absolute;
background: var(--theme-brand-gao);
opacity: .8;
}
&::before {
top: 45%;
left: 0;
width: 100%;
height: 10%;
}
&::after {
left: 45%;
width: 10%;
height: 100%;
}
}
}

View File

@ -20,7 +20,7 @@ Component({
createSnowflake() {
const snowflake = {
x: Toolkit.random(0, this.data.docWidth),
s: Toolkit.random(6, 20),
s: Toolkit.random(16, 64),
speed: Toolkit.random(14, 26)
};
this.setData({

View File

@ -1,9 +1,10 @@
<!--components/background/snow/index.wxml-->
<view class="snowflakes" style="width: {{docWidth}}px; height: {{docHeight}}px;">
<view
<t-icon
class="snowflake"
wx:for="{{snowflakes}}"
wx:key="index"
style="left: {{item.x}}px; width: {{item.s}}px; height: {{item.s}}px; animation-duration: {{item.speed}}s;"
></view>
style="left: {{item.x}}px; font-size: {{item.s}}rpx; animation-duration: {{item.speed}}s;"
name="snowflake"
/>
</view>

View File

@ -57,7 +57,7 @@
padding: 4rpx 12rpx;
font-size: 24rpx;
font-weight: bold;
background: var(--theme-bg-journal);
background: var(--theme-bg-card);
border-radius: 12rpx;
}
}

View File

@ -2,9 +2,10 @@
import { Journal } from "../../types/Journal";
import config from "../../config/index";
import Toolkit from "../../utils/Toolkit";
import { ImageMetadata, MediaAttachExt, MediaAttachType } from "../../types/Attachment";
import { MediaAttachType, PreviewImageMetadata } from "../../types/Attachment";
import { MediaItem, MediaItemType } from "../../types/UI";
import Time from "../../utils/Time";
import { JournalApi } from "../../api/JournalApi";
interface JournalDetailPanelData {
journals: Journal[];
@ -35,24 +36,7 @@ Component({
if (visible && ids && 0 < ids.length) {
wx.showLoading({ title: "加载中...", mask: true });
try {
const journals: Journal[] = await new Promise((resolve, reject) => {
wx.request({
url: `${config.url}/journal/list/ids`,
method: "POST",
header: {
Key: wx.getStorageSync("key")
},
data: ids,
success: (resp: any) => {
if (resp.data.code === 20000) {
resolve(resp.data.data);
} else {
reject(new Error(resp.data.message || "加载失败"));
}
},
fail: reject
});
}) || [];
const journals = await JournalApi.getListByIds(ids);
journals.forEach(journal => {
journal.date = Time.toPassedDate(journal.createdAt);
journal.time = Time.toTime(journal.createdAt);
@ -63,12 +47,12 @@ Component({
return;
}
const mediaItems: MediaItem[] = thumbItems.map((thumbItem, index) => {
const metadata = thumbItem.metadata as ImageMetadata;
const ext = thumbItem.ext = JSON.parse(thumbItem.ext!.toString()) as MediaAttachExt;
const metadata = (typeof thumbItem.metadata === "string" ? JSON.parse(thumbItem.metadata) : thumbItem.metadata) as PreviewImageMetadata;
const thumbURL = `${config.url}/attachment/read/${thumbItem.mongoId}`;
const sourceURL = `${config.url}/attachment/read/${ext.sourceMongoId}`;
const sourceURL = `${config.url}/attachment/read/${metadata.sourceMongoId}`;
const isVideo = metadata.sourceMimeType?.startsWith("video/");
return {
type: ext.isVideo ? MediaItemType.VIDEO : MediaItemType.IMAGE,
type: isVideo ? MediaItemType.VIDEO : MediaItemType.IMAGE,
thumbURL,
sourceURL,
size: thumbItem.size || 0,

View File

@ -57,7 +57,7 @@
.loading,
.finished {
color: var(--td-text-color-placeholder);
padding: 32rpx 0;
padding: 32rpx 0 64rpx 0;
font-size: 24rpx;
text-align: center;
}

View File

@ -4,6 +4,7 @@ import { JournalPage, JournalPageType } from "../../types/Journal";
import { OrderType } from "../../types/Model";
import Time from "../../utils/Time";
import Toolkit from "../../utils/Toolkit";
import { JournalApi } from "../../api/JournalApi";
export type JournalListItem = {
id: number;
@ -19,14 +20,10 @@ interface JournalListData {
isFinished: boolean;
page: JournalPage;
searchValue: string;
debouncedSearch?: any;
}
// 组件实例类型扩展
interface ComponentInstance {
debouncedSearch?: ((keyword: string) => void) & { cancel(): void };
}
Component<JournalListData, {}, {}, ComponentInstance>({
Component({
options: {
styleIsolation: 'apply-shared'
},
@ -64,25 +61,28 @@ Component<JournalListData, {}, {}, ComponentInstance>({
createdAt: OrderType.DESC
}
},
searchValue: ""
searchValue: "",
debouncedSearch: undefined
},
lifetimes: {
ready() {
// 创建防抖搜索函数
this.debouncedSearch = Toolkit.debounce(
(keyword: string) => {
this.resetAndSearch(keyword);
},
false, // 不立即执行,等待输入停止
400 // 400ms 延迟
);
this.setData({
debouncedSearch: Toolkit.debounce(
(keyword: string) => {
this.resetAndSearch(keyword);
},
false, // 不立即执行,等待输入停止
400 // 400ms 延迟
)
})
// 组件加载时就获取数据
this.fetch();
},
detached() {
// 组件销毁时取消防抖
if (this.debouncedSearch) {
this.debouncedSearch.cancel();
if (this.data.debouncedSearch) {
this.data.debouncedSearch.cancel();
}
}
},
@ -125,77 +125,74 @@ Component<JournalListData, {}, {}, ComponentInstance>({
});
},
/** 获取数据 */
fetch() {
async fetch() {
if (this.data.isFetching || this.data.isFinished) {
return;
}
this.setData({ isFetching: true });
wx.request({
url: `${config.url}/journal/list`,
method: "POST",
header: {
Key: wx.getStorageSync("key")
},
data: this.data.page,
success: (resp: any) => {
const list = resp.data.data.list;
if (!list || list.length === 0) {
this.setData({ isFinished: true });
return;
}
const result = list.map((journal: any) => {
const firstThumb = journal.items.find((item: any) => item.attachType === "THUMB");
return {
id: journal.id,
date: Time.toPassedDate(journal.createdAt),
idea: journal.idea,
location: journal.location,
thumbUrl: firstThumb ? `${config.url}/attachment/read/${firstThumb.mongoId}` : undefined
} as JournalListItem;
});
try {
const pageResult = await JournalApi.getList(this.data.page);
const list = pageResult.list;
if (!list || list.length === 0) {
this.setData({
page: {
...this.data.page,
index: this.data.page.index + 1,
type: JournalPageType.PREVIEW,
equalsExample: this.data.page.equalsExample,
likeExample: this.data.page.likeExample,
orderMap: {
createdAt: OrderType.DESC
}
},
list: this.data.list.concat(result),
isFinished: list.length < this.data.page.size
isFinished: true,
isFetching: false
});
},
complete: () => {
this.setData({ isFetching: false });
return;
}
});
const result = list.map((journal: any) => {
const firstThumb = journal.items.find((item: any) => item.attachType === "THUMB");
return {
id: journal.id,
date: Time.toPassedDate(journal.createdAt),
idea: journal.idea,
location: journal.location,
thumbUrl: firstThumb ? `${config.url}/attachment/read/${firstThumb.mongoId}` : undefined
} as JournalListItem;
});
this.setData({
page: {
...this.data.page,
index: this.data.page.index + 1,
type: JournalPageType.PREVIEW,
equalsExample: this.data.page.equalsExample,
likeExample: this.data.page.likeExample,
orderMap: {
createdAt: OrderType.DESC
}
},
list: this.data.list.concat(result),
isFinished: list.length < this.data.page.size,
isFetching: false
});
} catch (error) {
console.error("加载日记列表失败:", error);
this.setData({ isFetching: false });
}
},
/** 输入搜索 */
onSearchChange(e: WechatMiniprogram.CustomEvent) {
const value = e.detail.value.trim();
this.setData({ searchValue: value });
// 使用防抖自动搜索(包括清空的情况)
if (this.debouncedSearch) {
this.debouncedSearch(value);
if (this.data.debouncedSearch) {
this.data.debouncedSearch(value);
}
},
/** 提交搜索 */
onSearchSubmit(e: WechatMiniprogram.CustomEvent) {
const value = e.detail.value.trim();
// 立即搜索,取消防抖
if (this.debouncedSearch) {
this.debouncedSearch.cancel();
if (this.data.debouncedSearch) {
this.data.debouncedSearch.cancel();
}
this.resetAndSearch(value);
},
/** 清空搜索 */
onSearchClear() {
// 取消防抖,立即搜索
if (this.debouncedSearch) {
this.debouncedSearch.cancel();
if (this.data.debouncedSearch) {
this.data.debouncedSearch.cancel();
}
this.resetAndSearch("");
},

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-card);
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 { MediaAttachType, PreviewImageMetadata } 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 => item.attachType === MediaAttachType.THUMB);
if (0 < thumbItems.length) {
const mediaItems: MediaItem[] = thumbItems.map((thumbItem, index) => {
const metadata = (typeof thumbItem.metadata === "string" ? JSON.parse(thumbItem.metadata) : thumbItem.metadata) as PreviewImageMetadata;
const thumbURL = `${config.url}/attachment/read/${thumbItem.mongoId}`;
const sourceURL = `${config.url}/attachment/read/${metadata.sourceMongoId}`;
const isVideo = metadata.sourceMimeType?.startsWith("video/");
return {
type: 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,75 @@
<!-- 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.importance}}" theme="success" variant="outline">
重要性 {{item.importance}}
</t-tag>
<t-tag wx:if="{{item.requireIdCard}}" theme="danger" variant="outline">
需要身份证
</t-tag>
<t-tag wx:if="{{item.requireAppointment}}" 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,6 +1,12 @@
const envArgs = {
develop: {
url: "http://192.168.3.137:8091"
url: "http://localhost:8091"
// url: "https://api.imyeyu.dev"
// url: "https://api.imyeyu.com"
// url: "http://192.168.3.123:8091"
// url: "http://192.168.3.137:8091"
// url: "http://192.168.3.173:8091"
// url: "http://192.168.3.174:8091"
},
trial: {
url: "https://api.imyeyu.com"

View File

@ -1,7 +1,6 @@
// index.ts
import config from "../../config/index"
import { JournalPage, JournalPageType } from "../../types/Journal";
import { JournalPageType } from "../../types/Journal";
import { JournalApi } from "../../api/JournalApi";
interface IndexData {
key: string;
@ -19,32 +18,23 @@ Page({
});
}
},
navigateToMain() {
wx.request({
url: `${config.url}/journal/list`,
method: "POST",
header: {
Key: this.data.key
},
data: <JournalPage> {
async navigateToMain() {
try {
wx.setStorageSync("key", this.data.key);
await JournalApi.getList({
index: 0,
size: 1,
type: JournalPageType.PREVIEW
},
success: (resp) => {
const data = resp.data as any;
if (data.code === 20000) {
wx.setStorageSync("key", this.data.key);
wx.switchTab({
url: "/pages/main/journal/index",
})
} else if (data.code === 40100) {
wx.showToast({ title: "密码错误", icon: "error" });
} else {
wx.showToast({ title: "服务异常", icon: "error" });
}
},
fail: () => wx.showToast({ title: "验证失败", icon: "error" })
});
});
wx.switchTab({
url: "/pages/main/journal/index",
})
} catch (error: any) {
if (error?.code === 40100) {
wx.showToast({ title: "密码错误", icon: "error" });
} else {
wx.showToast({ title: "验证失败", icon: "error" });
}
}
}
})

View File

@ -1,6 +1,5 @@
/* pages/info/info.less */
page {
height: 100vh;
display: flex;
flex-direction: column;
}

View File

@ -26,7 +26,7 @@
</view>
<view class="item">
<text class="label">版本:</text>
<text>1.5.2</text>
<text>1.6.6</text>
</view>
<view class="item copyright">
<text>{{copyright}}</text>

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

@ -1,7 +1,7 @@
// pages/main/journal-date/index.ts
import config from "../../../config/index";
import { Journal, JournalPageType } from "../../../types/Journal";
import Time from "../../../utils/Time";
import { JournalApi } from "../../../api/JournalApi";
interface JournalDateData {
// 存储每个日期的日记 id 列表
@ -27,28 +27,12 @@ Page({
async loadJournals() {
this.setData({ isLoading: true });
try {
const list: Journal[] = await new Promise((resolve, reject) => {
wx.request({
url: `${config.url}/journal/list`,
method: "POST",
header: {
Key: wx.getStorageSync("key")
},
data: {
page: 0,
size: 9007199254740992,
type: JournalPageType.PREVIEW
},
success: (resp: any) => {
if (resp.data.code === 20000) {
resolve(resp.data.data.list);
} else {
reject(new Error(resp.data.message || "加载失败"));
}
},
fail: reject
});
}) || [];
const pageResult = await JournalApi.getList({
index: 0,
size: 9007199254740992,
type: JournalPageType.PREVIEW
});
const list: Journal[] = pageResult.list || [];
// 按日期分组,只存储 id
const journalMap: Record<string, number[]> = {};
list.forEach((journal: any) => {

View File

@ -26,6 +26,7 @@
display: flex;
.radio {
background: transparent;
margin-right: 1em;
}
}
@ -59,6 +60,10 @@
margin: 0;
font-size: 80rpx;
background: transparent;
&::after {
border-radius: 0;
}
}
.thumbnail {
@ -141,10 +146,12 @@
margin-top: 1rem;
align-items: center;
.clear,
.delete {
width: 200rpx;
}
.submit,
.save {
flex: 1;
margin-left: 12rpx;

View File

@ -4,33 +4,55 @@ import Time from "../../../utils/Time";
import Toolkit from "../../../utils/Toolkit";
import config from "../../../config/index";
import { Location, MediaItem, MediaItemType, WechatMediaItem } from "../../../types/UI";
import { Journal, JournalType } from "../../../types/Journal";
import { MediaAttachExt, MediaAttachType } from "../../../types/Attachment";
import { JournalType } from "../../../types/Journal";
import { MediaAttachType, PreviewImageMetadata } from "../../../types/Attachment";
import IOSize, { Unit } from "../../../utils/IOSize";
import { JournalApi } from "../../../api/JournalApi";
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 +62,7 @@ Page({
newMediaList: [],
location: undefined,
isSaving: false,
isLoading: true,
isLoading: false,
uploaded: "0",
uploadTotal: "0 MB",
uploadSpeed: "0 MB / s",
@ -52,10 +74,11 @@ Page({
deleteDialogVisible: false,
deleteConfirmText: ""
},
async onLoad(options: any) {
// 授权定位
const setting = await wx.getSetting();
wx.setStorageSync("isAuthLocation", setting.authSetting["scope.userLocation"]);
wx.setStorageSync("isAuthLocation", setting.authSetting["scope.userLocation"] || false);
let isAuthLocation = JSON.parse(wx.getStorageSync("isAuthLocation"));
this.setData({ isAuthLocation });
if (!isAuthLocation) {
@ -66,57 +89,86 @@ 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);
},
/** 加载日记详情 */
async loadJournalDetail(id: number) {
wx.showLoading({ title: "加载中...", mask: true });
try {
const journal: Journal = await new Promise((resolve, reject) => {
wx.request({
url: `${config.url}/journal/${id}`,
method: "POST",
header: {
Key: wx.getStorageSync("key")
},
success: (res: any) => {
if (res.data.code === 20000) {
resolve(res.data.data);
} else {
reject(new Error(res.data.message || "加载失败"));
}
},
fail: reject
});
/** 获取默认定位(创建模式) */
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) {
try {
const journal = await JournalApi.getDetail(id);
const items = journal.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 metadata = (typeof thumbItem.metadata === "string" ? JSON.parse(thumbItem.metadata) : thumbItem.metadata) as PreviewImageMetadata;
const thumbURL = `${config.url}/attachment/read/${thumbItem.mongoId}`;
const sourceURL = `${config.url}/attachment/read/${ext.sourceMongoId}`;
const sourceURL = `${config.url}/attachment/read/${metadata.sourceMongoId}`;
const isVideo = metadata.sourceMimeType?.startsWith("video/");
return {
type: ext.isVideo ? MediaItemType.VIDEO : MediaItemType.IMAGE,
type: isVideo ? MediaItemType.VIDEO : MediaItemType.IMAGE,
thumbURL,
sourceURL,
size: thumbItem.size || 0,
attachmentId: thumbItem.id
} as MediaItem;
});
this.setData({
idea: journal.idea || "",
date: Time.toDate(journal.createdAt),
@ -142,20 +194,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 +237,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: 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: item.type
}));
const newSources = this.data.newMediaList.map(item => ({
url: item.path,
type: item.type
}));
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,
@ -261,51 +377,34 @@ Page({
this.executeDelete();
},
/** 执行删除 */
executeDelete() {
wx.showLoading({ title: "删除中...", mask: true });
wx.request({
url: `${config.url}/journal/delete`,
method: "POST",
header: {
Key: wx.getStorageSync("key"),
"Content-Type": "application/json"
},
data: {
id: this.data.id
},
success: (res: any) => {
wx.hideLoading();
if (res.data.code === 20000 || res.statusCode === 200) {
Events.emit("JOURNAL_REFRESH");
Events.emit("JOURNAL_LIST_REFRESH");
wx.showToast({
title: "删除成功",
icon: "success"
});
setTimeout(() => {
wx.navigateBack();
}, 1000);
} else {
wx.showToast({
title: res.data.message || "删除失败",
icon: "error"
});
}
},
fail: () => {
wx.hideLoading();
wx.showToast({
title: "删除失败",
icon: "error"
});
}
});
async executeDelete() {
try {
await JournalApi.delete(this.data.id!);
Events.emit("JOURNAL_REFRESH");
Events.emit("JOURNAL_LIST_REFRESH");
wx.showToast({
title: "删除成功",
icon: "success"
});
setTimeout(() => {
wx.navigateBack();
}, 1000);
} catch (error) {
console.error("删除日记失败:", error);
}
},
/** 保存 */
save() {
/** 提交/保存 */
submit() {
if (this.data.mode === "create") {
this.createJournal();
} else {
this.updateJournal();
}
},
/** 创建日记 */
async createJournal() {
const handleFail = () => {
wx.showToast({ title: "保存失败", icon: "error" });
wx.hideLoading();
wx.showToast({ title: "上传失败", icon: "error" });
this.setData({
isSaving: false
});
@ -313,20 +412,115 @@ Page({
this.setData({
isSaving: true
});
// 收集保留的附件 ID缩略图 ID
const attachmentIds = this.data.mediaList.map(item => item.attachmentId);
try {
// 获取 openId
const getOpenId = new Promise<string>((resolve, reject) => {
wx.login({
success: async (res) => {
if (res.code) {
try {
const openId = await JournalApi.getOpenId(res.code);
resolve(openId);
} catch (error) {
reject(new Error("获取 openId 失败"));
}
} else {
reject(new Error("获取登录凭证失败"));
}
},
fail: () => reject(new Error("登录失败"))
});
});
// 文件上传
const uploadFiles = this.uploadMediaFiles(this.data.mediaList as WechatMediaItem[]);
// 并行执行获取 openId 和文件上传
const [openId, tempFileIds] = await Promise.all([getOpenId, uploadFiles]);
// 上传新媒体文件
const uploadFiles = new Promise<string[]>((resolve, reject) => {
if (this.data.newMediaList.length === 0) {
await JournalApi.create({
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
});
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"
});
} catch (error) {
console.error("创建日记失败:", error);
handleFail();
}
},
/** 更新日记 */
async updateJournal() {
const handleFail = () => {
wx.showToast({ title: "保存失败", icon: "error" });
this.setData({
isSaving: false
});
};
this.setData({
isSaving: true
});
try {
// 收集保留的附件 ID缩略图 ID
const attachmentIds = (this.data.mediaList as MediaItem[]).map(item => item.attachmentId);
// 上传新媒体文件
const tempFileIds = await this.uploadMediaFiles(this.data.newMediaList);
await JournalApi.update({
id: this.data.id!,
idea: this.data.idea,
type: this.data.type,
lat: this.data.location?.lat,
lng: this.data.location?.lng,
location: this.data.location?.text,
createdAt: new Date(`${this.data.date}T${this.data.time}:00`).getTime(),
attachmentIds,
tempFileIds
});
Events.emit("JOURNAL_REFRESH");
Events.emit("JOURNAL_LIST_REFRESH");
wx.showToast({ title: "保存成功", icon: "success" });
this.setData({
isSaving: false,
uploaded: "0",
uploadTotal: "0 MB",
uploadProgress: 0
});
await Toolkit.sleep(1000);
wx.navigateBack();
} catch (error) {
console.error("更新日记失败:", error);
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 = this.data.newMediaList.map(item => {
const sizePromises = mediaList.map(item => {
return new Promise<number>((sizeResolve, sizeReject) => {
wx.getFileSystemManager().getFileInfo({
filePath: item.path,
@ -356,7 +550,7 @@ Page({
}, 1000);
// 上传文件
const uploadPromises = this.data.newMediaList.map(item => {
const uploadPromises = mediaList.map(item => {
return new Promise<string>((uploadResolve, uploadReject) => {
const task = wx.uploadFile({
url: `${config.url}/temp/file/upload`,
@ -390,6 +584,7 @@ Page({
uploadTasks.push(task);
});
});
Promise.all(uploadPromises).then((tempFileIds) => {
// 清除定时器
clearInterval(speedUpdateInterval);
@ -407,47 +602,5 @@ Page({
});
}).catch(reject);
});
// 提交保存
uploadFiles.then((tempFileIds) => {
wx.showLoading({ title: "正在保存..", mask: true });
wx.request({
url: `${config.url}/journal/update`,
method: "POST",
header: {
Key: wx.getStorageSync("key")
},
data: {
id: this.data.id,
idea: this.data.idea,
type: this.data.type,
lat: this.data.location?.lat,
lng: this.data.location?.lng,
location: this.data.location?.text,
createdAt: new Date(`${this.data.date}T${this.data.time}:00`).getTime(),
// 保留的现有附件 ID
attachmentIds,
// 新上传的临时文件 ID
tempFileIds
},
success: async (resp: any) => {
if (resp.data.code === 20000 || resp.statusCode === 200) {
Events.emit("JOURNAL_REFRESH");
Events.emit("JOURNAL_LIST_REFRESH");
wx.showToast({ title: "保存成功", icon: "success" });
this.setData({
isSaving: false,
uploaded: "0",
uploadTotal: "0 MB",
uploadProgress: 0
});
await Toolkit.sleep(1000);
wx.navigateBack();
} else {
handleFail();
}
},
fail: handleFail
});
}).catch(handleFail);
}
});

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'} }}"
@ -168,10 +238,6 @@
<text style="color: var(--theme-error)">确认删除</text>
<text>" 以继续</text>
</view>
<t-input
class="confirm-input"
model:value="{{deleteConfirmText}}"
placeholder="请输入:确认删除"
/>
<t-input model:value="{{deleteConfirmText}}" placeholder="请输入:确认删除" />
</view>
</t-dialog>

View File

@ -8,75 +8,74 @@
.map {
width: 100%;
height: 100%;
}
.custom-callout {
width: fit-content;
min-width: 350rpx;
max-width: 450rpx;
background: #fff;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, .15);
border-radius: 6rpx;
.callout-content {
display: flex;
padding: 8rpx 16rpx 8rpx 8rpx;
align-items: flex-start;
.thumb-container {
width: 72rpx;
height: 72rpx;
position: relative;
flex-shrink: 0;
margin-right: 12rpx;
overflow: hidden;
border-radius: 6rpx;
.thumb {
width: 100%;
height: 100%;
}
.count-badge {
top: 4rpx;
right: 4rpx;
color: #fff;
padding: 2rpx 6rpx;
position: absolute;
font-size: 20rpx;
background: rgba(0, 0, 0, .7);
border-radius: 8rpx;
}
}
.text-container {
flex: 1;
.location {
width: fit-content;
background: var(--theme-bg-card);
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, .15);
border-radius: 6rpx;
.content {
display: flex;
overflow: hidden;
flex-direction: column;
.location {
color: #333;
padding: 8rpx 16rpx 8rpx 8rpx;
align-items: flex-start;
.thumb {
width: 72rpx;
height: 72rpx;
position: relative;
flex-shrink: 0;
margin-right: 12rpx;
overflow: hidden;
font-size: 30rpx;
white-space: nowrap;
text-overflow: ellipsis;
margin-bottom: 4rpx;
}
.date-count {
display: flex;
.date {
color: #999;
font-size: 24rpx;
margin-right: 16rpx;
border-radius: 6rpx;
.img {
width: 100%;
height: 100%;
}
.count {
color: var(--theme-wx);
font-size: 24rpx;
font-weight: 600;
.badge {
top: 4rpx;
right: 4rpx;
color: #fff;
padding: 2rpx 6rpx;
position: absolute;
font-size: 20rpx;
background: rgba(0, 0, 0, .7);
border-radius: 8rpx;
}
}
.text {
flex: 1;
display: flex;
overflow: hidden;
flex-direction: column;
.value {
color: var(--theme-text-primary);
width: calc(var(--title-length) * 30rpx);
overflow: hidden;
font-size: 30rpx;
white-space: nowrap;
text-overflow: ellipsis;
margin-bottom: 4rpx;
}
.date-count {
display: flex;
.date {
color: var(--theme-text-secondary);
font-size: 24rpx;
margin-right: 16rpx;
}
.count {
color: var(--theme-wx);
font-size: 24rpx;
font-weight: 600;
}
}
}
}

View File

@ -1,21 +1,10 @@
// pages/main/journal-map/index.ts
import config from "../../../config/index";
import Time from "../../../utils/Time";
import { Journal, JournalPageType } from "../../../types/Journal";
import { JournalPageType } from "../../../types/Journal";
import Toolkit from "../../../utils/Toolkit";
interface MapMarker {
id: number;
latitude: number;
longitude: number;
width: number;
height: number;
customCallout: {
anchorY: number;
anchorX: number;
display: string;
};
}
import { MapMarker } from "../../../types/UI";
import { JournalApi } from "../../../api/JournalApi";
interface LocationMarker {
locationKey: string; // 位置键 "lat,lng"
@ -62,28 +51,12 @@ Page({
async loadJournals() {
this.setData({ isLoading: true });
try {
const list: Journal[] = await new Promise((resolve, reject) => {
wx.request({
url: `${config.url}/journal/list`,
method: "POST",
header: {
Key: wx.getStorageSync("key")
},
data: {
page: 0,
size: 9007199254740992,
type: JournalPageType.PREVIEW
},
success: (resp: any) => {
if (resp.data.code === 20000) {
resolve(resp.data.data.list);
} else {
reject(new Error(resp.data.message || "加载失败"));
}
},
fail: reject
});
}) || [];
const result = await JournalApi.getList({
index: 0,
size: 9007199254740992,
type: JournalPageType.PREVIEW
});
const list = result.list || [];
// 过滤有位置信息的记录,并按位置分组
const locationMap = new Map<string, LocationMarker>();
list.filter((journal: any) => journal.lat && journal.lng).forEach((journal: any) => {

View File

@ -13,23 +13,28 @@
bindcallouttap="onCalloutTap"
>
<cover-view slot="callout">
<block wx:for="{{locations}}" wx:key="locationKey" wx:for-index="markerIndex">
<cover-view class="custom-callout" marker-id="{{markerIndex}}">
<cover-view class="callout-content">
<cover-view wx:if="{{item.previewThumb}}" class="thumb-container">
<cover-image class="thumb" src="{{item.previewThumb}}" />
<cover-view wx:if="{{item.count > 1}}" class="count-badge">{{item.count}}</cover-view>
</cover-view>
<cover-view class="text-container">
<cover-view wx:if="{{item.location}}" class="location">{{item.location}}</cover-view>
<cover-view class="date-count">
<cover-view class="date">{{item.date}}</cover-view>
<cover-view wx:if="{{item.count > 1}}" class="count">{{item.count}} 条日记</cover-view>
</cover-view>
<cover-view
class="location"
wx:for="{{locations}}"
wx:key="locationKey"
wx:for-index="locationIndex"
marker-id="{{locationIndex}}"
style="{{'--title-length: ' + item.location.length}}"
>
<cover-view class="content">
<cover-view wx:if="{{item.previewThumb}}" class="thumb">
<cover-image class="img" src="{{item.previewThumb}}" />
<cover-view wx:if="{{1 < item.count}}" class="badge">{{item.count}}</cover-view>
</cover-view>
<cover-view class="text">
<cover-view wx:if="{{item.location}}" class="value">{{item.location}}</cover-view>
<cover-view class="date-count">
<cover-view class="date">{{item.date}}</cover-view>
<cover-view wx:if="{{1 < item.count}}" class="count">{{item.count}} 条日记</cover-view>
</cover-view>
</cover-view>
</cover-view>
</block>
</cover-view>
</cover-view>
</map>
<view wx:if="{{isLoading}}" class="loading">

View File

@ -1,8 +1,21 @@
page {
height: 100vh;
background: var(--td-bg-color-page);
}
.content {
width: 100vw;
height: calc(100vh - var(--navbar-height, 88rpx));
.page-container {
width: 100%;
height: 100%;
display: flex;
overflow: hidden;
flex-direction: column;
.navbar {
flex-shrink: 0;
}
.content {
flex: 1;
overflow: hidden;
}
}

View File

@ -1,12 +1,13 @@
<view class="custom-navbar">
<t-navbar title="列表查找" left-arrow />
</view>
<view class="content">
<journal-list
id="listRef"
searchable="{{true}}"
mode="navigate"
bind:navigate="onNavigateItem"
/>
<view class="page-container">
<view class="navbar">
<t-navbar title="列表查找" left-arrow />
</view>
<view class="content">
<journal-list
id="listRef"
searchable="{{true}}"
mode="navigate"
bind:navigate="onNavigateItem"
/>
</view>
</view>

View File

@ -5,9 +5,10 @@ import config from "../../../config/index"
import Events from "../../../utils/Events";
import Toolkit from "../../../utils/Toolkit";
import { Journal, JournalPage, JournalPageType } from "../../../types/Journal";
import { OrderType, QueryPageResult } from "../../../types/Model";
import { ImageMetadata, MediaAttachExt } from "../../../types/Attachment";
import { OrderType } from "../../../types/Model";
import { PreviewImageMetadata } from "../../../types/Attachment";
import { MediaItem, MediaItemType } from "../../../types/UI";
import { JournalApi } from "../../../api/JournalApi";
interface JournalData {
page: JournalPage;
@ -125,7 +126,7 @@ Page({
},
toCreater() {
wx.navigateTo({
"url": "/pages/main/journal-creater/index?from=journal"
"url": "/pages/main/journal-editor/index?from=journal"
})
},
toSearch() {
@ -143,79 +144,71 @@ Page({
url: "/pages/main/journal-date/index"
})
},
fetch() {
async fetch() {
if (this.data.isFetching || this.data.isFinished) {
return;
}
this.setData({
isFetching: true
});
wx.request({
url: `${config.url}/journal/list`,
method: "POST",
header: {
Key: wx.getStorageSync("key")
},
data: this.data.page,
success: async (resp: any) => {
const pageResult = resp.data.data as QueryPageResult<Journal>;
const list = pageResult.list;
if (!list || list.length === 0) {
this.setData({
isFinished: true
})
return;
}
list.forEach(journal => {
const mediaItems = journal.items!.filter((item) => item.attachType === "THUMB").map((thumbItem, index) => {
const metadata = thumbItem.metadata as ImageMetadata;
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,
width: metadata.width,
height: metadata.height,
originalIndex: index
} as MediaItem;
});
journal.date = Time.toDate(journal.createdAt);
journal.time = Time.toTime(journal.createdAt);
journal.datetime = Time.toPassedDateTime(journal.createdAt);
journal.mediaItems = mediaItems;
journal.columnedItems = Toolkit.splitItemsIntoColumns(mediaItems, 3, (item) => {
if (item.width && item.height && 0 < item.width) {
return item.height / item.width;
}
return 1;
})
})
this.setData({
page: {
index: this.data.page.index + 1,
size: 8,
type: JournalPageType.NORMAL,
equalsExample: {
type: "NORMAL"
},
orderMap: {
createdAt: OrderType.DESC
}
},
list: this.data.list.concat(list),
isFinished: list.length < this.data.page.size
});
},
complete: () => {
try {
const pageResult = await JournalApi.getList(this.data.page);
const list = pageResult.list;
if (!list || list.length === 0) {
this.setData({
isFinished: true,
isFetching: false
});
return;
}
});
list.forEach(journal => {
const mediaItems = journal.items!.filter((item) => item.attachType === "THUMB").map((thumbItem, index) => {
const metadata = (typeof thumbItem.metadata === "string" ? JSON.parse(thumbItem.metadata) : thumbItem.metadata) as PreviewImageMetadata;
const thumbURL = `${config.url}/attachment/read/${thumbItem.mongoId}`;
const sourceURL = `${config.url}/attachment/read/${metadata.sourceMongoId}`;
const isVideo = metadata.sourceMimeType?.startsWith("video/");
return {
type: isVideo ? MediaItemType.VIDEO : MediaItemType.IMAGE,
thumbURL,
sourceURL,
size: thumbItem.size || 0,
attachmentId: thumbItem.id,
width: metadata.width,
height: metadata.height,
originalIndex: index
} as MediaItem;
});
journal.date = Time.toDate(journal.createdAt);
journal.time = Time.toTime(journal.createdAt);
journal.datetime = Time.toPassedDateTime(journal.createdAt);
journal.mediaItems = mediaItems;
journal.columnedItems = Toolkit.splitItemsIntoColumns(mediaItems, 3, (item) => {
if (item.width && item.height && 0 < item.width) {
return item.height / item.width;
}
return 1;
})
});
this.setData({
page: {
index: this.data.page.index + 1,
size: 8,
type: JournalPageType.NORMAL,
equalsExample: {
type: "NORMAL"
},
orderMap: {
createdAt: OrderType.DESC
}
},
list: this.data.list.concat(list),
isFinished: list.length < this.data.page.size,
isFetching: false
});
} catch (error) {
console.error("加载日记失败:", error);
this.setData({ isFetching: false });
}
},
preview(e: WechatMiniprogram.BaseEvent) {
const { journalIndex, itemIndex } = e.currentTarget.dataset;

View File

@ -24,7 +24,7 @@
sticky-offset="{{stickyOffset}}"
>
<view class="content" wx:for="{{list}}" wx:for-item="journal" wx:for-index="journalIndex" wx:key="index">
<t-indexes-anchor class="date" index="{{journal.date}}" />
<t-indexes-anchor class="date" index="{{journal.datetime}}" />
<view wx:if="{{journal.idea || journal.location}}" class="text">
<text class="idea">{{journal.idea}}</text>
<view
@ -41,7 +41,7 @@
<view wx:for="{{journal.columnedItems}}" wx:for-item="column" wx:for-index="columnIndex" wx:key="columnIndex" class="column">
<block wx:for="{{column}}" wx:for-item="item" wx:for-index="itemIndex" wx:key="attachmentId">
<image
class="item thumbnail {{item.type === 0 ? 'image' : 'video'}}"
class="item thumbnail {{item.type}}"
src="{{item.thumbURL}}"
mode="widthFix"
bindtap="preview"

View File

@ -4,22 +4,20 @@ 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 { Location, MediaItemType } from "../../../types/UI";
import { PreviewImageMetadata } from "../../../types/Attachment";
import { MomentApi } from "../../../api/MomentApi";
import { JournalApi } from "../../../api/JournalApi";
import { Network } from "../../../utils/Network";
type Item = {
id: number;
type: ItemType;
mongoId: string;
thumbUrl: string;
sourceMongoId: string;
type: MediaItemType;
thumbURL: string;
sourceURL: string;
checked: boolean;
}
enum ItemType {
IMAGE,
VIDEO
}
type MD5Result = {
path: string;
md5: string;
@ -73,7 +71,7 @@ Page({
// 授权定位
const setting = await wx.getSetting();
wx.setStorageSync("isAuthLocation", setting.authSetting["scope.userLocation"]);
wx.setStorageSync("isAuthLocation", setting.authSetting["scope.userLocation"] || false);
let isAuthLocation = JSON.parse(wx.getStorageSync("isAuthLocation"));
this.setData({ isAuthLocation });
if (!isAuthLocation) {
@ -128,33 +126,30 @@ Page({
}
});
},
fetch() {
wx.request({
url: `${config.url}/journal/moment/list`,
method: "POST",
header: {
Key: wx.getStorageSync("key")
},
success: async (resp: any) => {
const list = resp.data.data;
if (!list || list.length === 0) {
return;
}
this.setData({
list: list.map((item: any) => {
const extData = JSON.parse(item.ext);
return {
id: item.id,
type: extData.isImage ? ItemType.IMAGE : ItemType.VIDEO,
mongoId: item.mongoId,
thumbUrl: `${config.url}/attachment/read/${item.mongoId}`,
sourceMongoId: extData.sourceMongoId,
checked: false
} as Item;
})
});
async fetch() {
try {
const list = await MomentApi.getList();
if (!list || list.length === 0) {
return;
}
});
this.setData({
list: list.map((item: any) => {
const metadata = (typeof item.metadata === "string" ? JSON.parse(item.metadata) : item.metadata) as PreviewImageMetadata;
const thumbURL = `${config.url}/attachment/read/${item.mongoId}`;
const sourceURL = `${config.url}/attachment/read/${metadata.sourceMongoId}`;
const isImage = metadata.sourceMimeType?.startsWith("image/");
return {
id: item.id,
type: isImage ? MediaItemType.IMAGE : MediaItemType.VIDEO,
thumbURL,
sourceURL,
checked: false
} as Item;
})
});
} catch (error) {
console.error("加载 moment 列表失败:", error);
}
},
updateHasChecked() {
this.setData({ hasChecked: this.data.list.some(item => item.checked) });
@ -176,8 +171,8 @@ Page({
const sources = this.data.list.slice(startIndex, endIndex).map((item: Item) => {
return {
url: `${config.url}/attachment/read/${item.sourceMongoId}`,
type: item.type === 0 ? "image" : "video"
url: item.sourceURL,
type: item.type.toLowerCase()
}
}) as any;
wx.previewMedia({
@ -230,154 +225,66 @@ Page({
} as MD5Result);
}));
// 查重
const filterMD5Result: string[] = await new Promise((resolve, reject) => {
wx.request({
url: `${config.url}/journal/moment/filter`,
method: "POST",
header: {
Key: wx.getStorageSync("key")
},
data: md5Results.map(item => item.md5),
success: async (resp: any) => {
resolve(resp.data.data);
},
fail: reject
});
});
const filterMD5Result: string[] = await MomentApi.filterByMD5(
md5Results.map(item => item.md5)
);
// 过滤文件
const filterPath = md5Results.filter(item => filterMD5Result.indexOf(item.md5) !== -1)
.map(item => item.path);
files = files.filter(file => filterPath.indexOf(file.tempFilePath) !== -1);
if (files.length === 0) {
wx.hideLoading();
that.setData({ isUploading: false });
return;
}
wx.showLoading({
title: "正在上传..",
mask: true
})
// 计算上传大小
const sizePromises = files.map(file => {
return new Promise<number>((sizeResolve, sizeReject) => {
wx.getFileSystemManager().getFileInfo({
filePath: file.tempFilePath,
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;
// 使用 Network.uploadFiles 上传文件
try {
const tempFileIds = await Network.uploadFiles({
mediaList: files.map(file => ({
path: file.tempFilePath,
size: file.size
})),
onProgress: (progress) => {
that.setData({
uploaded: IOSize.formatWithoutUnit(progress.uploaded, 2, Unit.MB),
uploadTotal: IOSize.format(progress.total, 2, Unit.MB),
uploadSpeed: `${IOSize.format(progress.speed)} / s`,
uploadProgress: progress.percent
});
},
showLoading: true
});
// 上传完成转附件
const list = await MomentApi.create(tempFileIds);
wx.showToast({ title: "上传成功", icon: "success" });
const added = list.map((item: any) => {
const metadata = (typeof item.metadata === "string" ? JSON.parse(item.metadata) : item.metadata) as PreviewImageMetadata;
const thumbURL = `${config.url}/attachment/read/${item.mongoId}`;
const sourceURL = `${config.url}/attachment/read/${metadata.sourceMongoId}`;
const isImage = item.mimeType?.startsWith("image/");
return {
id: item.id,
type: isImage ? MediaItemType.IMAGE : MediaItemType.VIDEO,
thumbURL,
sourceURL,
checked: false
} as Item;
});
// 前插列表
that.data.list.unshift(...added);
that.setData({
uploadTotal: IOSize.format(totalSize, 2, Unit.MB)
list: that.data.list,
isUploading: false,
uploaded: "0",
uploadTotal: "0 MB",
uploadProgress: 0
});
// 计算上传速度
const speedUpdateInterval = setInterval(() => {
const chunkSize = uploadedSize - lastUploadedSize;
that.setData({
uploadSpeed: `${IOSize.format(chunkSize)} / s`
});
lastUploadedSize = uploadedSize;
}, 1000);
// 上传文件
const uploadPromises = files.map(file => {
return new Promise<string>((uploadResolve, uploadReject) => {
const task = wx.uploadFile({
url: `${config.url}/temp/file/upload`,
filePath: file.tempFilePath,
name: "file",
success: (resp) => {
const result = JSON.parse(resp.data);
if (result && result.code === 20000) {
// 更新进度
const progress = totalSize > 0 ? uploadedSize / totalSize : 1;
that.setData({
uploadProgress: Math.round(progress * 10000) / 100
});
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;
// 更新进度条
that.setData({
uploaded: IOSize.formatWithoutUnit(uploadedSize, 2, Unit.MB),
uploadProgress: Math.round((uploadedSize / totalSize) * 10000) / 100
});
});
uploadTasks.push(task);
});
});
Promise.all(uploadPromises).then((tempFileIds) => {
wx.showLoading({
title: "正在保存..",
mask: true
})
// 清除定时器
clearInterval(speedUpdateInterval);
uploadTasks.forEach(task => task.offProgressUpdate());
that.setData({
uploadProgress: 100,
uploadSpeed: "0 MB / s"
});
// 上传完成转附件
wx.request({
url: `${config.url}/journal/moment/create`,
method: "POST",
header: {
Key: wx.getStorageSync("key")
},
data: tempFileIds,
success: async (resp: any) => {
wx.showToast({ title: "上传成功", icon: "success" });
const list = resp.data.data;
const added = list.map((item: any) => {
const extData = JSON.parse(item.ext);
return {
id: item.id,
type: extData.isImage ? ItemType.IMAGE : ItemType.VIDEO,
mongoId: item.mongoId,
thumbUrl: `${config.url}/attachment/read/${item.mongoId}`,
sourceMongoId: extData.sourceMongoId,
checked: false
} as Item;
});
// 前插列表
that.data.list.unshift(...added);
that.setData({
list: that.data.list,
isUploading: false,
uploaded: "0",
uploadTotal: "0 MB",
uploadProgress: 0
});
that.updateHasChecked();
wx.hideLoading();
},
fail: handleFail
});
}).catch((e: Error) => {
// 取消所有上传任务
uploadTasks.forEach(task => task.abort());
that.updateHasChecked();
handleFail(e);
});
}).catch(handleFail);
that.updateHasChecked();
} catch (error) {
handleFail(error);
}
}
})
},
@ -453,27 +360,14 @@ Page({
})
const openId = await new Promise<string>((resolve, reject) => {
wx.login({
success: (res) => {
success: async (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 请求失败"))
});
try {
const openId = await JournalApi.getOpenId(res.code);
resolve(openId);
} catch (error) {
reject(new Error("获取 openId 失败"));
}
} else {
reject(new Error("获取登录凭证失败"));
}
@ -494,36 +388,23 @@ Page({
if (this.data.selectedJournalId) {
archiveData.id = this.data.selectedJournalId;
}
wx.request({
url: `${config.url}/journal/moment/archive`,
method: "POST",
header: {
Key: wx.getStorageSync("key")
},
data: archiveData,
success: async (resp: any) => {
if (resp.data && resp.data.code === 20000) {
Events.emit("JOURNAL_REFRESH");
wx.showToast({ title: "归档成功", icon: "success" });
this.setData({
idea: "",
list: [],
hasChecked: false,
isArchiving: false,
selectedJournalId: undefined,
isVisibleArchivePopup: false
});
await Toolkit.sleep(1000);
this.fetch();
} else {
wx.showToast({ title: "归档失败", icon: "error" });
this.setData({
isArchiving: false
});
}
},
fail: handleFail
});
try {
await MomentApi.archive(archiveData);
Events.emit("JOURNAL_REFRESH");
wx.showToast({ title: "归档成功", icon: "success" });
this.setData({
idea: "",
list: [],
hasChecked: false,
isArchiving: false,
selectedJournalId: undefined,
isVisibleArchivePopup: false
});
await Toolkit.sleep(1000);
this.fetch();
} catch (error) {
handleFail();
}
},
allChecked() {
this.data.list.forEach(item => item.checked = true);
@ -559,27 +440,21 @@ Page({
confirmText: "删除已选",
confirmColor: "#E64340",
cancelText: "取消",
success: res => {
success: async (res) => {
if (res.confirm) {
const selected = this.data.list.filter(item => item.checked);
wx.request({
url: `${config.url}/journal/moment/delete`,
method: "POST",
header: {
Key: wx.getStorageSync("key")
},
data: selected.map(item => item.id),
success: async (resp: any) => {
if (resp.data && resp.data.code === 20000) {
wx.showToast({ title: "删除成功", icon: "success" });
const list = this.data.list.filter(item => !item.checked);
this.setData({
list
});
this.updateHasChecked();
}
},
});
try {
await MomentApi.delete(selected.map(item => item.id));
wx.showToast({ title: "删除成功", icon: "success" });
const list = this.data.list.filter(item => !item.checked);
this.setData({
list
});
this.updateHasChecked();
} catch (error) {
console.error("删除 moment 失败:", error);
wx.showToast({ title: "删除失败", icon: "error" });
}
}
}
})

View File

@ -63,8 +63,8 @@
<view class="items">
<view class="item" wx:for="{{list}}" wx:key="mongoId">
<image
class="thumbnail {{item.type === 0 ? 'image' : 'video'}}"
src="{{item.thumbUrl}}"
class="thumbnail {{item.type}}"
src="{{item.thumbURL}}"
mode="widthFix"
bind:tap="preview"
data-index="{{index}}"

View File

@ -1,38 +1,62 @@
/* pages/main/portfolio/index.wxss */
.portfolio-list {
width: 100vw;
.location {
gap: 6rpx;
display: flex;
padding: 16rpx;
align-items: center;
.icon {
color: var(--theme-wx);
}
.text {
color: var(--theme-text-secondary);
}
}
.items {
gap: .25rem;
display: flex;
position: relative;
font-size: 14px;
column-gap: .25rem;
column-count: 3;
padding-bottom: 2rem;
align-items: flex-start;
.item {
overflow: hidden;
background: var(--theme-bg-card);
break-inside: avoid;
margin-bottom: .25rem;
.column {
flex: 1;
display: flex;
flex-direction: column;
&.thumbnail {
width: 100%;
display: block;
}
.item {
overflow: hidden;
background: var(--theme-bg-card);
margin-bottom: .25rem;
&.video-container {
height: auto;
position: relative;
&.thumbnail {
width: 100%;
display: block;
}
.play-icon {
top: 50%;
left: 50%;
width: 60rpx;
height: 60rpx;
z-index: 2;
position: absolute;
transform: translate(-50%, -50%);
pointer-events: none;
&.video {
height: auto;
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;
}
}
}
}

View File

@ -1,11 +1,14 @@
// pages/main/portfolio/index.ts
import config from "../../../config/index";
import { JournalPage, JournalPageType } from "../../../types/Journal";
import { QueryPage } from "../../../types/Model";
import Events from "../../../utils/Events";
import Time from "../../../utils/Time";
import { Journal, JournalItemType } from "../journal/index";
import config from "../../../config/index"
import Events from "../../../utils/Events";
import Toolkit from "../../../utils/Toolkit";
import { Journal, JournalPage, JournalPageType } from "../../../types/Journal";
import { OrderType, } from "../../../types/Model";
import { PreviewImageMetadata } from "../../../types/Attachment";
import { MediaItem, MediaItemType } from "../../../types/UI";
import { JournalApi } from "../../../api/JournalApi";
interface IPortfolioData {
page: JournalPage;
@ -23,6 +26,9 @@ Page({
type: JournalPageType.NORMAL,
equalsExample: {
type: "PORTFOLIO"
},
orderMap: {
createdAt: OrderType.DESC
}
},
list: [],
@ -39,6 +45,9 @@ Page({
type: JournalPageType.NORMAL,
equalsExample: {
type: "PORTFOLIO"
},
orderMap: {
createdAt: OrderType.DESC
}
},
list: [],
@ -64,79 +73,86 @@ Page({
this.setData({ stickyOffset: height });
});
},
fetch() {
async fetch() {
if (this.data.isFetching || this.data.isFinished) {
return;
}
this.setData({
isFetching: true
});
wx.request({
url: `${config.url}/journal/list`,
method: "POST",
header: {
Key: wx.getStorageSync("key")
},
data: this.data.page,
success: async (resp: any) => {
const list = resp.data.data.list;
if (!list || list.length === 0) {
this.setData({
isFinished: true
})
return;
}
const result = list.map((journal: any) => {
return {
date: Time.toPassedDate(journal.createdAt),
idea: journal.idea,
lat: journal.lat,
lng: journal.lng,
location: journal.location,
items: journal.items.filter((item: any) => item.attachType === "THUMB").map((item: any) => {
const ext = JSON.parse(item.ext);
return {
type: ext.isVideo ? JournalItemType.VIDEO : JournalItemType.IMAGE,
thumbUrl: `${config.url}/attachment/read/${item.mongoId}`,
mongoId: item.mongoId,
source: journal.items.find((source: any) => source.id === ext.sourceId)
}
})
}
})
this.setData({
page: {
index: this.data.page.index + 1,
size: 8,
type: JournalPageType.NORMAL,
equalsExample: {
type: "PORTFOLIO"
}
},
list: this.data.list.concat(result),
isFinished: list.length < this.data.page.size
});
},
complete: () => {
try {
const pageResult = await JournalApi.getList(this.data.page);
const list = pageResult.list;
if (!list || list.length === 0) {
this.setData({
isFinished: true,
isFetching: false
});
})
return;
}
});
list.forEach(journal => {
const mediaItems = journal.items!.filter((item) => item.attachType === "THUMB").map((thumbItem, index) => {
const metadata = (typeof thumbItem.metadata === "string" ? JSON.parse(thumbItem.metadata) : thumbItem.metadata) as PreviewImageMetadata;
const thumbURL = `${config.url}/attachment/read/${thumbItem.mongoId}`;
const sourceURL = `${config.url}/attachment/read/${metadata.sourceMongoId}`;
const isVideo = metadata.sourceMimeType?.startsWith("video/");
return {
type: isVideo ? MediaItemType.VIDEO : MediaItemType.IMAGE,
thumbURL,
sourceURL,
size: thumbItem.size || 0,
attachmentId: thumbItem.id,
width: metadata.width,
height: metadata.height,
originalIndex: index
} as MediaItem;
});
journal.date = Time.toDate(journal.createdAt);
journal.time = Time.toTime(journal.createdAt);
journal.datetime = Time.toPassedDateTime(journal.createdAt);
journal.mediaItems = mediaItems;
journal.columnedItems = Toolkit.splitItemsIntoColumns(mediaItems, 3, (item) => {
if (item.width && item.height && 0 < item.width) {
return item.height / item.width;
}
return 1;
})
})
this.setData({
page: {
index: this.data.page.index + 1,
size: 8,
type: JournalPageType.NORMAL,
equalsExample: {
type: "PORTFOLIO"
},
orderMap: {
createdAt: OrderType.DESC
}
},
list: this.data.list.concat(list),
isFinished: list.length < this.data.page.size,
isFetching: false
});
} catch (error) {
console.error("加载 portfolio 列表失败:", error);
this.setData({ isFetching: false });
}
},
preview(e: WechatMiniprogram.BaseEvent) {
const { journalIndex, itemIndex } = e.currentTarget.dataset;
const items = this.data.list[journalIndex].items;
const journal = this.data.list[journalIndex];
const items = journal.mediaItems!;
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: any) => {
const sources = items.slice(startIndex, endIndex).map((item) => {
return {
url: `${config.url}/attachment/read/${item.source.mongoId}`,
type: item.type === 0 ? "image" : "video"
url: item.sourceURL,
type: item.type === MediaItemType.IMAGE ? "image" : "video"
}
}) as any;
wx.previewMedia({

View File

@ -14,22 +14,19 @@
wx:for-index="journalIndex"
wx:key="journalIndex"
>
<view wx:if="{{journal.items}}" class="items">
<block
wx:for="{{journal.items}}"
wx:for-item="item"
wx:for-index="itemIndex"
wx:key="itemIndex"
>
<image
class="item thumbnail"
src="{{item.thumbUrl}}"
mode="widthFix"
bindtap="preview"
data-journal-index="{{journalIndex}}"
data-item-index="{{itemIndex}}"
></image>
</block>
<view wx:if="{{journal.columnedItems}}" class="items">
<view wx:for="{{journal.columnedItems}}" wx:for-item="column" wx:for-index="columnIndex" wx:key="columnIndex" class="column">
<block wx:for="{{column}}" wx:for-item="item" wx:for-index="itemIndex" wx:key="attachmentId">
<image
class="item thumbnail {{item.type.toLowerCase()}}"
src="{{item.thumbURL}}"
mode="widthFix"
bindtap="preview"
data-item-index="{{item.originalIndex}}"
data-journal-index="{{journalIndex}}"
></image>
</block>
</view>
</view>
</t-collapse-panel>
</t-collapse>

View File

@ -0,0 +1,15 @@
{
"component": true,
"usingComponents": {
"t-tag": "tdesign-miniprogram/tag/tag",
"t-cell": "tdesign-miniprogram/cell/cell",
"t-icon": "tdesign-miniprogram/icon/icon",
"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-cell-group": "tdesign-miniprogram/cell-group/cell-group"
},
"styleIsolation": "shared"
}

View File

@ -0,0 +1,149 @@
// pages/main/travel-detail/index.less
.travel-detail {
width: 100vw;
box-sizing: border-box;
background: var(--theme-bg-page);
.content {
display: flex;
padding-top: 48rpx;
flex-direction: column;
.section {
margin-top: 32rpx;
> .title {
color: var(--theme-text-secondary);
padding: 0 32rpx;
font-size: 28rpx;
line-height: 64rpx;
}
&.status {
display: flex;
margin-top: 24rpx;
justify-content: center;
}
&.title {
color: var(--theme-text-primary);
padding: 24rpx;
font-size: 40rpx;
text-align: center;
margin-top: 24rpx;
background: var(--theme-bg-card);
box-shadow: 0 2px 12px var(--theme-shadow-light);
font-weight: bold;
line-height: 1.5;
}
&.locations {
.header {
padding: 16rpx 32rpx;
.left-actions {
gap: 16rpx;
display: flex;
align-items: center;
.type-picker {
.picker-button {
gap: 8rpx;
color: var(--theme-wx);
border: 1px solid var(--theme-wx);
display: flex;
padding: 14rpx 24rpx 14rpx 32rpx;
font-size: 26rpx;
background: var(--theme-bg-card);
align-items: center;
border-radius: 16rpx;
}
}
}
}
.location {
.thumb {
width: 96rpx;
height: 96rpx;
border: 1px solid var(--theme-border-light);
overflow: hidden;
background: var(--theme-bg-page);
flex-shrink: 0;
border-radius: 16rpx;
}
.thumb-img {
width: 100%;
height: 100%;
}
.thumb-placeholder {
color: var(--theme-text-secondary);
width: 100%;
height: 100%;
display: flex;
font-size: 24rpx;
background: var(--theme-bg-page);
align-items: center;
justify-content: center;
}
.note {
width: 2em;
}
.description {
column-gap: 24rpx;
color: var(--theme-text-secondary);
display: flex;
flex-wrap: wrap;
font-size: 26rpx;
.item {
gap: 12rpx;
width: fit-content;
display: flex;
padding: 2rpx 4rpx;
align-items: center;
background: var(--theme-bg-page);
.stars {
gap: 8rpx;
display: flex;
}
}
}
}
}
&.action {
gap: 24rpx;
display: flex;
padding: 24rpx 16rpx 128rpx 16rpx;
.edit {
flex: 2;
}
.delete {
flex: 1;
}
}
}
}
}
.delete-dialog {
padding: 16rpx 0;
.tips {
color: var(--theme-text-secondary);
font-size: 28rpx;
line-height: 1.5;
margin-bottom: 24rpx;
}
}

View File

@ -0,0 +1,271 @@
// pages/main/travel-detail/index.ts
import Time from "../../../utils/Time";
import { TravelApi } from "../../../api/TravelApi";
import { TravelLocationApi } from "../../../api/TravelLocationApi";
import config from "../../../config/index";
import { Travel, TravelStatusLabel, TravelStatusIcon, TransportationTypeLabel, TravelLocation, TravelLocationTypeLabel, TravelLocationTypeIcon, TransportationTypeIcon, TravelLocationType } from "../../../types/Travel";
interface TravelLocationView extends TravelLocation {
/** 预览图 */
previewThumb?: string;
}
interface TravelDetailData {
/** 出行详情 */
travel: Travel | null;
/** 出行 ID */
travelId: string;
/** 是否正在加载 */
isLoading: boolean;
/** 地点列表 */
locations: TravelLocationView[];
/** 是否正在加载地点 */
isLoadingLocations: boolean;
/** 状态标签映射 */
statusLabels: typeof TravelStatusLabel;
/** 状态图标映射 */
statusIcons: typeof TravelStatusIcon;
/** 交通类型标签映射 */
transportLabels: typeof TransportationTypeLabel;
/** 交通类型图标映射 */
transportIcons: typeof TransportationTypeIcon;
/** 地点类型标签映射 */
locationTypeLabels: typeof TravelLocationTypeLabel;
/** 地点类型图标映射 */
locationTypeIcons: typeof TravelLocationTypeIcon;
/** 地点类型选项 */
locationTypes: string[];
/** 选中的地点类型索引 */
selectedLocationTypeIndex: number;
/** 删除对话框可见性 */
deleteDialogVisible: boolean;
/** 删除确认文本 */
deleteConfirmText: string;
}
Page({
data: <TravelDetailData>{
travel: null,
travelId: "",
isLoading: true,
locations: [],
isLoadingLocations: false,
statusLabels: TravelStatusLabel,
statusIcons: TravelStatusIcon,
transportLabels: TransportationTypeLabel,
transportIcons: TransportationTypeIcon,
locationTypeLabels: TravelLocationTypeLabel,
locationTypeIcons: TravelLocationTypeIcon,
locationTypes: ["全部", ...Object.values(TravelLocationTypeLabel)],
selectedLocationTypeIndex: 0,
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);
}
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 { selectedLocationTypeIndex, locationTypes } = this.data;
// 构建查询条件
const equalsExample: { [key: string]: number | string } = {
travelId: Number(travelId)
};
// 添加类型过滤(索引 0 表示"全部"
if (0 < selectedLocationTypeIndex) {
const selectedTypeLabel = locationTypes[selectedLocationTypeIndex];
const selectedType = Object.keys(TravelLocationTypeLabel).find(
key => TravelLocationTypeLabel[key as TravelLocationType] === selectedTypeLabel
) as TravelLocationType | undefined;
if (selectedType) {
equalsExample.type = selectedType;
}
}
const result = await TravelLocationApi.getList({
index: 0,
size: 999,
equalsExample
});
const locations = result.list.map((location) => {
const previewItem = location.items && 0 < location.items.length ? location.items[0] : undefined;
return {
...location,
previewThumb: previewItem ? `${config.url}/attachment/read/${previewItem.mongoId}` : undefined
};
});
this.setData({ locations });
} catch (error) {
console.error("获取地点列表失败:", error);
} finally {
this.setData({ isLoadingLocations: false });
}
},
/** 地点类型改变 */
onLocationTypeChange(e: WechatMiniprogram.PickerChange) {
const index = Number(e.detail.value);
this.setData({ selectedLocationTypeIndex: index });
// 重新从接口获取过滤后的数据
this.fetchLocations(this.data.travelId);
},
/** 编辑出行 */
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}`
});
}
},
/** 查看地点详情 */
toLocationDetail(e: WechatMiniprogram.BaseEvent) {
const { id } = e.currentTarget.dataset;
const { travel } = this.data;
if (id && travel && travel.id) {
wx.navigateTo({
url: `/pages/main/travel-location-detail/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,159 @@
<!--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="travel-detail setting-bg">
<!-- 加载状态 -->
<t-loading wx:if="{{isLoading}}" theme="dots" size="40rpx" />
<!-- 详情内容 -->
<view wx:if="{{!isLoading && travel}}" class="content">
<!-- 状态标签 -->
<view class="section status">
<t-tag
size="large"
theme="{{travel.status === 'PLANNING' ? 'default' : travel.status === 'ONGOING' ? 'warning' : 'success'}}"
variant="outline"
icon="{{statusIcons[travel.status]}}"
>
{{statusLabels[travel.status]}}
</t-tag>
</view>
<!-- 标题 -->
<view class="section title">{{travel.title || '未命名出行'}}</view>
<!-- 基本信息 -->
<t-cell-group class="section info">
<view slot="title" class="title">基本信息</view>
<t-cell left-icon="time" title="出行时间">
<view slot="note">
<text wx:if="{{travel.travelDate}}">{{travel.travelDate}} {{travel.travelTime}}</text>
<text wx:else class="undecided-value">未定</text>
</view>
</t-cell>
<t-cell left-icon="calendar" title="出行天数">
<view slot="note">
<text wx:if="{{travel.days}}">{{travel.days}} 天</text>
<text wx:else class="undecided-value">未定</text>
</view>
</t-cell>
<t-cell left-icon="{{transportIcons[travel.transportationType]}}" title="交通方式">
<view slot="note">{{transportLabels[travel.transportationType]}}</view>
</t-cell>
</t-cell-group>
<!-- 出行内容 -->
<t-cell-group wx:if="{{travel.content}}" class="section">
<view slot="title" class="title">详细说明</view>
<t-cell title="{{travel.content}}" />
</t-cell-group>
<t-cell-group class="section locations">
<view slot="title" class="title">地点列表</view>
<t-cell class="header">
<view slot="left-icon" class="left-actions">
<t-button theme="primary" icon="map" size="small" bind:tap="toMap">地图浏览</t-button>
<picker class="type-picker" mode="selector" range="{{locationTypes}}" value="{{selectedLocationTypeIndex}}" bind:change="onLocationTypeChange">
<view class="picker-button">
<text>{{locationTypes[selectedLocationTypeIndex]}}</text>
<t-icon class="icon" name="chevron-down" size="16px" />
</view>
</picker>
</view>
<t-icon slot="right-icon" name="add" size="20px" color="var(--theme-wx)" bind:tap="toAddLocation" />
</t-cell>
<t-cell wx:if="{{isLoadingLocations}}" class="loading">
<t-loading slot="title" theme="dots" size="40rpx" />
</t-cell>
<block wx:elif="{{0 < locations.length}}">
<t-cell
class="location"
wx:for="{{locations}}"
wx:key="id"
title="{{item.title || '未命名地点'}}"
bind:tap="toLocationDetail"
data-id="{{item.id}}"
arrow
>
<view slot="left-icon" class="thumb">
<image wx:if="{{item.previewThumb}}" class="thumb-img" src="{{item.previewThumb}}" mode="aspectFill" />
<view wx:else class="thumb-placeholder">
<t-icon name="{{locationTypeIcons[item.type]}}" size="28px" color="var(--theme-wx)" />
</view>
</view>
<view slot="note" class="note">{{locationTypeLabels[item.type]}}</view>
<view slot="description" class="description">
<view wx:if="{{item.requireIdCard}}" class="item">
<t-icon name="user" size="14px" />
<text>需要身份证</text>
</view>
<view wx:if="{{item.requireAppointment}}" class="item">
<t-icon name="user" size="14px" />
<text>需要预约</text>
</view>
<view wx:if="{{item.amount}}" class="item">
<t-icon name="money" size="14px" />
<text>¥{{item.amount}}</text>
</view>
<view wx:if="{{item.importance}}" class="item">
<t-icon name="chart-bubble" size="14px" />
<text>重要程度</text>
<view class="stars orange">
<t-icon
wx:for="{{item.importance}}"
wx:for-index="index"
wx:key="index"
name="star-filled"
size="14px"
/>
</view>
</view>
</view>
</t-cell>
</block>
<t-cell wx:else class="empty-state" description="暂无地点信息" />
</t-cell-group>
<!-- 操作按钮 -->
<view class="section action">
<t-button
theme="danger"
variant="outline"
size="large"
icon="delete"
t-class="delete"
bind:tap="deleteTravel"
>
删除
</t-button>
<t-button
theme="primary"
size="large"
icon="edit"
t-class="edit"
bind:tap="toEdit"
>
编辑出行计划
</t-button>
</view>
</view>
</view>
<!-- 删除确认对话框 -->
<t-dialog
visible="{{deleteDialogVisible}}"
title="删除出行计划"
confirm-btn="{{ {content: '删除', variant: 'text', theme: 'danger'} }}"
cancel-btn="取消"
bind:confirm="confirmDelete"
bind:cancel="cancelDelete"
>
<view slot="content" class="delete-dialog">
<view class="tips">
<text>此计划的地点、图片和视频也会同步删除,删除后无法恢复,请输入 "</text>
<text style="color: var(--theme-error)">确认删除</text>
<text>" 以继续</text>
</view>
<t-input placeholder="请输入:确认删除" model:value="{{deleteConfirmText}}" />
</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,112 @@
// pages/main/travel-editor/index.less
.travel-editor {
width: 100vw;
min-height: 100vh;
.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;
> .title {
color: var(--theme-text-secondary);
padding: 0 32rpx;
font-size: 28rpx;
line-height: 64rpx;
}
.picker .slot {
gap: 16rpx;
display: flex;
align-items: center;
}
.note {
color: var(--theme-text-primary);
}
.travel-at-content,
.days-content {
gap: 16rpx;
display: flex;
align-items: center;
.clear-icon {
color: var(--theme-text-tertiary);
cursor: pointer;
&:active {
opacity: .6;
}
}
.undecided-text {
color: var(--theme-text-tertiary);
cursor: pointer;
&:active {
opacity: .6;
}
}
}
.days {
&.decided {
--td-cell-vertical-padding: 24rpx;
.t-cell__title {
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 {
padding: 16rpx 0;
.tips {
color: var(--theme-text-secondary);
font-size: 28rpx;
line-height: 1.5;
margin-bottom: 24rpx;
}
}

View File

@ -0,0 +1,337 @@
// 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;
/** 出行时间是否未定 */
travelAtUndecided: boolean;
/** 天数 */
days: number;
/** 天数是否未定 */
daysUndecided: boolean;
/** 交通类型 */
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",
travelAtUndecided: true,
days: 1,
daysUndecided: true,
transportationType: TransportationType.SELF_DRIVING,
transportationTypeIndex: 4,
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 = "";
let travelAtUndecided = true;
if (travel.travelAt) {
date = Time.toDate(travel.travelAt);
time = Time.toTime(travel.travelAt);
travelAtUndecided = false;
}
// 判断天数是否未定
const daysUndecided = !travel.days;
const days = travel.days || 1;
// 计算交通类型索引
const transportationType = travel.transportationType || TransportationType.SELF_DRIVING;
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,
travelAtUndecided,
days,
daysUndecided,
transportationType,
transportationTypeIndex: transportationTypeIndex >= 0 ? transportationTypeIndex : 4,
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
});
},
/** 切换出行时间未定状态 */
toggleTravelAtUndecided(e: any) {
this.setData({ travelAtUndecided: e.detail.value });
},
/** 切换天数未定状态 */
toggleDaysUndecided(e: any) {
this.setData({ daysUndecided: e.detail.value });
},
/** 清除出行时间 */
clearTravelAt() {
this.setData({ travelAtUndecided: true });
},
/** 清除天数 */
clearDays() {
this.setData({ daysUndecided: true });
},
/** 点击未定文字选择时间 */
selectTravelAt() {
// 设置为已定状态,会自动显示选择器
const unixTime = new Date().getTime();
this.setData({
travelAtUndecided: false,
date: Time.toDate(unixTime),
time: Time.toTime(unixTime)
});
},
/** 点击未定文字选择天数 */
selectDays() {
this.setData({
daysUndecided: false,
days: 1
});
},
/** 取消 */
cancel() {
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: this.data.travelAtUndecided ? null : Time.now(),
days: this.data.daysUndecided ? null : 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: this.data.travelAtUndecided
? null
: new Date(`${this.data.date}T${this.data.time}:00`).getTime(),
days: this.data.daysUndecided ? null : 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,154 @@
<!--pages/main/travel-editor/index.wxml-->
<t-navbar title="{{mode === 'create' ? '新建出行' : '编辑出行'}}">
<text slot="left" bindtap="cancel">取消</text>
</t-navbar>
<scroll-view class="travel-editor setting-bg" 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">
<view slot="title" class="title">基本信息</view>
<t-input
class="input"
placeholder="请输入出行标题"
model:value="{{title}}"
maxlength="50"
>
<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">
<view slot="title" class="title">详细信息</view>
<t-cell class="travel-at" title="出行时间">
<view slot="right-icon" class="travel-at-content">
<picker wx:if="{{!travelAtUndecided}}" 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 wx:else class="slot" bindtap="selectTravelAt">
<text class="undecided-text">未定</text>
</view>
<t-icon
wx:if="{{!travelAtUndecided}}"
name="close-circle-filled"
size="20px"
class="clear-icon"
bindtap="clearTravelAt"
/>
</view>
</t-cell>
<t-cell title="出行天数" class="days {{daysUndecided ? 'undecided' : 'decided'}}">
<view slot="right-icon" class="days-content">
<t-stepper
wx:if="{{!daysUndecided}}"
theme="filled"
model:value="{{days}}"
size="large"
min="{{1}}"
max="{{999}}"
t-class="stepper"
/>
<view wx:else class="slot" bindtap="selectDays">
<text class="undecided-text">未定</text>
</view>
<t-icon
wx:if="{{!daysUndecided}}"
name="close-circle-filled"
size="20px"
class="clear-icon"
bindtap="clearDays"
/>
</view>
</t-cell>
<picker
mode="selector"
range="{{transportationTypes}}"
range-key="label"
value="{{transportationTypeIndex}}"
bindchange="onChangeTransportationType"
>
<t-cell title="交通方式" arrow>
<view slot="note" class="note">{{transportationTypes[transportationTypeIndex].label}}</view>
</t-cell>
</picker>
<picker
mode="selector"
range="{{statuses}}"
range-key="label"
value="{{statusIndex}}"
bindchange="onChangeStatus"
>
<t-cell title="状态" arrow>
<view slot="note" class="note">{{statuses[statusIndex].label}}</view>
</t-cell>
</picker>
</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="{{ {content: '删除', variant: 'text', theme: 'danger'} }}"
cancel-btn="取消"
bind:confirm="confirmDelete"
bind:cancel="cancelDelete"
>
<view slot="content" class="delete-dialog">
<view class="tips">
<text>此计划的地点、图片和视频也会同步删除,删除后无法恢复,请输入 "</text>
<text style="color: var(--theme-error)">确认删除</text>
<text>" 以继续</text>
</view>
<t-input placeholder="请输入:确认删除" model:value="{{deleteConfirmText}}" />
</view>
</t-dialog>

View File

@ -0,0 +1,16 @@
{
"component": true,
"usingComponents": {
"t-cell": "tdesign-miniprogram/cell/cell",
"t-rate": "tdesign-miniprogram/rate/rate",
"t-icon": "tdesign-miniprogram/icon/icon",
"t-input": "tdesign-miniprogram/input/input",
"t-button": "tdesign-miniprogram/button/button",
"t-empty": "tdesign-miniprogram/empty/empty",
"t-dialog": "tdesign-miniprogram/dialog/dialog",
"t-navbar": "tdesign-miniprogram/navbar/navbar",
"t-loading": "tdesign-miniprogram/loading/loading",
"t-cell-group": "tdesign-miniprogram/cell-group/cell-group"
},
"styleIsolation": "shared"
}

View File

@ -0,0 +1,150 @@
// pages/main/travel-location-detail/index.less
.travel-location-detail {
width: 100vw;
min-height: 100vh;
box-sizing: border-box;
.status-card {
padding: 64rpx 24rpx;
}
.content {
display: flex;
padding-top: 48rpx;
flex-direction: column;
padding-bottom: 128rpx;
.section {
margin-top: 32rpx;
> .title {
color: var(--theme-text-secondary);
padding: 0 32rpx;
font-size: 28rpx;
line-height: 64rpx;
}
&.title {
color: var(--theme-text-primary);
padding: 24rpx;
text-align: center;
margin-top: 24rpx;
background: var(--theme-bg-card);
box-shadow: 0 2px 12px var(--theme-shadow-light);
.title-text {
color: var(--theme-text-primary);
font-size: 40rpx;
font-weight: bold;
line-height: 1.5;
}
.subtitle {
color: var(--theme-text-secondary);
display: block;
font-size: 28rpx;
margin-top: 12rpx;
}
}
&.location {
.t-cell__title {
margin: 0;
}
.map {
padding: 0;
.instance {
width: 100%;
height: 520rpx;
.marker {
width: calc(var(--title-length) * 28rpx);
color: var(--theme-text-primary);
padding: 8rpx;
overflow: hidden;
font-size: 28rpx;
background: var(--theme-bg-card);
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, .15);
white-space: nowrap;
border-radius: 8rpx;
text-overflow: ellipsis;
}
}
}
}
&.media {
.media-grid {
gap: 16rpx;
display: grid;
grid-template-columns: repeat(3, 1fr);
.media-item {
width: 220rpx;
height: 220rpx;
overflow: hidden;
position: relative;
.thumbnail {
width: 100%;
height: 100%;
}
.video-container {
width: 100%;
height: 100%;
position: relative;
.thumbnail {
width: 100%;
height: 100%;
}
.play-icon {
top: 50%;
left: 50%;
color: rgba(255, 255, 255, .9);
position: absolute;
transform: translate(-50%, -50%);
}
}
}
}
}
&.navigate {
padding: 0 16rpx;
margin-top: 90rpx;
}
&.action {
gap: 24rpx;
display: flex;
padding: 0 16rpx;
.edit {
flex: 2;
}
.delete {
flex: 1;
}
}
}
}
}
.delete-dialog {
padding: 16rpx 0;
.tips {
color: var(--theme-text-secondary);
font-size: 28rpx;
line-height: 1.5;
margin-bottom: 24rpx;
}
}

View File

@ -0,0 +1,273 @@
// pages/main/travel-location-detail/index.ts
import config from "../../../config/index";
import { TravelLocationApi } from "../../../api/TravelLocationApi";
import { TravelLocation, TravelLocationTypeIcon, TravelLocationTypeLabel } from "../../../types/Travel";
import { MediaAttachType, PreviewImageMetadata } from "../../../types/Attachment";
import { MapMarker, MediaItem, MediaItemType } from "../../../types/UI";
import Toolkit from "../../../utils/Toolkit";
interface TravelLocationView extends TravelLocation {
/** 媒体列表 */
mediaItems?: MediaItem[];
}
interface TravelLocationDetailData {
/** 地点详情 */
location: TravelLocationView | null;
/** 地点 ID */
locationId: string;
/** 出行 ID */
travelId: string;
/** 是否正在加载 */
isLoading: boolean;
/** 错误信息 */
errorMessage: string;
/** 地点类型标签映射 */
locationTypeLabels: typeof TravelLocationTypeLabel;
/** 地点类型图标映射 */
locationTypeIcons: typeof TravelLocationTypeIcon;
/** 媒体类型枚举 */
mediaItemTypeEnum: typeof MediaItemType;
/** 地图标记 */
mapMarkers: MapMarker[];
/** 删除对话框可见性 */
deleteDialogVisible: boolean;
/** 删除确认文本 */
deleteConfirmText: string;
}
Page({
data: <TravelLocationDetailData>{
location: null,
locationId: "",
travelId: "",
isLoading: true,
errorMessage: "",
locationTypeLabels: TravelLocationTypeLabel,
locationTypeIcons: TravelLocationTypeIcon,
mediaItemTypeEnum: {
...MediaItemType
},
mapMarkers: [],
deleteDialogVisible: false,
deleteConfirmText: ""
},
onLoad(options: any) {
const { id, travelId } = options;
if (id) {
this.setData({
locationId: id,
travelId: travelId || ""
});
this.fetchDetail(id);
} else {
wx.showToast({
title: "参数错误",
icon: "error"
});
setTimeout(() => {
wx.navigateBack();
}, 1500);
}
},
onShow() {
// 页面显示时刷新数据(从编辑页返回时)
if (this.data.locationId && !this.data.isLoading && this.data.location) {
this.fetchDetail(this.data.locationId);
}
},
/** 获取地点详情 */
async fetchDetail(id: string) {
this.setData({
isLoading: true,
errorMessage: ""
});
try {
const location = await TravelLocationApi.getDetail(id);
const thumbItems = (location.items || []).filter((item) => item.attachType === MediaAttachType.THUMB);
const mediaItems: MediaItem[] = [];
thumbItems.forEach((thumbItem) => {
try {
const metadata = (typeof thumbItem.metadata === "string" ? JSON.parse(thumbItem.metadata) : thumbItem.metadata) as PreviewImageMetadata;
const thumbURL = `${config.url}/attachment/read/${thumbItem.mongoId}`;
const sourceURL = `${config.url}/attachment/read/${metadata.sourceMongoId}`;
const isVideo = metadata.sourceMimeType?.startsWith("video/");
mediaItems.push({
type: isVideo ? MediaItemType.VIDEO : MediaItemType.IMAGE,
thumbURL,
sourceURL,
size: thumbItem.size || 0,
attachmentId: thumbItem.id!
});
} catch (parseError) {
console.warn("解析附件元数据失败", parseError);
}
});
// 构建地图标记
const mapMarkers: MapMarker[] = [];
if (location.lat !== undefined && location.lng !== undefined) {
mapMarkers.push({
id: 0,
latitude: location.lat,
longitude: location.lng,
width: 24,
height: 30,
customCallout: {
anchorY: -2,
anchorX: 0,
display: "ALWAYS"
}
});
}
this.setData({
location: {
...location,
mediaItems
},
travelId: location.travelId ? String(location.travelId) : this.data.travelId,
mapMarkers
});
} catch (error) {
console.error("获取地点详情失败:", error);
this.setData({
errorMessage: "加载失败,请稍后重试"
});
wx.showToast({
title: "加载失败",
icon: "error"
});
} finally {
this.setData({ isLoading: false });
}
},
/** 重新加载 */
retryFetch() {
if (this.data.locationId) {
this.fetchDetail(this.data.locationId);
}
},
/** 编辑地点 */
toEdit() {
const { location, travelId } = this.data;
if (location && location.id) {
wx.navigateTo({
url: `/pages/main/travel-location-editor/index?id=${location.id}&travelId=${travelId || location.travelId || ""}`
});
}
},
/** 出发(打开地图导航) */
navigate() {
const { location } = this.data;
if (location && location.lat !== undefined && location.lng !== undefined) {
wx.openLocation({
latitude: location.lat,
longitude: location.lng,
name: location.title || "目的地",
address: location.location || "",
scale: 15
});
} else {
wx.showToast({
title: "位置信息不完整",
icon: "error"
});
}
},
/** 预览媒体 */
preview(e: WechatMiniprogram.BaseEvent) {
const index = e.currentTarget.dataset.index;
const { location } = this.data;
if (!location || !location.mediaItems) {
return;
}
const sources = location.mediaItems.map(item => ({
url: item.sourceURL,
type: item.type
}));
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[]
});
},
/** 删除地点 */
deleteLocation() {
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.location || !this.data.location.id) {
return;
}
wx.showLoading({ title: "删除中...", mask: true });
try {
await TravelLocationApi.delete(this.data.location.id);
wx.showToast({
title: "删除成功",
icon: "success"
});
setTimeout(() => {
wx.navigateBack();
}, 1500);
} catch (error) {
// 错误已由 Network 类处理
} finally {
wx.hideLoading();
}
},
/** 返回 */
async goBack() {
Toolkit.async(() => wx.navigateBack()); // 微信 BUG需要延时
}
});

View File

@ -0,0 +1,157 @@
<!--pages/main/travel-location-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="travel-location-detail setting-bg">
<t-loading wx:if="{{isLoading}}" theme="dots" size="40rpx" />
<view wx:elif="{{errorMessage}}" class="status-card">
<t-empty icon="error-circle" description="{{errorMessage}}">
<t-button size="small" theme="primary" variant="outline" bind:tap="retryFetch">
重新加载
</t-button>
</t-empty>
</view>
<view wx:elif="{{!location}}" class="status-card">
<t-empty icon="location" description="暂无地点信息" />
</view>
<view wx:else class="content">
<!-- 标题 -->
<view class="section title">
<text class="title-text">{{location.title || '未命名地点'}}</text>
</view>
<!-- 位置信息 -->
<t-cell-group wx:if="{{location.lat !== undefined && location.lng !== undefined}}" class="section location">
<view slot="title" class="title">位置信息</view>
<t-cell
left-icon="{{locationTypeIcons[location.type]}}"
title="类型"
note="{{locationTypeLabels[location.type]}}"
/>
<t-cell class="map">
<map
slot="description"
class="instance"
latitude="{{location.lat}}"
longitude="{{location.lng}}"
markers="{{mapMarkers}}"
scale="15"
show-location
>
<cover-view slot="callout">
<cover-view class="marker" marker-id="0" style="--title-length: {{location.title.length}}">
{{location.title || '地点'}}
</cover-view>
</cover-view>
</map>
</t-cell>
<t-cell
left-icon="location"
title="{{location.location || '位置信息'}}"
arrow
bind:tap="navigate"
/>
</t-cell-group>
<!-- 基础信息 -->
<t-cell-group class="section info">
<view slot="title" class="title">基础信息</view>
<t-cell wx:if="{{location.amount !== undefined && location.amount !== null}}" left-icon="money" title="费用">
<view slot="note">¥{{location.amount}}</view>
</t-cell>
<t-cell left-icon="verify" title="身份证">
<view slot="note" class="{{location.requireIdCard ? 'red' : ''}}">
{{location.requireIdCard ? '需要' : '无需'}}
</view>
</t-cell>
<t-cell left-icon="calendar" title="预约">
<view slot="note" class="{{location.requireAppointment ? 'red' : ''}}">
{{location.requireAppointment ? '需要' : '无需'}}
</view>
</t-cell>
<t-cell wx:if="{{location.score !== undefined && location.score !== null}}" left-icon="star" title="评分">
<view slot="note">
<t-rate value="{{location.score}}" count="{{5}}" size="20px" readonly />
</view>
</t-cell>
<t-cell wx:if="{{location.importance !== undefined && location.importance !== null}}" left-icon="chart-bubble" title="重要程度">
<view slot="note">
<t-rate value="{{location.importance}}" count="{{5}}" size="20px" readonly />
</view>
</t-cell>
</t-cell-group>
<!-- 详细说明 -->
<t-cell-group wx:if="{{location.description}}" class="section">
<view slot="title" class="title">详细说明</view>
<t-cell title="{{location.description}}" />
</t-cell-group>
<!-- 照片/视频 -->
<t-cell-group wx:if="{{location.mediaItems && 0 < location.mediaItems.length}}" class="section media">
<view slot="title" class="title">照片视频</view>
<t-cell>
<view slot="title" class="media-grid">
<view
wx:for="{{location.mediaItems}}"
wx:key="attachmentId"
class="media-item"
bind:tap="preview"
data-index="{{index}}"
>
<image
wx:if="{{item.type === mediaItemTypeEnum.IMAGE}}"
src="{{item.thumbURL}}"
class="thumbnail"
mode="aspectFill"
></image>
<view wx:if="{{item.type === mediaItemTypeEnum.VIDEO}}" class="video-container">
<image src="{{item.thumbURL}}" class="thumbnail" mode="aspectFill"></image>
<t-icon class="play-icon" name="play-circle-filled" size="48px" />
</view>
</view>
</view>
</t-cell>
</t-cell-group>
<view class="section navigate">
<t-button theme="primary" size="large" icon="rocket" block bind:tap="navigate">
出发导航
</t-button>
</view>
<!-- 操作按钮 -->
<view class="section action">
<t-button
theme="danger"
variant="outline"
size="large"
icon="delete"
t-class="delete"
bind:tap="deleteLocation"
>
删除
</t-button>
<t-button theme="primary" size="large" icon="edit" t-class="edit" bind:tap="toEdit">
编辑地点
</t-button>
</view>
</view>
</view>
<t-dialog
visible="{{deleteDialogVisible}}"
title="删除地点"
confirm-btn="{{ {content: '删除', variant: 'text', theme: 'danger'} }}"
cancel-btn="取消"
bind:confirm="confirmDelete"
bind:cancel="cancelDelete"
>
<view slot="content" class="delete-dialog">
<view class="tips">
<text>此地点的图片和视频也会同步删除,删除后无法恢复,请输入 "</text>
<text style="color: var(--theme-error)">确认删除</text>
<text>" 以继续</text>
</view>
<t-input placeholder="请输入:确认删除" model:value="{{deleteConfirmText}}" />
</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-rate": "tdesign-miniprogram/rate/rate",
"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,201 @@
// pages/main/travel-location-editor/index.less
.travel-location-editor {
width: 100vw;
min-height: 100vh;
.content {
padding-bottom: 64rpx;
.loading {
display: flex;
padding: 128rpx 0;
align-items: center;
flex-direction: column;
.text {
color: var(--theme-text-secondary);
margin-top: 24rpx;
font-size: 28rpx;
}
}
.section {
margin-top: 48rpx;
.rate-cell {
&.decided {
.rate {
display: block;
gap: 16rpx;
display: flex;
align-items: center;
}
.text {
display: none;
}
}
&.undecided {
.rate {
display: none;
}
.text {
display: block;
}
}
}
> .title {
color: var(--theme-text-secondary);
padding: 0 32rpx;
font-size: 28rpx;
line-height: 64rpx;
}
&.location {
.note {
color: var(--theme-text-primary);
}
.value {
.title {
width: 2em;
}
}
}
&.media {
.gallery {
gap: 10rpx;
padding: 0 6rpx;
display: grid;
grid-template-columns: repeat(3, 1fr);
.item {
width: 220rpx;
height: 220rpx;
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;
&::after {
border-radius: 0;
}
}
.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);
}
}
}
}
&.upload {
gap: 16rpx;
display: flex;
padding: 24rpx 32rpx;
margin-top: 24rpx;
align-items: center;
border-radius: 12rpx;
background: var(--theme-bg-card);
.text {
color: var(--theme-text-secondary);
font-size: 28rpx;
}
}
&.action {
gap: 24rpx;
display: flex;
padding: 24rpx 16rpx 48rpx 16rpx;
.delete {
flex: .6;
}
.submit {
flex: 1;
}
}
}
}
}
.delete-dialog {
padding: 16rpx 0;
.tips {
color: var(--theme-text-secondary);
font-size: 28rpx;
line-height: 1.5;
margin-bottom: 24rpx;
}
}

View File

@ -0,0 +1,605 @@
// pages/main/travel-location-editor/index.ts
import { Network, WechatMediaItem } from "../../../utils/Network";
import { TravelLocationApi } from "../../../api/TravelLocationApi";
import { TravelLocationType, TravelLocationTypeLabel } from "../../../types/Travel";
import { MediaAttachType, PreviewImageMetadata } 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;
/** 是否需要预约 */
requireAppointment: boolean;
/** 评分 */
score: number;
/** 评分是否未定 */
scoreUndecided: boolean;
/** 重要程度 */
importance: number;
/** 重要程度是否未定 */
importanceUndecided: boolean;
/** 媒体列表(创建和编辑模式使用) */
mediaList: (MediaItem | WechatMediaItem)[];
/** 新媒体列表(编辑模式使用) */
newMediaList: WechatMediaItem[];
/** 是否正在加载(编辑模式) */
isLoading: boolean;
/** 是否正在保存 */
isSaving: boolean;
/** 是否正在上传 */
isUploading: boolean;
/** 上传进度信息 */
uploadInfo: string;
/** 地点类型选项 */
locationTypes: string[];
/** 地点类型值数组 */
locationTypeValues: TravelLocationType[];
/** 地点类型选中索引 */
locationTypeIndex: number;
/** 删除对话框可见性 */
deleteDialogVisible: boolean;
/** 删除确认文本 */
deleteConfirmText: string;
/** 媒体类型枚举 */
mediaItemTypeEnum: any;
}
Page({
data: <TravelLocationEditorData>{
mode: "create",
id: undefined,
travelId: 0,
type: TravelLocationType.FOOD,
title: "",
description: "",
location: "",
lat: 0,
lng: 0,
amount: 0,
requireIdCard: false,
requireAppointment: false,
score: 3,
scoreUndecided: true,
importance: 1,
importanceUndecided: true,
mediaList: [],
newMediaList: [],
isLoading: false,
isSaving: false,
isUploading: false,
uploadInfo: "",
mediaItemTypeEnum: {
...MediaItemType
},
locationTypes: Object.values(TravelLocationTypeLabel),
locationTypeValues: [
TravelLocationType.FOOD,
TravelLocationType.HOTEL,
TravelLocationType.TRANSPORT,
TravelLocationType.ATTRACTION,
TravelLocationType.SHOPPING,
TravelLocationType.PLAY,
TravelLocationType.LIFE
],
locationTypeIndex: 0,
deleteDialogVisible: false,
deleteConfirmText: ""
},
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.locationTypeValues.findIndex(
item => item === type
);
const items = location.items || [];
const thumbItems = items.filter((item) => item.attachType === MediaAttachType.THUMB);
const mediaList: MediaItem[] = thumbItems.map((thumbItem) => {
const metadata = (typeof thumbItem.metadata === "string" ? JSON.parse(thumbItem.metadata) : thumbItem.metadata) as PreviewImageMetadata;
const thumbURL = `${config.url}/attachment/read/${thumbItem.mongoId}`;
const sourceURL = `${config.url}/attachment/read/${metadata.sourceMongoId}`;
const isVideo = metadata.sourceMimeType?.startsWith("video/");
return {
type: isVideo ? MediaItemType.VIDEO : MediaItemType.IMAGE,
thumbURL,
sourceURL,
size: thumbItem.size || 0,
attachmentId: thumbItem.id
} as MediaItem;
});
// 判断评分是否未定
const scoreUndecided = location.score === null || location.score === undefined;
const score = location.score !== null && location.score !== undefined ? location.score : 3;
// 判断重要程度是否未定
const importanceUndecided = location.importance === null || location.importance === undefined;
const importance = location.importance !== null && location.importance !== undefined ? location.importance : 1;
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,
requireAppointment: location.requireAppointment || false,
score,
scoreUndecided,
importance,
importanceUndecided,
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.locationTypeValues[index]
});
},
/** 改变是否需要身份证 */
onChangeRequireIdCard(e: any) {
this.setData({ requireIdCard: e.detail.value });
},
/** 改变是否需要预约 */
onChangeRequireAppointment(e: any) {
this.setData({ requireAppointment: e.detail.value });
},
/** 改变评分 */
onChangeScore(e: any) {
this.setData({ score: e.detail.value });
},
/** 改变重要程度 */
onChangeImportance(e: any) {
this.setData({ importance: e.detail.value });
},
/** 清除评分 */
clearScore() {
this.setData({ scoreUndecided: true });
},
/** 清除重要程度 */
clearImportance() {
this.setData({ importanceUndecided: true });
},
/** 点击未定文字选择评分 */
selectScore() {
this.setData({
scoreUndecided: false,
score: 3
});
},
/** 点击未定文字选择重要程度 */
selectImportance() {
this.setData({
importanceUndecided: false,
importance: 1
});
},
/** 选择位置 */
chooseLocation() {
wx.chooseLocation({
success: (res) => {
const locationName = res.name || res.address;
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: 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: item.type.toLowerCase()
}));
const newSources = this.data.newMediaList.map(item => ({
url: item.path,
type: 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() {
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 TravelLocationApi.delete(this.data.id);
wx.showToast({
title: "删除成功",
icon: "success"
});
setTimeout(() => {
wx.navigateBack();
}, 1500);
} catch (error) {
// 错误已由 Network 类处理
} finally {
wx.hideLoading();
}
},
/** 提交/保存 */
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,
requireAppointment: this.data.requireAppointment,
score: this.data.scoreUndecided ? null : this.data.score,
importance: this.data.importanceUndecided ? null : this.data.importance,
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,
requireAppointment: this.data.requireAppointment,
score: this.data.scoreUndecided ? null : this.data.score,
importance: this.data.importanceUndecided ? null : this.data.importance,
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,288 @@
<!--pages/main/travel-location-editor/index.wxml-->
<t-navbar title="{{mode === 'create' ? '添加地点' : '编辑地点'}}">
<text slot="left" bindtap="cancel">取消</text>
</t-navbar>
<scroll-view class="travel-location-editor setting-bg" type="custom" scroll-y show-scrollbar="{{false}}">
<view class="content">
<view wx:if="{{isLoading}}" class="loading">
<t-loading theme="dots" size="40rpx" />
<text class="text">加载中...</text>
</view>
<block wx:else>
<t-cell-group class="section location">
<view slot="title" class="title">位置信息</view>
<picker mode="selector" range="{{locationTypes}}" value="{{locationTypeIndex}}" bindchange="onChangeLocationType">
<t-cell title="地点类型" arrow>
<view slot="note" class="note">{{locationTypes[locationTypeIndex]}}</view>
</t-cell>
</picker>
<t-cell class="value" required arrow bind:click="chooseLocation">
<view slot="title" class="title">位置</view>
<view slot="note" class="note">{{location}}</view>
</t-cell>
</t-cell-group>
<t-cell-group class="section">
<view slot="title" class="title">基本信息</view>
<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">
<view slot="title" class="title">详细信息</view>
<t-input
model:value="{{amount}}"
placeholder="0"
label="费用"
suffix="元"
type="digit"
align="right"
/>
<t-cell title="需要身份证">
<view slot="right-icon">
<switch checked="{{requireIdCard}}" bindchange="onChangeRequireIdCard" />
</view>
</t-cell>
<t-cell title="需要预约">
<view slot="right-icon">
<switch checked="{{requireAppointment}}" bindchange="onChangeRequireAppointment" />
</view>
</t-cell>
<t-cell title="重要程度" class="rate-cell importance {{importanceUndecided ? 'undecided' : 'decided'}}">
<view slot="note">
<view class="rate">
<t-rate
value="{{importance}}"
count="{{5}}"
size="20px"
bind:change="onChangeImportance"
/>
<t-icon
name="close-circle-filled"
size="20px"
class="clear-icon"
bindtap="clearImportance"
/>
</view>
<view class="text" bindtap="selectImportance">
未定
</view>
</view>
</t-cell>
<t-cell title="评分" class="rate-cell score {{scoreUndecided ? 'undecided' : 'decided'}}">
<view slot="note">
<view class="rate">
<t-rate
value="{{score}}"
count="{{5}}"
size="20px"
bind:change="onChangeScore"
/>
<t-icon
name="close-circle-filled"
size="20px"
class="clear-icon"
bindtap="clearScore"
/>
</view>
<view class="text" bindtap="selectScore">
未定
</view>
</view>
</t-cell>
</t-cell-group>
<t-cell-group class="section media">
<view slot="title" class="title">图片视频</view>
<t-cell>
<view slot="title" 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="deleteNewMedia"
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>
</t-cell>
</t-cell-group>
<!-- 上传进度提示 -->
<view wx:if="{{isUploading}}" class="section upload">
<t-loading theme="circular" size="32rpx" />
<text class="text">{{uploadInfo}}</text>
</view>
<!-- 按钮 -->
<view class="section action">
<t-button
wx:if="{{mode === 'edit'}}"
class="delete"
theme="danger"
variant="outline"
size="large"
bind:tap="deleteLocation"
disabled="{{isSaving || isUploading}}"
>
删除
</t-button>
<t-button
class="submit"
theme="primary"
size="large"
bind:tap="submit"
loading="{{isSaving}}"
disabled="{{isSaving || isUploading}}"
>
{{mode === 'create' ? '创建地点' : '保存修改'}}
</t-button>
</view>
</block>
</view>
</scroll-view>
<!-- 删除确认对话框 -->
<t-dialog
visible="{{deleteDialogVisible}}"
title="删除地点"
confirm-btn="{{ {content: '删除', variant: 'text', theme: 'danger'} }}"
cancel-btn="取消"
bind:confirm="confirmDelete"
bind:cancel="cancelDelete"
>
<view slot="content" class="delete-dialog">
<view class="tips">
<text>此地点的图片和视频也会同步删除,删除后无法恢复,请输入 "</text>
<text style="color: var(--theme-error)">确认删除</text>
<text>" 以继续</text>
</view>
<t-input placeholder="请输入:确认删除" model:value="{{deleteConfirmText}}" />
</view>
</t-dialog>

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,64 @@
/* pages/main/travel-location-map/index.less */
.container {
width: 100%;
height: 100vh;
position: fixed;
overflow: hidden;
.map {
width: 100%;
height: 100%;
.marker {
width: fit-content;
display: flex;
background: var(--theme-bg-card);
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, .15);
border-radius: 6rpx;
flex-direction: column;
.location {
display: flex;
padding: 8rpx 16rpx 8rpx 8rpx;
align-items: center;
.type {
color: #fff;
padding: 4rpx 12rpx 6rpx 12rpx;
font-size: 28rpx;
flex-shrink: 0;
background: var(--theme-wx, #07c160);
margin-right: 12rpx;
border-radius: 4rpx;
}
.title {
flex: 1;
width: calc(var(--title-length) * 28rpx);
color: var(--theme-text-primary, #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: var(--theme-text-secondary, #666);
padding: 24rpx 48rpx;
background: var(--theme-bg-card, #fff);
border-radius: 8rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, .15);
}
}
}

View File

@ -0,0 +1,183 @@
// pages/main/travel-location-map/index.ts
import { TravelLocationApi } from "../../../api/TravelLocationApi";
import { TravelLocation, TravelLocationTypeLabel } from "../../../types/Travel";
import Toolkit from "../../../utils/Toolkit";
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: Toolkit.random(-10, 10),
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,41 @@
<!--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">
<cover-view
class="marker"
wx:for="{{markers}}"
wx:key="id"
wx:for-item="marker"
wx:for-index="markerIndex"
marker-id="{{markerIndex}}"
>
<cover-view
class="location"
wx:for="{{marker.locations}}"
wx:key="id"
wx:for-item="location"
style="{{'--title-length: ' + location.title.length}}"
>
<cover-view wx:if="{{location.typeLabel}}" class="type">{{location.typeLabel}}</cover-view>
<cover-view class="title">{{location.title || '未命名地点'}}</cover-view>
</cover-view>
</cover-view>
</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,118 @@
/* 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 {
.travels {
width: 100vw;
padding: 16rpx;
box-sizing: border-box;
padding-bottom: 120rpx;
.images {
column-gap: .25rem;
column-count: 3;
padding-bottom: 2rem;
.travel {
overflow: hidden;
box-shadow: 0 2px 12px var(--theme-shadow-light);
transition: all .3s;
background: var(--theme-bg-card);
margin-bottom: 24rpx;
border-radius: 16rpx;
.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);
}
.header {
padding: 24rpx;
border-bottom: 1px solid var(--theme-border-light);
}
.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: 32rpx;
display: flex;
flex-wrap: wrap;
.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,176 @@
// 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, TransportationTypeIcon } from "../../../types/Travel";
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;
/** 交通类型图标映射 */
transportIcons: typeof TransportationTypeIcon;
}
Page({
data: <ITravelData>{
luggage: undefined,
guides: [],
guidesDB: [],
activeCollapse: undefined
data: <TravelData>{
page: {
index: 0,
size: 10
},
list: [],
currentStatus: "ALL",
isFetching: false,
isFinished: false,
isShowFilterMenu: false,
menuTop: 0,
menuLeft: 0,
statusLabels: TravelStatusLabel,
statusIcons: TravelStatusIcon,
transportLabels: TransportationTypeLabel,
transportIcons: TransportationTypeIcon
},
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
})
// 页面显示时刷新数据(从编辑页返回时)
if (0 < this.data.list.length) {
this.resetAndFetch();
}
this.setData({
activeCollapse: index
})
},
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"
onHide() {
this.setData({
isShowFilterMenu: false
});
},
onPullDownRefresh() {
this.resetAndFetch();
wx.stopPullDownRefresh();
},
onReachBottom() {
this.fetch();
},
/** 重置并获取数据 */
resetAndFetch() {
this.setData({
page: {
index: 0,
size: 10,
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,101 @@
<!--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="travels">
<!-- 空状态 -->
<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"
bind:tap="toDetail"
data-id="{{travel.id}}"
>
<view class="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="body">
<view class="title">{{travel.title || '未命名出行'}}</view>
<view wx:if="{{travel.content}}" class="content">{{travel.content}}</view>
<view class="meta">
<view wx:if="{{travel.travelDate}}" class="item">
<t-icon name="time" size="16px" class="icon" />
<text class="text">{{travel.travelDate}}</text>
</view>
<view wx:if="{{travel.days}}" class="item">
<t-icon name="calendar" size="16px" class="icon" />
<text class="text">{{travel.days}} 天</text>
</view>
<view wx:if="{{travel.transportationType}}" class="item">
<t-icon name="{{transportIcons[travel.transportationType]}}" 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,10 +0,0 @@
{
"usingComponents": {
"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"
}
}

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

@ -256,10 +256,10 @@ page {
--td-font-size-l: var(--td-font-size-title-large);
--td-font-size-xl: var(--td-font-size-title-extra-large);
--td-font-size-xxl: var(--td-font-size-headline-large);
--td-radius-small: 2px;
--td-radius-default: 4px;
--td-radius-large: 6px;
--td-radius-extraLarge: 7px;
--td-radius-round: 999px;
--td-radius-small: 8rpx;
--td-radius-default: 16rpx;
--td-radius-large: 24px;
--td-radius-extraLarge: 32rpx;
--td-radius-round: 9999rpx;
--td-radius-circle: 50%;
}

View File

@ -1,14 +1,14 @@
{
"light": {
"navigationBarBackgroundColor": "#FFFFFF",
"navigationBarBackgroundColor": "#FFF",
"navigationBarTextStyle": "black",
"backgroundColor": "#FFFFFF",
"backgroundTextStyle": "dark",
"backgroundColorTop": "#FFFFFF",
"backgroundColorBottom": "#FFFFFF",
"tabBarColor": "#8a8a8a",
"backgroundColor": "#EDEDED",
"backgroundTextStyle": "#111",
"backgroundColorTop": "#F00",
"backgroundColorBottom": "#EDEDED",
"tabBarColor": "#8A8A8A",
"tabBarSelectedColor": "#07C160",
"tabBarBackgroundColor": "#ffffff",
"tabBarBackgroundColor": "#FFF",
"tabBarBorderStyle": "white",
"tabBarIconJournal": "assets/icon/light/journal.png",
"tabBarIconJournalActive": "assets/icon/light/journal_active.png",
@ -24,11 +24,11 @@
"dark": {
"navigationBarBackgroundColor": "#1A1A1A",
"navigationBarTextStyle": "white",
"backgroundColor": "#1A1A1A",
"backgroundColor": "#111",
"backgroundTextStyle": "light",
"backgroundColorTop": "#1A1A1A",
"backgroundColorBottom": "#1A1A1A",
"tabBarColor": "#aaaaaa",
"tabBarColor": "#AAA",
"tabBarSelectedColor": "#07C160",
"tabBarBackgroundColor": "#1A1A1A",
"tabBarBorderStyle": "black",

View File

@ -9,10 +9,12 @@ page {
--theme-wx: #07C160;
/* === 背景色 === */
--theme-bg-wx: #EDEDED;
--theme-bg-primary: #FFF;
--theme-bg-secondary: #F5F5F5;
--theme-bg-card: #FFF;
--theme-bg-journal: #fff2C8;
--theme-bg-card-secondary: #F4F4F4;
--theme-bg-journal: #FFF2C8;
--theme-bg-overlay: rgba(0, 0, 0, .1);
--theme-bg-menu: rgba(255, 255, 255, .95);
@ -56,46 +58,51 @@ 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-wx: #111;
--theme-bg-primary: #1A1A1A;
--theme-bg-secondary: #2A2A2A;
--theme-bg-card: #3D3D3D;
--theme-bg-card-secondary: #525252;
--theme-bg-journal: #4B4B4B;
--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);
}
}

59
miniprogram/timi-web.less Normal file
View File

@ -0,0 +1,59 @@
@tuiColors: {
red: #F33;
pink: #FF7A9B;
black: #111;
blue: #006EFF;
light-blue: #00A6FF;
green: GREEN;
orange: #E7913B;
gray: #666;
light-gray: #AAA;
white: #FFF;
dark-white: #E7EAEF;
yellow: #FF0;
purple: PURPLE;
}
:root {
--tui-font: SimSun, PingFang SC, Microsoft YaHei, Arial Regular;
--tui-cur-default: url("../img/default.png"), default;
--tui-cur-pointer: url("../img/link.png"), pointer;
--tui-cur-text: url("../img/input.png"), text;
--tui-shadow: 3px 3px 0 var(--tui-shadow-color);
--tui-bezier: cubic-bezier(.19, .1, .22, 1);
--tui-shadow-color: rgba(0, 0, 0, .2);
each(@tuiColors, {
--tui-@{key}: @value;
});
--tui-border: 1px solid var(--tui-light-gray);
/* 等级对应颜色 */
--tui-level-0: #BFBFBF;
--tui-level-1: #BFBFBF;
--tui-level-2: #95DDB2;
--tui-level-3: #92D1E5;
--tui-level-4: #FFB37C;
--tui-level-5: #FF6C00;
--tui-level-6: #F00;
--tui-level-7: #E52FEC;
--tui-level-8: #841CF9;
--tui-level-9: #151515;
--tui-page-padding: .5rem 1rem;
}
// 字体颜色
each(@tuiColors, {
.@{key} {
color: @value;
}
});
// 背景颜色
each(@tuiColors, {
.bg-@{key} {
background: @value;
}
});

View File

@ -17,7 +17,7 @@ export type Attachment = {
mimeType?: string;
metadata?: string | ImageMetadata;
metadata?: string | ImageMetadata | PreviewImageMetadata;
/** 文件 MD5 */
md5: string;
@ -27,9 +27,6 @@ export type Attachment = {
/** 大小 */
size: number;
/** 扩展数据 */
ext?: string | MediaAttachExt;
} & Model;
/** 媒体附件类型 */
@ -52,8 +49,7 @@ export type ImageMetadata = {
height: number;
}
/** 媒体附件扩展数据 */
export type MediaAttachExt = {
export type PreviewImageMetadata = {
/** 原文件附件 ID */
sourceId: number;
@ -61,15 +57,6 @@ export type MediaAttachExt = {
/** 原文件访问 mongoId */
sourceMongoId: string;
/** true 为图片 */
isImage: boolean;
/** true 为视频 */
isVideo: boolean;
/** 原图宽度(像素) */
width?: number;
/** 原图高度(像素) */
height?: number;
}
/** 原文件 MimeType */
sourceMimeType: string;
} & ImageMetadata;

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

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

@ -0,0 +1,187 @@
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 const TransportationTypeIcon: Record<TransportationType, string> = {
[TransportationType.PLANE]: "flight-takeoff",
[TransportationType.TRAIN]: "map-route",
[TransportationType.CAR]: "vehicle",
[TransportationType.SHIP]: "anchor",
[TransportationType.SELF_DRIVING]: "vehicle",
[TransportationType.OTHER]: "compass"
};
/** 出行状态 */
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 | null;
/** 天数 */
days?: number | null;
/** 状态 */
status?: TravelStatus;
/** 格式化的出行日期 */
travelDate?: string;
/** 格式化的出行时间 */
travelTime?: string;
}
/** 出行分页查询 */
export interface TravelPage extends QueryPage {
/** 条件过滤 */
equalsExample?: {
status?: TravelStatus;
};
}
/** 地点类型 */
export enum TravelLocationType {
FOOD = "FOOD",
HOTEL = "HOTEL",
TRANSPORT = "TRANSPORT",
ATTRACTION = "ATTRACTION",
MALL = "MALL",
SHOPPING = "SHOPPING",
PLAY = "PLAY",
LIFE = "LIFE"
}
/** 地点类型中文映射 */
export const TravelLocationTypeLabel: Record<TravelLocationType, string> = {
[TravelLocationType.FOOD]: "美食",
[TravelLocationType.HOTEL]: "酒店",
[TravelLocationType.TRANSPORT]: "交通",
[TravelLocationType.ATTRACTION]: "景点",
[TravelLocationType.MALL]: "商场",
[TravelLocationType.SHOPPING]: "购物",
[TravelLocationType.PLAY]: "玩乐",
[TravelLocationType.LIFE]: "生活"
};
/** 地点类型图标映射 */
export const TravelLocationTypeIcon: Record<TravelLocationType, string> = {
[TravelLocationType.FOOD]: "chicken",
[TravelLocationType.HOTEL]: "city-8",
[TravelLocationType.TRANSPORT]: "map-route-planning",
[TravelLocationType.ATTRACTION]: "image-1",
[TravelLocationType.MALL]: "chart-3d",
[TravelLocationType.SHOPPING]: "shop",
[TravelLocationType.PLAY]: "ferris-wheel",
[TravelLocationType.LIFE]: "location"
};
/** 出行地点实体 */
export interface TravelLocation extends Model {
/** 关联的出行计划 ID */
travelId?: number;
/** 地点类型 */
type?: TravelLocationType;
/** 标题 */
title?: string;
/** 说明 */
description?: string;
/** 纬度 */
lat?: number;
/** 经度 */
lng?: number;
/** 位置描述 */
location?: string;
/** 费用 */
amount?: number;
/** 是否需要身份证 */
requireIdCard?: boolean;
/** 是否需要预约 */
requireAppointment?: boolean;
/** 评分 */
score?: number | null;
/** 重要程度 */
importance?: number | null;
/** 附件 */
items?: Attachment[];
/** 保留的附件 ID 列表(更新时使用) */
attachmentIds?: number[];
/** 临时文件 ID 列表(创建/更新时上传附件用) */
tempFileIds?: string[];
// ---------- 视图属性 ----------
/** 类型标签 */
typeLabel?: string;
/** 类型图标 */
typeIcon?: string;
/** 分列后的 items用于瀑布流展示 */
columnedItems?: MediaItem[][];
/** 媒体项(由附件转) */
mediaItems?: MediaItem[];
}

View File

@ -55,10 +55,10 @@ export type WechatMediaItem = {
export enum MediaItemType {
/** 图片 */
IMAGE,
IMAGE = "image",
/** 视频 */
VIDEO
VIDEO = "video"
}
/** 位置 */
@ -79,4 +79,17 @@ export enum JournalDetailType {
DATE = "DATE",
LOCATION = "LOCATION"
}
}
export interface MapMarker {
id: number;
latitude: number;
longitude: number;
width: number;
height: number;
customCallout: {
anchorY: number;
anchorX: number;
display: string;
};
}

View File

@ -0,0 +1,441 @@
import config from "../config/index";
import { Response, QueryPage, QueryPageResult, TempFileResponse } from "../types/Model";
import { MediaItemType } from "../types/UI";
/** 微信媒体项(用于上传) */
export interface WechatMediaItem {
/** 媒体路径或 URL */
path?: string;
url?: string;
/** 文件大小 */
size?: number;
/** 媒体类型 */
type?: MediaItemType;
}
/** 请求选项 */
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);
});
});
}
}