Compare commits

...

76 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
c0a0242a02 fix media item sort 2025-12-11 19:21:41 +08:00
f837c4d4d9 update 1.5.2 2025-12-11 15:09:12 +08:00
3a8f7b0f19 fix location margin 2025-12-11 15:09:03 +08:00
dc71590bd9 fix progress 2025-12-11 15:06:50 +08:00
0cdef54954 fix menu position 2025-12-11 14:46:55 +08:00
fc6edcb5ab fix creater,editor preview 2025-12-11 14:26:16 +08:00
61171a6df7 fix openLocation fail 2025-12-11 14:17:24 +08:00
501745d937 fix bottom padding 2025-12-11 12:07:06 +08:00
1bf655c0dc refactor journal-detail-popup 2025-12-11 12:02:01 +08:00
0379a1d3b5 open Location in journal-map 2025-12-11 01:05:02 +08:00
861ae8baff support change type 2025-12-11 00:46:11 +08:00
2cb8f16ccc fix preview overflow 2025-12-11 00:27:58 +08:00
980d0e1431 update 1.5.1 2025-12-10 19:00:44 +08:00
1905d1cd5a fix dark mode 2025-12-10 19:00:25 +08:00
18c6a41549 fix location icon 2025-12-10 17:17:57 +08:00
920b3c87e8 fix map callout 2025-12-10 16:59:22 +08:00
1e54782600 add journal-detail-panel 2025-12-10 16:45:29 +08:00
a5fb1d752b fix title 2025-12-10 15:52:56 +08:00
79bc51bc56 fix delete btn style 2025-12-10 15:47:22 +08:00
b6be7c50e8 fix delete journal 2025-12-10 15:38:10 +08:00
fd533cb92d fix thumb size 2025-12-10 15:36:24 +08:00
16335821e2 fix journal list type 2025-12-10 15:31:50 +08:00
d07fa0ef88 fix journal list sort 2025-12-10 15:28:49 +08:00
432cc85663 fix map scale 2025-12-10 15:13:20 +08:00
71cbf554d4 animate popup 2025-12-10 15:08:01 +08:00
69bf186f0e adaptation dark mode 2025-12-10 15:02:17 +08:00
ad755c7d0e fix calendar layout style 2025-12-10 14:20:21 +08:00
4f1942f3f0 hidden other month day 2025-12-10 14:11:01 +08:00
b4f25929df fix journal detail sort 2025-12-10 14:08:24 +08:00
3305e4a508 fix map scale 2025-12-10 13:55:29 +08:00
aa6b00a87d bigger detail popup 2025-12-10 13:46:51 +08:00
ea0186447a fix thumb margin 2025-12-10 13:46:27 +08:00
c3d1276a24 remove search-list 2025-12-10 13:28:22 +08:00
58c55f3672 add journal-date 2025-12-10 13:27:34 +08:00
630c7fefcb add journal-map 2025-12-10 00:54:52 +08:00
a38ed44fd1 update creater style 2025-12-09 18:12:02 +08:00
19b6206695 add editor 2025-12-09 18:02:23 +08:00
6dc4d71718 better layout 2025-12-08 20:47:59 +08:00
4786df0c36 remove unused t-dialog 2025-12-08 18:52:32 +08:00
c4f84681d4 update version 1.4.0 2025-12-08 18:50:15 +08:00
3524de0cb6 fix QueryPage equalsExample arg 2025-12-08 18:48:54 +08:00
6755c483c7 add journal-search page 2025-12-08 18:48:22 +08:00
00bcbf9e63 add searchable 2025-12-08 18:47:44 +08:00
71fc077726 update Page obj 2025-12-08 17:14:00 +08:00
a25c38d50d support archive moment to exist journal 2025-12-08 15:52:59 +08:00
75b553ac93 add wechat color in theme 2025-12-08 15:27:21 +08:00
1002d463e0 update Page obj 2025-12-08 14:56:29 +08:00
2685ff4524 update Tdesign to 1.12.0 2025-12-08 10:45:28 +08:00
114 changed files with 8895 additions and 1786 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,20 +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",
"pages/main/journal-list/index"
"pages/main/moment/index"
],
"darkmode": true,
"themeLocation": "theme.json",
"window": {
"navigationStyle": "custom",
"navigationBarTextStyle": "black",
"navigationBarBackgroundColor": "#FFFFFF"
"navigationBarTextStyle": "@navigationBarTextStyle",
"navigationBarBackgroundColor": "@navigationBarBackgroundColor"
},
"lazyCodeLoading": "requiredComponents",
"tabBar": {
@ -43,7 +49,7 @@
"selectedIconPath": "@tabBarIconMomentActive"
},
{
"text": "行",
"text": "行",
"pagePath": "pages/main/travel/index",
"iconPath": "@tabBarIconTravel",
"selectedIconPath": "@tabBarIconTravelActive"
@ -65,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";

Binary file not shown.

Before

Width:  |  Height:  |  Size: 416 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 517 B

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

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

View File

@ -0,0 +1,112 @@
/* components/calendar/index.less */
.calendar {
flex: 1;
height: 100%;
display: flex;
overflow: hidden;
flex-direction: column;
.year {
display: flex;
padding: 16rpx 32rpx;
align-items: center;
margin-bottom: 24rpx;
justify-content: space-between;
.btn {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
border-radius: 12rpx;
justify-content: center;
&:active {
opacity: .7;
}
}
.text {
color: var(--theme-text);
font-size: 32rpx;
font-weight: 600;
}
}
.weekdays {
display: grid;
padding: 0 32rpx;
margin-bottom: 16rpx;
grid-template-columns: repeat(7, 1fr);
.weekday {
color: var(--theme-text-secondary);
padding: 16rpx 0;
font-size: 24rpx;
text-align: center;
}
}
.months {
flex: 1;
height: 0;
padding-bottom: 210rpx;
.month {
padding: 0 32rpx;
.title {
color: var(--theme-text);
font-size: 32rpx;
margin-top: 32rpx;
font-weight: bold;
}
.days {
display: grid;
grid-gap: 8rpx;
grid-template-columns: repeat(7, 1fr);
.day {
display: flex;
position: relative;
background: transparent;
align-items: center;
aspect-ratio: 1;
border-radius: 12rpx;
flex-direction: column;
justify-content: center;
.number {
color: var(--theme-text);
font-size: 28rpx;
}
.dot {
width: 8rpx;
height: 8rpx;
border-radius: 50%;
background: var(--theme-primary);
}
&.other-month {
visibility: hidden;
}
&.has-journal {
background: var(--theme-bg-journal);
.number {
color: var(--theme-wx);
font-weight: bold;
}
&:active {
opacity: .7;
}
}
}
}
}
}
}

View File

@ -0,0 +1,210 @@
// components/calendar/index.ts
interface DayInfo {
date: string; // YYYY-MM-DD
year: number;
month: number;
day: number;
isCurrentMonth: boolean;
hasJournal: boolean;
isToday: boolean;
}
interface MonthInfo {
year: number;
month: number;
days: DayInfo[];
}
interface CalendarData {
currentYear: number;
months: MonthInfo[];
scrollIntoView: string; // 滚动到的元素 id
}
Component({
properties: {
// 日记数据key 为日期 YYYY-MM-DDvalue 为日记 id 数组
journalMap: {
type: Object,
value: {}
}
},
data: <CalendarData>{
currentYear: 0,
months: [],
scrollIntoView: ''
},
lifetimes: {
attached() {
this.initCalendar();
}
},
observers: {
'journalMap.**'(journalMap: Record<string, number[]>) {
// 日记数据更新时重新生成所有月份的日期
if (this.data.months.length > 0) {
this.updateAllMonthsDays(journalMap);
}
}
},
methods: {
/** 初始化日历 */
initCalendar() {
const now = new Date();
const currentYear = now.getFullYear();
const currentMonth = now.getMonth() + 1;
this.setData({
currentYear,
months: this.generateYearMonths(currentYear)
});
// 滚动到当前月份
this.scrollToMonth(currentMonth);
},
/** 生成指定年份的 12 个月 */
generateYearMonths(year: number): MonthInfo[] {
const months: MonthInfo[] = [];
const today = new Date();
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
for (let month = 1; month <= 12; month++) {
months.push({
year,
month,
days: this.generateDaysForMonth(year, month, this.data.journalMap || {}, todayStr)
});
}
return months;
},
/** 更新所有月份的日期数据 */
updateAllMonthsDays(journalMap: Record<string, number[]>) {
const { months } = this.data;
const today = new Date();
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
const updatedMonths = months.map(monthInfo => ({
...monthInfo,
days: this.generateDaysForMonth(monthInfo.year, monthInfo.month, journalMap, todayStr)
}));
this.setData({ months: updatedMonths });
},
/** 生成指定月份的日期数据 */
generateDaysForMonth(year: number, month: number, journalMap: Record<string, number[]>, todayStr: string): DayInfo[] {
const days: DayInfo[] = [];
// 当前月第一天
const firstDay = new Date(year, month - 1, 1);
const firstDayWeek = firstDay.getDay();
// 当前月最后一天
const lastDay = new Date(year, month, 0);
const lastDate = lastDay.getDate();
// 上个月需要显示的日期
const prevMonthLastDay = new Date(year, month - 1, 0);
const prevMonthLastDate = prevMonthLastDay.getDate();
for (let i = firstDayWeek - 1; i >= 0; i--) {
const day = prevMonthLastDate - i;
const prevMonth = month === 1 ? 12 : month - 1;
const prevYear = month === 1 ? year - 1 : year;
const dateKey = `${prevYear}-${String(prevMonth).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
days.push({
date: dateKey,
year: prevYear,
month: prevMonth,
day,
isCurrentMonth: false,
hasJournal: !!journalMap[dateKey],
isToday: dateKey === todayStr
});
}
// 当前月的日期
for (let day = 1; day <= lastDate; day++) {
const dateKey = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
days.push({
date: dateKey,
year,
month,
day,
isCurrentMonth: true,
hasJournal: !!journalMap[dateKey],
isToday: dateKey === todayStr
});
}
// 下个月需要显示的日期(补齐到 35 个格子6 行)
const remainingDays = 35 - days.length;
for (let day = 1; day <= remainingDays; day++) {
const nextMonth = month === 12 ? 1 : month + 1;
const nextYear = month === 12 ? year + 1 : year;
const dateKey = `${nextYear}-${String(nextMonth).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
days.push({
date: dateKey,
year: nextYear,
month: nextMonth,
day,
isCurrentMonth: false,
hasJournal: !!journalMap[dateKey],
isToday: dateKey === todayStr
});
}
return days;
},
/** 切换年份 */
changeYear(delta: number) {
const newYear = this.data.currentYear + delta;
this.setData({
currentYear: newYear,
months: this.generateYearMonths(newYear)
});
// 滚动到第一个月
this.scrollToMonth(1);
},
/** 上一年 */
prevYear() {
this.changeYear(-1);
},
/** 下一年 */
nextYear() {
this.changeYear(1);
},
/** 滚动到指定月份 */
scrollToMonth(month: number) {
// 先清空,然后设置,触发滚动
this.setData({ scrollIntoView: '' });
wx.nextTick(() => {
this.setData({ scrollIntoView: `month-${month}` });
});
},
/** 点击日期 */
onDayTap(e: WechatMiniprogram.BaseEvent) {
const { date, year, month, day } = e.currentTarget.dataset;
// 触发自定义事件,传递选中的日期信息
this.triggerEvent('dateselect', {
date,
year,
month,
day
});
}
}
});

View File

@ -0,0 +1,46 @@
<!-- components/calendar/index.wxml -->
<view class="calendar">
<!-- 年份切换器 -->
<view class="year">
<t-icon class="btn" name="chevron-left" size="40rpx" catchtap="prevYear" />
<text class="text">{{currentYear}} 年</text>
<t-icon class="btn" name="chevron-right" size="40rpx" catchtap="nextYear" />
</view>
<!-- 星期标题 -->
<view class="weekdays">
<view class="weekday">日</view>
<view class="weekday">一</view>
<view class="weekday">二</view>
<view class="weekday">三</view>
<view class="weekday">四</view>
<view class="weekday">五</view>
<view class="weekday">六</view>
</view>
<!-- 月份列表 -->
<scroll-view class="months" scroll-y enhanced show-scrollbar="{{false}}" scroll-into-view="{{scrollIntoView}}" scroll-with-animation>
<block wx:for="{{months}}" wx:key="month">
<view class="month" id="month-{{item.month}}">
<!-- 月份标题 -->
<view class="title">{{item.month}} 月</view>
<!-- 日期网格 -->
<view class="days">
<block wx:for="{{item.days}}" wx:key="date" wx:for-item="day">
<view
class="day {{day.isCurrentMonth ? '' : 'other-month'}} {{day.hasJournal ? 'has-journal' : ''}} {{day.isToday ? 'today' : ''}}"
catchtap="onDayTap"
data-date="{{day.date}}"
data-year="{{day.year}}"
data-month="{{day.month}}"
data-day="{{day.day}}"
>
<view class="number">{{day.day}}</view>
<view wx:if="{{day.hasJournal}}" class="dot"></view>
</view>
</block>
</view>
</view>
</block>
</scroll-view>
</view>

View File

@ -0,0 +1,7 @@
{
"component": true,
"usingComponents": {
"t-icon": "tdesign-miniprogram/icon/icon",
"t-popup": "tdesign-miniprogram/popup/popup"
}
}

View File

@ -0,0 +1,179 @@
/* components/journal-detail-panel/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;
flex-direction: column;
.title {
display: flex;
font-size: 32rpx;
font-weight: 600;
align-items: center;
.icon {
color: var(--theme-wx);
font-size: 48rpx;
margin-right: 8rpx;
}
.text {
width: calc(100% - 90rpx);
overflow: hidden;
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;
}
}
}
.journals-swiper {
flex: 1;
height: 100%;
.swiper-item-wrapper {
height: 100%;
.journal-item {
height: 100%;
display: flex;
flex-direction: column;
.header {
gap: 16rpx;
display: flex;
padding: 0 32rpx;
flex-shrink: 0;
align-items: center;
margin-bottom: 16rpx;
.portfolio {
color: #FFF;
width: 52rpx;
padding: 4rpx 8rpx;
font-size: 24rpx;
background: var(--theme-wx);
text-align: center;
font-weight: bold;
border-radius: 8rpx;
}
.location {
gap: 8rpx;
display: flex;
font-size: 24rpx;
flex-basis: 100%;
align-items: center;
.icon {
color: var(--theme-wx);
font-size: 32rpx;
}
.text {
font-size: 24rpx;
}
}
.datetime {
font-size: 28rpx;
font-weight: bold;
}
}
.idea {
display: block;
padding: 0 32rpx;
font-size: 28rpx;
line-height: 1.6;
flex-shrink: 0;
margin-bottom: 16rpx;
}
.items-scroll {
flex: 1;
height: 100%;
.items {
gap: 8rpx;
display: flex;
padding: 0 32rpx 128rpx 32rpx;
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;
}
}
}
}
}
}
}
}
}
}
}

View File

@ -0,0 +1,144 @@
// components/journal-detail-panel/index.ts
import { Journal } from "../../types/Journal";
import config from "../../config/index";
import Toolkit from "../../utils/Toolkit";
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[];
currentJournalIndex: number;
}
Component({
properties: {
visible: {
type: Boolean,
value: false
},
ids: {
type: Array,
value: []
},
mode: {
type: String,
value: "DATE"
}
},
data: <JournalDetailPanelData>{
journals: [],
currentJournalIndex: 0,
},
observers: {
async 'ids, visible'(ids: number[], visible: boolean) {
if (visible && ids && 0 < ids.length) {
wx.showLoading({ title: "加载中...", mask: true });
try {
const journals = await JournalApi.getListByIds(ids);
journals.forEach(journal => {
journal.date = Time.toPassedDate(journal.createdAt);
journal.time = Time.toTime(journal.createdAt);
journal.datetime = Time.toPassedDateTime(journal.createdAt);
const thumbItems = journal.items?.filter((item) => item.attachType === MediaAttachType.THUMB);
if (!thumbItems) {
return;
}
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;
});
journal.mediaItems = mediaItems;
// 为每个 journal 添加分列后的 mediaItems
(journal as any).columnedMediaItems = Toolkit.splitItemsIntoColumns(mediaItems, 3, (item) => {
// 计算相对高度:如果有宽高信息,使用宽高比;否则假设为正方形
if (item.width && item.height && 0 < item.width) {
return item.height / item.width;
}
return 1; // 默认正方形
});
})
this.setData({
journals,
currentJournalIndex: 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({
currentJournalIndex: e.detail.current
});
},
/** 打开位置 */
openLocation(e: WechatMiniprogram.BaseEvent) {
const { journalIndex } = e.currentTarget.dataset;
if (!journalIndex && this.properties.mode !== "LOCATION") {
return;
}
const journals = this.properties.journals as Journal[];
const journal = journals[journalIndex || 0];
if (journal.lat && journal.lng) {
wx.openLocation({
latitude: journal.lat,
longitude: journal.lng,
});
}
},
/** 预览媒体 */
previewMedia(e: WechatMiniprogram.BaseEvent) {
const journals = this.data.journals;
if (!journals || journals.length === 0) {
return;
}
const { itemIndex } = e.currentTarget.dataset;
const journal = journals[this.data.currentJournalIndex];
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) => {
return {
url: item.sourceURL,
type: item.type === MediaItemType.IMAGE ? "image" : "video"
}
}) as any;
wx.previewMedia({
current: newCurrentIndex,
sources
})
}
}
});

