plan-detail.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779
  1. <template>
  2. <view class="plan-detail">
  3. <!-- 顶部导航栏 -->
  4. <view class="navbar">
  5. <view class="back-btn" @tap="goBack">
  6. <text class="back-icon">←</text>
  7. <text class="back-text">返回</text>
  8. </view>
  9. <view class="title">{{editMode ? '修改行程' : '定制行程'}}</view>
  10. </view>
  11. <view class="container">
  12. <view class="header">
  13. <view class="title">自定义行程</view>
  14. <view class="subtitle">{{selectedLocations.length}}个景点 · {{editMode ? '请修改行程信息' : '请完善行程信息'}}</view>
  15. </view>
  16. <!-- 表单区域 -->
  17. <view class="form-section">
  18. <!-- 行程天数 -->
  19. <view class="form-item">
  20. <view class="form-label">行程天数</view>
  21. <view class="days-selector">
  22. <view class="days-btn minus" @tap="decreaseDays" :class="{'disabled': tripDays <= 1}">-</view>
  23. <view class="days-value">{{tripDays}}天</view>
  24. <view class="days-btn plus" @tap="increaseDays">+</view>
  25. </view>
  26. <view class="form-hint">建议天数: {{recommendedDays}}天</view>
  27. </view>
  28. <!-- 预算金额 -->
  29. <view class="form-item">
  30. <view class="form-label">预算金额</view>
  31. <view class="budget-input">
  32. <text class="currency">¥</text>
  33. <input type="number" v-model="budget" placeholder="请输入预算金额" />
  34. </view>
  35. <view class="form-hint">人均预计花费</view>
  36. </view>
  37. <!-- 出发日期 -->
  38. <view class="form-item">
  39. <view class="form-label">出发日期</view>
  40. <view class="date-picker">
  41. <picker mode="date" :value="startDate" @change="onDateChange">
  42. <view class="picker-value">{{formatDate(startDate)}}</view>
  43. </picker>
  44. <text class="picker-icon">▼</text>
  45. </view>
  46. </view>
  47. <!-- 出行人数 -->
  48. <view class="form-item">
  49. <view class="form-label">出行人数</view>
  50. <view class="people-selector">
  51. <view class="people-btn minus" @tap="decreasePeople" :class="{'disabled': peopleCount <= 1}">-</view>
  52. <view class="people-value">{{peopleCount}}人</view>
  53. <view class="people-btn plus" @tap="increasePeople">+</view>
  54. </view>
  55. </view>
  56. </view>
  57. <!-- 已选景点列表 -->
  58. <view class="spots-section">
  59. <view class="section-title">已选景点</view>
  60. <view class="spots-list">
  61. <view class="spot-item" v-for="(spot, index) in selectedLocations" :key="index">
  62. <view class="spot-index">{{index + 1}}</view>
  63. <view class="spot-info">
  64. <view class="spot-name">{{spot.name || '未命名景点'}}</view>
  65. <view class="spot-address">{{spot.address || '无地址信息'}}</view>
  66. </view>
  67. <view class="remove-btn" @tap="removeSpot(index)">×</view>
  68. </view>
  69. </view>
  70. <!-- 空状态提示 -->
  71. <view class="empty-state" v-if="selectedLocations.length === 0">
  72. <view class="empty-icon">🗺️</view>
  73. <view class="empty-text">未选择任何景点,请返回地图选择</view>
  74. </view>
  75. </view>
  76. <!-- 底部操作按钮 -->
  77. <view class="action-buttons">
  78. <view class="back-btn" @tap="goBack">返回{{editMode ? '' : '修改'}}</view>
  79. <view class="submit-btn" @tap="createTrip" :class="{'disabled': !canSubmit}">{{editMode ? '保存修改' : '生成行程'}}</view>
  80. </view>
  81. </view>
  82. </view>
  83. </template>
  84. <script>
  85. import { API } from '@/util/api.js';
  86. export default {
  87. data() {
  88. return {
  89. selectedLocations: [],
  90. tripDays: 1,
  91. budget: '',
  92. startDate: new Date().toISOString().split('T')[0], // 今天的日期,格式:YYYY-MM-DD
  93. peopleCount: 2,
  94. options: {},
  95. editMode: false,
  96. tripId: null
  97. }
  98. },
  99. computed: {
  100. // 计算推荐天数
  101. recommendedDays() {
  102. return Math.max(1, Math.ceil(this.selectedLocations.length / 3));
  103. },
  104. // 检查是否可以提交
  105. canSubmit() {
  106. return this.selectedLocations.length > 0 &&
  107. this.tripDays >= 1 &&
  108. this.budget &&
  109. this.startDate;
  110. }
  111. },
  112. onLoad(options) {
  113. console.log('规划详情页参数:', options);
  114. this.options = options || {};
  115. this.editMode = options && options.tripId ? true : false;
  116. this.tripId = options && options.tripId ? options.tripId : null;
  117. // 从URL参数或本地存储获取已选景点数据
  118. try {
  119. const locationsData = uni.getStorageSync('selectedLocations');
  120. if (locationsData) {
  121. this.selectedLocations = JSON.parse(locationsData);
  122. console.log('已加载选择的景点数据:', this.selectedLocations);
  123. // 如果是编辑模式,尝试加载现有行程数据
  124. if (this.editMode && this.tripId) {
  125. this.loadExistingTripData();
  126. } else {
  127. // 根据景点数量设置推荐天数
  128. this.tripDays = this.recommendedDays;
  129. }
  130. } else {
  131. console.log('未找到已选景点数据');
  132. // 如果没有数据,返回地图页面
  133. uni.showToast({
  134. title: '请先选择景点',
  135. icon: 'none'
  136. });
  137. setTimeout(() => {
  138. uni.navigateBack();
  139. }, 1500);
  140. }
  141. } catch (e) {
  142. console.error('读取已选景点数据失败:', e);
  143. }
  144. },
  145. methods: {
  146. // 加载现有行程数据
  147. loadExistingTripData() {
  148. try {
  149. // 获取所有已保存的行程
  150. const savedTrips = uni.getStorageSync('savedTrips') || [];
  151. // 查找当前行程ID对应的行程
  152. const tripData = savedTrips.find(trip => trip.id === this.tripId);
  153. if (tripData) {
  154. console.log('找到现有行程数据:', tripData);
  155. // 设置表单数据
  156. this.tripDays = tripData.days || 1;
  157. this.budget = tripData.budget ? tripData.budget.toString() : '';
  158. this.startDate = tripData.startDate;
  159. this.peopleCount = tripData.peopleCount || 2;
  160. // 更新页面标题
  161. uni.setNavigationBarTitle({
  162. title: '修改行程'
  163. });
  164. } else {
  165. console.log('未找到现有行程数据');
  166. this.tripDays = this.recommendedDays;
  167. }
  168. } catch (e) {
  169. console.error('加载现有行程数据失败:', e);
  170. this.tripDays = this.recommendedDays;
  171. }
  172. },
  173. // 增加天数
  174. increaseDays() {
  175. this.tripDays++;
  176. },
  177. // 减少天数
  178. decreaseDays() {
  179. if (this.tripDays > 1) {
  180. this.tripDays--;
  181. }
  182. },
  183. // 增加人数
  184. increasePeople() {
  185. this.peopleCount++;
  186. },
  187. // 减少人数
  188. decreasePeople() {
  189. if (this.peopleCount > 1) {
  190. this.peopleCount--;
  191. }
  192. },
  193. // 格式化日期显示
  194. formatDate(dateString) {
  195. if (!dateString) return '请选择日期';
  196. const date = new Date(dateString);
  197. return `${date.getFullYear()}.${date.getMonth() + 1}.${date.getDate()}`;
  198. },
  199. // 日期选择变化处理
  200. onDateChange(e) {
  201. this.startDate = e.detail.value;
  202. },
  203. // 移除景点
  204. removeSpot(index) {
  205. uni.showModal({
  206. title: '移除景点',
  207. content: `确定要移除"${this.selectedLocations[index].name}"吗?`,
  208. success: (res) => {
  209. if (res.confirm) {
  210. this.selectedLocations.splice(index, 1);
  211. // 更新本地存储
  212. this.updateLocalStorage();
  213. }
  214. }
  215. });
  216. },
  217. // 更新本地存储
  218. updateLocalStorage() {
  219. try {
  220. uni.setStorageSync('selectedLocations', JSON.stringify(this.selectedLocations));
  221. console.log('已更新本地存储的景点数据');
  222. } catch (e) {
  223. console.error('更新本地存储失败:', e);
  224. }
  225. },
  226. // 创建行程
  227. createTrip() {
  228. if (!this.canSubmit) {
  229. uni.showToast({
  230. title: '请完善行程信息',
  231. icon: 'none'
  232. });
  233. return;
  234. }
  235. // 显示加载提示
  236. uni.showLoading({
  237. title: this.editMode ? '保存更改中...' : '行程生成中...'
  238. });
  239. // 为景点添加默认封面图片
  240. const spotsWithCoverImage = this.selectedLocations.map((location, index) => {
  241. let coverImage = '/static/baoding.jpg';
  242. // 根据景点名称设置不同的封面图
  243. if (location.name && location.name.includes('古莲花池')) {
  244. coverImage = '/static/baoding.jpg';
  245. } else if (location.name && location.name.includes('直隶')) {
  246. coverImage = '/static/xian.jpg';
  247. } else if (location.name && location.name.includes('野三坡')) {
  248. coverImage = '/static/beijing.jpg';
  249. } else {
  250. // 根据景点ID或索引设置不同的封面图
  251. const images = [
  252. '/static/baoding.jpg',
  253. '/static/beijing.jpg',
  254. '/static/shanghai.jpg',
  255. '/static/chengdu.jpg'
  256. ];
  257. const imageIndex = index % images.length;
  258. coverImage = images[imageIndex];
  259. }
  260. // 计算与上一个景点的距离
  261. let distanceFromPrevious = null;
  262. if (index > 0) {
  263. const prevLocation = this.selectedLocations[index - 1];
  264. distanceFromPrevious = this.calculateDistance(
  265. prevLocation.latitude,
  266. prevLocation.longitude,
  267. location.latitude,
  268. location.longitude
  269. );
  270. }
  271. // 确保所有数值类型的字段都是数字
  272. const latitude = Number(location.latitude);
  273. const longitude = Number(location.longitude);
  274. console.log(`景点${index+1} - ${location.name} 经纬度:`, latitude, longitude);
  275. return {
  276. id: location.id || `spot_${Date.now()}_${index}`, // 确保有唯一ID
  277. name: location.name || '未命名景点',
  278. address: location.address || '无地址信息',
  279. latitude: latitude,
  280. longitude: longitude,
  281. order: index + 1,
  282. coverImage: coverImage, // 添加封面图片
  283. distanceFromPrevious: distanceFromPrevious // 添加距离信息
  284. };
  285. });
  286. // 准备行程数据
  287. const tripData = {
  288. name: '保定自定义行程',
  289. days: this.tripDays,
  290. budget: this.budget ? Number(this.budget) : null,
  291. startDate: this.startDate,
  292. peopleCount: this.peopleCount,
  293. spots: spotsWithCoverImage,
  294. createdAt: new Date().toISOString(),
  295. updatedAt: new Date().toISOString()
  296. };
  297. // 如果是编辑模式,添加ID
  298. if (this.editMode && this.tripId) {
  299. // 检查ID是否是数字字符串
  300. if (this.tripId.startsWith('trip_')) {
  301. // 前端生成的临时ID,需要移除
  302. tripData.id = null;
  303. } else {
  304. tripData.id = this.tripId;
  305. }
  306. } else {
  307. // 创建模式,不设置ID,让后端生成
  308. tripData.id = null;
  309. }
  310. console.log('准备保存行程数据:', JSON.stringify(tripData));
  311. // 检查API是否可用
  312. if (this.$api && this.$api.trip) {
  313. // 使用API模块发起请求
  314. const apiMethod = this.editMode ? this.$api.trip.update : this.$api.trip.create;
  315. apiMethod(tripData)
  316. .then(res => {
  317. console.log('API保存行程成功:', res);
  318. // 同时保存到本地存储,确保显示正常
  319. // 如果API返回了完整数据,使用API返回的数据
  320. const finalTripData = (res && res.data) ? res.data : tripData;
  321. this.saveToLocalStorage(finalTripData, true);
  322. // 服务器保存成功,清除本地存储
  323. uni.removeStorageSync('selectedLocations');
  324. // 显示成功提示
  325. uni.showToast({
  326. title: this.editMode ? '行程更新成功!' : '行程创建成功!',
  327. icon: 'success',
  328. duration: 2000
  329. });
  330. // 跳转回规划页面
  331. setTimeout(() => {
  332. uni.switchTab({
  333. url: '/pages/planning/index',
  334. success: () => {
  335. // 通知规划页面刷新数据
  336. uni.$emit('refreshTrips');
  337. console.log('成功返回规划页面并请求刷新');
  338. }
  339. });
  340. }, 1000);
  341. })
  342. .catch(err => {
  343. console.error('API请求失败,回退到本地存储:', err);
  344. // 回退到本地存储方案
  345. this.saveToLocalStorage(tripData);
  346. })
  347. .finally(() => {
  348. uni.hideLoading();
  349. });
  350. } else {
  351. // API不可用,直接使用本地存储
  352. console.log('API不可用,使用本地存储保存行程');
  353. this.saveToLocalStorage(tripData);
  354. }
  355. },
  356. // 保存到本地存储(作为后备方案)
  357. saveToLocalStorage(tripData, skipToast = false) {
  358. try {
  359. // 获取已有的行程
  360. let savedTrips = uni.getStorageSync('savedTrips') || [];
  361. // 编辑模式 - 更新现有行程
  362. if (this.editMode && this.tripId) {
  363. const index = savedTrips.findIndex(trip => trip.id === this.tripId);
  364. if (index !== -1) {
  365. // 保留原创建时间
  366. const originalCreatedAt = savedTrips[index].createdAt;
  367. tripData.createdAt = originalCreatedAt;
  368. // 更新行程
  369. savedTrips[index] = tripData;
  370. if (!skipToast) {
  371. uni.showToast({
  372. title: '行程更新成功!',
  373. icon: 'success',
  374. duration: 2000
  375. });
  376. }
  377. }
  378. } else {
  379. // 创建模式 - 添加新行程
  380. savedTrips.push(tripData);
  381. if (!skipToast) {
  382. uni.showToast({
  383. title: '行程创建成功!',
  384. icon: 'success',
  385. duration: 2000
  386. });
  387. }
  388. }
  389. console.log('保存到本地的行程数据:', savedTrips);
  390. // 保存回本地存储
  391. uni.setStorageSync('savedTrips', savedTrips);
  392. // 清除已选景点的临时存储
  393. uni.removeStorageSync('selectedLocations');
  394. // 如果是API回调中的保存,不需要再跳转
  395. if (!skipToast) {
  396. // 跳转回规划页面
  397. setTimeout(() => {
  398. uni.switchTab({
  399. url: '/pages/planning/index',
  400. success: () => {
  401. // 通知规划页面刷新数据
  402. uni.$emit('refreshTrips');
  403. console.log('成功返回规划页面并请求刷新');
  404. },
  405. fail: (err) => {
  406. console.error('返回规划页面失败:', err);
  407. }
  408. });
  409. }, 1000);
  410. }
  411. } catch (e) {
  412. console.error('保存行程失败:', e);
  413. uni.showToast({
  414. title: '保存行程失败',
  415. icon: 'none'
  416. });
  417. }
  418. },
  419. // 返回上一页
  420. goBack() {
  421. uni.navigateBack();
  422. },
  423. // 计算两点之间的距离(公里)
  424. calculateDistance(lat1, lng1, lat2, lng2) {
  425. // 将经纬度转换为数字类型
  426. lat1 = Number(lat1);
  427. lng1 = Number(lng1);
  428. lat2 = Number(lat2);
  429. lng2 = Number(lng2);
  430. // 检查经纬度是否有效
  431. if (isNaN(lat1) || isNaN(lng1) || isNaN(lat2) || isNaN(lng2)) {
  432. return null;
  433. }
  434. // 使用半正矢公式计算球面距离
  435. const R = 6371; // 地球半径,单位公里
  436. const dLat = this.toRadians(lat2 - lat1);
  437. const dLng = this.toRadians(lng2 - lng1);
  438. const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
  439. Math.cos(this.toRadians(lat1)) * Math.cos(this.toRadians(lat2)) *
  440. Math.sin(dLng/2) * Math.sin(dLng/2);
  441. const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
  442. const distance = R * c; // 距离,单位公里
  443. // 保留一位小数
  444. return parseFloat(distance.toFixed(1));
  445. },
  446. // 角度转弧度
  447. toRadians(degrees) {
  448. return degrees * Math.PI / 180;
  449. }
  450. }
  451. }
  452. </script>
  453. <style>
  454. .plan-detail {
  455. min-height: 100vh;
  456. background-color: #f8f8f8;
  457. }
  458. .navbar {
  459. display: flex;
  460. align-items: center;
  461. height: 90rpx;
  462. padding: 0 30rpx;
  463. background-color: #ffffff;
  464. border-bottom: 1rpx solid #eaeaea;
  465. position: relative;
  466. }
  467. .back-btn {
  468. display: flex;
  469. align-items: center;
  470. position: absolute;
  471. left: 30rpx;
  472. }
  473. .back-icon {
  474. font-size: 40rpx;
  475. }
  476. .back-text {
  477. font-size: 28rpx;
  478. margin-left: 6rpx;
  479. }
  480. .navbar .title {
  481. flex: 1;
  482. text-align: center;
  483. font-size: 32rpx;
  484. font-weight: 500;
  485. }
  486. .container {
  487. padding: 30rpx;
  488. }
  489. .header {
  490. margin-bottom: 30rpx;
  491. }
  492. .header .title {
  493. font-size: 36rpx;
  494. font-weight: 600;
  495. margin-bottom: 10rpx;
  496. }
  497. .header .subtitle {
  498. font-size: 28rpx;
  499. color: #666;
  500. }
  501. .form-section {
  502. background-color: #ffffff;
  503. border-radius: 12rpx;
  504. padding: 20rpx;
  505. margin-bottom: 30rpx;
  506. }
  507. .form-item {
  508. padding: 20rpx 10rpx;
  509. border-bottom: 1rpx solid #f0f0f0;
  510. }
  511. .form-item:last-child {
  512. border-bottom: none;
  513. }
  514. .form-label {
  515. font-size: 30rpx;
  516. margin-bottom: 20rpx;
  517. font-weight: 500;
  518. }
  519. .form-hint {
  520. font-size: 24rpx;
  521. color: #999;
  522. margin-top: 10rpx;
  523. }
  524. .days-selector, .people-selector {
  525. display: flex;
  526. align-items: center;
  527. }
  528. .days-btn, .people-btn {
  529. width: 60rpx;
  530. height: 60rpx;
  531. border-radius: 50%;
  532. background-color: #f0f0f0;
  533. color: #333;
  534. display: flex;
  535. align-items: center;
  536. justify-content: center;
  537. font-size: 32rpx;
  538. }
  539. .days-value, .people-value {
  540. margin: 0 30rpx;
  541. font-size: 32rpx;
  542. width: 100rpx;
  543. text-align: center;
  544. }
  545. .disabled {
  546. opacity: 0.5;
  547. pointer-events: none;
  548. }
  549. .budget-input {
  550. display: flex;
  551. align-items: center;
  552. border: 1rpx solid #eaeaea;
  553. border-radius: 8rpx;
  554. padding: 0 20rpx;
  555. height: 80rpx;
  556. }
  557. .currency {
  558. font-size: 32rpx;
  559. color: #666;
  560. margin-right: 10rpx;
  561. }
  562. .budget-input input {
  563. flex: 1;
  564. height: 80rpx;
  565. font-size: 32rpx;
  566. }
  567. .date-picker {
  568. display: flex;
  569. align-items: center;
  570. justify-content: space-between;
  571. border: 1rpx solid #eaeaea;
  572. border-radius: 8rpx;
  573. padding: 0 20rpx;
  574. height: 80rpx;
  575. }
  576. .picker-value {
  577. font-size: 32rpx;
  578. }
  579. .picker-icon {
  580. font-size: 24rpx;
  581. color: #999;
  582. }
  583. .spots-section {
  584. background-color: #ffffff;
  585. border-radius: 12rpx;
  586. padding: 20rpx;
  587. margin-bottom: 30rpx;
  588. }
  589. .section-title {
  590. font-size: 30rpx;
  591. font-weight: 500;
  592. margin-bottom: 20rpx;
  593. padding: 0 10rpx;
  594. }
  595. .spots-list {
  596. max-height: 500rpx;
  597. overflow-y: auto;
  598. }
  599. .spot-item {
  600. display: flex;
  601. align-items: center;
  602. padding: 20rpx 10rpx;
  603. border-bottom: 1rpx solid #f0f0f0;
  604. }
  605. .spot-item:last-child {
  606. border-bottom: none;
  607. }
  608. .spot-index {
  609. width: 40rpx;
  610. height: 40rpx;
  611. border-radius: 50%;
  612. background-color: #3e98ff;
  613. color: #ffffff;
  614. display: flex;
  615. align-items: center;
  616. justify-content: center;
  617. font-size: 24rpx;
  618. margin-right: 20rpx;
  619. }
  620. .spot-info {
  621. flex: 1;
  622. }
  623. .spot-name {
  624. font-size: 28rpx;
  625. margin-bottom: 6rpx;
  626. }
  627. .spot-address {
  628. font-size: 24rpx;
  629. color: #999;
  630. }
  631. .remove-btn {
  632. width: 50rpx;
  633. height: 50rpx;
  634. display: flex;
  635. align-items: center;
  636. justify-content: center;
  637. font-size: 36rpx;
  638. color: #999;
  639. }
  640. .empty-state {
  641. display: flex;
  642. flex-direction: column;
  643. align-items: center;
  644. justify-content: center;
  645. padding: 60rpx 0;
  646. }
  647. .empty-icon {
  648. font-size: 80rpx;
  649. margin-bottom: 20rpx;
  650. }
  651. .empty-text {
  652. font-size: 28rpx;
  653. color: #999;
  654. }
  655. .action-buttons {
  656. display: flex;
  657. gap: 20rpx;
  658. }
  659. .back-btn, .submit-btn {
  660. flex: 1;
  661. height: 90rpx;
  662. display: flex;
  663. align-items: center;
  664. justify-content: center;
  665. border-radius: 45rpx;
  666. font-size: 32rpx;
  667. }
  668. .back-btn {
  669. background-color: #f0f0f0;
  670. color: #333;
  671. }
  672. .submit-btn {
  673. background-color: #3e98ff;
  674. color: #ffffff;
  675. }
  676. .submit-btn.disabled {
  677. background-color: #cccccc;
  678. }
  679. </style>