付晓文。 hai 1 día
pai
achega
2498d331a4
Modificáronse 36 ficheiros con 2508 adicións e 1160 borrados
  1. 12 0
      src/api/workbench.js
  2. 478 273
      src/pages/index/index.vue
  3. 5 5
      src/pages/my/my.vue
  4. BIN=BIN
      src/static/workbench/address.png
  5. BIN=BIN
      src/static/workbench/checkCircle.png
  6. BIN=BIN
      src/static/workbench/city.png
  7. BIN=BIN
      src/static/workbench/contract.png
  8. BIN=BIN
      src/static/workbench/down.png
  9. BIN=BIN
      src/static/workbench/freeCar.png
  10. BIN=BIN
      src/static/workbench/indexBg.png
  11. BIN=BIN
      src/static/workbench/info.png
  12. BIN=BIN
      src/static/workbench/more.png
  13. BIN=BIN
      src/static/workbench/move.png
  14. BIN=BIN
      src/static/workbench/newSkill.png
  15. BIN=BIN
      src/static/workbench/orderAddress.png
  16. BIN=BIN
      src/static/workbench/orderTime.png
  17. BIN=BIN
      src/static/workbench/orderTip.png
  18. BIN=BIN
      src/static/workbench/orderTop.png
  19. BIN=BIN
      src/static/workbench/right-icon.png
  20. BIN=BIN
      src/static/workbench/right.png
  21. BIN=BIN
      src/static/workbench/skill.png
  22. BIN=BIN
      src/static/workbench/skillTip.png
  23. BIN=BIN
      src/static/workbench/store.png
  24. 193 0
      src/utils/address.js
  25. 3 0
      src/utils/index.js
  26. 1 1
      src/utils/location.js
  27. 13 13
      src/workbench/city/apply.vue
  28. 61 28
      src/workbench/city/index.vue
  29. 135 182
      src/workbench/fare/index.vue
  30. 658 121
      src/workbench/income/index.vue
  31. 86 59
      src/workbench/rating/index.vue
  32. 49 18
      src/workbench/rating/mock.js
  33. 283 251
      src/workbench/skill/add.vue
  34. 225 202
      src/workbench/skill/edit.vue
  35. 3 1
      src/workbench/skill/index.vue
  36. 303 6
      src/workbench/skill/mock.js

+ 12 - 0
src/api/workbench.js

@@ -0,0 +1,12 @@
+import request from "@/utils/request.js";
+
+export function getTechnician(data) {
+    return request({
+        method: 'get',
+        url: '/technician/technician/getTechnician',
+        data,
+		header: {
+		    'Authorization': `tf:${uni.getStorageSync('access-token')}`
+		}
+    });
+}

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 478 - 273
src/pages/index/index.vue


+ 5 - 5
src/pages/my/my.vue