View File

@ -0,0 +1,64 @@
<!-- components/journal-detail-panel/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 wx:if="{{mode === 'LOCATION'}}" class="title" catchtap="openLocation">
<t-icon wx:if="{{mode === 'LOCATION'}}" class="icon" name="location-filled" />
<text class="text">{{journals[currentJournalIndex].location}}</text>
</view>
<text wx:if="{{mode === 'DATE'}}" class="title">{{journals[currentJournalIndex].datetime}}</text>
</view>
<view class="actions">
<view wx:if="{{journals.length > 1}}" class="indicator">
{{currentJournalIndex + 1}}/{{journals.length}}
</view>
<t-icon name="close" catchtap="closeDetail" size="48rpx" />
</view>
</view>
<swiper class="journals-swiper" current="{{currentJournalIndex}}" bindchange="onSwiperChange">
<block wx:for="{{journals}}" wx:key="id">
<swiper-item class="swiper-item-wrapper">
<view class="journal-item">
<view class="header">
<view wx:if="{{item.type === 'PORTFOLIO'}}" class="portfolio">专拍</view>
<view
wx:if="{{item.location && mode === 'DATE'}}"
class="location"
catchtap="openLocation"
data-journal-index="{{currentJournalIndex}}"
>
<t-icon class="icon" name="location-filled" />
<text class="text">{{item.location}}</text>
</view>
<view wx:if="{{mode === 'LOCATION'}}" class="datetime">{{item.datetime}}</view>
</view>
<view wx:if="{{item.idea}}" class="idea">{{item.idea}}</view>
<scroll-view wx:if="{{item.columnedMediaItems && item.columnedMediaItems.length > 0}}" scroll-y class="items-scroll">
<view class="items">
<view wx:for="{{item.columnedMediaItems}}" 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>
</scroll-view>
</view>
</swiper-item>
</block>
</swiper>
</view>
</view>
</t-popup>

View File

@ -0,0 +1,13 @@
{
"component": true,
"usingComponents": {
"t-cell": "tdesign-miniprogram/cell/cell",
"t-icon": "tdesign-miniprogram/icon/icon",
"t-button": "tdesign-miniprogram/button/button",
"t-loading": "tdesign-miniprogram/loading/loading",
"t-cell-group": "tdesign-miniprogram/cell-group/cell-group",
"t-search": "tdesign-miniprogram/search/search",
"t-empty": "tdesign-miniprogram/empty/empty"
},
"styleIsolation": "shared"
}

View File

@ -0,0 +1,65 @@
/* components/journal-list/index.less */
.journal-list {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
.search-bar {
padding: 8rpx 16rpx;
flex-shrink: 0;
background: var(--td-bg-color-container);
box-shadow: 0 4rpx 8rpx var(--theme-shadow-light);
}
.scroll-content {
flex: 1;
height: 0;
padding: 0;
.t-cell {
padding: 16rpx 24rpx;
.t-cell__left {
display: flex;
align-items: center;
}
}
.item {
&.selected {
background: var(--td-bg-color-secondarycontainer);
}
.t-cell__left {
margin-right: 16rpx;
}
.thumb {
width: 96rpx;
height: 96rpx;
object-fit: cover;
background: var(--td-bg-color-component);
flex-shrink: 0;
border-radius: 8rpx;
}
.date {
color: gray;
}
.description {
font-size: 24rpx;
}
}
.loading,
.finished {
color: var(--td-text-color-placeholder);
padding: 32rpx 0 64rpx 0;
font-size: 24rpx;
text-align: center;
}
}
}

View File

@ -0,0 +1,248 @@
// components/journal-list/index.ts
import config from "../../config/index";
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;
date: string;
location?: string;
idea?: string;
thumbUrl?: string;
}
interface JournalListData {
list: JournalListItem[];
isFetching: boolean;
isFinished: boolean;
page: JournalPage;
searchValue: string;
debouncedSearch?: any;
}
Component({
options: {
styleIsolation: 'apply-shared'
},
properties: {
visible: {
type: Boolean,
value: false
},
type: {
type: String,
value: undefined // NORMAL 或 PORTFOLIO不传则获取所有类型
},
selectedId: {
type: Number,
value: undefined
},
searchable: {
type: Boolean,
value: false // 是否显示搜索框
},
mode: {
type: String,
value: 'select' // 'select' 选择模式 或 'navigate' 跳转模式
}
},
data: <JournalListData>{
list: [],
isFetching: false,
isFinished: false,
page: {
index: 0,
size: 16,
type: JournalPageType.PREVIEW,
orderMap: {
createdAt: OrderType.DESC
}
},
searchValue: "",
debouncedSearch: undefined
},
lifetimes: {
ready() {
// 创建防抖搜索函数
this.setData({
debouncedSearch: Toolkit.debounce(
(keyword: string) => {
this.resetAndSearch(keyword);
},
false, // 不立即执行,等待输入停止
400 // 400ms 延迟
)
})
// 组件加载时就获取数据
this.fetch();
},
detached() {
// 组件销毁时取消防抖
if (this.data.debouncedSearch) {
this.data.debouncedSearch.cancel();
}
}
},
observers: {
'visible': function(visible: boolean) {
// visible 从 false 变为 true 时,如果还没有数据则加载
if (visible && this.data.list.length === 0) {
this.fetch();
}
},
'type': function() {
this.resetPage();
}
},
methods: {
/** 重置搜索页面 */
resetPage() {
const likeExample = this.data.searchValue ? {
idea: this.data.searchValue,
location: this.data.searchValue
} : undefined;
const equalsExample = this.properties.type ? {
type: this.properties.type
} : undefined;
this.setData({
page: {
index: 0,
size: 16,
type: JournalPageType.PREVIEW,
equalsExample,
likeExample,
orderMap: {
createdAt: OrderType.DESC
}
},
list: [],
isFinished: false
});
},
/** 获取数据 */
async fetch() {
if (this.data.isFetching || this.data.isFinished) {
return;
}
this.setData({ isFetching: true });
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;
}
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.data.debouncedSearch) {
this.data.debouncedSearch(value);
}
},
/** 提交搜索 */
onSearchSubmit(e: WechatMiniprogram.CustomEvent) {
const value = e.detail.value.trim();
// 立即搜索,取消防抖
if (this.data.debouncedSearch) {
this.data.debouncedSearch.cancel();
}
this.resetAndSearch(value);
},
/** 清空搜索 */
onSearchClear() {
// 取消防抖,立即搜索
if (this.data.debouncedSearch) {
this.data.debouncedSearch.cancel();
}
this.resetAndSearch("");
},
/** 保留搜索关键字重新搜索 */
reSearch() {
this.resetAndSearch(this.data.searchValue);
},
/** 重置配置重新搜索 */
resetAndSearch(keyword: string) {
const likeExample = keyword ? {
idea: keyword,
location: keyword
} : undefined;
const equalsExample = this.properties.type ? {
type: this.properties.type
} : undefined;
this.setData({
searchValue: keyword,
list: [],
page: {
index: 0,
size: 16,
type: JournalPageType.PREVIEW,
equalsExample,
likeExample,
orderMap: {
createdAt: OrderType.DESC
}
},
isFetching: false,
isFinished: false
}, () => {
// 无论是否有搜索词,都重新获取列表
this.fetch();
});
},
onSelectItem(e: WechatMiniprogram.BaseEvent) {
const { id } = e.currentTarget.dataset;
if (this.properties.mode === 'navigate') {
// 跳转模式:触发 navigate 事件
this.triggerEvent('navigate', { id });
} else {
// 选择模式:触发 select 事件
this.triggerEvent('select', { id });
}
},
onScrollToLower() {
this.fetch();
}
}
});

View File

@ -0,0 +1,79 @@
<!--components/journal-list/index.wxml-->
<view class="journal-list">
<view wx:if="{{searchable}}" class="search-bar">
<t-search
value="{{searchValue}}"
placeholder="搜索日记内容或地点"
bind:change="onSearchChange"
bind:submit="onSearchSubmit"
bind:clear="onSearchClear"
/>
</view>
<scroll-view
class="scroll-content"
scroll-y
bindscrolltolower="onScrollToLower"
>
<block wx:if="{{list.length > 0}}">
<t-cell-group>
<t-cell
class="item {{selectedId === item.id ? 'selected' : ''}}"
wx:for="{{list}}"
wx:key="id"
hover
bind:tap="onSelectItem"
data-id="{{item.id}}"
>
<image
slot="left-icon"
wx:if="{{item.thumbUrl}}"
class="thumb"
src="{{item.thumbUrl}}"
mode="aspectFill"
/>
<t-icon wx:else slot="left-icon" class="icon" name="image" color="gray" size="96rpx" />
<view wx:if="{{item.idea}}" slot="title">
<text>{{item.idea}}</text>
</view>
<view class="description" slot="description">
<text>{{item.date}}</text>
<text wx:if="{{item.location}}"> · {{item.location}}</text>
</view>
<t-icon
wx:if="{{mode === 'select' && selectedId === item.id}}"
slot="right-icon"
class="check"
name="check"
size="48rpx"
color="var(--theme-wx)"
/>
<t-icon
wx:elif="{{mode === 'navigate'}}"
slot="right-icon"
name="chevron-right"
/>
</t-cell>
</t-cell-group>
<view wx:if="{{isFetching}}" class="loading">
<t-loading theme="circular" size="40rpx" text="加载中..." />
</view>
<view wx:elif="{{isFinished}}" class="finished">
{{searchable && searchValue ? '已显示全部结果' : '没有更多了'}}
</view>
</block>
<block wx:elif="{{isFetching}}">
<view class="loading">
<t-loading theme="circular" size="40rpx" text="加载中..." />
</view>
</block>
<block wx:elif="{{searchable && searchValue}}">
<t-empty icon="search" description="未找到相关日记" />
</block>
<block wx:else>
<t-empty icon="image" description="暂无记录" />
</block>
</scroll-view>
</view>

View File

@ -1,4 +0,0 @@
{
"component": true,
"usingComponents": {}
}

View File

@ -1 +0,0 @@
/* components/search-list/index.wxss */

View File

@ -1,24 +0,0 @@
// components/search-list/index.ts
Component({
/**
* 组件的属性列表
*/
properties: {
},
/**
* 组件的初始数据
*/
data: {
},
/**
* 组件的方法列表
*/
methods: {
}
})

View File

@ -1,2 +0,0 @@
<!--components/search-list/index.wxml-->
<text>components/search-list/index.wxml</text>

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: "https://api.imyeyu.com"
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

@ -9,7 +9,7 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"tdesign-miniprogram": "^1.11.2"
"tdesign-miniprogram": "^1.12.0"
},
"devDependencies": {
"miniprogram-api-typings": "^4.1.0"
@ -22,9 +22,9 @@
"dev": true
},
"node_modules/tdesign-miniprogram": {
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/tdesign-miniprogram/-/tdesign-miniprogram-1.11.2.tgz",
"integrity": "sha512-lXcry3vRa9jHzjpOdIxuIAh7F85kImym82VwLbCqr/TkMhycOsOepx+r1S9fum7u2nsWiYRTV+HuvDHN3KlIuA=="
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/tdesign-miniprogram/-/tdesign-miniprogram-1.12.0.tgz",
"integrity": "sha512-Ft+B1HWMOKuOpM9+Z0mflprWrxSB/ESo6TVymjxJ6xzMgSfEcbmFaXpd0nJ+Oj/5GCljqP06ZTeWazuk1G6Ugg=="
}
}
}

View File

@ -10,7 +10,7 @@
"author": "",
"license": "ISC",
"dependencies": {
"tdesign-miniprogram": "^1.11.2"
"tdesign-miniprogram": "^1.12.0"
},
"devDependencies": {
"miniprogram-api-typings": "^4.1.0"

View File

@ -1,6 +1,6 @@
// index.ts
import config from "../../config/index"
import { JournalPageType } from "../../types/Journal";
import { JournalApi } from "../../api/JournalApi";
interface IndexData {
key: string;
@ -18,29 +18,23 @@ Page({
});
}
},
navigateToMain() {
wx.request({
url: `${config.url}/journal/list`,
method: "POST",
header: {
Key: this.data.key
},
data: {
async navigateToMain() {
try {
wx.setStorageSync("key", this.data.key);
await JournalApi.getList({
index: 0,
size: 1
},
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 {
wx.showToast({ title: "密码错误", icon: "error" });
}
},
fail: () => wx.showToast({ title: "验证失败", icon: "error" })
});
size: 1,
type: JournalPageType.PREVIEW
});
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.2.2</text>
<text>1.6.6</text>
</view>
<view class="item copyright">
<text>{{copyright}}</text>

View File

@ -1,7 +0,0 @@
{
"component": true,
"usingComponents": {
"t-button": "tdesign-miniprogram/button/button",
"t-navbar": "tdesign-miniprogram/navbar/navbar"
}
}

View File

@ -1,101 +0,0 @@
/* pages/main/journal-creater/index.wxss */
.container {
height: 100vh;
.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;
&.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;
margin-top: 1rem;
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;
.thumbnail {
height: 200rpx;
display: block;
}
.video-container {
position: relative;
.play-icon {
top: 50%;
left: 50%;
width: 60rpx;
height: 60rpx;
z-index: 2;
position: absolute;
transform: translate(-50%, -50%);
pointer-events: none;
}
}
.delete {
top: 10rpx;
right: 10rpx;
width: 40rpx;
height: 40rpx;
z-index: 3;
padding: 5rpx;
position: absolute;
background: rgba(0, 0, 0, 0.5);
border-radius: 50%;
}
}
}
}
}
.progress {
width: 100%;
}
.submit {
width: 10rem;
margin-top: 1rem;
}
}
}

View File

@ -1,306 +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";
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;
mediaList: MediaItem[];
location?: Location;
qqMapSDK?: any;
isAuthLocation: boolean;
}
Page({
data: <JournalEditorData>{
idea: "",
date: "2025-06-28",
time: "16:00",
mediaList: [],
location: undefined,
submitText: "提交",
isSubmitting: false,
submitProgress: 0,
mediaItemTypeEnum: {
...MediaItemType
},
isAuthLocation: false
},
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
}
});
}
}
});
});
},
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) {
wx.previewMedia({
current: e.currentTarget.dataset.index,
sources: this.data.mediaList.map(item => {
return {
url: item.path,
type: MediaItemType[item.type].toLowerCase()
} as WechatMiniprogram.MediaSource;
})
});
},
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" });
this.setData({
submitText: "提交",
isSubmitting: false
})
};
this.setData({
submitText: "正在提交..",
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 || [];
const total = mediaList.length;
let completed = 0;
if (total === 0) {
resolve([]);
return;
}
// 更新进度初始状态
this.setData({
submitProgress: 0,
});
const uploadPromises = mediaList.map((item) => {
return new Promise<string>((uploadResolve, uploadReject) => {
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) {
completed++;
// 更新进度
this.setData({
submitProgress: (completed / total),
});
uploadResolve(result.data[0].id);
} else {
uploadReject(new Error(`文件上传失败: ${result?.message || '未知错误'}`));
}
},
fail: (err) => uploadReject(new Error(`文件上传失败: ${err.errMsg}`))
});
});
});
// 并行执行所有文件上传
Promise.all(uploadPromises).then((tempFileIds) => {
this.setData({
submitProgress: 1,
});
resolve(tempFileIds);
}).catch(reject);
});
// 并行执行获取 openId 和文件上传
Promise.all([getOpenId, uploadFiles]).then(([openId, tempFileIds]) => {
wx.request({
url: `${config.url}/journal/create`,
method: "POST",
header: {
Key: wx.getStorageSync("key")
},
data: {
type: "NORMAL",
idea: this.data.idea,
createdAt: Date.parse(`${this.data.date} ${this.data.time}`),
lat: this.data.location?.lat,
lng: this.data.location?.lng,
location: this.data.location?.text,
pusher: openId,
tempFileIds
},
success: async (resp: any) => {
Events.emit("JOURNAL_REFRESH");
wx.showToast({ title: "提交成功", icon: "success" });
this.setData({
idea: "",
mediaList: [],
submitText: "提交",
isSubmitting: false,
});
await Toolkit.sleep(1000);
wx.switchTab({
url: "/pages/main/journal/index",
})
},
fail: handleFail
});
}).catch(handleFail);
}
});

View File

@ -1,104 +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 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="ctrl">
<t-button
class="select"
theme="primary"
plain="true"
disabled="{{isSubmitting}}"
bind:tap="addMedia"
>选择照片/视频</t-button>
<t-button
class="clear"
theme="danger"
variant="outline"
disabled="{{isSubmitting}}"
bind:tap="clearMedia"
disabled="{{mediaList.length === 0}}"
>清空已选</t-button>
</view>
<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>
<image class="play-icon" src="/assets/icon/play.png"></image>
</view>
<!-- 删除 -->
<image
src="/assets/icon/delete.png"
class="delete"
bindtap="deleteMedia"
data-index="{{index}}"
></image>
</view>
</block>
</view>
</view>
<progress
wx:if="{{isSubmitting}}"
class="progress"
percent="{{submitProgress.toFixed(2) * 100}}"
show-info
stroke-width="4"
/>
<t-button
class="submit"
theme="primary"
bind:tap="submit"
disabled="{{(!idea && mediaList.length === 0) || isSubmitting}}"
>{{submitText}}</t-button>
</view>
</scroll-view>

View File

@ -0,0 +1,8 @@
{
"usingComponents": {
"calendar": "/components/calendar/index",
"t-navbar": "tdesign-miniprogram/navbar/navbar",
"journal-detail-popup": "/components/journal-detail-popup/index"
},
"navigationStyle": "custom"
}

View File

@ -0,0 +1,24 @@
/* pages/main/journal-date/index.less */
.container {
width: 100%;
height: 100vh;
position: fixed;
overflow: hidden;
background: var(--theme-bg);
.loading {
top: 50%;
left: 50%;
z-index: 1000;
position: fixed;
transform: translate(-50%, -50%);
.loading-text {
color: var(--theme-text-secondary);
padding: 24rpx 48rpx;
background: var(--theme-bg-card);
border-radius: 8rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, .15);
}
}
}

View File

@ -0,0 +1,90 @@
// pages/main/journal-date/index.ts
import { Journal, JournalPageType } from "../../../types/Journal";
import Time from "../../../utils/Time";
import { JournalApi } from "../../../api/JournalApi";
interface JournalDateData {
// 存储每个日期的日记 id 列表
isLoading: boolean;
journalMap: Record<string, number[]>;
popupIds: number[];
popupVisible: boolean;
}
Page({
data: <JournalDateData>{
isLoading: true,
journalMap: {},
popupIds: [],
popupVisible: false,
},
async onLoad() {
await this.loadJournals();
},
/** 加载所有日记 */
async loadJournals() {
this.setData({ isLoading: true });
try {
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) => {
const dateKey = Time.toDate(journal.createdAt);
if (!journalMap[dateKey]) {
journalMap[dateKey] = [];
}
journalMap[dateKey].push(journal.id);
});
// 按 id 倒序排序每天的日记
Object.keys(journalMap).forEach(dateKey => {
journalMap[dateKey].sort((a, b) => b - a);
});
this.setData({
journalMap,
isLoading: false
});
} catch (err: any) {
wx.showToast({
title: err.message || "加载失败",
icon: "error"
});
this.setData({ isLoading: false });
}
},
/** 日期选择事件(来自 calendar 组件) */
onDateSelect(e: WechatMiniprogram.CustomEvent) {
const { date } = e.detail;
const journalIds = this.data.journalMap[date];
if (!journalIds || journalIds.length === 0) {
wx.showToast({
title: "该日期无日记",
icon: "none"
});
return;
}
// 调用接口获取详情
this.loadJournalsByIds(journalIds);
},
/** 根据 id 列表加载日记详情 */
async loadJournalsByIds(ids: number[]) {
this.setData({
popupIds: ids,
popupVisible: true
});
wx.hideLoading();
},
/** 关闭详情 */
closeDetail() {
this.setData({
popupVisible: false,
popupIds: []
});
}
});

View File

@ -0,0 +1,17 @@
<!--pages/main/journal-date/index.wxml-->
<t-navbar title="日期查找" left-arrow />
<view class="container">
<!-- 日历视图 -->
<calendar journal-map="{{journalMap}}" bind:dateselect="onDateSelect" />
<!-- 加载状态 -->
<view wx:if="{{isLoading}}" class="loading">
<view class="loading-text">加载中...</view>
</view>
</view>
<!-- 详情面板 -->
<journal-detail-popup
visible="{{popupVisible}}"
ids="{{popupIds}}"
mode="DATE"
bind:close="closeDetail"
/>

View File

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

View File

@ -0,0 +1,172 @@
/* pages/main/journal-editor/index.wxss */
.container {
.content {
width: calc(100% - 64px);
padding: 0 32px 32px 32px;
display: flex;
align-items: center;
flex-direction: column;
.loading {
padding: 64px 0;
text-align: center;
color: var(--theme-text-secondary);
}
.label {
color: var(--theme-text-secondary);
}
.section {
width: 100%;
margin-top: 1.5rem;
&.type {
display: flex;
.radio {
background: transparent;
margin-right: 1em;
}
}
&.time {
display: flex;
.picker {
margin-right: .25rem;
}
}
&.media {
.gallery {
gap: 10rpx;
display: grid;
grid-template-columns: repeat(3, 1fr);
.item {
width: 200rpx;
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;
&::after {
border-radius: 0;
}
}
.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%;
}
}
.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);
}
}
}
}
}
.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,
.delete {
width: 200rpx;
}
.submit,
.save {
flex: 1;
margin-left: 12rpx;
}
}
}
}
.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,606 @@
// pages/main/journal-editor/index.ts
import Events from "../../../utils/Events";
import Time from "../../../utils/Time";
import Toolkit from "../../../utils/Toolkit";
import config from "../../../config/index";
import { Location, MediaItem, MediaItemType, WechatMediaItem } from "../../../types/UI";
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 | 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",
time: "16:00",
type: JournalType.NORMAL,
mediaList: [],
newMediaList: [],
location: undefined,
isSaving: false,
isLoading: false,
uploaded: "0",
uploadTotal: "0 MB",
uploadSpeed: "0 MB / s",
uploadProgress: 0,
mediaItemTypeEnum: {
...MediaItemType
},
isAuthLocation: false,
deleteDialogVisible: false,
deleteConfirmText: ""
},
async onLoad(options: any) {
// 授权定位
const setting = await wx.getSetting();
wx.setStorageSync("isAuthLocation", setting.authSetting["scope.userLocation"] || false);
let isAuthLocation = JSON.parse(wx.getStorageSync("isAuthLocation"));
this.setData({ isAuthLocation });
if (!isAuthLocation) {
wx.authorize({
scope: "scope.userLocation"
}).then(() => {
isAuthLocation = true;
this.setData({ isAuthLocation });
});
}
// 判断模式:有 ID 是编辑,无 ID 是创建
const id = options.id ? parseInt(options.id) : undefined;
if (id) {
// 编辑模式
this.setData({
mode: "edit",
id,
isLoading: true
});
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();
}
},
/** 获取默认定位(创建模式) */
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 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;
});
this.setData({
idea: journal.idea || "",
date: Time.toDate(journal.createdAt),
time: Time.toTime(journal.createdAt),
type: journal.type,
location: journal.location ? {
lat: journal.lat,
lng: journal.lng,
text: journal.location
} : undefined,
mediaList,
isLoading: false
});
wx.hideLoading();
} catch (err: any) {
wx.hideLoading();
wx.showToast({
title: err.message || "加载失败",
icon: "error"
});
setTimeout(() => {
wx.navigateBack();
}, 1500);
}
},
/** 改变类型 */
onChangeType(e: any) {
const { value } = e.detail;
this.setData({ type: value });
},
/** 选择位置 */
async chooseLocation() {
try {
const location = await wx.chooseLocation({});
this.setData({
location: {
lat: location.latitude,
lng: location.longitude,
text: location.name
}
});
} catch (err) {
// 用户取消选择
}
},
/** 新增附件 */
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") {
// 创建模式:直接添加到 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;
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
}));
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 (this.data.mode === "create") {
// 创建模式:从 mediaList 删除
const mediaList = [...this.data.mediaList];
mediaList.splice(index, 1);
this.setData({ mediaList });
} else {
// 编辑模式:根据标识删除
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() {
if (this.data.mode === "create") {
wx.switchTab({
url: "/pages/main/journal/index"
});
} else {
wx.navigateBack();
}
},
/** 删除记录(仅编辑模式) */
deleteJournal() {
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() {
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);
}
},
/** 提交/保存 */
submit() {
if (this.data.mode === "create") {
this.createJournal();
} else {
this.updateJournal();
}
},
/** 创建日记 */
async createJournal() {
const handleFail = () => {
wx.showToast({ title: "上传失败", icon: "error" });
this.setData({
isSaving: false
});
};
this.setData({
isSaving: true
});
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]);
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 = mediaList.map(item => {
return new Promise<number>((sizeResolve, sizeReject) => {
wx.getFileSystemManager().getFileInfo({
filePath: item.path,
success: (res) => sizeResolve(res.size),
fail: (err) => sizeReject(err)
});
});
});
Promise.all(sizePromises).then(fileSizes => {
const totalSize = fileSizes.reduce((acc, size) => acc + size, 0);
const uploadTasks: WechatMiniprogram.UploadTask[] = [];
let uploadedSize = 0;
let lastUploadedSize = 0;
this.setData({
uploadTotal: IOSize.format(totalSize, 2, Unit.MB)
});
// 计算上传速度
const speedUpdateInterval = setInterval(() => {
const chunkSize = uploadedSize - lastUploadedSize;
this.setData({
uploadSpeed: `${IOSize.format(chunkSize)} / s`
});
lastUploadedSize = uploadedSize;
}, 1000);
// 上传文件
const uploadPromises = mediaList.map(item => {
return new Promise<string>((uploadResolve, uploadReject) => {
const task = wx.uploadFile({
url: `${config.url}/temp/file/upload`,
filePath: item.path,
name: "file",
success: (resp) => {
const result = JSON.parse(resp.data);
if (result && result.code === 20000) {
uploadResolve(result.data[0].id);
} else {
uploadReject(new Error(`文件上传失败: ${result?.message || "未知错误"}`));
}
},
fail: (err) => uploadReject(new Error(`文件上传失败: ${err.errMsg}`))
});
// 监听上传进度
let prevProgress = 0;
task.onProgressUpdate((res) => {
const fileUploaded = (res.totalBytesExpectedToSend * res.progress) / 100;
const delta = fileUploaded - prevProgress;
uploadedSize += delta;
prevProgress = fileUploaded;
// 更新进度条
this.setData({
uploaded: IOSize.formatWithoutUnit(uploadedSize, 2, Unit.MB),
uploadProgress: Math.round((uploadedSize / totalSize) * 10000) / 100
});
});
uploadTasks.push(task);
});
});
Promise.all(uploadPromises).then((tempFileIds) => {
// 清除定时器
clearInterval(speedUpdateInterval);
uploadTasks.forEach(task => task.offProgressUpdate());
this.setData({
uploadProgress: 100,
uploadSpeed: "0 MB / s"
});
resolve(tempFileIds);
}).catch((e: Error) => {
// 取消所有上传任务
uploadTasks.forEach(task => task.abort());
clearInterval(speedUpdateInterval);
reject(e);
});
}).catch(reject);
});
}
});

View File

@ -0,0 +1,243 @@
<!--pages/main/journal-editor/index.wxml-->
<t-navbar title="{{mode === 'create' ? '新纪录' : '编辑记录'}}">
<text slot="left" bindtap="cancel">取消</text>
</t-navbar>
<scroll-view
class="container"
type="custom"
scroll-y
show-scrollbar="{{false}}"
scroll-into-view="{{intoView}}"
>
<view class="content">
<view wx:if="{{isLoading}}" class="loading">
<text>加载中...</text>
</view>
<block wx:else>
<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="{{type}}" 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">
<!-- 创建模式mediaList 显示新选择的媒体 -->
<block wx:if="{{mode === 'create'}}">
<block wx:for="{{mediaList}}" wx:key="index">
<view class="item">
<!-- 图片 -->
<image
wx:if="{{item.type === mediaItemTypeEnum.IMAGE}}"
src="{{item.path}}"
class="thumbnail"
mode="aspectFill"
bindtap="preview"
data-index="{{index}}"
data-new-media="{{true}}"
></image>
<!-- 视频 -->
<view wx:if="{{item.type === mediaItemTypeEnum.VIDEO}}" class="video-container">
<image
src="{{item.thumbPath}}"
class="thumbnail"
mode="aspectFill"
bindtap="preview"
data-index="{{index}}"
data-new-media="{{true}}"
></image>
<t-icon class="play-icon" name="play" />
</view>
<!-- 删除 -->
<t-icon
class="delete"
name="close"
bindtap="deleteMedia"
data-index="{{index}}"
data-new-media="{{true}}"
/>
</view>
</block>
</block>
<!-- 编辑模式mediaList 显示现有附件newMediaList 显示新添加的附件 -->
<block wx:else>
<!-- 现有附件 -->
<block wx:for="{{mediaList}}" wx:key="attachmentId">
<view class="item">
<!-- 图片 -->
<image
wx:if="{{item.type === mediaItemTypeEnum.IMAGE}}"
src="{{item.thumbURL}}"
class="thumbnail"
mode="aspectFill"
bindtap="preview"
data-index="{{index}}"
data-new-media="{{false}}"
></image>
<!-- 视频 -->
<view wx:if="{{item.type === mediaItemTypeEnum.VIDEO}}" class="video-container">
<image
src="{{item.thumbURL}}"
class="thumbnail"
mode="aspectFill"
bindtap="preview"
data-index="{{index}}"
data-new-media="{{false}}"
></image>
<t-icon class="play-icon" name="play" />
</view>
<!-- 删除 -->
<t-icon
class="delete"
name="close"
bindtap="deleteMedia"
data-index="{{index}}"
data-new-media="{{false}}"
/>
</view>
</block>
<!-- 新选择附件 -->
<block wx:for="{{newMediaList}}" wx:key="index">
<view class="item new-item">
<!-- 图片 -->
<image
wx:if="{{item.type === mediaItemTypeEnum.IMAGE}}"
src="{{item.path}}"
class="thumbnail"
mode="aspectFill"
bindtap="preview"
data-index="{{index}}"
data-new-media="{{true}}"
></image>
<!-- 视频 -->
<view wx:if="{{item.type === mediaItemTypeEnum.VIDEO}}" class="video-container">
<image
src="{{item.thumbPath}}"
class="thumbnail"
mode="aspectFill"
bindtap="preview"
data-index="{{index}}"
data-new-media="{{true}}"
></image>
<t-icon class="play-icon" name="play" />
</view>
<!-- 新增标识 -->
<t-icon class="new-badge" name="add" />
<!-- 删除 -->
<t-icon
class="delete"
name="close"
bindtap="deleteMedia"
data-index="{{index}}"
data-new-media="{{true}}"
/>
</view>
</block>
</block>
<!-- 添加按钮 -->
<t-button
class="item add"
theme="primary"
plain="true"
disabled="{{isSaving}}"
bind:tap="addMedia"
>
<t-icon name="add" />
</t-button>
</view>
</view>
<view wx:if="{{isSaving}}" 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">
<!-- 创建模式 -->
<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'} }}"
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 model:value="{{deleteConfirmText}}" placeholder="请输入:确认删除" />
</view>
</t-dialog>

View File

@ -1,3 +0,0 @@
{
"usingComponents": {}
}

View File

@ -1 +0,0 @@
/* pages/main/journal-list/index.wxss */

View File

@ -1,66 +0,0 @@
// pages/main/journal-list/index.ts
Page({
/**
* 页面的初始数据
*/
data: {
},
/**
* 生命周期函数--监听页面加载
*/
onLoad() {
},
/**
* 生命周期函数--监听页面初次渲染完成
*/
onReady() {
},
/**
* 生命周期函数--监听页面显示
*/
onShow() {
},
/**
* 生命周期函数--监听页面隐藏
*/
onHide() {
},
/**
* 生命周期函数--监听页面卸载
*/
onUnload() {
},
/**
* 页面相关事件处理函数--监听用户下拉动作
*/
onPullDownRefresh() {
},
/**
* 页面上拉触底事件的处理函数
*/
onReachBottom() {
},
/**
* 用户点击右上角分享
*/
onShareAppMessage() {
}
})

View File

@ -1,2 +0,0 @@
<!--pages/main/journal-list/index.wxml-->
<text>pages/main/journal-list/index.wxml</text>

View File

@ -0,0 +1,7 @@
{
"usingComponents": {
"t-navbar": "tdesign-miniprogram/navbar/navbar",
"journal-detail-popup": "/components/journal-detail-popup/index"
},
"navigationStyle": "custom"
}

View File

@ -0,0 +1,100 @@
/* pages/main/journal-map/index.less */
.container {
width: 100%;
height: 100vh;
position: fixed;
overflow: hidden;
.map {
width: 100%;
height: 100%;
.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;
padding: 8rpx 16rpx 8rpx 8rpx;
align-items: flex-start;
.thumb {
width: 72rpx;
height: 72rpx;
position: relative;
flex-shrink: 0;
margin-right: 12rpx;
overflow: hidden;
border-radius: 6rpx;
.img {
width: 100%;
height: 100%;
}
.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;
}
}
}
}
}
}
.loading {
top: 50%;
left: 50%;
z-index: 1000;
position: fixed;
transform: translate(-50%, -50%);
.loading-text {
color: #666;
padding: 24rpx 48rpx;
background: #FFF;
border-radius: 8rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, .15);
}
}
}

View File

@ -0,0 +1,170 @@
// pages/main/journal-map/index.ts
import config from "../../../config/index";
import Time from "../../../utils/Time";
import { JournalPageType } from "../../../types/Journal";
import Toolkit from "../../../utils/Toolkit";
import { MapMarker } from "../../../types/UI";
import { JournalApi } from "../../../api/JournalApi";
interface LocationMarker {
locationKey: string; // 位置键 "lat,lng"
lat: number;
lng: number;
location?: string;
journalIds: number[]; // 该位置的所有日记 ID
count: number; // 日记数量
date: string;
previewThumb?: string; // 预览缩略图
}
interface JournalMapData {
centerLat: number;
centerLng: number;
scale: number;
markers: MapMarker[];
locations: LocationMarker[]; // 位置标记列表
customCalloutMarkerIds: string[]; // 改为 string[] 以支持 locationKey
includePoints: Array<{ latitude: number; longitude: number }>; // 缩放视野以包含所有点
popupIds: number[];
popupVisible: boolean;
isLoading: boolean;
}
Page({
data: <JournalMapData>{
centerLat: 39.908823,
centerLng: 116.397470,
scale: 13,
markers: [],
locations: [],
customCalloutMarkerIds: [],
includePoints: [],
selectedLocation: null,
popupIds: [],
popupVisible: false,
isLoading: true,
},
async onLoad() {
await this.loadJournals();
},
/** 加载所有记录 */
async loadJournals() {
this.setData({ isLoading: true });
try {
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) => {
// 保留 6 位小数作为位置键,约等于 0.1 米精度
const lat = Number(journal.lat.toFixed(6));
const lng = Number(journal.lng.toFixed(6));
const locationKey = `${lat},${lng}`;
if (!locationMap.has(locationKey)) {
const thumbItem = journal.items.find((item: any) => item.attachType === "THUMB");
locationMap.set(locationKey, {
locationKey,
lat,
lng,
location: journal.location,
journalIds: [],
count: 0,
date: Time.toPassedDate(journal.createdAt),
previewThumb: thumbItem ? `${config.url}/attachment/read/${thumbItem.mongoId}` : undefined
});
}
const marker = locationMap.get(locationKey)!;
marker.journalIds.push(journal.id);
marker.count++;
// 如果还没有预览图,尝试从当前日记获取
if (!marker.previewThumb) {
const thumbItem = journal.items.find((item: any) => item.attachType === "THUMB");
if (thumbItem) {
marker.previewThumb = `${config.url}/attachment/read/${thumbItem.mongoId}`;
}
}
});
const locations = Array.from(locationMap.values());
if (locations.length === 0) {
wx.showToast({
title: "暂无位置记录",
icon: "none"
});
this.setData({ isLoading: false });
return;
}
// 生成地图标记
const markers: MapMarker[] = locations.map((location, index) => ({
id: index,
latitude: location.lat,
longitude: location.lng,
width: 24,
height: 30,
customCallout: {
anchorY: -2,
// 随机错位避免近距离重叠
anchorX: Toolkit.random(-10, 10),
display: "ALWAYS"
}
}));
// 所有标记的 locationKey 列表
const customCalloutMarkerIds = locations.map(l => l.locationKey);
// 计算中心点(所有标记的平均位置)
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,
customCalloutMarkerIds,
centerLat,
centerLng,
includePoints,
isLoading: false
});
} catch (err: any) {
wx.showToast({
title: err.message || "加载失败",
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);
},
/** 加载位置详情(该位置的所有日记) */
async loadLocationDetail(markerId: number) {
const location = this.data.locations[markerId];
if (!location) {
return;
}
this.setData({
popupIds: location.journalIds,
popupVisible: true
});
},
/** 关闭详情 */
async closeDetail() {
this.setData({
popupVisible: false,
selectedLocation: null
});
},
});

View File

@ -0,0 +1,50 @@
<!--pages/main/journal-map/index.wxml-->
<t-navbar title="地图查找" left-arrow />
<view class="container">
<map
id="journal-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="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>
</cover-view>
</cover-view>
</map>
<view wx:if="{{isLoading}}" class="loading">
<view class="loading-text">加载中...</view>
</view>
</view>
<!-- 详情面板 -->
<journal-detail-popup
visible="{{popupVisible}}"
ids="{{popupIds}}"
mode="LOCATION"
bind:close="closeDetail"
/>

View File

@ -0,0 +1,7 @@
{
"usingComponents": {
"t-navbar": "tdesign-miniprogram/navbar/navbar",
"journal-list": "/components/journal-list/index"
},
"navigationBarTitleText": "按列表查找"
}

View File

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

View File

@ -0,0 +1,19 @@
// pages/main/journal-search/index.ts
import Events from "../../../utils/Events";
Page({
onLoad() {
Events.reset("JOURNAL_LIST_REFRESH", () => {
const listRef = this.selectComponent('#listRef');
if (listRef) {
listRef.reSearch();
}
});
},
onNavigateItem(e: WechatMiniprogram.CustomEvent) {
const { id } = e.detail;
wx.navigateTo({
url: `/pages/main/journal-editor/index?id=${id}`
});
}
});

View File

@ -0,0 +1,13 @@
<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,7 +5,6 @@
"t-icon": "tdesign-miniprogram/icon/icon",
"t-navbar": "tdesign-miniprogram/navbar/navbar",
"t-indexes": "tdesign-miniprogram/indexes/indexes",
"t-calendar": "tdesign-miniprogram/calendar/calendar",
"t-cell-group": "tdesign-miniprogram/cell-group/cell-group",
"t-indexes-anchor": "tdesign-miniprogram/indexes-anchor/indexes-anchor"
},

View File

@ -1,36 +1,21 @@
.custom-navbar {
.more-menu {
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 999;
position: fixed;
background: var(--theme-bg-overlay);
.more-menu {
top: 0;
left: 0;
width: 100%;
height: 100%;
.content {
z-index: 1000;
position: fixed;
background: var(--theme-bg-overlay);
.content {
margin: 190rpx 0 0 12rpx;
z-index: 1;
position: fixed;
background: var(--theme-bg-menu);
box-shadow: 0 0 12px var(--theme-shadow-medium);
border-radius: 2px;
}
background: var(--theme-bg-menu);
box-shadow: 0 0 12px var(--theme-shadow-medium);
border-radius: 8rpx;
}
}
.calendar {
// .t-calendar__dates-item {
// color: var(--td-text-color-disabled);
// &.t-calendar__dates-item--selected {
// color: var(--td-calendar-title-color);
// background: transparent;
// }
// }
}
.journal-list {
width: 100vw;
@ -46,7 +31,8 @@
font-size: 14px;
}
.text {
> .text {
color: var(--theme-text-primary);
width: calc(100% - 32px - 2rem);
padding: 8px 16px;
margin: .5rem 1rem 1rem 1rem;
@ -75,43 +61,60 @@
}
.location {
color: var(--theme-text-secondary);
text-align: right;
gap: 6rpx;
display: flex;
margin-top: 16rpx;
align-items: center;
justify-content: flex-end;
.icon {
color: var(--theme-wx);
}
.text {
color: var(--theme-text-secondary);
}
}
}
.items {
column-gap: .25rem;
column-count: 3;
gap: .25rem;
display: flex;
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 {
height: auto;
position: relative;
&.thumbnail {
width: 100%;
display: block;
}
&::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;
&.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

@ -3,33 +3,15 @@
import Time from "../../../utils/Time";
import config from "../../../config/index"
import Events from "../../../utils/Events";
export type Journal = {
date: string;
location?: string;
lat?: number;
lng?: number;
idea?: string;
items: JournalItem[]
}
export type JournalItem = {
type: JournalItemType;
mongoId: string;
}
export enum JournalItemType {
IMAGE,
VIDEO
}
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 JournalData {
page: {
index: number;
size: number;
type: string;
orderMap?: object;
}
page: JournalPage;
list: Journal[];
dateFilterMin: number;
dateFilterMax: number;
@ -39,6 +21,8 @@ interface JournalData {
isFinished: boolean;
stickyOffset: number;
isShowMoreMenu: boolean;
menuTop: number;
menuLeft: number;
}
Page({
@ -46,9 +30,12 @@ Page({
page: {
index: 0,
size: 8,
type: "NORMAL",
type: JournalPageType.NORMAL,
likeMap: {
type: "NORMAL"
},
orderMap: {
createdAt: "DESC"
createdAt: OrderType.DESC
}
},
list: [],
@ -63,7 +50,9 @@ Page({
isFetching: false,
isFinished: false,
stickyOffset: 0,
isShowMoreMenu: false
isShowMoreMenu: false,
menuTop: 0,
menuLeft: 0
},
onLoad() {
Events.reset("JOURNAL_REFRESH", () => {
@ -71,9 +60,12 @@ Page({
page: {
index: 0,
size: 8,
type: "NORMAL",
type: JournalPageType.NORMAL,
equalsExample: {
type: "NORMAL"
},
orderMap: {
createdAt: "DESC"
createdAt: OrderType.DESC
}
},
list: [],
@ -86,20 +78,6 @@ Page({
list: []
})
this.fetch();
// 可选日期
wx.request({
url: `${config.url}/journal/list/date?key=${wx.getStorageSync("key")}`,
method: "GET",
success: async (resp: any) => {
const dates = resp.data.data.sort((a: number, b: number) => a - b);
this.setData({
// dateFilterMin: dates[0],
// dateFilterMax: dates[dates.length - 1],
dateFilterAllows: dates,
// dateFilterVisible: this.data.dateFilterVisible
});
}
});
},
onReady() {
this.getCustomNavbarHeight();
@ -121,96 +99,131 @@ Page({
});
},
toggleMoreMenu() {
this.setData({
isShowMoreMenu: !this.data.isShowMoreMenu
if (!this.data.isShowMoreMenu) {
// 打开菜单时计算位置
const query = wx.createSelectorQuery();
query.select(".more").boundingClientRect();
query.exec((res) => {
if (res[0]) {
const { top, left, height } = res[0];
this.setData({
isShowMoreMenu: true,
menuTop: top + height + 16, // 按钮下方 8px
menuLeft: left
});
}
});
} else {
// 关闭菜单
this.setData({
isShowMoreMenu: false
});
}
},
/** 阻止事件冒泡 */
stopPropagation() {
// 空函数,仅用于阻止事件冒泡
},
toCreater() {
wx.navigateTo({
"url": "/pages/main/journal-editor/index?from=journal"
})
},
openDateFilter() {
this.setData({
dateFilterVisible: true
});
toSearch() {
wx.navigateTo({
url: "/pages/main/journal-search/index"
})
},
tapCalendar(e: any) {
console.log(e);
toMap() {
wx.navigateTo({
url: "/pages/main/journal-map/index"
})
},
toDateFilter(e: any) {
console.log(e);
// console.log(Toolkit.symmetricDiff(this.data.dateFilter.allows, e.detail.value));
toDate() {
wx.navigateTo({
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 list = resp.data.data.list;
if (!list || list.length === 0) {
this.setData({
isFinished: true
})
return;
}
const result = list.map((journal: any) => {
return {
date: Time.toPassedDateTime(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: "NORMAL",
orderMap: {
createdAt: "DESC"
}
},
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: "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 = e.target.dataset.journalIndex;
const itemIndex = e.target.dataset.itemIndex;
const items = this.data.list[journalIndex].items;
const { journalIndex, itemIndex } = e.currentTarget.dataset;
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({
@ -219,7 +232,7 @@ Page({
})
},
openLocation(e: WechatMiniprogram.BaseEvent) {
const journalIndex = e.target.dataset.journalIndex;
const { journalIndex } = e.currentTarget.dataset;
const journal = this.data.list[journalIndex] as Journal;
if (journal.lat && journal.lng) {
wx.openLocation({
@ -228,16 +241,4 @@ Page({
});
}
},
toCreater() {
wx.navigateTo({
"url": "/pages/main/journal-creater/index?from=journal"
})
},
toDetail() {
wx.showToast({
title: "此功能暂不可用",
icon: "none",
duration: 2000
})
}
});

View File

@ -2,35 +2,29 @@
<t-navbar title="我们的记录">
<view slot="left" class="more" bind:tap="toggleMoreMenu">
<t-icon name="view-list" size="24px" />
<view wx:if="{{isShowMoreMenu}}" class="more-menu">
<t-cell-group class="content" theme="card">
<t-cell title="新纪录" leftIcon="add" bind:tap="toCreater" />
<t-cell title="按列表查找" leftIcon="view-list" bind:tap="toDetail" />
<t-cell title="按日期查找" leftIcon="calendar-1" bind:tap="openDateFilter" />
<t-cell title="按地图查找" leftIcon="location" bind:tap="toDetail" />
</t-cell-group>
</view>
</view>
</t-navbar>
</view>
<t-calendar
class="calendar"
type="multiple"
min-date="{{dateFilterMin}}"
max-date="{{dateFilterMax}}"
value="{{dateFilterAllows}}"
visible="{{dateFilterVisible}}"
switch-mode="year-month"
confirm-btn="{{null}}"
bind:tap="tapCalendar"
/>
<view wx:if="{{isShowMoreMenu}}" class="more-menu" catchtap="toggleMoreMenu">
<t-cell-group
class="content"
theme="card"
style="top: {{menuTop}}px; left: {{menuLeft}}px;"
catchtap="stopPropagation"
>
<t-cell title="新纪录" leftIcon="add" bind:tap="toCreater" />
<t-cell title="按列表查找" leftIcon="view-list" bind:tap="toSearch" />
<t-cell title="按日期查找" leftIcon="calendar-1" bind:tap="toDate" />
<t-cell title="按地图查找" leftIcon="location" bind:tap="toMap" />
</t-cell-group>
</view>
<t-indexes
class="journal-list"
bind:scroll="onScroll"
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
@ -38,19 +32,24 @@
class="location"
bind:tap="openLocation"
data-journal-index="{{journalIndex}}"
>📍 {{journal.location}}</view>
>
<t-icon class="icon" name="location-filled" />
<text class="text">{{journal.location}}</text>
</view>
</view>
<view wx:if="{{journal.items}}" class="items">
<block wx:for="{{journal.items}}" wx:for-item="item" wx:for-index="itemIndex" wx:key="date">
<image
class="item thumbnail {{item.type === 0 ? 'image' : 'video'}}"
src="{{item.thumbUrl}}"
mode="widthFix"
bindtap="preview"
data-item-index="{{itemIndex}}"
data-journal-index="{{journalIndex}}"
></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}}"
src="{{item.thumbURL}}"
mode="widthFix"
bindtap="preview"
data-item-index="{{item.originalIndex}}"
data-journal-index="{{journalIndex}}"
></image>
</block>
</view>
</view>
</view>
<view wx:if="{{isFinished}}" class="start">已回到最初的起点</view>

View File

@ -1,10 +1,14 @@
{
"usingComponents": {
"t-cell": "tdesign-miniprogram/cell/cell",
"t-popup": "tdesign-miniprogram/popup/popup",
"t-radio": "tdesign-miniprogram/radio/radio",
"t-navbar": "tdesign-miniprogram/navbar/navbar",
"t-button": "tdesign-miniprogram/button/button",
"t-checkbox": "tdesign-miniprogram/checkbox/checkbox",
"t-cell-group": "tdesign-miniprogram/cell-group/cell-group",
"journal-list": "../../../components/journal-list/index",
"t-radio-group": "tdesign-miniprogram/radio-group/radio-group"
}
},
"styleIsolation": "shared"
}

View File

@ -102,66 +102,88 @@
}
}
.t-popup--bottom {
padding-bottom: 0 !important;
}
.archive-popup {
.container {
width: 100vw;
display: flex;
overflow: hidden;
background: var(--td-bg-color-container);
flex-direction: column;
border-top-left-radius: 16rpx;
border-top-right-radius: 16rpx;
&.select-journal {
height: 80vh;
.content {
flex: 1;
height: 0;
}
}
&.form {
padding-bottom: 32rpx;
.content {
padding: 16rpx 32rpx;
.section {
width: 100%;
margin-top: 1.5rem;
.label {
color: var(--theme-text-secondary);
}
&.type {
display: flex;
.radio {
margin-right: 1em;
}
}
&.time {
display: flex;
.picker {
margin-right: .25rem;
}
}
}
}
}
.header {
display: flex;
align-items: center;
height: 116rpx;
border-bottom: 1px solid var(--td-component-stroke);
.title {
flex: 1;
color: var(--td-text-color-primary);
font-size: 36rpx;
text-align: center;
font-weight: 600;
font-weight: bold;
}
.placeholder {
width: 128rpx;
}
.btn {
margin: 0 .5rem;
&.back,
&.cancel {
color: var(--td-text-color-secondary);
}
&.confirm {
color: var(--theme-success);
}
}
}
.content {
padding: 16rpx 32rpx;
.section {
width: 100%;
margin-top: 1.5rem;
.label {
color: var(--theme-text-secondary);
}
&.type {
display: flex;
.radio {
margin-right: 1em;
}
}
&.time {
display: flex;
.picker {
margin-right: .25rem;
}
}
}
}
}

View File

@ -4,27 +4,27 @@ 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;
}
type ArchiveStep = "select-type" | "select-journal" | "form";
interface MomentData {
list: Item[];
type: string;
@ -42,6 +42,8 @@ interface MomentData {
uploadProgress: number;
isAuthLocation: boolean;
isVisibleArchivePopup: boolean;
archiveStep: ArchiveStep;
selectedJournalId: number | null;
}
Page({
@ -60,14 +62,16 @@ Page({
uploadTotal: "0 MB",
uploadProgress: 0,
isAuthLocation: false,
isVisibleArchivePopup: false
isVisibleArchivePopup: false,
archiveStep: "select-type",
selectedJournalId: null
},
async onLoad() {
this.fetch();
// 授权定位
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) {
@ -122,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) });
@ -163,15 +164,15 @@ Page({
preview(e: WechatMiniprogram.BaseEvent) {
const index = e.currentTarget.dataset.index;
const total = this.data.list.length;
const startIndex = Math.max(0, index - 25);
const endIndex = Math.min(total, startIndex + 50);
const newCurrentIndex = index - startIndex;
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({
@ -224,157 +225,110 @@ 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);
.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);
}
}
})
},
showArchivePopup() {
this.setData({
isVisibleArchivePopup: true,
archiveStep: "select-type",
selectedJournalId: null
});
},
onArchiveToNew() {
this.setData({
archiveStep: "form",
selectedJournalId: null
});
},
onArchiveToExisting() {
this.setData({
archiveStep: "select-journal",
selectedJournalId: null
});
},
onJournalListSelect(e: any) {
const { id } = e.detail;
this.setData({
selectedJournalId: id
});
},
onJournalListConfirm() {
if (this.data.selectedJournalId) {
this.archiveChecked();
}
},
onJournalListCancel() {
this.setData({
archiveStep: "select-type",
selectedJournalId: null
});
},
onNewJournalBack() {
this.setData({
archiveStep: "select-type"
});
},
toggleArchivePopup() {
this.setData({
isVisibleArchivePopup: !this.data.isVisibleArchivePopup
@ -406,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("获取登录凭证失败"));
}
@ -434,44 +375,36 @@ Page({
fail: handleFail
});
});
wx.request({
url: `${config.url}/journal/moment/archive`,
method: "POST",
header: {
Key: wx.getStorageSync("key")
},
data: {
type: this.data.type,
idea: this.data.idea,
createdAt: Date.parse(`${this.data.date} ${this.data.time}`),
lat: this.data.location?.lat,
lng: this.data.location?.lng,
location: this.data.location?.text,
pusher: openId,
thumbIds: this.data.list.filter(item => item.checked).map(item => item.id)
},
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
});
await Toolkit.sleep(1000);
this.fetch();
this.toggleArchivePopup();
} else {
wx.showToast({ title: "归档失败", icon: "error" });
this.setData({
isArchiving: false
});
}
},
fail: handleFail
});
const archiveData: any = {
type: this.data.type,
idea: this.data.idea,
lat: this.data.location?.lat,
lng: this.data.location?.lng,
location: this.data.location?.text,
pusher: openId,
thumbIds: this.data.list.filter(item => item.checked).map(item => item.id)
};
// 如果选择了已存在的记录,添加 id 参数
if (this.data.selectedJournalId) {
archiveData.id = this.data.selectedJournalId;
}
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);
@ -507,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

@ -21,7 +21,7 @@
class="btn archive"
theme="primary"
plain="true"
bind:tap="toggleArchivePopup"
bind:tap="showArchivePopup"
disabled="{{isSubmitting || list.length === 0 || !hasChecked}}"
>归档已选</t-button>
</view>
@ -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}}"
@ -87,18 +87,44 @@
usingCustomNavbar
bind:visible-change="onArchivePopupVisibleChange"
>
<view class="container">
<!-- 选择归档方式 -->
<view wx:if="{{archiveStep === 'select-type'}}" class="container select-type">
<view class="header">
<t-button
class="btn cancel"
aria-role="button"
bind:tap="toggleArchivePopup"
variant="text"
>取消</t-button>
<view class="title">归档已选项</view>
<view class="title">归档</view>
<view class="placeholder"></view>
</view>
<t-cell-group class="content">
<t-cell
title="新纪录"
leftIcon="bookmark-add"
arrow
bind:tap="onArchiveToNew"
/>
<t-cell
title="已存在记录"
leftIcon="view-list"
arrow
bind:tap="onArchiveToExisting"
/>
</t-cell-group>
</view>
<!-- 归档表单 -->
<view wx:if="{{archiveStep === 'form'}}" class="container form">
<view class="header">
<t-button
class="btn back"
bind:tap="onNewJournalBack"
variant="text"
>返回</t-button>
<view class="title">归档到新纪录</view>
<t-button
class="btn confirm"
aria-role="button"
theme="primary"
bind:tap="archiveChecked"
variant="text"
>确定</t-button>
@ -138,4 +164,30 @@
</view>
</view>
</view>
<!-- 选择已存在记录 -->
<view wx:if="{{archiveStep === 'select-journal'}}" class="container select-journal">
<view class="header">
<t-button
class="btn back"
bind:tap="onJournalListCancel"
variant="text"
>返回</t-button>
<view class="title">归档到已存在记录</view>
<t-button
class="btn confirm"
bind:tap="onJournalListConfirm"
theme="primary"
variant="text"
disabled="{{!selectedJournalId}}"
>确定</t-button>
</view>
<view class="content">
<journal-list
visible="{{archiveStep === 'select-journal'}}"
searchable="{{true}}"
selectedId="{{selectedJournalId}}"
bind:select="onJournalListSelect"
/>
</view>
</view>
</t-popup>

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,17 +1,17 @@
// pages/main/portfolio/index.ts
import config from "../../../config/index";
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: {
index: number;
size: number;
type: string;
orderMap?: object;
}
page: JournalPage;
list: Journal[];
isFetching: boolean;
isFinished: boolean;
@ -23,9 +23,12 @@ Page({
page: {
index: 0,
size: 8,
type: "PORTFOLIO",
type: JournalPageType.NORMAL,
equalsExample: {
type: "PORTFOLIO"
},
orderMap: {
createdAt: "DESC"
createdAt: OrderType.DESC
}
},
list: [],
@ -39,9 +42,12 @@ Page({
page: {
index: 0,
size: 8,
type: "PORTFOLIO",
type: JournalPageType.NORMAL,
equalsExample: {
type: "PORTFOLIO"
},
orderMap: {
createdAt: "DESC"
createdAt: OrderType.DESC
}
},
list: [],
@ -67,80 +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: "PORTFOLIO",
orderMap: {
createdAt: "DESC"
}
},
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 = e.target.dataset.journalIndex;
const itemIndex = e.target.dataset.itemIndex;
const items = this.data.list[journalIndex].items;
const { journalIndex, itemIndex } = e.currentTarget.dataset;
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,107 +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.target.dataset.name;
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 = e.target.dataset.index;
const imageIndex = e.target.dataset.imageIndex;
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,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;
}
}

Some files were not shown because too many files have changed in this diff Show More