@@ -197,11 +197,11 @@ export default {
 
 	},
 	onShow() {
-		this.getData();
-		this.getCoupon();
-		this.getMerData();
-		this.getCollectData();
-    this.getPointsInfo();
+		// this.getData();
+		// this.getCoupon();
+		// this.getMerData();
+		// this.getCollectData();
+  //   this.getPointsInfo();
 		// this.getQrCode();
 	},
 	methods: {

BIN=BIN
src/static/workbench/address.png


BIN=BIN
src/static/workbench/checkCircle.png


BIN=BIN
src/static/workbench/city.png


BIN=BIN
src/static/workbench/contract.png


BIN=BIN
src/static/workbench/down.png


BIN=BIN
src/static/workbench/freeCar.png


BIN=BIN
src/static/workbench/indexBg.png


BIN=BIN
src/static/workbench/info.png


BIN=BIN
src/static/workbench/more.png


BIN=BIN
src/static/workbench/move.png


BIN=BIN
src/static/workbench/newSkill.png


BIN=BIN
src/static/workbench/orderAddress.png


BIN=BIN
src/static/workbench/orderTime.png


BIN=BIN
src/static/workbench/orderTip.png


BIN=BIN
src/static/workbench/orderTop.png


BIN=BIN
src/static/workbench/right-icon.png


BIN=BIN
src/static/workbench/right.png


BIN=BIN
src/static/workbench/skill.png


BIN=BIN
src/static/workbench/skillTip.png


BIN=BIN
src/static/workbench/store.png


+ 193 - 0
src/utils/address.js

@@ -0,0 +1,193 @@
+import { getSignature } from '@/api/index.js';
+// #ifdef H5
+import wx from 'weixin-js-sdk';
+import { h5Url } from '@/common/config.js';
+// #endif
+import locationService from '@/utils/location.js';
+
+const DEFAULT_JS_API_LIST = ['openAddress', 'getLocation', 'openLocation'];
+
+class AddressService {
+    isWeChatBrowser() {
+        // #ifdef H5
+        return typeof navigator !== 'undefined' && /micromessenger/i.test(navigator.userAgent);
+        // #endif
+        // #ifndef H5
+        return false;
+        // #endif
+    }
+
+    isWebPlatform() {
+        return uni.getSystemInfoSync().uniPlatform === 'web';
+    }
+
+    /**
+     * 初始化微信 JSSDK(地址相关接口)
+     * @param {string[]} jsApiList
+     */
+    async initWxJssdk(jsApiList = DEFAULT_JS_API_LIST) {
+        // #ifdef H5
+        if (!this.isWeChatBrowser() || typeof wx === 'undefined') return false;
+        try {
+            const url = typeof window !== 'undefined'
+                ? window.location.href.split('#')[0]
+                : `${h5Url}/`;
+            const res = await getSignature({ url });
+            const config = res.data || {};
+            return new Promise((resolve) => {
+                wx.config({
+                    debug: false,
+                    appId: config.appId,
+                    timestamp: config.timestamp,
+                    nonceStr: config.nonceStr,
+                    signature: config.sign || config.signature,
+                    jsApiList,
+                });
+                wx.ready(() => resolve(true));
+                wx.error(() => resolve(false));
+            });
+        } catch (e) {
+            console.error('微信 JSSDK 初始化失败', e);
+            return false;
+        }
+        // #endif
+        // #ifndef H5
+        return false;
+        // #endif
+    }
+
+    /**
+     * 将微信 openAddress 返回值格式化为项目通用结构
+     */
+    formatWxAddress(data = {}) {
+        const detail = data.detailInfo || '';
+        const region = [
+            data.provinceName,
+            data.cityName,
+            data.countryName,
+        ].filter(Boolean).join('');
+        return {
+            userName: data.userName || '',
+            phone: data.telNumber || '',
+            province: data.provinceName || '',
+            city: data.cityName || '',
+            district: data.countryName || '',
+            address: detail,
+            atlasAdd: detail,
+            name: detail || region,
+            fullAddress: `${region}${detail}`,
+            postalCode: data.postalCode || '',
+            nationalCode: data.nationalCode || '',
+        };
+    }
+
+    /**
+     * 调起微信收货地址(仅微信内置浏览器 H5)
+     * @returns {Promise<Object>}
+     */
+    openAddress() {
+        return new Promise(async (resolve, reject) => {
+            if (!this.isWeChatBrowser()) {
+                reject(new Error('仅支持微信内置浏览器'));
+                return;
+            }
+            const inited = await this.initWxJssdk(['openAddress']);
+            if (!inited) {
+                reject(new Error('微信 JSSDK 初始化失败'));
+                return;
+            }
+            // #ifdef H5
+            wx.openAddress({
+                success: (res) => resolve(this.formatWxAddress(res)),
+                cancel: () => reject({ cancelled: true, message: '用户取消选择地址' }),
+                fail: (err) => reject(err),
+            });
+            // #endif
+        });
+    }
+
+    /**
+     * 微信 H5:通过 getLocation + 逆地理编码获取当前位置地址
+     */
+    async getWxCurrentAddress() {
+        const gpsResult = await locationService.getWxGPSLocation();
+        if (gpsResult.status !== 'success') {
+            throw gpsResult.error || new Error('获取位置失败');
+        }
+        await locationService.updateGeocode();
+        const positioning = uni.getStorageSync('positioning') || {};
+        const name = positioning.formattedAddress || positioning.cityName || '';
+        const address = positioning.formattedAddress || positioning.cityName || '';
+        return {
+            name,
+            address,
+            latitude: positioning.latitude,
+            longitude: positioning.longitude,
+        };
+    }
+
+    /**
+     * 选择地图位置(统一入口)
+     * 微信 H5:getLocation + 逆地理编码;其他平台:uni.chooseLocation
+     */
+    chooseLocation() {
+        return new Promise(async (resolve, reject) => {
+            if (this.isWebPlatform() && this.isWeChatBrowser()) {
+                try {
+                    const result = await this.getWxCurrentAddress();
+                    resolve(result);
+                } catch (err) {
+                    reject(err);
+                }
+                return;
+            }
+            uni.chooseLocation({
+                success: resolve,
+                fail: reject,
+            });
+        });
+    }
+
+    /**
+     * 打开地图查看位置
+     */
+    openLocation({ latitude, longitude, name = '', address = '', scale = 16 }) {
+        return new Promise(async (resolve, reject) => {
+            if (!latitude || !longitude) {
+                reject(new Error('缺少经纬度'));
+                return;
+            }
+            if (this.isWebPlatform() && this.isWeChatBrowser()) {
+                const inited = await this.initWxJssdk(['openLocation']);
+                if (!inited) {
+                    reject(new Error('微信 JSSDK 初始化失败'));
+                    return;
+                }
+                // #ifdef H5
+                wx.openLocation({
+                    latitude: Number(latitude),
+                    longitude: Number(longitude),
+                    name,
+                    address,
+                    scale,
+                    success: resolve,
+                    fail: reject,
+                });
+                // #endif
+                return;
+            }
+            uni.openLocation({
+                latitude: Number(latitude),
+                longitude: Number(longitude),
+                name,
+                address,
+                scale,
+                success: resolve,
+                fail: reject,
+            });
+        });
+    }
+}
+
+export { AddressService };
+export default new AddressService();

+ 3 - 0
src/utils/index.js

@@ -1,4 +1,5 @@
 import statusManager from '@/utils/statusManager.js';
+import addressService from '@/utils/address.js';
 
 // 日期格式化原型扩展(单独维护)
 Date.prototype.Format = function (fmt) {
@@ -200,6 +201,8 @@ const otherUtil = {
 	},
 	// 全局状态管理
 	statusManager,
+	// 微信 JSSDK 地址
+	addressService,
 };
 
 // 组合导出

+ 1 - 1
src/utils/location.js

@@ -1,4 +1,4 @@
-import { getCity } from '@/api/home/home.js'; 
+import { getCity } from '@/api/index.js'; 
 
 let TIANDITU_KEY = '92c4ddd919f4e41a30e765680e3a29b7';
 if (process.env.NODE_ENV == 'development') {

+ 13 - 13
src/workbench/city/apply.vue

@@ -6,14 +6,14 @@
 				<text class="value" :class="{ placeholder: !cityText }">
 					{{ cityText || '选择城市' }}
 				</text>
-				<text class="chevron">&gt;</text>
+				<image class="chevron" src="@/static/workbench/right.png" mode=""></image>
 			</view>
 			<view class="form-row" @click="openCenterPicker">
 				<text class="label">运营中心</text>
 				<text class="value" :class="{ placeholder: !form.centerName }">
 					{{ form.centerName || '选择' }}
 				</text>
-				<text class="chevron">&gt;</text>
+				<image class="chevron" src="@/static/workbench/right.png" mode=""></image>
 			</view>
 			<view class="reason-block">
 				<text class="label">申请原因</text>
@@ -28,8 +28,9 @@
 			</view>
 		</view>
 
+	
 		<view class="footer-bar">
-			<view class="footer-btn" @click="onSubmit">申请开通</view>
+			<view class="submit-btn" @click="onSubmit">申请开通</view>
 		</view>
 
 		<!-- 省 / 市 二级联动 -->
@@ -196,9 +197,8 @@ export default {
 }
 
 .chevron {
-	margin-left: 8rpx;
-	font-size: 28rpx;
-	color: #999;
+	width: 25rpx;
+	height: 25rpx;
 }
 
 .reason-block {
@@ -230,17 +230,17 @@ export default {
 	bottom: 0;
 	padding: 20rpx 32rpx;
 	padding-bottom: calc(env(safe-area-inset-bottom) + 20rpx);
-	background: #fff;
-	border-top: 1rpx solid #eee;
 }
-
-.footer-btn {
+.submit-btn {
+	margin: 0 auto;
+	width: 654rpx;
 	height: 88rpx;
 	line-height: 88rpx;
 	text-align: center;
-	background: #333;
-	color: #fff;
+	font-weight: 500;
 	font-size: 32rpx;
-	border-radius: 12rpx;
+	color: #FFFFFF;
+	background: #333335;
+	border-radius: 60rpx 60rpx 60rpx 60rpx;
 }
 </style>

+ 61 - 28
src/workbench/city/index.vue

@@ -8,14 +8,24 @@
 						{{ statusLabel(item.status) }}
 					</text>
 				</view>
-				<text class="meta">开通原因:{{ item.reason || '暂无' }}</text>
-				<text class="meta">开通时间:{{ item.openTime }}</text>
+				<view class="card-divider"></view>
+				<view class="card-body">
+					<view class="meta-row">
+						<text class="meta-label">开通原因</text>
+						<text class="meta-value">{{ item.reason || '暂无' }}</text>
+					</view>
+					<view class="meta-row">
+						<text class="meta-label">开通时间</text>
+						<text class="meta-value">{{ item.openTime }}</text>
+					</view>
+				</view>
 			</view>
 		</view>
 		<view class="empty" v-else>暂无城市记录</view>
 
+		
 		<view class="footer-bar">
-			<view class="footer-btn" @click="goApply">开通新城市</view>
+			<view class="submit-btn" @click="goApply">开通新城市</view>
 		</view>
 	</view>
 </template>
@@ -65,20 +75,19 @@ export default {
 
 .city-card {
 	background: #fff;
-	border-radius: 12rpx;
-	padding: 28rpx 32rpx;
+	border-radius: 16rpx;
+	padding: 32rpx;
 }
 
 .card-head {
 	display: flex;
-	align-items: flex-start;
+	align-items: center;
 	justify-content: space-between;
-	margin-bottom: 16rpx;
 }
 
 .center-name {
 	flex: 1;
-	font-size: 30rpx;
+	font-size: 32rpx;
 	color: #333;
 	font-weight: 600;
 	line-height: 1.4;
@@ -88,32 +97,56 @@ export default {
 .status-tag {
 	flex-shrink: 0;
 	font-size: 22rpx;
-	padding: 4rpx 12rpx;
-	border-radius: 6rpx;
+	padding: 6rpx 16rpx;
+	border-radius: 8rpx;
+	line-height: 1.4;
 
 	&.opened {
-		color: #333;
-		background: #f0f0f0;
+		color: #1db870;
+		background: #e8faf0;
 	}
 
 	&.pending {
-		color: #666;
-		background: #f5f5f5;
-		border: 1rpx solid #ddd;
+		color: #ff8800;
+		background: #fff4e6;
 	}
 
 	&.rejected {
-		color: #999;
-		background: #fafafa;
-		border: 1rpx solid #eee;
+		color: #ff4d4f;
+		background: #fff0f0;
 	}
 }
 
-.meta {
-	display: block;
+.card-divider {
+	height: 1rpx;
+	background: #f0f0f0;
+	margin: 24rpx 0;
+}
+
+.card-body {
+	display: flex;
+	flex-direction: column;
+	gap: 16rpx;
+}
+
+.meta-row {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+}
+
+.meta-label {
 	font-size: 26rpx;
 	color: #999;
-	line-height: 1.6;
+	flex-shrink: 0;
+}
+
+.meta-value {
+	flex: 1;
+	text-align: right;
+	font-size: 26rpx;
+	color: #333;
+	margin-left: 24rpx;
 }
 
 .empty {
@@ -130,17 +163,17 @@ export default {
 	bottom: 0;
 	padding: 20rpx 32rpx;
 	padding-bottom: calc(env(safe-area-inset-bottom) + 20rpx);
-	background: #fff;
-	border-top: 1rpx solid #eee;
 }
-
-.footer-btn {
+.submit-btn {
+	margin: 0 auto;
+	width: 654rpx;
 	height: 88rpx;
 	line-height: 88rpx;
 	text-align: center;
-	background: #333;
-	color: #fff;
+	font-weight: 500;
 	font-size: 32rpx;
-	border-radius: 12rpx;
+	color: #FFFFFF;
+	background: #333335;
+	border-radius: 60rpx 60rpx 60rpx 60rpx;
 }
 </style>

+ 135 - 182
src/workbench/fare/index.vue

@@ -1,62 +1,71 @@
 <template>
 	<view class="fare-page">
-		<!-- 设置方式 -->
-		<view class="form-section">
-			<view
-				class="mode-row"
-				:class="{ clickable: canSwitchMode }"
-				@click="openModeSheet"
-			>
-				<text class="mode-label">免车费</text>
-				<text class="mode-value">
-					{{ settingModeLabel }}
-					<text v-if="canSwitchMode" class="chevron">&gt;</text>
-				</text>
-			</view>
-		</view>
-
-		<!-- 统一设置 -->
-		<view class="form-section" v-if="settingMode === 'unified'">
-			<view class="km-row">
-				<view class="km-label-wrap">
-					<text class="km-label">白天免费公里数</text>
-					<text class="km-time">7:30 至 19:30</text>
-				</view>
-				<view class="km-input-wrap">
-					<input
-						class="km-input"
-						v-model="unifiedForm.dayKm"
-						type="number"
-						placeholder="请输入里程"
-						placeholder-class="placeholder"
-						@input="onKmInput('unified', 'dayKm', $event)"
-					/>
-					<text class="km-unit">km</text>
+		<!-- 设置方式 + 统一设置 -->
+		<view class="setting-card">
+			<view class="card-head">
+				<text class="card-title">免车费</text>
+				<view class="mode-switch">
+					<view class="mode-option" @click="selectMode('unified')">
+						<view class="radio-dot" :class="{ checked: settingMode === 'unified' }">
+							<text v-if="settingMode === 'unified'" class="radio-check">✓</text>
+						</view>
+						<text class="mode-text">统一设置</text>
+					</view>
+					<view
+						class="mode-option"
+						:class="{ disabled: !canSwitchMode }"
+						@click="selectMode('byProject')"
+					>
+						<view class="radio-dot" :class="{ checked: settingMode === 'byProject' }">
+							<text v-if="settingMode === 'byProject'" class="radio-check">✓</text>
+						</view>
+						<text class="mode-text">按项目设置</text>
+					</view>
 				</view>
 			</view>
-			<view class="km-row">
-				<view class="km-label-wrap">
-					<text class="km-label">夜间免费公里数</text>
-					<text class="km-time">20:00 至 7:00</text>
+
+			<template v-if="settingMode === 'unified'">
+				<view class="card-divider"></view>
+				<view class="km-row">
+					<view class="km-label-wrap">
+						<text class="km-label">白天免费公里数</text>
+						<text class="km-time">7:30 至 19:30</text>
+					</view>
+					<view class="km-input-wrap">
+						<input
+							class="km-input"
+							v-model="unifiedForm.dayKm"
+							type="number"
+							placeholder="请输入里程"
+							placeholder-class="placeholder"
+							@input="onKmInput('unified', 'dayKm', $event)"
+						/>
+						<text class="km-unit">km</text>
+					</view>
 				</view>
-				<view class="km-input-wrap">
-					<input
-						class="km-input"
-						v-model="unifiedForm.nightKm"
-						type="number"
-						placeholder="请输入里程"
-						placeholder-class="placeholder"
-						@input="onKmInput('unified', 'nightKm', $event)"
-					/>
-					<text class="km-unit">km</text>
+				<view class="km-row">
+					<view class="km-label-wrap">
+						<text class="km-label">夜间免费公里数</text>
+						<text class="km-time">20:00 至 7:00</text>
+					</view>
+					<view class="km-input-wrap">
+						<input
+							class="km-input"
+							v-model="unifiedForm.nightKm"
+							type="number"
+							placeholder="请输入里程"
+							placeholder-class="placeholder"
+							@input="onKmInput('unified', 'nightKm', $event)"
+						/>
+						<text class="km-unit">km</text>
+					</view>
 				</view>
-			</view>
+			</template>
 		</view>
-
-		<!-- 按项目分类设置 -->
-		<view v-if="settingMode === 'byProject'">
+		<!-- 按项目设置 -->
+		<view class="project-list" v-if="settingMode === 'byProject'">
 			<view
-				class="form-section project-block"
+				class="setting-card"
 				v-for="item in projectForms"
 				:key="item.categoryId"
 			>
@@ -64,6 +73,7 @@
 					<text class="service-label">服务</text>
 					<text class="service-name">{{ item.categoryName }}</text>
 				</view>
+				<view class="card-divider"></view>
 				<view class="km-row">
 					<view class="km-label-wrap">
 						<text class="km-label">白天免费公里数</text>
@@ -100,66 +110,33 @@
 				</view>
 			</view>
 		</view>
-
 		<view class="footer-bar">
 			<view class="save-btn" @click="onSave">保存</view>
 		</view>
-
-		<!-- 设置方式底部弹窗 -->
-		<view class="sheet-mask" v-if="showModeSheet" @click="showModeSheet = false">
-			<view class="sheet-panel" @click.stop>
-				<view class="sheet-header">
-					<text class="sheet-title">免车费设置</text>
-					<text class="sheet-close" @click="showModeSheet = false">×</text>
-				</view>
-				<view
-					class="sheet-option"
-					v-for="opt in modeOptions"
-					:key="opt.value"
-					@click="selectMode(opt.value)"
-				>
-					<text class="option-text">{{ opt.label }}</text>
-					<text class="option-check" v-if="settingMode === opt.value">✓</text>
-				</view>
-			</view>
-		</view>
 	</view>
 </template>
-
 <script>
 /** 假数据:用户已开通的服务分类,接口就绪后替换 */
 const MOCK_OPENED_CATEGORIES = [
 	{ id: '1', name: '上门按摩' },
 	{ id: '3', name: '爬山' },
 ]
-
-const MODE_OPTIONS = [
-	{ value: 'unified', label: '统一设置' },
-	{ value: 'byProject', label: '按项目分类设置' },
-]
-
 export default {
 	data() {
 		return {
 			openedCategories: [...MOCK_OPENED_CATEGORIES],
 			settingMode: 'unified',
-			modeOptions: MODE_OPTIONS,
 			unifiedForm: {
 				dayKm: '',
 				nightKm: '',
 			},
 			projectForms: [],
-			showModeSheet: false,
 		}
 	},
 	computed: {
 		canSwitchMode() {
 			return this.openedCategories.length > 1
 		},
-		settingModeLabel() {
-			const opt = MODE_OPTIONS.find(o => o.value === this.settingMode)
-			return opt ? opt.label : '统一设置'
-		},
 	},
 	onLoad() {
 		this.initProjectForms()
@@ -176,13 +153,9 @@ export default {
 				nightKm: '',
 			}))
 		},
-		openModeSheet() {
-			if (!this.canSwitchMode) return
-			this.showModeSheet = true
-		},
 		selectMode(mode) {
+			if (mode === 'byProject' && !this.canSwitchMode) return
 			this.settingMode = mode
-			this.showModeSheet = false
 		},
 		onKmInput(scope, field, e, categoryId) {
 			const val = String(e.detail.value || '').replace(/\D/g, '')
@@ -219,57 +192,92 @@ export default {
 .fare-page {
 	min-height: 100vh;
 	background: #f5f5f5;
-	padding-bottom: 140rpx;
+	padding: 24rpx 24rpx 140rpx;
+	box-sizing: border-box;
 }
 
-.form-section {
+.setting-card {
 	background: #fff;
-	margin-bottom: 16rpx;
+	border-radius: 16rpx;
+	padding: 32rpx;
+	margin-bottom: 20rpx;
 }
 
-.mode-row {
+.card-head {
 	display: flex;
 	align-items: center;
 	justify-content: space-between;
-	padding: 28rpx 32rpx;
-	border-bottom: 1rpx solid #f5f5f5;
+}
+
+.card-title {
+	font-size: 32rpx;
+	color: #333;
+	font-weight: 600;
+	flex-shrink: 0;
+}
+
+.mode-switch {
+	display: flex;
+	align-items: center;
+	gap: 24rpx;
+	margin-left: 16rpx;
+}
 
-	&.clickable:active {
-		background: #fafafa;
+.mode-option {
+	display: flex;
+	align-items: center;
+	gap: 8rpx;
+	&.disabled {
+		opacity: 0.4;
 	}
 }
 
-.mode-label {
-	font-size: 30rpx;
-	color: #333;
+.radio-dot {
+	width: 32rpx;
+	height: 32rpx;
+	border-radius: 50%;
+	border: 2rpx solid #ccc;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	flex-shrink: 0;
+	box-sizing: border-box;
+	&.checked {
+		background: #333;
+		border-color: #333;
+	}
 }
 
-.mode-value {
-	font-size: 28rpx;
-	color: #666;
+.radio-check {
+	font-size: 20rpx;
+	color: #fff;
+	line-height: 1;
 }
 
-.chevron {
-	margin-left: 8rpx;
-	color: #999;
+.mode-text {
+	font-size: 24rpx;
+	color: #333;
+	white-space: nowrap;
+}
+
+.card-divider {
+	height: 1rpx;
+	background: #f0f0f0;
+	margin: 24rpx 0;
 }
 
 .km-row {
 	display: flex;
 	align-items: center;
 	justify-content: space-between;
-	padding: 28rpx 32rpx;
-	border-bottom: 1rpx solid #f5f5f5;
-
-	&:last-child {
-		border-bottom: none;
+	& + .km-row {
+		margin-top: 28rpx;
 	}
 }
 
 .km-label-wrap {
 	flex-shrink: 0;
 }
-
 .km-label {
 	display: block;
 	font-size: 30rpx;
@@ -310,26 +318,28 @@ export default {
 	flex-shrink: 0;
 }
 
-.project-block {
-	margin-bottom: 16rpx;
+
+
+.project-list {
+	display: flex;
+	flex-direction: column;
+	gap: 20rpx;
 }
 
 .service-row {
 	display: flex;
 	align-items: center;
-	padding: 28rpx 32rpx;
-	border-bottom: 1rpx solid #f5f5f5;
+	justify-content: space-between;
 }
 
 .service-label {
 	font-size: 30rpx;
 	color: #333;
-	margin-right: 24rpx;
 }
 
 .service-name {
 	font-size: 28rpx;
-	color: #666;
+	color: #333;
 }
 
 .footer-bar {
@@ -339,77 +349,20 @@ export default {
 	bottom: 0;
 	padding: 20rpx 32rpx;
 	padding-bottom: calc(env(safe-area-inset-bottom) + 20rpx);
-	background: #fff;
-	border-top: 1rpx solid #eee;
 }
 
 .save-btn {
+	margin: 0 auto;
+	width: 654rpx;
 	height: 88rpx;
 	line-height: 88rpx;
 	text-align: center;
-	background: #333;
-	color: #fff;
-	font-size: 32rpx;
-	border-radius: 12rpx;
-}
-
-.sheet-mask {
-	position: fixed;
-	left: 0;
-	top: 0;
-	right: 0;
-	bottom: 0;
-	background: rgba(0, 0, 0, 0.5);
-	z-index: 999;
-	display: flex;
-	align-items: flex-end;
-}
-
-.sheet-panel {
-	width: 100%;
-	background: #fff;
-	border-radius: 24rpx 24rpx 0 0;
-	padding-bottom: calc(env(safe-area-inset-bottom) + 24rpx);
-}
-
-.sheet-header {
-	display: flex;
-	align-items: center;
-	justify-content: center;
-	position: relative;
-	padding: 32rpx;
-	border-bottom: 1rpx solid #eee;
-}
-
-.sheet-title {
+	font-weight: 500;
 	font-size: 32rpx;
-	font-weight: 600;
-	color: #333;
-}
-
-.sheet-close {
-	position: absolute;
-	right: 32rpx;
-	font-size: 40rpx;
-	color: #999;
-	line-height: 1;
-}
-
-.sheet-option {
-	display: flex;
-	align-items: center;
-	justify-content: space-between;
-	padding: 32rpx;
-	border-bottom: 1rpx solid #f5f5f5;
-}
-
-.option-text {
-	font-size: 30rpx;
-	color: #333;
+	color: #fff;
+	background: #333335;
+	border-radius: 60rpx;
 }
 
-.option-check {
-	font-size: 32rpx;
-	color: #0879ff;
-}
 </style>
+

+ 658 - 121
src/workbench/income/index.vue

@@ -1,433 +1,970 @@
 <template>
+
 	<view class="income-page">
+
 		<!-- 筛选栏 -->
+
 		<view class="filter-bar">
-			<view class="filter-item" @click="openFilterModal">
+
+			<view class="filter-item" @click="openServiceModal">
+
 				<text>{{ serviceFilterLabel }}</text>
+
 				<text class="arrow">▼</text>
+
 			</view>
-			<view class="filter-item" @click="openFilterModal">
+
+			<view class="filter-item" @click="openStatusModal">
+
 				<text>{{ statusFilterLabel }}</text>
+
 				<text class="arrow">▼</text>
+
 			</view>
+
 		</view>
 
+
+
 		<!-- 日期 & 合计 -->
+
 		<view class="summary-row">
+
 			<view class="date-range" @click="openDatePicker">
+
 				<text>{{ dateRangeText }}</text>
+
 				<text class="arrow">▼</text>
+
 			</view>
+
 			<text class="total" @click="onTotalClick">合计: {{ totalText }}</text>
+
 		</view>
 
+
+
 		<!-- 收入列表 -->
+
 		<view class="income-list" v-if="displayList.length">
+
 			<view class="income-card" v-for="item in displayList" :key="item.id">
-				<view class="card-left">
-					<view class="category-icon"></view>
-					<view class="card-info">
-						<view class="info-top">
-							<text class="category-name">{{ item.categoryName }}</text>
-						</view>
-						<text class="service-name">{{ item.serviceName }}</text>
-						<text class="order-time">{{ item.completionTime }}</text>
+
+				<view class="card-row card-row-top">
+
+					<view class="category-wrap">
+
+						<view class="category-icon"></view>
+
+						<text class="category-name">{{ item.categoryName }}</text>
+
 					</view>
-				</view>
-				<view class="card-right">
+
 					<text class="settle-status" :class="item.status">
-						{{ item.status === 'settled' ? '已结算' : '待结算' }}
+
+						{{ item.status === 'settled' ? '已结算' : '未结算' }}
+
 					</text>
+
+				</view>
+
+				<view class="card-row card-row-middle">
+
+					<text class="service-name">{{ item.serviceName }}</text>
+
 					<text class="income-amount">+{{ formatAmount(item.incomeAmount) }}</text>
+
+				</view>
+
+				<view class="card-row card-row-bottom">
+
+					<text class="order-time">{{ item.completionTime }}</text>
+
 					<text class="order-total">{{ formatAmount(item.orderTotal) }}</text>
+
 				</view>
+
 			</view>
+
 		</view>
+
 		<view class="empty" v-else>暂无收入记录</view>
 
-		<!-- 筛选弹窗 -->
-		<view class="modal-mask" v-if="showFilterModal" @click="showFilterModal = false">
+
+
+		<!-- 服务分类弹窗 -->
+
+		<view class="modal-mask" v-if="showServiceModal" @click="closeServiceModal">
+
 			<view class="filter-panel" @click.stop>
-				<view class="panel-section">
-					<text class="section-title">服务分类</text>
-					<view class="tag-list">
-						<view
-							v-for="cat in serviceCategories"
-							:key="cat.id"
-							class="tag"
-							:class="{ active: draftCategoryId === cat.id }"
-							@click="draftCategoryId = cat.id"
-						>{{ cat.name }}</view>
-					</view>
+
+				<view class="panel-header">
+
+					<text class="panel-title">服务分类</text>
+
 				</view>
-				<view class="panel-section">
-					<text class="section-title">结算状态</text>
-					<view class="tag-list">
-						<view
-							v-for="opt in settleOptions"
-							:key="opt.key"
-							class="tag"
-							:class="{ active: draftStatus === opt.key }"
-							@click="draftStatus = opt.key"
-						>{{ opt.label }}</view>
-					</view>
+
+				<view class="tag-grid">
+
+					<view
+
+						v-for="cat in serviceCategories"
+
+						:key="cat.id"
+
+						class="tag"
+
+						:class="{ active: draftCategoryId === cat.id }"
+
+						@click="draftCategoryId = cat.id"
+
+					>{{ cat.name }}</view>
+
+				</view>
+
+				<view class="panel-btns">
+
+					<view class="btn cancel" @click="closeServiceModal">取消</view>
+
+					<view class="btn confirm" @click="confirmService">确定</view>
+
+				</view>
+
+			</view>
+
+		</view>
+
+
+
+		<!-- 结算状态弹窗 -->
+
+		<view class="modal-mask" v-if="showStatusModal" @click="closeStatusModal">
+
+			<view class="filter-panel" @click.stop>
+
+				<view class="panel-header">
+
+					<text class="panel-title">结算状态</text>
+
+				</view>
+
+				<view class="tag-row">
+
+					<view
+
+						v-for="opt in settleOptions"
+
+						:key="opt.key"
+
+						class="tag"
+
+						:class="{ active: draftStatus === opt.key }"
+
+						@click="draftStatus = opt.key"
+
+					>{{ opt.label }}</view>
+
 				</view>
+
 				<view class="panel-btns">
-					<view class="btn cancel" @click="showFilterModal = false">取消</view>
-					<view class="btn confirm" @click="confirmFilter">确定</view>
+
+					<view class="btn cancel" @click="closeStatusModal">取消</view>
+
+					<view class="btn confirm" @click="confirmStatus">确定</view>
+
 				</view>
+
 			</view>
+
 		</view>
 
+
+
 		<!-- 日期选择 -->
+
 		<u-datetime-picker
+
 			:show="showDatePicker"
+
 			v-model="pickerValue"
+
 			mode="date"
+
 			@confirm="onDateConfirm"
+
 			@cancel="showDatePicker = false"
+
 		></u-datetime-picker>
+
 	</view>
+
 </template>
 
+
+
 <script>
+
 import {
+
 	SERVICE_CATEGORIES,
+
 	SETTLE_STATUS_OPTIONS,
+
 	MOCK_INCOME_LIST,
+
 	formatIncomeTotal,
+
 	formatDateYmd,
+
 	getMockDateRange,
+
 	parseTimeStr,
+
 } from './mock.js'
 
+
+
 export default {
+
 	data() {
+
 		const range = getMockDateRange()
+
 		return {
+
 			allList: [...MOCK_INCOME_LIST],
+
 			serviceCategories: SERVICE_CATEGORIES,
+
 			settleOptions: SETTLE_STATUS_OPTIONS,
+
 			categoryId: 'all',
+
 			settleStatus: 'all',
+
 			draftCategoryId: 'all',
+
 			draftStatus: 'all',
+
 			dateStart: range.start,
+
 			dateEnd: range.end,
+
 			dateStartTs: range.startTs,
+
 			dateEndTs: range.endTs,
-			showFilterModal: false,
+
+			showServiceModal: false,
+
+			showStatusModal: false,
+
 			showDatePicker: false,
+
 			pickerValue: Date.now(),
+
 			datePickTarget: 'start',
+
 		}
+
 	},
+
 	computed: {
+
 		serviceFilterLabel() {
+
 			if (this.categoryId === 'all') return '全部服务'
+
 			const cat = this.serviceCategories.find(c => c.id === this.categoryId)
+
 			return cat ? cat.name : '全部服务'
+
 		},
+
 		statusFilterLabel() {
-			if (this.settleStatus === 'all') return '结算状态'
+
+			if (this.settleStatus === 'all') return '全部状态'
+
 			const opt = this.settleOptions.find(o => o.key === this.settleStatus)
-			return opt ? opt.label : '结算状态'
+
+			return opt ? opt.label : '全部状态'
+
 		},
+
 		dateRangeText() {
-			return `${this.dateStart} 至 ${this.dateEnd}`
+
+			return `${this.dateStart} - ${this.dateEnd}`
+
 		},
+
 		displayList() {
+
 			let list = [...this.allList]
+
 			if (this.categoryId !== 'all') {
+
 				list = list.filter(item => item.categoryId === this.categoryId)
+
 			}
+
 			if (this.settleStatus === 'settled') {
+
 				list = list.filter(item => item.status === 'settled')
+
 			} else if (this.settleStatus === 'unsettled') {
+
 				list = list.filter(item => item.status === 'unsettled')
+
 			}
+
 			list = list.filter(item => {
+
 				const ts = parseTimeStr(item.completionTime)
+
 				return ts >= this.dateStartTs && ts <= this.dateEndTs + 86400000 - 1
+
 			})
+
 			return list.sort((a, b) => parseTimeStr(b.completionTime) - parseTimeStr(a.completionTime))
+
 		},
+
 		totalAmount() {
+
 			return this.displayList.reduce((sum, item) => sum + Number(item.incomeAmount || 0), 0)
+
 		},
+
 		totalText() {
+
 			return formatIncomeTotal(this.totalAmount)
+
 		},
+
 	},
+
 	methods: {
+
 		formatAmount(val) {
+
 			return Number(val || 0).toFixed(2)
+
 		},
-		openFilterModal() {
+
+		openServiceModal() {
+
 			this.draftCategoryId = this.categoryId
-			this.draftStatus = this.settleStatus
-			this.showFilterModal = true
+
+			this.showServiceModal = true
+
+		},
+
+		closeServiceModal() {
+
+			this.showServiceModal = false
+
 		},
-		confirmFilter() {
+
+		confirmService() {
+
 			this.categoryId = this.draftCategoryId
+
+			this.showServiceModal = false
+
+		},
+
+		openStatusModal() {
+
+			this.draftStatus = this.settleStatus
+
+			this.showStatusModal = true
+
+		},
+
+		closeStatusModal() {
+
+			this.showStatusModal = false
+
+		},
+
+		confirmStatus() {
+
 			this.settleStatus = this.draftStatus
-			this.showFilterModal = false
+
+			this.showStatusModal = false
+
 		},
+
 		openDatePicker() {
+
 			this.datePickTarget = 'start'
+
 			this.pickerValue = this.dateStartTs
+
 			this.showDatePicker = true
+
 		},
+
 		onDateConfirm(e) {
+
 			const ts = e.value
+
 			const formatted = formatDateYmd(new Date(ts))
+
 			if (this.datePickTarget === 'start') {
+
 				this.dateStart = formatted
+
 				this.dateStartTs = ts
+
 				this.datePickTarget = 'end'
+
 				this.pickerValue = this.dateEndTs
+
 				this.showDatePicker = false
+
 				this.$nextTick(() => {
+
 					this.showDatePicker = true
+
 				})
+
 			} else {
+
 				this.dateEnd = formatted
+
 				this.dateEndTs = ts
+
 				if (this.dateStartTs > this.dateEndTs) {
+
 					const tmpS = this.dateStart
+
 					const tmpSt = this.dateStartTs
+
 					this.dateStart = this.dateEnd
+
 					this.dateEnd = tmpS
+
 					this.dateStartTs = this.dateEndTs
+
 					this.dateEndTs = tmpSt
+
 				}
+
 				this.showDatePicker = false
+
 			}
+
 		},
+
 		onTotalClick() {
+
 			uni.showToast({ title: '收入明细待对接', icon: 'none' })
+
 		},
+
 	},
+
 }
+
 </script>
 
+
+
 <style lang="scss" scoped>
+
 .income-page {
+
 	min-height: 100vh;
+
 	background: #f5f5f5;
+
 }
 
+
+
 .filter-bar {
+
 	display: flex;
+
 	background: #fff;
-	border-bottom: 1rpx solid #eee;
+
 }
 
+
+
 .filter-item {
+
 	flex: 1;
+
 	display: flex;
+
 	align-items: center;
+
 	justify-content: center;
-	padding: 24rpx 0;
+
+	padding: 28rpx 0;
+
 	font-size: 28rpx;
-	color: #333;
+
+	color: #666;
+
 }
 
+
+
 .arrow {
-	font-size: 20rpx;
-	color: #999;
+
+	font-size: 18rpx;
+
+	color: #bbb;
+
 	margin-left: 8rpx;
+
+	transform: scale(0.85);
+
 }
 
+
+
 .summary-row {
+
 	display: flex;
+
 	align-items: center;
+
 	justify-content: space-between;
+
 	padding: 20rpx 32rpx;
-	background: #fff;
-	border-bottom: 1rpx solid #eee;
+
+	background: #f5f5f5;
+
 }
 
+
+
 .date-range {
+
 	display: flex;
+
 	align-items: center;
+
 	font-size: 26rpx;
-	color: #333;
+
+	color: #666;
+
 }
 
+
+
 .total {
+
 	font-size: 26rpx;
-	color: #333;
-	font-weight: 500;
+
+	color: #666;
+
 }
 
+
+
 .income-list {
-	padding: 16rpx 0;
+
+	padding: 0 24rpx 32rpx;
+
 }
 
+
+
 .income-card {
+
+	background: #fff;
+
+	border-radius: 16rpx;
+
+	padding: 28rpx 24rpx;
+
+	margin-bottom: 16rpx;
+
+}
+
+
+
+.card-row {
+
 	display: flex;
+
+	align-items: center;
+
 	justify-content: space-between;
-	background: #fff;
-	padding: 28rpx 32rpx;
-	border-bottom: 1rpx solid #f5f5f5;
+
 }
 
-.card-left {
+
+
+.card-row-top {
+
+	margin-bottom: 20rpx;
+
+}
+
+
+
+.category-wrap {
+
 	display: flex;
+
+	align-items: center;
+
 	flex: 1;
+
 	overflow: hidden;
+
+	margin-right: 16rpx;
+
 }
 
+
+
 .category-icon {
-	width: 72rpx;
-	height: 72rpx;
-	background: #eee;
-	border-radius: 8rpx;
+
+	width: 40rpx;
+
+	height: 40rpx;
+
+	background: #3d4f5f;
+
+	border-radius: 50%;
+
 	flex-shrink: 0;
-	margin-right: 20rpx;
-}
 
-.card-info {
-	flex: 1;
-	overflow: hidden;
+	margin-right: 12rpx;
+
 }
 
+
+
 .category-name {
+
 	font-size: 28rpx;
+
 	color: #333;
+
 	font-weight: 500;
-}
 
-.service-name {
-	display: block;
-	font-size: 26rpx;
-	color: #666;
-	margin-top: 8rpx;
 	overflow: hidden;
+
 	text-overflow: ellipsis;
+
 	white-space: nowrap;
-}
 
-.order-time {
-	display: block;
-	font-size: 24rpx;
-	color: #999;
-	margin-top: 12rpx;
 }
 
-.card-right {
-	text-align: right;
-	flex-shrink: 0;
-	margin-left: 16rpx;
-}
+
 
 .settle-status {
-	display: block;
-	font-size: 24rpx;
-	color: #999;
-	margin-bottom: 8rpx;
+
+	flex-shrink: 0;
+
+	font-size: 22rpx;
+
+	padding: 4rpx 16rpx;
+
+	border-radius: 6rpx;
+
+
 
 	&.unsettled {
-		color: #666;
+
+		color: #fa8c16;
+
+		background: #fff7e6;
+
+	}
+
+
+
+	&.settled {
+
+		color: #52c41a;
+
+		background: #f6ffed;
+
 	}
+
+}
+
+
+
+.card-row-middle {
+
+	margin-bottom: 16rpx;
+
+}
+
+
+
+.service-name {
+
+	flex: 1;
+
+	font-size: 28rpx;
+
+	color: #333;
+
+	overflow: hidden;
+
+	text-overflow: ellipsis;
+
+	white-space: nowrap;
+
+	margin-right: 16rpx;
+
 }
 
+
+
 .income-amount {
-	display: block;
+
+	flex-shrink: 0;
+
 	font-size: 32rpx;
+
 	color: #e54d42;
+
 	font-weight: 600;
+
 }
 
+
+
+.card-row-bottom {
+
+	align-items: flex-end;
+
+}
+
+
+
+.order-time {
+
+	font-size: 24rpx;
+
+	color: #bbb;
+
+}
+
+
+
 .order-total {
-	display: block;
+
 	font-size: 24rpx;
-	color: #999;
-	margin-top: 4rpx;
+
+	color: #bbb;
+
 }
 
+
+
 .empty {
+
 	text-align: center;
+
 	padding: 120rpx 0;
+
 	font-size: 28rpx;
+
 	color: #999;
+
 }
 
+
+
 .modal-mask {
+
 	position: fixed;
+
 	left: 0;
+
 	top: 0;
+
 	right: 0;
+
 	bottom: 0;
+
 	background: rgba(0, 0, 0, 0.5);
+
 	z-index: 999;
+
 	display: flex;
+
 	align-items: flex-end;
+
 }
 
+
+
 .filter-panel {
+
 	width: 100%;
+
 	background: #fff;
+
 	border-radius: 24rpx 24rpx 0 0;
-	padding: 32rpx 32rpx calc(env(safe-area-inset-bottom) + 32rpx);
+
+	padding: 0 32rpx calc(env(safe-area-inset-bottom) + 32rpx);
+
 }
 
-.panel-section {
-	margin-bottom: 32rpx;
+
+
+.panel-header {
+
+	padding: 32rpx 0 28rpx;
+
+	border-bottom: 1rpx solid #f0f0f0;
+
 }
 
-.section-title {
+
+
+.panel-title {
+
 	display: block;
-	font-size: 28rpx;
+
+	text-align: center;
+
+	font-size: 30rpx;
+
 	color: #333;
-	font-weight: 600;
-	margin-bottom: 20rpx;
+
+	font-weight: 500;
+
 }
 
-.tag-list {
+
+
+.tag-grid {
+
 	display: flex;
+
 	flex-wrap: wrap;
-	gap: 16rpx;
+
+	gap: 20rpx;
+
+	padding: 32rpx 0 40rpx;
+
+
+
+	.tag {
+
+		width: calc((100% - 40rpx) / 3);
+
+		box-sizing: border-box;
+
+		text-align: center;
+
+	}
+
 }
 
+
+
+.tag-row {
+
+	display: flex;
+
+	gap: 20rpx;
+
+	padding: 32rpx 0 40rpx;
+
+
+
+	.tag {
+
+		flex: 1;
+
+		text-align: center;
+
+	}
+
+}
+
+
+
 .tag {
-	padding: 12rpx 28rpx;
+
+	padding: 18rpx 0;
+
 	font-size: 26rpx;
-	color: #666;
-	border: 1rpx solid #ddd;
+
+	color: #333;
+
+	border: 1rpx solid #e8e8e8;
+
 	border-radius: 8rpx;
+
 	background: #fff;
 
+
+
 	&.active {
-		color: #0879ff;
-		border-color: #0879ff;
-		background: #f0f7ff;
+
+		color: #333;
+
+		border-color: #00c08b;
+
+		background: #e8f8f4;
+
 	}
+
 }
 
+
+
 .panel-btns {
+
 	display: flex;
-	gap: 20rpx;
-	margin-top: 16rpx;
+
+	gap: 24rpx;
+
 }
 
+
+
 .btn {
+
 	flex: 1;
-	height: 80rpx;
-	line-height: 80rpx;
+
+	height: 88rpx;
+
+	line-height: 88rpx;
+
 	text-align: center;
-	border-radius: 8rpx;
+
+	border-radius: 44rpx;
+
 	font-size: 30rpx;
 
+
+
 	&.cancel {
-		border: 1rpx solid #ddd;
-		color: #666;
+
+		background: #f5f5f5;
+
+		color: #333;
+
 	}
 
+
+
 	&.confirm {
-		background: #0879ff;
+
+		background: #333;
+
 		color: #fff;
+
 	}
+
 }
+
 </style>
+

+ 86 - 59
src/workbench/rating/index.vue

@@ -2,16 +2,29 @@
 	<view class="rating-page">
 		<view class="rating-list" v-if="reviewList.length">
 			<view class="rating-card" v-for="item in reviewList" :key="item.id">
-				<text class="service-title">{{ item.serviceName }}</text>
+				<view class="card-header">
+					<view class="user-info">
+						<image
+							v-if="item.avatar"
+							class="avatar"
+							:src="item.avatar"
+							mode="aspectFill"
+						></image>
+						<view v-else class="avatar placeholder"></view>
+						<text class="nickname">{{ item.nickName }}</text>
+					</view>
+					<text class="review-time">{{ item.reviewTime }}</text>
+				</view>
 
 				<view class="star-row">
 					<u-rate
 						:value="item.score"
 						:count="5"
+						:allowHalf="true"
 						readonly
 						size="18"
 						gutter="4"
-						activeColor="#ffca28"
+						activeColor="#FF4D4F"
 						inactiveColor="#e5e5e5"
 					></u-rate>
 					<text class="score-text">{{ formatScore(item.score) }}分</text>
@@ -22,25 +35,23 @@
 						class="review-content"
 						:class="{ collapsed: item.needExpand && !isExpanded(item.id), empty: !item.hasContent }"
 					>{{ item.displayContent }}</text>
-					<text
+					<view
 						v-if="item.needExpand"
 						class="toggle"
 						@click="toggleExpand(item.id)"
-					>{{ isExpanded(item.id) ? '收起' : '展开' }}</text>
+					>
+						<text class="toggle-text">{{ isExpanded(item.id) ? '收起' : '展开' }}</text>
+						<u-icon
+							:name="isExpanded(item.id) ? 'arrow-up' : 'arrow-down'"
+							color="#00C08B"
+							size="12"
+						></u-icon>
+					</view>
 				</view>
 
-				<view class="card-footer">
-					<view class="user-info">
-						<image
-							v-if="item.avatar"
-							class="avatar"
-							:src="item.avatar"
-							mode="aspectFill"
-						></image>
-						<view v-else class="avatar placeholder"></view>
-						<text class="nickname">{{ item.nickName }}</text>
-					</view>
-					<text class="review-time">{{ formatReviewTime(item.createTime) }}</text>
+				<view class="service-tag">
+					<text class="tag-hash">#</text>
+					<text class="tag-text">{{ item.serviceName }}</text>
 				</view>
 			</view>
 		</view>
@@ -54,7 +65,6 @@ import {
 	MOCK_RATING_LIST,
 	normalizeRatingItem,
 	sortByTimeDesc,
-	formatReviewTime,
 	formatScore,
 } from './mock.js'
 
@@ -69,7 +79,6 @@ export default {
 		this.loadList()
 	},
 	methods: {
-		formatReviewTime,
 		formatScore,
 		loadList() {
 			const openId = uni.getStorageSync('wx_copenid')
@@ -120,17 +129,50 @@ export default {
 
 .rating-card {
 	background: #fff;
-	border-radius: 12rpx;
+	border-radius: 16rpx;
 	padding: 28rpx 24rpx;
 }
 
-.service-title {
-	display: block;
-	font-size: 30rpx;
-	font-weight: 600;
+.card-header {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	margin-bottom: 20rpx;
+}
+
+.user-info {
+	display: flex;
+	align-items: center;
+	flex: 1;
+	overflow: hidden;
+	margin-right: 16rpx;
+}
+
+.avatar {
+	width: 64rpx;
+	height: 64rpx;
+	border-radius: 50%;
+	flex-shrink: 0;
+	margin-right: 16rpx;
+
+	&.placeholder {
+		background: #eee;
+	}
+}
+
+.nickname {
+	font-size: 28rpx;
 	color: #333;
-	line-height: 1.4;
-	margin-bottom: 16rpx;
+	font-weight: 500;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+}
+
+.review-time {
+	font-size: 24rpx;
+	color: #999;
+	flex-shrink: 0;
 }
 
 .star-row {
@@ -141,12 +183,13 @@ export default {
 
 .score-text {
 	font-size: 26rpx;
-	color: #666;
+	color: #ff4d4f;
 	margin-left: 12rpx;
 }
 
 .content-wrap {
 	margin-bottom: 20rpx;
+	margin-left: 80rpx;
 }
 
 .review-content {
@@ -169,57 +212,41 @@ export default {
 }
 
 .toggle {
-	display: inline-block;
-	font-size: 26rpx;
-	color: #0879ff;
+	display: inline-flex;
+	align-items: center;
 	margin-top: 8rpx;
 }
 
-.card-footer {
-	display: flex;
-	align-items: center;
-	justify-content: space-between;
-	padding-top: 16rpx;
-	border-top: 1rpx solid #f5f5f5;
+.toggle-text {
+	font-size: 26rpx;
+	color: #00c08b;
+	margin-right: 4rpx;
 }
 
-.user-info {
+.service-tag {
 	display: flex;
 	align-items: center;
-	flex: 1;
-	overflow: hidden;
-	margin-right: 16rpx;
+	line-height: 1.4;
+	margin-left: 80rpx;
 }
 
-.avatar {
-	width: 48rpx;
-	height: 48rpx;
-	border-radius: 50%;
+.tag-hash {
+	font-size: 26rpx;
+	color: #00c08b;
+	margin-right: 4rpx;
 	flex-shrink: 0;
-	margin-right: 12rpx;
-
-	&.placeholder {
-		background: #eee;
-	}
 }
 
-.nickname {
-	font-size: 24rpx;
-	color: #999;
+.tag-text {
+	font-size: 26rpx;
+	color: #666;
 	overflow: hidden;
 	text-overflow: ellipsis;
 	white-space: nowrap;
 }
 
-.review-time {
-	font-size: 24rpx;
-	color: #999;
-	flex-shrink: 0;
-}
-
 .empty {
-	text-align: center;
-	padding: 120rpx 0;
+	padding: 20rpx 0;
 	font-size: 28rpx;
 	color: #999;
 }

+ 49 - 18
src/workbench/rating/mock.js

@@ -8,30 +8,31 @@ export const MOCK_RATING_LIST = [
 	{
 		id: 'r1',
 		serviceName: '斯诺克技术指导-个性化陪练',
-		score: 3.2,
+		score: 3.5,
 		content:
-			'教练很专业,讲解耐心,练了一下午进步明显。场地环境也不错,就是周末人有点多,建议提前预约时间段。',
+			'教练很专业,讲解耐心,练了一下午进步明显。场地环境也不错,就是周末人有点多,建议提前预约时间段。教练会根据你的水平调整教学节奏,从基础走位到高级战术都有涉及,整体体验超出预期,下次还会再来。',
 		avatar: '',
-		nickName: '用户***8',
-		createTime: '2025-06-03 12:25:18',
+		nickName: '你用户昵称',
+		createTime: '2025-05-01 12:25:00',
 	},
 	{
 		id: 'r2',
-		serviceName: '中式八球入门教学',
-		score: 5.0,
-		content: '非常满意,从零基础到能稳定进球,老师节奏把控很好,强烈推荐!',
+		serviceName: '斯诺克技术指导-个性化陪练',
+		score: 5,
+		content: '',
 		avatar: '',
-		nickName: '台球爱好者',
-		createTime: '2025-06-01 18:40:00',
+		nickName: '你用户昵称',
+		createTime: '2025-05-01 12:25:00',
 	},
 	{
 		id: 'r3',
-		serviceName: '九球战术陪练',
-		score: 4.5,
-		content: '',
+		serviceName: '斯诺克技术指导-个性化陪练',
+		score: 3,
+		content:
+			'教练很专业,讲解耐心,练了一下午进步明显。场地环境也不错,就是周末人有点多,建议提前预约时间段。教练会根据你的水平调整教学节奏,从基础走位到高级战术都有涉及,整体体验超出预期,下次还会再来。',
 		avatar: '',
-		nickName: '阿***明',
-		createTime: '2025-05-28 09:15:33',
+		nickName: '你用户昵称',
+		createTime: '2025-05-01 12:25:00',
 	},
 	{
 		id: 'r4',
@@ -56,12 +57,31 @@ export const MOCK_RATING_LIST = [
 
 export function parseTime(str) {
 	if (!str) return 0
-	return new Date(String(str).replace(/-/g, '/')).getTime()
+	if (/^\d+$/.test(String(str).trim())) {
+		const num = Number(str)
+		return str.length <= 10 ? num * 1000 : num
+	}
+	const normalized = String(str).trim().replace('T', ' ').replace(/-/g, '/')
+	const ts = new Date(normalized).getTime()
+	return Number.isFinite(ts) ? ts : 0
 }
 
 export function formatReviewTime(timeStr) {
 	if (!timeStr) return ''
-	const d = new Date(String(timeStr).replace(/-/g, '/'))
+	if (/^\d+$/.test(String(timeStr).trim())) {
+		const num = Number(timeStr)
+		const d = new Date(timeStr.length <= 10 ? num * 1000 : num)
+		if (Number.isFinite(d.getTime())) {
+			return formatReviewTimeFromDate(d)
+		}
+	}
+	const normalized = String(timeStr).trim().replace('T', ' ').replace(/-/g, '/')
+	const d = new Date(normalized)
+	if (!Number.isFinite(d.getTime())) return ''
+	return formatReviewTimeFromDate(d)
+}
+
+function formatReviewTimeFromDate(d) {
 	const m = d.getMonth() + 1
 	const day = d.getDate()
 	const hh = String(d.getHours()).padStart(2, '0')
@@ -71,13 +91,23 @@ export function formatReviewTime(timeStr) {
 
 export function formatScore(score) {
 	const num = Number(score)
-	if (!Number.isFinite(num)) return '0.0'
+	if (!Number.isFinite(num)) return '0'
+	if (Number.isInteger(num)) return String(num)
 	return num.toFixed(1)
 }
 
 export function normalizeRatingItem(raw, index) {
 	const content = (raw.cContent || raw.content || raw.cText || raw.text || '').trim()
 	const score = Number(raw.nStar ?? raw.score ?? 0)
+	const createTime =
+		raw.createTime ||
+		raw.dtCreateTime ||
+		raw.dTime ||
+		raw.cTime ||
+		raw.dtTime ||
+		raw.commentTime ||
+		raw.completionTime ||
+		''
 	return {
 		id: raw.id || raw.cId || `api-${index}`,
 		serviceName:
@@ -92,7 +122,8 @@ export function normalizeRatingItem(raw, index) {
 		hasContent: !!content,
 		avatar: raw.cAvatar || raw.avatar || raw.cHeadImg || '',
 		nickName: raw.cNickName || raw.cName || raw.userName || '用户',
-		createTime: raw.dtCreateTime || raw.dTime || raw.cTime || raw.completionTime || '',
+		createTime,
+		reviewTime: formatReviewTime(createTime),
 		needExpand: content.length > CONTENT_COLLAPSE_LEN,
 	}
 }

+ 283 - 251
src/workbench/skill/add.vue

@@ -1,53 +1,44 @@
 <template>
 	<view class="add-page">
-		<!-- 分类 Tab -->
-		<scroll-view scroll-x class="category-tabs" :show-scrollbar="false">
-			<view
-				v-for="cat in categories"
-				:key="cat.id"
-				class="category-tab"
-				:class="{ active: activeCategory === cat.id }"
-				@click="activeCategory = cat.id"
-			>{{ cat.name }}</view>
-		</scroll-view>
-
+		<!-- 陪玩:子分类 Tab -->
+		<view class="sub-tabs-wrap" v-if="isPlaymate">
+			<u-tabs :list="playmateTabList" :current="activeSubTabIndex" :scrollable="true" lineWidth="30"
+				lineColor="#20CAC2" :activeStyle="{
+					color: '#333',
+					fontWeight: 'bold',
+				}" :inactiveStyle="{
+					color: '#999',
+				}" itemStyle="padding: 0 28rpx; height: 88rpx; font-size: 28rpx;" @change="onSubTabChange"></u-tabs>
+		</view>
 		<!-- 可选服务列表 -->
 		<view class="service-list" v-if="filteredList.length">
-			<view
-				class="service-item"
-				v-for="item in filteredList"
-				:key="item.id"
-				@click="toggleSelect(item.id)"
-			>
-				<view class="checkbox" :class="{ checked: selectedIds.includes(item.id) }">
-					<text v-if="selectedIds.includes(item.id)">✓</text>
+			<view class="service-item" v-for="item in filteredList" :key="item.id" @click="toggleSelect(item.id)">
+				<view class="cover">
+					<image v-if="item.cover" :src="item.cover" mode="aspectFill" />
 				</view>
-				<view class="cover"></view>
 				<view class="info">
 					<text class="title">{{ item.title }}</text>
-					<text class="price">¥{{ item.price }}/{{ item.unit || '次' }}</text>
+					<text class="price">{{ formatAvailablePrice(item) }}</text>
+				</view>
+				<view class="select-icon">
+					<image v-if="selectedIds.includes(item.id)" class="checked-icon"
+						src="@/static/workbench/checkCircle.png" mode="aspectFit" />
+					<view v-else class="unchecked-icon"></view>
 				</view>
 			</view>
 		</view>
 		<view class="empty" v-else>暂无可开通服务</view>
-
 		<!-- 底部栏 -->
 		<view class="footer-bar">
-			<text class="selected-count">已选{{ selectedIds.length }}</text>
+			<text class="selected-count">已选 {{ selectedIds.length }}</text>
 			<view class="apply-btn" @click="onApply">申请开通</view>
 		</view>
-
 		<!-- 申请开通弹窗 -->
 		<view class="modal-mask" v-if="showApplyModal" @click="showApplyModal = false">
 			<view class="modal-box" @click.stop>
 				<view class="modal-title">申请开通</view>
 				<view class="apply-tip">已申请开通{{ selectedIds.length }}项服务</view>
-				<textarea
-					class="apply-reason"
-					v-model="applyReason"
-					placeholder="请输入开通原因"
-					maxlength="200"
-				/>
+				<textarea class="apply-reason" v-model="applyReason" placeholder="请输入开通原因" maxlength="200" />
 				<view class="apply-btns">
 					<view class="btn ghost flex1" @click="showApplyModal = false">取消</view>
 					<view class="btn primary flex1" @click="submitApply">提交申请</view>
@@ -56,252 +47,293 @@
 		</view>
 	</view>
 </template>
-
 <script>
-import { SKILL_CATEGORIES, MOCK_AVAILABLE_SKILLS } from './mock.js'
-
-export default {
-	data() {
-		return {
-			categories: SKILL_CATEGORIES,
-			serviceList: [...MOCK_AVAILABLE_SKILLS],
-			activeCategory: '1',
-			selectedIds: [],
-			showApplyModal: false,
-			applyReason: '',
-		}
-	},
-	computed: {
-		filteredList() {
-			return this.serviceList.filter(item => item.categoryId === this.activeCategory)
-		},
-	},
-	methods: {
-		toggleSelect(id) {
-			const idx = this.selectedIds.indexOf(id)
-			if (idx > -1) {
-				this.selectedIds.splice(idx, 1)
-			} else {
-				this.selectedIds.push(id)
+	import {
+		MAIN_TYPES,
+		PLAYMATE_TABS,
+		MOCK_AVAILABLE_SKILLS,
+		formatAvailablePrice,
+	} from './mock.js'
+	export default {
+		data() {
+			return {
+				mainType: MAIN_TYPES.MASSAGE,
+				playmateTabs: PLAYMATE_TABS,
+				serviceList: [...MOCK_AVAILABLE_SKILLS],
+				activeSubTab: PLAYMATE_TABS[0].id,
+				selectedIds: [],
+				showApplyModal: false,
+				applyReason: '',
 			}
 		},
-		onApply() {
-			if (this.selectedIds.length === 0) {
-				uni.showToast({ title: '请至少选择1条数据', icon: 'none' })
-				return
-			}
-			this.applyReason = ''
-			this.showApplyModal = true
+		computed: {
+			isPlaymate() {
+				return this.mainType === MAIN_TYPES.PLAYMATE
+			},
+			isMassage() {
+				return this.mainType === MAIN_TYPES.MASSAGE
+			},
+			playmateTabList() {
+				return this.playmateTabs.map(item => ({
+					name: item.name
+				}))
+			},
+			activeSubTabIndex() {
+				return this.playmateTabs.findIndex(item => item.id === this.activeSubTab)
+			},
+			filteredList() {
+				if (this.isMassage) {
+					return this.serviceList.filter(item => item.mainType === MAIN_TYPES.MASSAGE)
+				}
+				return this.serviceList.filter(
+					item => item.mainType === MAIN_TYPES.PLAYMATE && item.categoryId === this.activeSubTab
+				)
+			},
 		},
-		submitApply() {
-			if (!this.applyReason.trim()) {
-				uni.showToast({ title: '请输入开通原因', icon: 'none' })
-				return
+		onLoad(query) {
+			const type = query.type || query.mainType || MAIN_TYPES.MASSAGE
+			this.mainType = type === MAIN_TYPES.PLAYMATE ? MAIN_TYPES.PLAYMATE : MAIN_TYPES.MASSAGE
+			if (query.tab && this.isPlaymate) {
+				const matched = this.playmateTabs.find(item => item.id === query.tab)
+				if (matched) this.activeSubTab = matched.id
 			}
-			this.showApplyModal = false
-			this.selectedIds = []
-			uni.showToast({ title: '已提交,待审核', icon: 'none' })
-			setTimeout(() => uni.navigateBack(), 1200)
 		},
-	},
-}
+		methods: {
+			formatAvailablePrice,
+			onSubTabChange(e) {
+				const index = typeof e === 'object' ? e.index : e
+				this.activeSubTab = this.playmateTabs[index]?.id || this.playmateTabs[0].id
+			},
+			toggleSelect(id) {
+				const idx = this.selectedIds.indexOf(id)
+				if (idx > -1) {
+					this.selectedIds.splice(idx, 1)
+				} else {
+					this.selectedIds.push(id)
+				}
+			},
+			onApply() {
+				if (this.selectedIds.length === 0) {
+					uni.showToast({
+						title: '请至少选择1条数据',
+						icon: 'none'
+					})
+					return
+				}
+				this.applyReason = ''
+				this.showApplyModal = true
+			},
+			submitApply() {
+				if (!this.applyReason.trim()) {
+					uni.showToast({
+						title: '请输入开通原因',
+						icon: 'none'
+					})
+					return
+				}
+				this.showApplyModal = false
+				this.selectedIds = []
+				uni.showToast({
+					title: '已提交,待审核',
+					icon: 'none'
+				})
+				setTimeout(() => uni.navigateBack(), 1200)
+			},
+		},
+	}
 </script>
-
 <style lang="scss" scoped>
-.add-page {
-	min-height: 100vh;
-	background: #f5f5f5;
-	padding-bottom: 140rpx;
-}
-
-.category-tabs {
-	white-space: nowrap;
-	background: #fff;
-	padding: 16rpx 24rpx;
-	border-bottom: 1rpx solid #eee;
-}
-
-.category-tab {
-	display: inline-block;
-	padding: 16rpx 24rpx;
-	font-size: 28rpx;
-	color: #666;
-	margin-right: 8rpx;
-	border-bottom: 4rpx solid transparent;
-
-	&.active {
+	.add-page {
+		min-height: 100vh;
+		background: #f5f5f5;
+		padding-bottom: calc(env(safe-area-inset-bottom) + 140rpx);
+		box-sizing: border-box;
+	}
+	.sub-tabs-wrap {
+		background: #fff;
+		border-bottom: 1rpx solid #f0f0f0;
+	}
+	.service-list {
+		padding: 24rpx;
+	}
+	.service-item {
+		display: flex;
+		align-items: center;
+		background: #fff;
+		border-radius: 16rpx;
+		padding: 24rpx;
+		margin-bottom: 20rpx;
+	}
+	.cover {
+		width: 120rpx;
+		height: 120rpx;
+		background: #eee;
+		border-radius: 12rpx;
+		flex-shrink: 0;
+		overflow: hidden;
+		image {
+			width: 100%;
+			height: 100%;
+		}
+	}
+	.info {
+		flex: 1;
+		margin: 0 20rpx;
+		overflow: hidden;
+		min-width: 0;
+	}
+	.title {
+		display: block;
+		font-size: 28rpx;
 		color: #333;
-		font-weight: 600;
-		border-bottom-color: #333;
+		font-weight: 500;
+		line-height: 1.5;
+		overflow: hidden;
+		text-overflow: ellipsis;
+		display: -webkit-box;
+		-webkit-line-clamp: 2;
+		-webkit-box-orient: vertical;
 	}
-}
-
-.service-list {
-	padding: 24rpx;
-}
-
-.service-item {
-	display: flex;
-	align-items: center;
-	background: #fff;
-	border: 1rpx solid #eee;
-	border-radius: 12rpx;
-	padding: 24rpx;
-	margin-bottom: 16rpx;
-}
-
-.checkbox {
-	width: 40rpx;
-	height: 40rpx;
-	border: 2rpx solid #ccc;
-	border-radius: 50%;
-	margin-right: 20rpx;
-	display: flex;
-	align-items: center;
-	justify-content: center;
-	flex-shrink: 0;
-	font-size: 24rpx;
-	color: #fff;
-
-	&.checked {
-		background: #333;
-		border-color: #333;
+	.price {
+		display: block;
+		margin-top: 12rpx;
+		font-size: 28rpx;
+		color: #ff6b4a;
+		font-weight: 500;
 	}
-}
 
-.cover {
-	width: 100rpx;
-	height: 100rpx;
-	background: #eee;
-	border-radius: 8rpx;
-	flex-shrink: 0;
-	margin-right: 20rpx;
-}
-
-.info {
-	flex: 1;
-	overflow: hidden;
-}
-
-.title {
-	display: block;
-	font-size: 28rpx;
-	color: #333;
-	margin-bottom: 8rpx;
-}
-
-.price {
-	font-size: 26rpx;
-	color: #e54d42;
-}
-
-.empty {
-	text-align: center;
-	padding: 120rpx 0;
-	font-size: 28rpx;
-	color: #999;
-}
-
-.footer-bar {
-	position: fixed;
-	left: 0;
-	right: 0;
-	bottom: 0;
-	display: flex;
-	align-items: center;
-	justify-content: space-between;
-	padding: 20rpx 32rpx;
-	padding-bottom: calc(env(safe-area-inset-bottom) + 20rpx);
-	background: #fff;
-	border-top: 1rpx solid #eee;
-}
+	.select-icon {
+		flex-shrink: 0;
+		width: 44rpx;
+		height: 44rpx;
+		display: flex;
+		align-items: center;
+		justify-content: center;
+	}
 
-.selected-count {
-	font-size: 28rpx;
-	color: #333;
-}
+	.checked-icon {
+		width: 44rpx;
+		height: 44rpx;
+	}
 
-.apply-btn {
-	padding: 20rpx 48rpx;
-	background: #333;
-	color: #fff;
-	font-size: 28rpx;
-	border-radius: 8rpx;
-}
+	.unchecked-icon {
+		width: 40rpx;
+		height: 40rpx;
+		border: 2rpx solid #d9d9d9;
+		border-radius: 50%;
+		box-sizing: border-box;
+	}
 
-.modal-mask {
-	position: fixed;
-	left: 0;
-	top: 0;
-	right: 0;
-	bottom: 0;
-	background: rgba(0, 0, 0, 0.5);
-	z-index: 999;
-	display: flex;
-	align-items: center;
-	justify-content: center;
-	padding: 48rpx;
-	box-sizing: border-box;
-}
+	.empty {
+		text-align: center;
+		padding: 120rpx 0;
+		font-size: 28rpx;
+		color: #999;
+	}
 
-.modal-box {
-	width: 100%;
-	max-width: 600rpx;
-	background: #fff;
-	border-radius: 16rpx;
-	padding-bottom: 32rpx;
-}
+	.footer-bar {
+		position: fixed;
+		left: 0;
+		right: 0;
+		bottom: 0;
+		display: flex;
+		align-items: center;
+		justify-content: space-between;
+		padding: 20rpx 32rpx;
+		padding-bottom: calc(env(safe-area-inset-bottom) + 20rpx);
+		background: #fff;
+		border-top: 1rpx solid #eee;
+		z-index: 10;
+	}
 
-.modal-title {
-	font-size: 32rpx;
-	font-weight: 600;
-	text-align: center;
-	padding: 32rpx 32rpx 16rpx;
-}
+	.selected-count {
+		font-size: 28rpx;
+		color: #666;
+	}
 
-.apply-tip {
-	text-align: center;
-	font-size: 26rpx;
-	color: #666;
-	padding-bottom: 16rpx;
-}
+	.apply-btn {
+		min-width: 240rpx;
+		padding: 22rpx 48rpx;
+		background: #333;
+		color: #fff;
+		font-size: 28rpx;
+		border-radius: 44rpx;
+		text-align: center;
+		line-height: 1;
+	}
 
-.apply-reason {
-	display: block;
-	width: calc(100% - 64rpx);
-	margin: 0 32rpx 24rpx;
-	min-height: 200rpx;
-	padding: 20rpx;
-	border: 1rpx solid #ddd;
-	border-radius: 8rpx;
-	font-size: 26rpx;
-	box-sizing: border-box;
-}
+	.modal-mask {
+		position: fixed;
+		left: 0;
+		top: 0;
+		right: 0;
+		bottom: 0;
+		background: rgba(0, 0, 0, 0.5);
+		z-index: 999;
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		padding: 48rpx;
+		box-sizing: border-box;
+	}
 
-.apply-btns {
-	display: flex;
-	gap: 16rpx;
-	padding: 0 32rpx;
-}
+	.modal-box {
+		width: 100%;
+		max-width: 600rpx;
+		background: #fff;
+		border-radius: 16rpx;
+		padding-bottom: 32rpx;
+	}
 
-.btn {
-	padding: 20rpx;
-	font-size: 28rpx;
-	border-radius: 8rpx;
-	text-align: center;
+	.modal-title {
+		font-size: 32rpx;
+		font-weight: 600;
+		text-align: center;
+		padding: 32rpx 32rpx 16rpx;
+	}
 
-	&.ghost {
-		border: 1rpx solid #ccc;
+	.apply-tip {
+		text-align: center;
+		font-size: 26rpx;
 		color: #666;
+		padding-bottom: 16rpx;
 	}
 
-	&.primary {
-		background: #333;
-		color: #fff;
+	.apply-reason {
+		display: block;
+		width: calc(100% - 64rpx);
+		margin: 0 32rpx 24rpx;
+		min-height: 200rpx;
+		padding: 20rpx;
+		border: 1rpx solid #ddd;
+		border-radius: 8rpx;
+		font-size: 26rpx;
+		box-sizing: border-box;
 	}
 
-	&.flex1 {
-		flex: 1;
+	.apply-btns {
+		display: flex;
+		gap: 16rpx;
+		padding: 0 32rpx;
+	}
+
+	.btn {
+		padding: 20rpx;
+		font-size: 28rpx;
+		border-radius: 8rpx;
+		text-align: center;
+		&.ghost {
+			border: 1rpx solid #ccc;
+			color: #666;
+		}
+
+		&.primary {
+			background: #333;
+			color: #fff;
+		}
+
+		&.flex1 {
+			flex: 1;
+		}
 	}
-}
-</style>
+</style>

+ 225 - 202
src/workbench/skill/edit.vue

@@ -2,232 +2,255 @@
 	<view class="edit-page">
 		<!-- 项目摘要 -->
 		<view class="summary">
-			<view class="cover"></view>
+			<view class="cover">
+				<image v-if="detail.cover" :src="detail.cover" mode="aspectFill" />
+			</view>
 			<view class="summary-info">
 				<text class="title">{{ detail.title }}</text>
 				<text class="billing">{{ detail.billingType }}</text>
 			</view>
 		</view>
-
-		<!-- 只读信息行 -->
-		<view class="info-section">
-			<view class="info-row">
+		<!-- 表单区域 -->
+		<view class="form-section">
+			<view class="form-row">
 				<text class="label">服务分类</text>
-				<text class="value">{{ detail.categoryName }} &gt;</text>
+				<text class="value">{{ detail.categoryName }}</text>
+				<image class="chevron" src="@/static/workbench/right.png" mode=""></image>
 			</view>
-			<view class="info-row">
+			<view class="form-row">
 				<text class="label">价格</text>
-				<text class="value">¥{{ detail.platformPrice }} &gt;</text>
+				<text class="value">{{ platformPriceText }}</text>
+				<image class="chevron" src="@/static/workbench/right.png" mode=""></image>
 			</view>
-			<view class="info-row">
+			<view class="form-row">
 				<text class="label">价格区间</text>
 				<text class="value">{{ priceRangeText }}</text>
 			</view>
+			<view class="price-block">
+				<view class="form-row no-border">
+					<text class="label">我的售价</text>
+					<input class="price-input" v-model="myPrice" type="digit" placeholder="请输入"
+						placeholder-class="placeholder" />
+				</view>
+				<text class="price-tip">
+					设置您的服务价格,请参照价格区间的最高价和最低价,不允许高于最高价或者低于最低价
+				</text>
+			</view>
 		</view>
-
-		<!-- 我的售价 -->
-		<view class="price-section">
-			<text class="section-label">我的售价</text>
-			<input
-				class="price-input"
-				v-model="myPrice"
-				type="digit"
-				placeholder="请输入"
-			/>
-			<text class="price-tip">
-				设置您的服务价格,请参照价格区间的最高价和最低价,不允许高于最高价或者低于最低价
-			</text>
-		</view>
-
 		<view class="footer-bar">
-			<view class="submit-btn" :class="{ active: canSubmit }" @click="onSubmit">提交</view>
+			<view class="submit-btn" @click="onSubmit">提交</view>
 		</view>
 	</view>
 </template>
-
 <script>
-import { formatPriceRange } from './mock.js'
-
-export default {
-	data() {
-		return {
-			detail: {
-				id: '',
-				title: '',
-				categoryName: '',
-				billingType: '',
-				platformPrice: '',
-				priceRangeMin: '',
-				priceRangeMax: '',
-			},
-			myPrice: '',
-		}
-	},
-	computed: {
-		priceRangeText() {
-			const { priceRangeMin, priceRangeMax } = this.detail
-			if (priceRangeMin && priceRangeMax) {
-				return formatPriceRange(priceRangeMin, priceRangeMax)
+	export default {
+		data() {
+			return {
+				detail: {
+					id: '',
+					title: '',
+					cover: '',
+					categoryName: '',
+					billingType: '',
+					platformPrice: '',
+					priceRangeMin: '',
+					priceRangeMax: '',
+				},
+				myPrice: '',
 			}
-			return '-'
 		},
-		canSubmit() {
-			return this.myPrice !== '' && this.validatePrice()
-		},
-	},
-	onLoad(query) {
-		this.detail = {
-			id: query.id || '',
-			title: query.title || '中式推拿-培元疏通',
-			categoryName: query.categoryName || '上门按摩',
-			billingType: query.billingType || '按分钟计费',
-			platformPrice: query.platformPrice || '368.00',
-			priceRangeMin: Number(query.priceRangeMin) || 288,
-			priceRangeMax: Number(query.priceRangeMax) || 368,
-		}
-		this.myPrice = query.myPrice || ''
-	},
-	methods: {
-		validatePrice() {
-			const val = Number(this.myPrice)
-			if (isNaN(val)) return false
-			return val >= this.detail.priceRangeMin && val <= this.detail.priceRangeMax
+		computed: {
+			platformPriceText() {
+				const price = Number(this.detail.platformPrice)
+				if (isNaN(price)) return '-'
+				return `¥ ${price.toFixed(2)}`
+			},
+			priceRangeText() {
+				const {
+					priceRangeMin,
+					priceRangeMax
+				} = this.detail
+				if (priceRangeMin != null && priceRangeMax != null) {
+					const max = Number(priceRangeMax).toFixed(2)
+					const min = Number(priceRangeMin).toFixed(2)
+					return `¥ ${max} 至 ${min}`
+				}
+				return '-'
+			},
 		},
-		onSubmit() {
-			if (!this.myPrice) {
-				uni.showToast({ title: '请输入售价', icon: 'none' })
-				return
+		onLoad(query) {
+			this.detail = {
+				id: query.id || '',
+				title: query.title || '中式推拿-培元疏通',
+				cover: query.cover || '',
+				categoryName: query.categoryName || '上门按摩',
+				billingType: query.billingType || '按分钟计费',
+				platformPrice: query.platformPrice || '368.00',
+				priceRangeMin: Number(query.priceRangeMin) || 288,
+				priceRangeMax: Number(query.priceRangeMax) || 368,
 			}
-			if (!this.validatePrice()) {
+			this.myPrice = query.myPrice || ''
+		},
+		methods: {
+			validatePrice() {
+				const val = Number(this.myPrice)
+				if (isNaN(val)) return false
+				return val >= this.detail.priceRangeMin && val <= this.detail.priceRangeMax
+			},
+			onSubmit() {
+				if (!this.myPrice) {
+					uni.showToast({
+						title: '请输入售价',
+						icon: 'none'
+					})
+					return
+				}
+				if (!this.validatePrice()) {
+					uni.showToast({
+						title: `价格需在${this.detail.priceRangeMin}-${this.detail.priceRangeMax}之间`,
+						icon: 'none',
+					})
+					return
+				}
 				uni.showToast({
-					title: `价格需在${this.detail.priceRangeMin}-${this.detail.priceRangeMax}之间`,
-					icon: 'none',
+					title: '修改完成',
+					icon: 'none'
 				})
-				return
-			}
-			uni.showToast({ title: '修改完成', icon: 'none' })
-			setTimeout(() => uni.navigateBack(), 1000)
+				setTimeout(() => uni.navigateBack(), 1000)
+			},
 		},
-	},
-}
+	}
 </script>
-
 <style lang="scss" scoped>
-.edit-page {
-	min-height: 100vh;
-	background: #f5f5f5;
-	padding-bottom: 140rpx;
-}
-
-.summary {
-	display: flex;
-	align-items: center;
-	background: #fff;
-	padding: 32rpx;
-	margin-bottom: 16rpx;
-}
-
-.cover {
-	width: 120rpx;
-	height: 120rpx;
-	background: #eee;
-	border-radius: 8rpx;
-	flex-shrink: 0;
-}
-
-.summary-info {
-	margin-left: 24rpx;
-}
-
-.title {
-	display: block;
-	font-size: 30rpx;
-	color: #333;
-	font-weight: 500;
-	margin-bottom: 8rpx;
-}
-
-.billing {
-	font-size: 24rpx;
-	color: #999;
-}
-
-.info-section {
-	background: #fff;
-	margin-bottom: 16rpx;
-}
-
-.info-row {
-	display: flex;
-	align-items: center;
-	justify-content: space-between;
-	padding: 28rpx 32rpx;
-	border-bottom: 1rpx solid #eee;
-	font-size: 28rpx;
-
-	&:last-child {
-		border-bottom: none;
-	}
-}
-
-.label {
-	color: #333;
-}
-
-.value {
-	color: #666;
-}
-
-.price-section {
-	background: #fff;
-	padding: 32rpx;
-}
-
-.section-label {
-	display: block;
-	font-size: 28rpx;
-	color: #333;
-	margin-bottom: 20rpx;
-}
-
-.price-input {
-	width: 100%;
-	height: 80rpx;
-	border-bottom: 1rpx solid #eee;
-	font-size: 30rpx;
-	color: #333;
-	margin-bottom: 20rpx;
-}
-
-.price-tip {
-	font-size: 24rpx;
-	color: #999;
-	line-height: 1.6;
-}
-
-.footer-bar {
-	position: fixed;
-	left: 0;
-	right: 0;
-	bottom: 0;
-	padding: 20rpx 32rpx;
-	padding-bottom: calc(env(safe-area-inset-bottom) + 20rpx);
-	background: #fff;
-	border-top: 1rpx solid #eee;
-}
-
-.submit-btn {
-	height: 88rpx;
-	line-height: 88rpx;
-	text-align: center;
-	border-radius: 12rpx;
-	font-size: 32rpx;
-	color: #999;
-	background: #f0f0f0;
-
-	&.active {
-		color: #fff;
-		background: #333;
-	}
-}
-</style>
+	.edit-page {
+		min-height: 100vh;
+		background: #f5f5f5;
+		padding-bottom: calc(env(safe-area-inset-bottom) + 140rpx);
+		box-sizing: border-box;
+	}
+
+	.summary {
+		display: flex;
+		align-items: center;
+		background: #fff;
+		padding: 32rpx;
+		margin-bottom: 16rpx;
+	}
+
+	.cover {
+		width: 120rpx;
+		height: 120rpx;
+		background: #eee;
+		border-radius: 12rpx;
+		flex-shrink: 0;
+		overflow: hidden;
+
+		image {
+			width: 100%;
+			height: 100%;
+		}
+	}
+
+	.summary-info {
+		flex: 1;
+		margin-left: 24rpx;
+		min-width: 0;
+	}
+
+	.title {
+		display: block;
+		font-size: 30rpx;
+		color: #333;
+		font-weight: 500;
+		line-height: 1.5;
+		margin-bottom: 8rpx;
+	}
+
+	.billing {
+		font-size: 24rpx;
+		color: #999;
+	}
+
+	.form-section {
+		background: #fff;
+	}
+
+	.form-row {
+		display: flex;
+		align-items: center;
+		padding: 28rpx 32rpx;
+		border-bottom: 1rpx solid #f5f5f5;
+
+		&.no-border {
+			border-bottom: none;
+			padding-bottom: 16rpx;
+		}
+	}
+
+	.label {
+		width: 160rpx;
+		flex-shrink: 0;
+		font-size: 30rpx;
+		color: #333;
+	}
+
+	.value {
+		flex: 1;
+		text-align: right;
+		font-size: 28rpx;
+		color: #333;
+	}
+
+	.chevron {
+		width: 25rpx;
+		height: 25rpx;
+	}
+
+	.price-block {
+		border-top: 1rpx solid #f5f5f5;
+	}
+
+	.price-input {
+		flex: 1;
+		text-align: right;
+		font-size: 28rpx;
+		color: #333;
+		height: 44rpx;
+		line-height: 44rpx;
+	}
+
+	.placeholder {
+		color: #ccc;
+		font-size: 28rpx;
+	}
+
+	.price-tip {
+		display: block;
+		padding: 0 32rpx 28rpx;
+		font-size: 24rpx;
+		color: #999;
+		line-height: 1.6;
+	}
+
+	.footer-bar {
+		position: fixed;
+		left: 0;
+		right: 0;
+		bottom: 0;
+		padding: 20rpx 32rpx;
+		padding-bottom: calc(env(safe-area-inset-bottom) + 20rpx);
+	}
+
+	.submit-btn {
+		margin: 0 auto;
+		width: 654rpx;
+		height: 88rpx;
+		line-height: 88rpx;
+		text-align: center;
+		font-weight: 500;
+		font-size: 32rpx;
+		color: #FFFFFF;
+		background: #333335;
+		border-radius: 60rpx 60rpx 60rpx 60rpx;
+	}
+</style>

+ 3 - 1
src/workbench/skill/index.vue

@@ -145,12 +145,14 @@ export default {
 			}
 		},
 		goAdd() {
-			uni.navigateTo({ url: '/workbench/skill/add' })
+			// type=playmate  是陪玩
+			uni.navigateTo({ url: '/workbench/skill/add?type=playmate' })
 		},
 		goEdit(item) {
 			const str = uni.$u.queryParams({
 				id: item.id,
 				title: item.title,
+				cover: item.cover || '',
 				categoryName: this.categories.find(c => c.id === item.categoryId)?.name || '',
 				billingType: item.billingType || '',
 				platformPrice: item.platformPrice,

+ 303 - 6
src/workbench/skill/mock.js

@@ -1,124 +1,421 @@
 /** 技能模块假数据,接口就绪后替换 */
 
+
+
+export const MAIN_TYPES = {
+
+	MASSAGE: 'massage',
+
+	PLAYMATE: 'playmate',
+
+}
+
+
+
 export const SKILL_CATEGORIES = [
+
 	{ id: '1', name: '上门按摩' },
+
 	{ id: '2', name: '台球' },
+
 	{ id: '3', name: '爬山' },
+
 	{ id: '4', name: '电影' },
+
 	{ id: '5', name: '健身运动' },
+
 	{ id: '6', name: '读书学习' },
+
 ]
 
+
+
+/** 陪玩子分类 Tab(add 页使用) */
+
+export const PLAYMATE_TABS = [
+
+	{ id: 'p1', name: '羽毛球' },
+
+	{ id: 'p2', name: '爬山' },
+
+	{ id: 'p3', name: '电影' },
+
+	{ id: 'p4', name: '台球' },
+
+	{ id: 'p5', name: '健身运动' },
+
+]
+
+
+
 /** status: opened | pending | rejected */
+
 export const MOCK_MY_SKILLS = [
+
 	{
+
 		id: 's1',
+
 		categoryId: '1',
+
 		title: '中式推拿-培元疏通',
+
 		cover: '',
+
 		price: 200,
+
 		priceMin: null,
+
 		priceMax: null,
+
 		unit: '次',
+
 		soldCount: 200,
+
 		status: 'opened',
+
 		applyDate: '2025年12月1日申请开通服务',
+
 		rejectReason: '',
+
 		billingType: '按分钟计费',
+
 		platformPrice: 368,
+
 		priceRangeMin: 288,
+
 		priceRangeMax: 368,
+
 		canEdit: true,
+
 	},
+
 	{
+
 		id: 's2',
+
 		categoryId: '1',
+
 		title: '中式推拿-培元疏通',
+
 		cover: '',
+
 		price: 200,
+
 		priceMin: 200,
+
 		priceMax: 260,
+
 		unit: '次',
+
 		soldCount: 0,
+
 		status: 'rejected',
+
 		applyDate: '',
+
 		rejectReason: '申请上架审核驳回:当前时段该服务不支持申请开通,请稍后再试或联系客服了解详情。',
+
 		billingType: '按分钟计费',
+
 		platformPrice: 368,
+
 		priceRangeMin: 288,
+
 		priceRangeMax: 368,
+
 		canEdit: false,
+
 	},
+
 	{
+
 		id: 's3',
+
 		categoryId: '1',
+
 		title: '中式推拿-培元疏通',
+
 		cover: '',
+
 		price: 200,
+
 		priceMin: 200,
+
 		priceMax: 260,
+
 		unit: '次',
+
 		soldCount: 0,
+
 		status: 'pending',
+
 		applyDate: '',
+
 		rejectReason: '',
+
 		billingType: '按分钟计费',
+
 		platformPrice: 368,
+
 		priceRangeMin: 288,
+
 		priceRangeMax: 368,
+
 		canEdit: false,
+
 	},
+
 ]
 
+
+
 /** 可开通的服务(未开通) */
+
 export const MOCK_AVAILABLE_SKILLS = [
+
 	{
+
 		id: 'a1',
+
+		mainType: MAIN_TYPES.MASSAGE,
+
 		categoryId: '1',
-		title: '中式推拿-培元疏通',
+
+		title: '中式推拿-培元疏通标题标题标题标题标题',
+
 		cover: '',
-		price: 200,
+
+		price: 175,
+
 		unit: '次',
+
 	},
+
 	{
+
 		id: 'a2',
+
+		mainType: MAIN_TYPES.MASSAGE,
+
 		categoryId: '1',
+
 		title: '泰式SPA-舒缓放松',
+
 		cover: '',
+
 		price: 268,
+
 		unit: '次',
+
 	},
+
 	{
+
 		id: 'a3',
-		categoryId: '2',
-		title: '中式8球教学',
+
+		mainType: MAIN_TYPES.MASSAGE,
+
+		categoryId: '1',
+
+		title: '精油开背-深度调理',
+
+		cover: '',
+
+		price: 198,
+
+		unit: '次',
+
+	},
+
+	{
+
+		id: 'a4',
+
+		mainType: MAIN_TYPES.MASSAGE,
+
+		categoryId: '1',
+
+		title: '足疗养生-经典套餐',
+
+		cover: '',
+
+		price: 128,
+
+		unit: '次',
+
+	},
+
+	{
+
+		id: 'a5',
+
+		mainType: MAIN_TYPES.PLAYMATE,
+
+		categoryId: 'p1',
+
+		title: '羽毛球陪练-初级教学',
+
 		cover: '',
+
 		price: 128,
+
 		unit: '小时',
+
 	},
+
 	{
-		id: 'a4',
-		categoryId: '3',
+
+		id: 'a6',
+
+		mainType: MAIN_TYPES.PLAYMATE,
+
+		categoryId: 'p1',
+
+		title: '羽毛球双打搭档',
+
+		cover: '',
+
+		price: 150,
+
+		unit: '小时',
+
+	},
+
+	{
+
+		id: 'a7',
+
+		mainType: MAIN_TYPES.PLAYMATE,
+
+		categoryId: 'p2',
+
 		title: '户外徒步向导',
+
 		cover: '',
+
 		price: 150,
+
+		unit: '次',
+
+	},
+
+	{
+
+		id: 'a8',
+
+		mainType: MAIN_TYPES.PLAYMATE,
+
+		categoryId: 'p2',
+
+		title: '登山陪爬-周末专线',
+
+		cover: '',
+
+		price: 180,
+
+		unit: '次',
+
+	},
+
+	{
+
+		id: 'a9',
+
+		mainType: MAIN_TYPES.PLAYMATE,
+
+		categoryId: 'p3',
+
+		title: '影院观影陪伴',
+
+		cover: '',
+
+		price: 88,
+
 		unit: '次',
+
+	},
+
+	{
+
+		id: 'a10',
+
+		mainType: MAIN_TYPES.PLAYMATE,
+
+		categoryId: 'p4',
+
+		title: '中式8球教学',
+
+		cover: '',
+
+		price: 128,
+
+		unit: '小时',
+
+	},
+
+	{
+
+		id: 'a11',
+
+		mainType: MAIN_TYPES.PLAYMATE,
+
+		categoryId: 'p5',
+
+		title: '健身房私教陪练',
+
+		cover: '',
+
+		price: 168,
+
+		unit: '小时',
+
 	},
+
 ]
 
+
+
 export const STATUS_TABS = [
+
 	{ key: 'opened', label: '已开通' },
+
 	{ key: 'pending', label: '申请中' },
+
 	{ key: 'rejected', label: '申请驳回' },
+
 ]
 
+
+
 export function formatPrice(item) {
+
 	if (item.priceMin != null && item.priceMax != null) {
+
 		return `¥${item.priceMin} 至 ${item.priceMax}/${item.unit || '次'}`
+
 	}
+
 	return `¥${item.price}/${item.unit || '次'}`
+
+}
+
+
+
+export function formatAvailablePrice(item) {
+
+	const price = Number(item.price || 0).toFixed(2)
+
+	return `¥ ${price}/${item.unit || '次'}`
+
 }
 
+
+
 export function formatPriceRange(min, max) {
+
 	return `¥${min} 至 ${max}`
+
 }
+
+

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio