Explorar el Código

完成表单生成提交

yuanmingze hace 2 meses
padre
commit
4065f5d238

+ 4 - 0
components.d.ts

@@ -12,6 +12,8 @@ declare module 'vue' {
     FormField: typeof import('./src/components/FormField/index.vue')['default']
     WdActionSheet: typeof import('@wot-ui/ui/components/wd-action-sheet/wd-action-sheet.vue')['default']
     WdButton: typeof import('@wot-ui/ui/components/wd-button/wd-button.vue')['default']
+    WdCalendar: typeof import('@wot-ui/ui/components/wd-calendar/wd-calendar.vue')['default']
+    WdCascader: typeof import('@wot-ui/ui/components/wd-cascader/wd-cascader.vue')['default']
     WdCell: typeof import('@wot-ui/ui/components/wd-cell/wd-cell.vue')['default']
     WdCheckbox: typeof import('@wot-ui/ui/components/wd-checkbox/wd-checkbox.vue')['default']
     WdConfigProvider: typeof import('@wot-ui/ui/components/wd-config-provider/wd-config-provider.vue')['default']
@@ -29,8 +31,10 @@ declare module 'vue' {
     WdPopup: typeof import('@wot-ui/ui/components/wd-popup/wd-popup.vue')['default']
     WdProgress: typeof import('@wot-ui/ui/components/wd-progress/wd-progress.vue')['default']
     WdSearch: typeof import('@wot-ui/ui/components/wd-search/wd-search.vue')['default']
+    WdSelectPicker: typeof import('@wot-ui/ui/components/wd-select-picker/wd-select-picker.vue')['default']
     WdSwiper: typeof import('@wot-ui/ui/components/wd-swiper/wd-swiper.vue')['default']
     WdTag: typeof import('@wot-ui/ui/components/wd-tag/wd-tag.vue')['default']
+    WdTextarea: typeof import('@wot-ui/ui/components/wd-textarea/wd-textarea.vue')['default']
     WdToast: typeof import('@wot-ui/ui/components/wd-toast/wd-toast.vue')['default']
   }
 }

+ 1 - 0
package.json

@@ -32,6 +32,7 @@
     "@dcloudio/uni-mp-xhs": "3.0.0-4080420251103001",
     "@dcloudio/uni-quickapp-webview": "3.0.0-4080420251103001",
     "@dcloudio/uni-ui": "^1.5.11",
+    "@vant/area-data": "^2.1.0",
     "@wot-ui/ui": "^2.1.0",
     "async-validator": "^4.2.5",
     "dayjs": "^1.11.19",

+ 8 - 0
pnpm-lock.yaml

@@ -59,6 +59,9 @@ importers:
       '@dcloudio/uni-ui':
         specifier: ^1.5.11
         version: 1.5.11
+      '@vant/area-data':
+        specifier: ^2.1.0
+        version: 2.1.0
       '@wot-ui/ui':
         specifier: ^2.1.0
         version: 2.1.0(vue@3.5.26(typescript@4.9.5))
@@ -1922,6 +1925,9 @@ packages:
     peerDependencies:
       vite: ^5.0.0
 
+  '@vant/area-data@2.1.0':
+    resolution: {integrity: sha512-wx9PrUX7wSUJiFcz8UrcvZfTjV6sTc+7SHcbjGQQzEcv5y+EwOo5uV4ZKdfrR5Hzcw4MA08LQdvXPSEb4nWbug==}
+
   '@vitejs/plugin-legacy@5.3.2':
     resolution: {integrity: sha512-8moCOrIMaZ/Rjln0Q6GsH6s8fAt1JOI3k8nmfX4tXUxE5KAExVctSyOBk+A25GClsdSWqIk2yaUthH3KJ2X4tg==}
     engines: {node: ^18.0.0 || >=20.0.0}
@@ -7053,6 +7059,8 @@ snapshots:
       jsonc-parser: 3.3.1
       vite: 5.2.8(@types/node@25.0.3)(sass@1.78.0)(terser@5.44.1)
 
+  '@vant/area-data@2.1.0': {}
+
   '@vitejs/plugin-legacy@5.3.2(terser@5.44.1)(vite@5.2.8(@types/node@25.0.3)(sass@1.78.0)(terser@5.44.1))':
     dependencies:
       '@babel/core': 7.25.2

+ 57 - 29
src/components/FormField/index.vue

@@ -1,44 +1,50 @@
 <template>
   <view class="form-field">
-    <!-- 横向 -->
+    <!-- 横向布局 -->
     <view v-if="layout === 'horizontal'" class="form-field-horizontal">
       <view class="form-field-label" :style="{ width: labelWidth }">
-        <view class="form-field-required">
-          <text v-if="required"> * </text>
-        </view>
-        <text class="form-field-title">
-          {{ label }}
+        <text v-if="required" class="form-field-required">*</text>
+
+        <text class="form-field-title-text">
+          {{ title }}
+        </text>
+
+        <text v-if="description" class="form-field-description">
+          {{ description }}
         </text>
       </view>
+
       <view class="form-field-body-right">
         <slot />
       </view>
     </view>
 
-    <!-- 纵向 -->
+    <!-- 纵向布局 -->
     <view v-else class="form-field-vertical">
       <view class="form-field-label">
-        <view class="form-field-required">
-          <text v-if="required"> * </text>
-        </view>
-        <text class="form-field-title">
-          {{ label }}
+        <text v-if="required" class="form-field-required">*</text>
+
+        <text class="form-field-title-text">
+          {{ title }}
         </text>
+
         <text v-if="description" class="form-field-description">
           {{ description }}
         </text>
       </view>
+
       <view class="form-field-body-left">
         <slot />
       </view>
     </view>
   </view>
 </template>
+
 <script setup lang="ts">
 type FormFieldLayout = 'horizontal' | 'vertical'
 
 interface Props {
-  label: string
+  title: string
   required?: boolean
   layout?: FormFieldLayout
   labelWidth?: string
@@ -56,55 +62,77 @@ withDefaults(defineProps<Props>(), {
 <style lang="scss" scoped>
 .form-field {
   background-color: #fff;
+  box-sizing: border-box;
+
   .form-field-horizontal {
     display: flex;
     align-items: center;
+    padding: 24rpx;
+    box-sizing: border-box;
     text-align: left;
-    padding: 20rpx 28rpx;
+
+    .form-field-label {
+      flex-shrink: 0;
+    }
 
     .form-field-body-right {
-      line-height: 36rpx;
       display: flex;
+      flex: 1;
       align-items: center;
       justify-content: flex-end;
-      flex: 1;
+      min-width: 0;
+      line-height: 40rpx;
     }
   }
 
   .form-field-vertical {
-    padding-bottom: 20rpx;
     display: flex;
     flex-direction: column;
+    box-sizing: border-box;
+
     .form-field-label {
-      padding: 24rpx 28rpx;
+      padding: 24rpx;
+      box-sizing: border-box;
     }
 
     .form-field-body-left {
-      padding: 0 28rpx;
       display: flex;
+      padding: 0 24rpx;
       align-items: center;
       justify-content: flex-start;
-      flex: 1;
+      box-sizing: border-box;
     }
   }
 
-  .form-field-title {
-    color: #303133;
-    font-size: 32rpx;
-    white-space: normal;
-    word-break: break-all;
-  }
-
   .form-field-label {
     display: flex;
     align-items: center;
-    line-height: 36rpx;
+    min-width: 0;
+    line-height: 40rpx;
+    box-sizing: border-box;
+  }
+
+  .form-field-title-text {
+    color: #1d1f29;
+    font-size: 28rpx;
+    line-height: 40rpx;
+    white-space: normal;
+    word-break: break-all;
   }
 
   .form-field-required {
+    flex-shrink: 0;
     margin-right: 8rpx;
-    color: #fa3534;
+    color: #f14646;
     font-size: 30rpx;
+    line-height: 40rpx;
+  }
+
+  .form-field-description {
+    margin-left: 12rpx;
+    color: #999;
+    font-size: 24rpx;
+    line-height: 36rpx;
   }
 }
 </style>

+ 161 - 4
src/lib/location.ts

@@ -8,7 +8,23 @@ export interface LocationResult {
   latitude: number
   longitude: number
   address?: string
-  raw?: any
+  raw?: unknown
+}
+
+export interface PoiSearchItem {
+  id: string
+  title: string
+  address: string
+  latitude: number
+  longitude: number
+  category?: string
+  raw?: unknown
+}
+
+export interface PoiSearchResult {
+  count: number
+  list: PoiSearchItem[]
+  raw?: unknown
 }
 
 export function getLocation(
@@ -24,15 +40,23 @@ export function getLocation(
             latitude: location.latitude,
             longitude: location.longitude,
           },
-          success(res: any) {
+
+          success(res: unknown) {
+            const result = res as {
+              result?: {
+                address?: string
+              }
+            }
+
             resolve({
               latitude: location.latitude,
               longitude: location.longitude,
-              address: res?.result?.address,
+              address: result?.result?.address,
               raw: res,
             })
           },
-          fail(err: any) {
+
+          fail(err: unknown) {
             reject(err)
           },
         })
@@ -45,6 +69,71 @@ export function getLocation(
   })
 }
 
+/**
+ * 根据关键词搜索附近地点
+ */
+export function searchPoiByKeyword(
+  keyword: string,
+  options?: {
+    latitude?: number
+    longitude?: number
+    pageSize?: number
+  }
+): Promise<PoiSearchResult> {
+  return new Promise((resolve, reject) => {
+    qqmapsdk.search({
+      keyword,
+
+      location:
+        options?.latitude && options?.longitude
+          ? {
+              latitude: options.latitude,
+              longitude: options.longitude,
+            }
+          : undefined,
+
+      page_size: options?.pageSize ?? 20,
+
+      success(res: unknown) {
+        const result = res as {
+          count?: number
+          data?: Array<{
+            id: string
+            title: string
+            address: string
+            location: {
+              lat: number
+              lng: number
+            }
+            category?: string
+          }>
+        }
+
+        resolve({
+          count: result.count ?? 0,
+
+          list:
+            result.data?.map((item) => ({
+              id: item.id,
+              title: item.title,
+              address: item.address,
+              latitude: item.location.lat,
+              longitude: item.location.lng,
+              category: item.category,
+              raw: item,
+            })) ?? [],
+
+          raw: res,
+        })
+      },
+
+      fail(error: unknown) {
+        reject(error)
+      },
+    })
+  })
+}
+
 function handleLocationAuth(error: unknown): Promise<LocationResult> {
   return new Promise((resolve, reject) => {
     uni.getSetting({
@@ -57,6 +146,7 @@ function handleLocationAuth(error: unknown): Promise<LocationResult> {
         uni.showModal({
           title: '友情提示',
           content: '小程序需要获取您的定位功能才可使用',
+
           success(modal) {
             if (!modal.confirm) {
               reject(error)
@@ -71,6 +161,7 @@ function handleLocationAuth(error: unknown): Promise<LocationResult> {
                   raw: openRes,
                 })
               },
+
               fail(err) {
                 reject(err)
               },
@@ -78,9 +169,75 @@ function handleLocationAuth(error: unknown): Promise<LocationResult> {
           },
         })
       },
+
       fail(err) {
         reject(err)
       },
     })
   })
 }
+
+export interface ReverseGeocodeResult {
+  latitude: number
+  longitude: number
+  address: string
+  provinceCityDistrict: string
+  raw?: unknown
+}
+
+interface TencentReverseGeocoderResponse {
+  result?: {
+    address?: string
+    formatted_addresses?: {
+      standard_address?: string
+    }
+    address_component?: {
+      province?: string
+      city?: string
+      district?: string
+    }
+    address_reference?: {
+      town?: {
+        title?: string
+      }
+    }
+  }
+}
+
+export const reverseGeocodeByCoordinate = (
+  latitude: number,
+  longitude: number
+): Promise<ReverseGeocodeResult> => {
+  return new Promise((resolve, reject) => {
+    qqmapsdk.reverseGeocoder({
+      location: {
+        latitude,
+        longitude,
+      },
+
+      success(res: unknown) {
+        const raw = res as TencentReverseGeocoderResponse
+        const result = raw.result
+
+        const address = result?.formatted_addresses?.standard_address || result?.address || ''
+
+        const component = result?.address_component
+        const provinceCityDistrict = [component?.province, component?.city, component?.district]
+          .filter(Boolean)
+          .join('')
+
+        resolve({
+          latitude,
+          longitude,
+          address,
+          provinceCityDistrict,
+          raw: res,
+        })
+      },
+
+      fail(error: unknown) {
+        reject(error)
+      },
+    })
+  })
+}

+ 714 - 0
src/pages-common/location-select/index.vue

@@ -0,0 +1,714 @@
+<template>
+  <view class="page">
+    <view class="map-wrap">
+      <map
+        class="map"
+        :latitude="state.latitude"
+        :longitude="state.longitude"
+        :markers="markers"
+        :circles="isCurrentPositionMode ? circles : []"
+        scale="16"
+        show-location
+        show-compass
+        @poitap="handlePoiTap"
+      >
+        <cover-view class="location-btn" @click="resetLocation">
+          <cover-view class="location-btn-text">⌖</cover-view>
+        </cover-view>
+
+        <cover-view v-if="isCurrentPositionMode" class="range-tip">
+          <cover-view class="range-tip-icon">!</cover-view>
+          <cover-view class="range-tip-text"> 可以在当前范围内点击重新选择打卡地点 </cover-view>
+        </cover-view>
+      </map>
+    </view>
+
+    <view class="content-panel">
+      <template v-if="!isCurrentPositionMode">
+        <view class="search-card">
+          <wd-icon name="search" size="22px" color="#8a8f99" />
+
+          <input
+            v-model="state.keyword"
+            class="search-input"
+            placeholder="搜索附近位置"
+            placeholder-class="search-placeholder"
+            confirm-type="search"
+            @input="onKeywordInput"
+            @confirm="searchPlaces"
+          />
+
+          <wd-icon
+            v-if="state.keyword"
+            name="close"
+            size="18px"
+            color="#a0a4ad"
+            @click="clearSearch"
+          />
+        </view>
+
+        <view v-if="state.keyword" class="result-card">
+          <template v-if="results.length">
+            <view
+              v-for="item in results"
+              :key="item.id"
+              class="result-item"
+              @click="selectPlace(item)"
+            >
+              <view class="result-main">
+                <view class="result-title">
+                  {{ item.title }}
+                </view>
+
+                <view class="result-address">
+                  {{ item.address }}
+                </view>
+              </view>
+
+              <wd-icon
+                v-if="item.id === state.currentId"
+                name="check"
+                color="#2f8cff"
+                size="20px"
+              />
+            </view>
+          </template>
+
+          <view v-else class="empty">
+            {{ state.searching ? '搜索中...' : '暂无搜索结果,请完善关键词' }}
+          </view>
+        </view>
+      </template>
+
+      <view v-else class="range-card">
+        <view class="range-card-title"> 范围选择 </view>
+
+        <view class="range-card-desc">
+          当前允许在 {{ state.mapRadius }} 米范围内点击地图重新选择打卡地点。
+        </view>
+      </view>
+
+      <view class="current-card">
+        <view class="current-icon">
+          <wd-icon name="location" size="20px" color="#2f8cff" />
+        </view>
+
+        <view class="current-content">
+          <view class="current-label"> 当前选择位置 </view>
+
+          <view class="current-address">
+            {{ state.address || '正在获取位置...' }}
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <view class="footer">
+      <view
+        class="check-btn"
+        :class="{ 'check-btn--disabled': !state.address }"
+        @click="confirmLocation"
+      >
+        打卡
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed, getCurrentInstance, reactive, ref } from 'vue'
+
+import { onLoad } from '@dcloudio/uni-app'
+
+import {
+  getLocation,
+  type PoiSearchItem,
+  reverseGeocodeByCoordinate,
+  searchPoiByKeyword,
+} from '@/lib/location'
+import { debounce } from '@/plugins/debounce'
+
+type LocationSelectType = 'currPosi' | string
+
+interface PageOptions {
+  type?: LocationSelectType
+}
+
+interface TencentReverseGeocoderRaw {
+  result?: {
+    formatted_addresses?: {
+      standard_address?: string
+    }
+    address_component?: {
+      province?: string
+      city?: string
+      district?: string
+    }
+  }
+}
+
+interface TencentPoiRaw {
+  ad_info?: {
+    province?: string
+    city?: string
+    district?: string
+  }
+}
+
+interface MapPoiTapEvent {
+  detail?: {
+    latitude?: number
+    longitude?: number
+    name?: string
+  }
+}
+
+interface OpenerEventChannel {
+  emit: (eventName: 'locationSelect', data: string) => void
+}
+
+interface PageInstanceProxy {
+  getOpenerEventChannel?: () => OpenerEventChannel
+}
+
+const instance = getCurrentInstance()
+
+const type = ref<LocationSelectType>('')
+
+const state = reactive({
+  latitude: 39.909,
+  longitude: 116.39742,
+  rangeCenterLatitude: 39.909,
+  rangeCenterLongitude: 116.39742,
+  address: '',
+  keyword: '',
+  provinceCityDistrict: '',
+  currentId: '',
+  mapRadius: 100,
+  searching: false,
+})
+
+const results = ref<PoiSearchItem[]>([])
+
+let searchToken = 0
+
+const isCurrentPositionMode = computed(() => type.value === 'currPosi')
+
+const markers = computed(() => [
+  {
+    id: 1,
+    latitude: state.latitude,
+    longitude: state.longitude,
+    width: 32,
+    height: 32,
+    callout: {
+      content: state.address,
+      color: '#1f2937',
+      fontSize: 13,
+      borderRadius: 6,
+      bgColor: '#ffffff',
+      padding: 8,
+      display: 'BYCLICK',
+    },
+  },
+])
+
+const circles = computed(() => [
+  {
+    latitude: state.rangeCenterLatitude,
+    longitude: state.rangeCenterLongitude,
+    color: '#ffffff',
+    fillColor: '#7cb5ec88',
+    radius: state.mapRadius,
+    strokeWidth: 2,
+  },
+])
+
+const getEventChannel = (): OpenerEventChannel | undefined => {
+  const proxy = instance?.proxy as unknown as PageInstanceProxy | undefined
+
+  return proxy?.getOpenerEventChannel?.()
+}
+
+onLoad((options?: PageOptions) => {
+  type.value = options?.type ?? ''
+
+  void resetLocation()
+})
+
+const debouncedSearch = debounce(() => {
+  void searchPlaces()
+}, 300)
+
+const onKeywordInput = () => {
+  if (isCurrentPositionMode.value) return
+
+  debouncedSearch()
+}
+
+const resetLocation = async () => {
+  clearSearch()
+
+  const location = await getLocation()
+  const raw = location.raw as TencentReverseGeocoderRaw
+
+  state.latitude = location.latitude
+  state.longitude = location.longitude
+
+  state.rangeCenterLatitude = location.latitude
+  state.rangeCenterLongitude = location.longitude
+
+  state.address = raw?.result?.formatted_addresses?.standard_address || location.address || ''
+  state.provinceCityDistrict = getReverseGeocoderDistrict(raw)
+}
+
+const searchPlaces = async () => {
+  if (isCurrentPositionMode.value) return
+
+  const keyword = state.keyword.trim()
+
+  if (!keyword) {
+    results.value = []
+    return
+  }
+
+  const currentToken = ++searchToken
+
+  try {
+    state.searching = true
+
+    const res = await searchPoiByKeyword(keyword, {
+      latitude: state.latitude,
+      longitude: state.longitude,
+    })
+
+    if (currentToken !== searchToken) return
+
+    results.value = res.list
+  } catch {
+    if (currentToken !== searchToken) return
+
+    results.value = []
+
+    uni.showToast({
+      title: '搜索失败',
+      icon: 'none',
+    })
+  } finally {
+    if (currentToken === searchToken) {
+      state.searching = false
+    }
+  }
+}
+
+const handlePoiTap = async (event: MapPoiTapEvent) => {
+  if (!isCurrentPositionMode.value) return
+
+  const latitude = event.detail?.latitude
+  const longitude = event.detail?.longitude
+
+  if (typeof latitude !== 'number' || typeof longitude !== 'number') return
+
+  const distance = calculateDistanceInMeters({
+    fromLatitude: state.rangeCenterLatitude,
+    fromLongitude: state.rangeCenterLongitude,
+    toLatitude: latitude,
+    toLongitude: longitude,
+  })
+
+  if (distance > state.mapRadius) {
+    uni.showToast({
+      title: '不能超出打卡范围',
+      icon: 'none',
+    })
+
+    return
+  }
+
+  try {
+    const location = await reverseGeocodeByCoordinate(latitude, longitude)
+
+    state.latitude = latitude
+    state.longitude = longitude
+    state.address = buildPoiAddress(location.address, event.detail?.name)
+    state.provinceCityDistrict = location.provinceCityDistrict
+    state.currentId = ''
+  } catch {
+    uni.showToast({
+      title: '获取地点失败',
+      icon: 'none',
+    })
+  }
+}
+
+const selectPlace = (item: PoiSearchItem) => {
+  if (isCurrentPositionMode.value) return
+
+  const raw = item.raw as TencentPoiRaw
+
+  state.latitude = item.latitude
+  state.longitude = item.longitude
+  state.address = item.address
+  state.currentId = item.id
+  state.provinceCityDistrict = getPoiDistrict(raw)
+
+  state.keyword = ''
+  results.value = []
+}
+
+const clearSearch = () => {
+  state.keyword = ''
+  results.value = []
+  state.searching = false
+  searchToken += 1
+}
+
+const confirmLocation = () => {
+  if (!state.address) return
+
+  const location = `${state.address}(任务定位:${state.longitude},${state.latitude})`
+
+  getEventChannel()?.emit('locationSelect', location)
+
+  uni.navigateBack()
+}
+
+const getReverseGeocoderDistrict = (raw: TencentReverseGeocoderRaw): string => {
+  const component = raw.result?.address_component
+
+  return [component?.province, component?.city, component?.district].filter(Boolean).join('')
+}
+
+const getPoiDistrict = (raw: TencentPoiRaw): string => {
+  const adInfo = raw.ad_info
+
+  return [adInfo?.province, adInfo?.city, adInfo?.district].filter(Boolean).join('')
+}
+
+const buildPoiAddress = (address: string, poiName?: string): string => {
+  if (!poiName) return address
+
+  return address.includes(poiName) ? address : `${address}${poiName}`
+}
+
+const calculateDistanceInMeters = (params: {
+  fromLatitude: number
+  fromLongitude: number
+  toLatitude: number
+  toLongitude: number
+}): number => {
+  const earthRadius = 6371000
+
+  const fromLatitudeRad = toRadians(params.fromLatitude)
+  const toLatitudeRad = toRadians(params.toLatitude)
+  const latitudeDeltaRad = toRadians(params.toLatitude - params.fromLatitude)
+  const longitudeDeltaRad = toRadians(params.toLongitude - params.fromLongitude)
+
+  const a =
+    Math.sin(latitudeDeltaRad / 2) ** 2 +
+    Math.cos(fromLatitudeRad) * Math.cos(toLatitudeRad) * Math.sin(longitudeDeltaRad / 2) ** 2
+
+  const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
+
+  return earthRadius * c
+}
+
+const toRadians = (degree: number): number => {
+  return (degree * Math.PI) / 180
+}
+</script>
+
+<style scoped lang="scss">
+.page {
+  position: relative;
+  min-height: 100vh;
+  overflow: hidden;
+  background: linear-gradient(180deg, #f5f8ff 0%, #ffffff 42%), #ffffff;
+}
+
+.map-wrap {
+  position: relative;
+  height: 40vh;
+  overflow: hidden;
+  background: #eef3f8;
+}
+
+.map {
+  width: 100%;
+  height: 100%;
+}
+
+.location-btn {
+  position: absolute;
+  left: 28rpx;
+  bottom: 150rpx;
+
+  width: 76rpx;
+  height: 76rpx;
+  border-radius: 50%;
+
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  background: rgba(255, 255, 255, 0.96);
+  box-shadow: 0 12rpx 32rpx rgba(20, 32, 56, 0.18);
+}
+
+.location-btn-text {
+  color: #1f2937;
+  font-size: 42rpx;
+  font-weight: 700;
+  line-height: 1;
+}
+
+.range-tip {
+  position: absolute;
+  left: 28rpx;
+  right: 28rpx;
+  bottom: 50rpx;
+  min-height: 84rpx;
+  padding: 0 26rpx;
+  display: flex;
+  align-items: center;
+  border-radius: 22rpx;
+  background: rgba(255, 255, 255, 0.96);
+  box-shadow: 0 12rpx 32rpx rgba(20, 32, 56, 0.14);
+}
+
+.range-tip-icon {
+  width: 34rpx;
+  height: 34rpx;
+  margin-right: 16rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 50%;
+  color: #ffffff;
+  font-size: 22rpx;
+  font-weight: 700;
+  background: #2f8cff;
+}
+
+.range-tip-text {
+  flex: 1;
+  color: #5f6b7a;
+  font-size: 26rpx;
+  line-height: 1.4;
+}
+
+.content-panel {
+  position: relative;
+  z-index: 5;
+
+  min-height: 60vh;
+  margin-top: -44rpx;
+  padding: 28rpx 32rpx 220rpx;
+
+  border-radius: 40rpx 40rpx 0 0;
+  background: #ffffff;
+  box-shadow: 0 -12rpx 40rpx rgba(31, 45, 61, 0.08);
+}
+
+.search-card {
+  height: 96rpx;
+  padding: 0 28rpx;
+
+  display: flex;
+  align-items: center;
+
+  border: 1rpx solid rgba(47, 140, 255, 0.08);
+  border-radius: 26rpx;
+  background: #f7f9fc;
+  box-shadow:
+    inset 0 1rpx 0 rgba(255, 255, 255, 0.8),
+    0 12rpx 28rpx rgba(24, 39, 75, 0.06);
+}
+
+.search-input {
+  flex: 1;
+  height: 96rpx;
+  margin-left: 18rpx;
+
+  color: #1f2937;
+  font-size: 30rpx;
+  line-height: 96rpx;
+}
+
+.search-placeholder {
+  color: #a8b0bd;
+}
+
+.result-card {
+  margin-top: 22rpx;
+  max-height: 430rpx;
+  overflow: auto;
+
+  border-radius: 28rpx;
+  background: #ffffff;
+  box-shadow: 0 14rpx 36rpx rgba(24, 39, 75, 0.08);
+}
+
+.result-item {
+  min-height: 112rpx;
+  padding: 24rpx 28rpx;
+
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 24rpx;
+
+  border-bottom: 1rpx solid #f0f2f5;
+}
+
+.result-item:last-child {
+  border-bottom: none;
+}
+
+.result-main {
+  flex: 1;
+  min-width: 0;
+}
+
+.result-title {
+  color: #1f2937;
+  font-size: 30rpx;
+  font-weight: 600;
+
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.result-address {
+  margin-top: 10rpx;
+
+  color: #7b8494;
+  font-size: 24rpx;
+  line-height: 1.45;
+
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.empty {
+  padding: 46rpx 24rpx;
+
+  text-align: center;
+  color: #8a8f99;
+  font-size: 26rpx;
+}
+
+.range-card {
+  padding: 30rpx 28rpx;
+  border-radius: 28rpx;
+  background: linear-gradient(180deg, #f7fbff 0%, #ffffff 100%);
+  box-shadow: 0 12rpx 32rpx rgba(24, 39, 75, 0.06);
+}
+
+.range-card-title {
+  color: #1f2937;
+  font-size: 30rpx;
+  font-weight: 700;
+}
+
+.range-card-desc {
+  margin-top: 12rpx;
+  color: #7b8494;
+  font-size: 26rpx;
+  line-height: 1.5;
+}
+
+.current-card {
+  margin-top: 34rpx;
+  padding: 30rpx 28rpx;
+
+  display: flex;
+  align-items: flex-start;
+  gap: 20rpx;
+
+  border-radius: 28rpx;
+  background: linear-gradient(180deg, #ffffff 0%, #f9fbff 100%);
+  box-shadow: 0 12rpx 32rpx rgba(24, 39, 75, 0.06);
+}
+
+.current-icon {
+  flex: none;
+
+  width: 52rpx;
+  height: 52rpx;
+  border-radius: 50%;
+
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  background: rgba(47, 140, 255, 0.1);
+}
+
+.current-content {
+  flex: 1;
+  min-width: 0;
+}
+
+.current-label {
+  color: #8a8f99;
+  font-size: 24rpx;
+  line-height: 1;
+}
+
+.current-address {
+  margin-top: 14rpx;
+
+  color: #1f2937;
+  font-size: 30rpx;
+  font-weight: 500;
+  line-height: 1.45;
+}
+
+.footer {
+  position: fixed;
+  left: 0;
+  right: 0;
+  bottom: calc(64rpx + env(safe-area-inset-bottom));
+  z-index: 10;
+
+  display: flex;
+  justify-content: center;
+  pointer-events: none;
+}
+
+.check-btn {
+  width: 188rpx;
+  height: 188rpx;
+
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  border-radius: 50%;
+  color: #ffffff;
+  font-size: 34rpx;
+  font-weight: 700;
+  letter-spacing: 2rpx;
+
+  background:
+    radial-gradient(circle at 35% 25%, rgba(255, 255, 255, 0.35), transparent 32%),
+    linear-gradient(135deg, #4fb0ff 0%, #1f86ff 100%);
+
+  box-shadow:
+    0 24rpx 52rpx rgba(31, 134, 255, 0.36),
+    0 6rpx 14rpx rgba(31, 134, 255, 0.18);
+
+  pointer-events: auto;
+}
+
+.check-btn--disabled {
+  opacity: 0.45;
+}
+
+.check-btn:active {
+  transform: scale(0.96);
+}
+</style>

+ 69 - 22
src/pages-common/signature/index.vue

@@ -14,7 +14,7 @@ import { useUserStore } from '@/stores/modules/user'
 
 import SignaturePad from './components/SignaturePad.vue'
 
-type SignatureType = 'HONEST_AGREEMENT_V2' | 'SETTLEMENT_CHANNEL_SIGN' | ''
+type SignatureType = 'HONEST_AGREEMENT_V2' | 'SETTLEMENT_CHANNEL_SIGN' | 'TASK_FORM_SIGN'
 
 interface UploadResponse {
   code: number
@@ -36,11 +36,16 @@ interface PageProxyWithEventChannel {
   getOpenerEventChannel?: () => EventChannelLike | undefined
 }
 
-const userStore = useUserStore()
+const SIGNATURE_TYPE_SET = new Set<SignatureType>([
+  'HONEST_AGREEMENT_V2',
+  'SETTLEMENT_CHANNEL_SIGN',
+  'TASK_FORM_SIGN',
+])
 
+const userStore = useUserStore()
 const pageInstance = getCurrentInstance()
 
-const type = ref<SignatureType>('')
+const type = ref<SignatureType | ''>('')
 
 const accessToken = computed(() => userStore.access_token || '')
 
@@ -58,21 +63,32 @@ const showToast = (title: string) => {
   })
 }
 
+const normalizeSignatureType = (rawType: unknown): SignatureType | '' => {
+  if (typeof rawType !== 'string') return ''
+
+  return SIGNATURE_TYPE_SET.has(rawType as SignatureType) ? (rawType as SignatureType) : ''
+}
+
 const isSupportedSignatureType = () => {
-  return type.value === 'HONEST_AGREEMENT_V2' || type.value === 'SETTLEMENT_CHANNEL_SIGN'
+  return Boolean(type.value)
 }
 
 const parseUploadResponse = (data: string): UploadResponse | undefined => {
   try {
     return JSON.parse(data) as UploadResponse
   } catch (error) {
-    console.error('parseUploadResponse error:', error)
+    console.error('[Signature] parse upload response failed:', error)
     return undefined
   }
 }
 
 const uploadSignatureFile = (filePath: string): Promise<string> => {
   return new Promise((resolve, reject) => {
+    if (!accessToken.value) {
+      reject(new Error('登录状态异常'))
+      return
+    }
+
     uni.uploadFile({
       url: fileApi.fileUpload(),
       filePath,
@@ -120,6 +136,11 @@ const handleSignatureDone = async (filePath: string) => {
     return
   }
 
+  if (!filePath) {
+    showToast('签名文件异常')
+    return
+  }
+
   uni.showLoading({
     title: '保存中',
     mask: true,
@@ -130,7 +151,7 @@ const handleSignatureDone = async (filePath: string) => {
 
     await handleUploadedSignature(signatureUrl)
   } catch (error) {
-    console.error('handleSignatureDone error:', error)
+    console.error('[Signature] handle signature done failed:', error)
 
     showToast(error instanceof Error ? error.message : '上传失败')
   } finally {
@@ -139,20 +160,25 @@ const handleSignatureDone = async (filePath: string) => {
 }
 
 const handleUploadedSignature = async (signatureUrl: string) => {
-  if (type.value === 'HONEST_AGREEMENT_V2') {
-    await saveSignature(signatureUrl)
-    return
-  }
+  switch (type.value) {
+    case 'HONEST_AGREEMENT_V2':
+      await saveSignature(signatureUrl)
+      return
 
-  if (type.value === 'SETTLEMENT_CHANNEL_SIGN') {
-    settleChannelSign(signatureUrl)
-    return
-  }
+    case 'SETTLEMENT_CHANNEL_SIGN':
+      settleChannelSign(signatureUrl)
+      return
+
+    case 'TASK_FORM_SIGN':
+      taskFormSign(signatureUrl)
+      return
 
-  showToast('签署类型异常')
+    default:
+      showToast('签署类型异常')
+  }
 }
 
-// 廉洁承诺书签署
+// 廉洁承诺书签署:上传后调用后端签署接口
 const saveSignature = async (signatureUrl: string) => {
   try {
     const res = await signAgreementApi({
@@ -169,18 +195,18 @@ const saveSignature = async (signatureUrl: string) => {
       delta: 1,
     })
   } catch (error) {
-    console.error('saveSignature error:', error)
+    console.error('[Signature] save signature failed:', error)
 
     showToast('签署失败')
   }
 }
 
-// 结算渠道签署:只回传 signatureUrl
+// 结算渠道签署:上传后回传 payload
 const settleChannelSign = (signatureUrl: string) => {
   const eventChannel = getOpenerEventChannel()
 
   if (!eventChannel) {
-    console.error('getOpenerEventChannel failed')
+    console.error('[Signature] get opener event channel failed')
     showToast('页面通信通道不存在')
     return
   }
@@ -196,18 +222,39 @@ const settleChannelSign = (signatureUrl: string) => {
   })
 }
 
+// 任务表单签名:上传后只回传签名图片地址
+const taskFormSign = (signatureUrl: string) => {
+  const eventChannel = getOpenerEventChannel()
+
+  if (!eventChannel) {
+    console.error('[Signature] get opener event channel failed')
+    showToast('页面通信通道不存在')
+    return
+  }
+
+  eventChannel.emit('taskFormSign', signatureUrl)
+
+  uni.navigateBack({
+    delta: 1,
+  })
+}
+
 const handleClear = () => {
-  console.log('签名已清空')
+  console.log('[Signature] cleared')
 }
 
 const handleError = (error: unknown) => {
-  console.error('SignaturePad error:', error)
+  console.error('[Signature] pad error:', error)
 
   showToast('签名出错,请重试')
 }
 
 onLoad((query) => {
-  type.value = query?.type || ''
+  type.value = normalizeSignatureType(query?.type)
+
+  if (!type.value) {
+    showToast('签署类型异常')
+  }
 })
 </script>
 

+ 10 - 0
src/pages-task/task-form/components/123

@@ -0,0 +1,10 @@
+<template>
+
+</template>
+
+<script setup lang="ts">
+
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 153 - 0
src/pages-task/task-form/components/Area.vue

@@ -0,0 +1,153 @@
+<template>
+  <view class="area-select">
+    <wd-cell
+      title-width="200rpx"
+      :placeholder="'请选择' + taskFieldConfig.taskFiledValue"
+      :title="taskFieldConfig.taskFiledValue"
+      :required="taskFieldConfig.isMustfill === '1'"
+      :value="cellValue"
+      :is-link="!isDisabled"
+      @click="handleOpen"
+    />
+
+    <wd-cascader
+      v-model="cascaderValue"
+      v-model:visible="cascaderShow"
+      :title="'请选择' + taskFieldConfig.taskFiledValue"
+      :options="areaOptions"
+      @confirm="handleConfirm"
+    />
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from 'vue'
+
+import { useCascaderAreaData } from '@vant/area-data'
+
+import type { TaskFieldConfigItem } from '@/services/modules/task/taskFrom/type'
+
+type FieldValue = string | number | undefined
+type FieldLabel = string | undefined
+type AreaSelectLevel = 2 | 3
+
+interface AreaOption {
+  value: string | number
+  text: string
+  children?: AreaOption[]
+  disabled?: boolean
+  tip?: string
+  isLeaf?: boolean
+}
+
+interface CascaderConfirmEvent {
+  value: FieldValue | FieldValue[]
+  selectedItems?: AreaOption[]
+  selectedOptions?: AreaOption[]
+}
+
+interface Props {
+  taskFieldConfig: TaskFieldConfigItem
+  disabled?: boolean
+
+  /**
+   * 2:只选择省市
+   * 3:选择省市区
+   */
+  selectLevel?: AreaSelectLevel
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  disabled: false,
+  selectLevel: 3,
+})
+
+const label = defineModel<FieldLabel>('label')
+const value = defineModel<FieldValue>('value')
+
+const cascaderShow = ref(false)
+const cascaderValue = ref<FieldValue>()
+
+const SELECTED_LABEL_SEPARATOR = '-'
+
+const isDisabled = computed(() => props.disabled)
+
+const rawAreaOptions = useCascaderAreaData() as AreaOption[]
+
+const areaOptions = computed<AreaOption[]>(() => {
+  if (props.selectLevel === 2) {
+    return toProvinceCityOptions(rawAreaOptions)
+  }
+
+  return rawAreaOptions
+})
+
+const cellValue = computed(() => {
+  if (label.value) return label.value
+
+  const selectedPath = findOptionPath(areaOptions.value, value.value)
+  return formatSelectedOptions(selectedPath)
+})
+
+const handleOpen = () => {
+  if (isDisabled.value) return
+
+  cascaderValue.value = value.value
+  cascaderShow.value = true
+}
+
+const handleConfirm = (event: CascaderConfirmEvent) => {
+  const selectedItems = event.selectedItems ?? event.selectedOptions ?? []
+  const lastSelectedOption = selectedItems[selectedItems.length - 1]
+
+  if (!lastSelectedOption) return
+
+  value.value = lastSelectedOption.value
+  label.value = formatSelectedOptions(selectedItems)
+  cascaderValue.value = lastSelectedOption.value
+}
+
+const toProvinceCityOptions = (options: AreaOption[]): AreaOption[] => {
+  return options.map((province) => ({
+    ...province,
+    children: province.children?.map(toCityLeafOption),
+  }))
+}
+
+const toCityLeafOption = (city: AreaOption): AreaOption => {
+  return {
+    value: city.value,
+    text: city.text,
+    disabled: city.disabled,
+    tip: city.tip,
+    isLeaf: true,
+  }
+}
+
+const formatSelectedOptions = (options: AreaOption[]) => {
+  return options
+    .map((item) => item.text)
+    .filter(Boolean)
+    .join(SELECTED_LABEL_SEPARATOR)
+}
+
+const findOptionPath = (options: AreaOption[], targetValue: FieldValue): AreaOption[] => {
+  if (targetValue === undefined || targetValue === '') return []
+
+  for (const option of options) {
+    if (option.value === targetValue) {
+      return [option]
+    }
+
+    if (option.children?.length) {
+      const childPath = findOptionPath(option.children, targetValue)
+
+      if (childPath.length) {
+        return [option, ...childPath]
+      }
+    }
+  }
+
+  return []
+}
+</script>

+ 107 - 0
src/pages-task/task-form/components/DataTimeRange.vue

@@ -0,0 +1,107 @@
+<template>
+  <view class="single-select">
+    <wd-cell
+      title-width="200rpx"
+      :placeholder="'请选择' + taskFieldConfig.taskFiledValue"
+      :title="taskFieldConfig.taskFiledValue"
+      :required="taskFieldConfig.isMustfill === '1'"
+      :value="cellValue"
+      :is-link="!isDisabled"
+      @click="calendarClick"
+    />
+
+    <wd-calendar
+      v-model="calendarValue"
+      v-model:visible="calendarShow"
+      type="daterange"
+      allow-same-day
+      @confirm="handleConfirm"
+    />
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue'
+
+import dayjs from 'dayjs'
+
+import type { TaskFieldConfigItem } from '@/services/modules/task/taskFrom/type'
+
+interface Props {
+  taskFieldConfig: TaskFieldConfigItem
+  disabled?: boolean
+}
+
+interface CalendarConfirmPayload {
+  value: number[]
+}
+
+type FieldModelValue = string | undefined
+
+const props = withDefaults(defineProps<Props>(), {
+  disabled: false,
+})
+
+const DATE_FORMAT = 'YYYY-MM-DD'
+const RANGE_SEPARATOR = ' ~ '
+
+const value = defineModel<FieldModelValue>('value')
+const label = defineModel<FieldModelValue>('label')
+
+const calendarShow = ref(false)
+const calendarValue = ref<number[]>([])
+
+const isDisabled = computed(() => props.disabled)
+
+const cellValue = computed(() => label.value || value.value || '')
+
+watch(
+  value,
+  (newValue) => {
+    if (!newValue) {
+      calendarValue.value = []
+      return
+    }
+
+    const [startDate, endDate] = newValue.split(RANGE_SEPARATOR)
+
+    if (!startDate || !endDate) {
+      calendarValue.value = []
+      return
+    }
+
+    const startTimestamp = dayjs(startDate, DATE_FORMAT).valueOf()
+    const endTimestamp = dayjs(endDate, DATE_FORMAT).valueOf()
+
+    if (!Number.isFinite(startTimestamp) || !Number.isFinite(endTimestamp)) {
+      calendarValue.value = []
+      return
+    }
+
+    calendarValue.value = [startTimestamp, endTimestamp]
+  },
+  {
+    immediate: true,
+  }
+)
+
+const calendarClick = () => {
+  if (isDisabled.value) return
+
+  calendarShow.value = true
+}
+
+const handleConfirm = ({ value: selectedRange }: CalendarConfirmPayload) => {
+  const [startTimestamp, endTimestamp] = selectedRange
+
+  if (!startTimestamp || !endTimestamp) return
+
+  const startDate = dayjs(startTimestamp).format(DATE_FORMAT)
+  const endDate = dayjs(endTimestamp).format(DATE_FORMAT)
+  const formattedValue = `${startDate}${RANGE_SEPARATOR}${endDate}`
+
+  value.value = formattedValue
+  label.value = formattedValue
+  calendarValue.value = selectedRange
+}
+</script>

+ 2 - 3
src/pages-task/task-form/components/DateTimePicker.vue

@@ -1,6 +1,7 @@
 <template>
   <view class="single-select">
     <wd-cell
+      title-width="200rpx"
       :placeholder="'请选择' + taskFieldConfig.taskFiledValue"
       :title="taskFieldConfig.taskFiledValue"
       :required="taskFieldConfig.isMustfill === '1'"
@@ -28,12 +29,10 @@ import type { TaskFieldConfigItem } from '@/services/modules/task/taskFrom/type'
 interface Props {
   taskFieldConfig: TaskFieldConfigItem
   disabled?: boolean
-  selectDisabled?: boolean
 }
 
 const props = withDefaults(defineProps<Props>(), {
   disabled: false,
-  selectDisabled: false,
 })
 
 const DATE_TIME_FORMAT = 'YYYY-MM-DD HH:mm'
@@ -44,7 +43,7 @@ const label = defineModel<FieldValue>('label')
 const datetimePickerShow = ref(false)
 const datetimePickerValue = ref(Date.now())
 
-const isDisabled = computed(() => props.disabled || props.selectDisabled)
+const isDisabled = computed(() => props.disabled)
 
 const cellValue = computed(() => label.value || value.value || '')
 

+ 637 - 0
src/pages-task/task-form/components/ImgUpload.vue

@@ -0,0 +1,637 @@
+<template>
+  <view class="img-upload">
+    <FormField
+      :title="taskFieldConfig.taskFiledValue"
+      :required="taskFieldConfig.isMustfill === '1'"
+      :description="description"
+      layout="vertical"
+    >
+      <view class="upload-container" :class="{ 'is-disabled': isDisabled }">
+        <view
+          v-for="(url, index) in imageList"
+          :key="`${url}-${index}`"
+          class="image-box"
+          @click.stop="previewImage(index)"
+        >
+          <image class="upload-image" :src="toFullUrl(url)" mode="aspectFill" />
+
+          <view v-if="!isDisabled" class="delete-btn" @click.stop="removeImage(index)">
+            <wd-icon name="close" size="24rpx" color="#fff" />
+          </view>
+        </view>
+
+        <view
+          v-if="canUpload"
+          class="upload-box"
+          :class="{ 'is-uploading': uploading }"
+          @click.stop="chooseImage"
+        >
+          <text class="upload-plus">+</text>
+          <text class="upload-text">
+            {{ uploading ? '上传中' : '选择图片' }}
+          </text>
+          <text class="upload-count"> {{ imageList.length }}/{{ maxCount }} </text>
+        </view>
+      </view>
+    </FormField>
+
+    <canvas canvas-id="imgUploadWatermarkCanvas" :style="watermarkCanvasStyle" />
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed, getCurrentInstance, onMounted, ref } from 'vue'
+
+import { getLocation } from '@/lib/location'
+
+import type { TaskFieldConfigItem } from '@/services/modules/task/taskFrom/type'
+
+import { useUserStore } from '@/stores/modules/user'
+
+type FieldValue = string | null
+type UploadSource = 'camera' | 'album'
+
+interface UploadResponse {
+  code: number
+  success: boolean
+  msg: string | null
+  data: {
+    url: string
+  } | null
+}
+
+interface LocationResult {
+  result?: {
+    address?: string
+  }
+}
+
+interface Props {
+  taskFieldConfig: TaskFieldConfigItem
+  disabled?: boolean
+
+  watermarkText?: string
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  disabled: false,
+  watermarkText: '',
+})
+
+const value = defineModel<FieldValue>('value', {
+  default: null,
+})
+
+const label = defineModel<FieldValue>('label', {
+  default: null,
+})
+
+const userStore = useUserStore()
+const instance = getCurrentInstance()
+
+const uploading = ref(false)
+const watermarkAddress = ref('')
+const watermarkCanvasWidth = ref(1)
+const watermarkCanvasHeight = ref(1)
+
+const baseUrl = String(import.meta.env.VITE_BASE_API ?? '').replace(/\/$/, '')
+
+const isDisabled = computed(() => props.disabled)
+
+const watermarkCanvasStyle = computed(() => {
+  return {
+    width: `${watermarkCanvasWidth.value}px`,
+    height: `${watermarkCanvasHeight.value}px`,
+    position: 'fixed',
+    left: '-9999px',
+    top: '-9999px',
+    pointerEvents: 'none',
+  }
+})
+
+const minCount = computed(() => {
+  return toSafeNumber(props.taskFieldConfig.taskFiledMinsize, 0)
+})
+
+const maxCount = computed(() => {
+  const config = props.taskFieldConfig as TaskFieldConfigItem & {
+    taskFiledMaxsize?: string | number | null
+  }
+
+  return toSafeNumber(config.taskFiledMaxsize, 20)
+})
+
+const description = computed(() => {
+  return minCount.value > 0 ? `(至少上传${minCount.value}张图片)` : ''
+})
+
+const modelText = computed(() => {
+  return normalizeImageValue(value.value) ?? normalizeImageValue(label.value)
+})
+
+const imageList = computed<string[]>({
+  get: () => {
+    return parseImageValue(modelText.value)
+  },
+  set: (urls) => {
+    const nextValue = stringifyImageList(urls)
+
+    value.value = nextValue
+    label.value = nextValue
+  },
+})
+
+const remainingCount = computed(() => {
+  return Math.max(maxCount.value - imageList.value.length, 0)
+})
+
+const canUpload = computed(() => {
+  return !isDisabled.value && imageList.value.length < maxCount.value
+})
+
+onMounted(() => {
+  initWatermarkAddress()
+})
+
+const initWatermarkAddress = async () => {
+  if (props.watermarkText) return
+
+  try {
+    const result = (await getLocation()) as LocationResult
+    watermarkAddress.value = result.result?.address ?? ''
+  } catch (error) {
+    console.warn('[ImgUpload] get location failed:', error)
+  }
+}
+
+const chooseImage = async () => {
+  if (isDisabled.value || uploading.value) return
+
+  if (remainingCount.value <= 0) {
+    uni.showToast({
+      title: `最多上传${maxCount.value}张图片`,
+      icon: 'none',
+    })
+    return
+  }
+
+  try {
+    const source = await chooseUploadSource()
+    const count = source === 'camera' ? 1 : Math.min(remainingCount.value, 9)
+    const filePaths = await chooseImages(count, source)
+
+    if (filePaths.length === 0) return
+
+    uploading.value = true
+
+    uni.showLoading({
+      title: '上传中',
+      mask: true,
+    })
+
+    const uploadedUrls: string[] = []
+
+    for (const filePath of filePaths) {
+      const uploadFilePath = source === 'camera' ? await addWatermark(filePath) : filePath
+      const uploadedUrl = await upload(uploadFilePath)
+      uploadedUrls.push(appendUploadSourceSuffix(uploadedUrl, source))
+    }
+
+    imageList.value = [...imageList.value, ...uploadedUrls].slice(0, maxCount.value)
+
+    uni.showToast({
+      title: '上传成功',
+      icon: 'success',
+    })
+  } catch (error) {
+    if (isCancelError(error)) return
+
+    console.error('[ImgUpload] upload failed:', error)
+
+    uni.showToast({
+      title: '上传失败',
+      icon: 'none',
+    })
+  } finally {
+    uploading.value = false
+    uni.hideLoading()
+  }
+}
+
+const chooseUploadSource = (): Promise<UploadSource> => {
+  return new Promise((resolve, reject) => {
+    uni.showActionSheet({
+      itemList: ['拍摄', '从相册选择'],
+      success: (res) => {
+        resolve(res.tapIndex === 0 ? 'camera' : 'album')
+      },
+      fail: (error) => {
+        if (isCancelError(error)) {
+          reject(new Error('cancel'))
+          return
+        }
+
+        reject(error)
+      },
+    })
+  })
+}
+
+const chooseImages = (count: number, source: UploadSource): Promise<string[]> => {
+  return new Promise((resolve, reject) => {
+    uni.chooseImage({
+      count,
+      sizeType: ['compressed'],
+      sourceType: [source],
+      success: (res) => {
+        const filePaths = Array.isArray(res.tempFilePaths)
+          ? res.tempFilePaths
+          : [res.tempFilePaths].filter(Boolean)
+
+        resolve(filePaths)
+      },
+      fail: (error) => {
+        if (isCancelError(error)) {
+          reject(new Error('cancel'))
+          return
+        }
+
+        reject(error)
+      },
+    })
+  })
+}
+
+const upload = (filePath: string): Promise<string> => {
+  return new Promise((resolve, reject) => {
+    uni.uploadFile({
+      url: `${baseUrl}/admin/api/file/upload/mobile`,
+      filePath,
+      name: 'file',
+      header: {
+        Authorization: `Bearer ${userStore.access_token}`,
+      },
+      success: (res) => {
+        try {
+          const parsed = JSON.parse(res.data) as UploadResponse
+
+          if (
+            res.statusCode < 200 ||
+            res.statusCode >= 300 ||
+            !parsed.success ||
+            parsed.code !== 0 ||
+            !parsed.data?.url
+          ) {
+            reject(new Error(parsed.msg || 'upload failed'))
+            return
+          }
+
+          resolve(parsed.data.url)
+        } catch {
+          reject(new Error('parse upload response failed'))
+        }
+      },
+      fail: reject,
+    })
+  })
+}
+
+const addWatermark = (filePath: string): Promise<string> => {
+  return new Promise((resolve, reject) => {
+    uni.getImageInfo({
+      src: filePath,
+      success: (imageInfo) => {
+        const width = imageInfo.width
+        const height = imageInfo.height
+
+        watermarkCanvasWidth.value = width
+        watermarkCanvasHeight.value = height
+
+        const componentInstance = instance?.proxy
+        const ctx = uni.createCanvasContext(
+          'imgUploadWatermarkCanvas',
+          componentInstance
+        ) as UniApp.CanvasContext & {
+          measureText: (text: string) => { width: number }
+        }
+
+        const designWidth = 375
+        const scaleRatio = width / designWidth
+
+        const radius = 10 * scaleRatio
+        const padding = 8 * scaleRatio
+        const iconSize = 10 * scaleRatio
+        const textSpacing = 4 * scaleRatio
+        const lineHeight = 14 * scaleRatio
+        const bottomSpacing = 14 * scaleRatio
+        const leftSpacing = 14 * scaleRatio
+        const textTop = 6 * scaleRatio
+        const fontSize = 10 * scaleRatio
+
+        ctx.drawImage(filePath, 0, 0, width, height)
+        ctx.setFontSize(fontSize)
+
+        const text = getWatermarkText()
+        const maxTextWidth = width - iconSize - textSpacing - padding * 3 - leftSpacing * 2
+        const textRows = wrapText(ctx, text, maxTextWidth)
+
+        textRows.push(formatDate(new Date()))
+
+        const totalRows = textRows.length
+        const boxHeight = lineHeight * totalRows + textTop
+        const textWidth = Math.max(...textRows.map((row) => ctx.measureText(row).width), 0)
+        const boxWidth = Math.min(
+          width - leftSpacing * 2,
+          Math.max(iconSize + textWidth + textSpacing * 3 + padding, padding * 6)
+        )
+        const boxX = leftSpacing
+        const boxY = Math.max(height - boxHeight - bottomSpacing, bottomSpacing)
+
+        drawRoundRect(ctx, boxX, boxY, boxWidth, boxHeight, radius)
+
+        const iconPath = '/static/images/task/watermarkIcon.png'
+        ctx.drawImage(iconPath, boxX + padding, boxY + padding - 12, iconSize, iconSize)
+
+        ctx.setFontSize(fontSize)
+        ctx.setFillStyle('#ffffff')
+
+        for (let index = 0; index < textRows.length; index += 1) {
+          ctx.fillText(
+            textRows[index],
+            boxX + iconSize + textSpacing + padding,
+            boxY + lineHeight * (index + 1)
+          )
+        }
+
+        ctx.draw(false, () => {
+          setTimeout(() => {
+            uni.canvasToTempFilePath(
+              {
+                canvasId: 'imgUploadWatermarkCanvas',
+                fileType: 'jpg',
+                success: (res) => {
+                  resolve(res.tempFilePath)
+                },
+                fail: reject,
+              },
+              componentInstance
+            )
+          }, 300)
+        })
+      },
+      fail: reject,
+    })
+  })
+}
+
+const previewImage = (index: number) => {
+  const urls = imageList.value.map(toFullUrl)
+
+  if (urls.length === 0) return
+
+  uni.previewImage({
+    urls,
+    current: urls[index],
+  })
+}
+
+const removeImage = (index: number) => {
+  if (isDisabled.value || uploading.value) return
+
+  imageList.value = imageList.value.filter((_, currentIndex) => {
+    return currentIndex !== index
+  })
+}
+
+const parseImageValue = (input: unknown): string[] => {
+  if (Array.isArray(input)) {
+    return input.map((item) => String(item).trim()).filter(Boolean)
+  }
+
+  if (typeof input !== 'string') return []
+
+  return input
+    .split(',')
+    .map((item) => item.trim())
+    .filter(Boolean)
+}
+
+const stringifyImageList = (urls: string[]): FieldValue => {
+  const normalizedUrls = urls.map((url) => String(url).trim()).filter(Boolean)
+
+  return normalizedUrls.length > 0 ? normalizedUrls.join(',') : null
+}
+
+const normalizeImageValue = (input: unknown): FieldValue => {
+  return stringifyImageList(parseImageValue(input))
+}
+
+const appendUploadSourceSuffix = (url: string, source: UploadSource) => {
+  const normalizedUrl = String(url).trim()
+
+  if (!normalizedUrl) return ''
+
+  if (hasUploadSourceSuffix(normalizedUrl)) {
+    return normalizedUrl
+  }
+
+  return `${normalizedUrl};${source === 'camera' ? '1' : '2'}`
+}
+
+const hasUploadSourceSuffix = (url: string) => {
+  return /;(1|2|3)$/.test(url)
+}
+
+const removeUploadSourceSuffix = (url: string) => {
+  return url.replace(/;(1|2|3)$/, '')
+}
+
+const toFullUrl = (url: string) => {
+  const cleanUrl = removeUploadSourceSuffix(url).trim()
+
+  if (!cleanUrl) return ''
+
+  if (/^(https?:|wxfile:|blob:|data:)/.test(cleanUrl)) {
+    return cleanUrl
+  }
+
+  return `${baseUrl}${cleanUrl.startsWith('/') ? cleanUrl : `/${cleanUrl}`}`
+}
+
+const getWatermarkText = () => {
+  return props.watermarkText || watermarkAddress.value || ''
+}
+
+const wrapText = (
+  ctx: UniApp.CanvasContext & { measureText: (text: string) => { width: number } },
+  text: string,
+  maxWidth: number
+) => {
+  const rows: string[] = []
+  let currentText = ''
+
+  for (const char of [...text]) {
+    const nextText = `${currentText}${char}`
+
+    if (ctx.measureText(nextText).width > maxWidth && currentText) {
+      rows.push(currentText)
+      currentText = char
+      continue
+    }
+
+    currentText = nextText
+  }
+
+  if (currentText) {
+    rows.push(currentText)
+  }
+
+  return rows
+}
+
+const drawRoundRect = (
+  ctx: UniApp.CanvasContext,
+  x: number,
+  y: number,
+  width: number,
+  height: number,
+  radius: number
+) => {
+  ctx.setFillStyle('rgba(0, 0, 0, 0.43)')
+  ctx.beginPath()
+  ctx.moveTo(x + radius, y)
+  ctx.lineTo(x + width - radius, y)
+  ctx.arc(x + width - radius, y + radius, radius, -Math.PI / 2, 0)
+  ctx.lineTo(x + width, y + height - radius)
+  ctx.arc(x + width - radius, y + height - radius, radius, 0, Math.PI / 2)
+  ctx.lineTo(x + radius, y + height)
+  ctx.arc(x + radius, y + height - radius, radius, Math.PI / 2, Math.PI)
+  ctx.lineTo(x, y + radius)
+  ctx.arc(x + radius, y + radius, radius, Math.PI, -Math.PI / 2)
+  ctx.closePath()
+  ctx.fill()
+}
+
+const formatDate = (date: Date) => {
+  const year = date.getFullYear()
+  const month = padNumber(date.getMonth() + 1)
+  const day = padNumber(date.getDate())
+  const hour = padNumber(date.getHours())
+  const minute = padNumber(date.getMinutes())
+  const second = padNumber(date.getSeconds())
+
+  return `${year}/${month}/${day} ${hour}:${minute}:${second}`
+}
+
+const padNumber = (value: number) => {
+  return String(value).padStart(2, '0')
+}
+
+const toSafeNumber = (value: unknown, fallback: number) => {
+  const numberValue = Number(value)
+
+  if (!Number.isFinite(numberValue) || numberValue < 0) {
+    return fallback
+  }
+
+  return numberValue
+}
+
+const isCancelError = (error: unknown) => {
+  if (error instanceof Error && error.message === 'cancel') return true
+
+  const errMsg =
+    typeof (error as { errMsg?: unknown })?.errMsg === 'string'
+      ? String((error as { errMsg?: string }).errMsg)
+      : ''
+
+  return errMsg.includes('cancel')
+}
+</script>
+
+<style lang="scss" scoped>
+.img-upload {
+  width: 100%;
+  background: #fff;
+}
+
+.upload-container {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 16rpx;
+  padding: 20rpx 0 18rpx;
+  box-sizing: border-box;
+}
+
+.upload-container.is-disabled {
+  opacity: 0.65;
+}
+
+.image-box,
+.upload-box {
+  position: relative;
+  width: 164rpx;
+  height: 164rpx;
+  border-radius: 12rpx;
+  overflow: hidden;
+  box-sizing: border-box;
+}
+
+.image-box {
+  background: #f5f7fa;
+}
+
+.upload-image {
+  width: 100%;
+  height: 100%;
+}
+
+.upload-box {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  border: 2rpx dashed #d2d8e3;
+  background: #f8faff;
+}
+
+.upload-box.is-uploading {
+  pointer-events: none;
+  opacity: 0.7;
+}
+
+.upload-plus {
+  font-size: 58rpx;
+  line-height: 58rpx;
+  font-weight: 300;
+  color: #60656f;
+}
+
+.upload-text {
+  margin-top: 10rpx;
+  font-size: 28rpx;
+  line-height: 36rpx;
+  color: #60656f;
+}
+
+.upload-count {
+  margin-top: 2rpx;
+  font-size: 26rpx;
+  line-height: 32rpx;
+  color: #9aa1ad;
+}
+
+.delete-btn {
+  position: absolute;
+  top: 8rpx;
+  right: 8rpx;
+  z-index: 2;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 36rpx;
+  height: 36rpx;
+  border-radius: 50%;
+  background: rgba(0, 0, 0, 0.55);
+}
+</style>

+ 35 - 0
src/pages-task/task-form/components/InputAutoSelect.vue

@@ -0,0 +1,35 @@
+<template>
+  <view class="single-select">
+    <wd-cell
+      title-width="200rpx"
+      :placeholder="'请选择' + taskFieldConfig.taskFiledValue"
+      :title="taskFieldConfig.taskFiledValue"
+      :required="taskFieldConfig.isMustfill === '1'"
+      :value="cellValue"
+      :is-link="!isDisabled"
+      @click="pickerClick"
+    />
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+
+import type { TaskFieldConfigItem, TaskFieldDictMap } from '@/services/modules/task/taskFrom/type'
+
+interface Props {
+  taskFieldConfig: TaskFieldConfigItem
+  taskFieldDict: TaskFieldDictMap
+  disabled?: boolean
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  disabled: false,
+})
+
+const isDisabled = computed(() => props.disabled)
+
+const pickerClick = () => {
+  console.log('qaq')
+}
+</script>

+ 87 - 0
src/pages-task/task-form/components/LongText.vue

@@ -0,0 +1,87 @@
+<template>
+  <view class="long-text">
+    <FormField
+      :title="taskFieldConfig.taskFiledValue"
+      :required="taskFieldConfig.isMustfill === '1'"
+      layout="vertical"
+    >
+      <view class="textarea-wrapper">
+        <wd-textarea
+          v-model="textareaValue"
+          :maxlength="maxLength"
+          :placeholder="'请输入' + taskFieldConfig.taskFiledValue"
+          :disabled="props.disabled"
+          clearable
+          auto-height
+          custom-style="min-height: 300rpx; width: 100%; padding-bottom: 56rpx; box-sizing: border-box;"
+        />
+
+        <text class="word-limit"> {{ currentLength }}/{{ maxLength }} </text>
+      </view>
+    </FormField>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+
+import type { TaskFieldConfigItem } from '@/services/modules/task/taskFrom/type'
+
+type FieldValue = string | null
+
+interface Props {
+  taskFieldConfig: TaskFieldConfigItem
+  disabled?: boolean
+  watermarkText?: string
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  disabled: false,
+  watermarkText: '',
+})
+
+const value = defineModel<FieldValue>('value', {
+  default: null,
+})
+
+defineModel<FieldValue>('label', {
+  default: null,
+})
+
+const maxLength = computed(() => {
+  return Number(props.taskFieldConfig.taskFiledMaxsize) || 800
+})
+
+const textareaValue = computed({
+  get() {
+    return value.value ?? ''
+  },
+  set(newValue: string) {
+    value.value = newValue
+  },
+})
+
+const currentLength = computed(() => textareaValue.value.length)
+</script>
+
+<style lang="scss" scoped>
+.long-text {
+  .textarea-wrapper {
+    position: relative;
+    min-height: 300rpx;
+    width: 100%;
+  }
+
+  .word-limit {
+    position: absolute;
+    right: 16rpx;
+    bottom: 16rpx;
+    z-index: 2;
+
+    color: #8a8f99;
+    font-size: 28rpx;
+    line-height: 1;
+    pointer-events: none;
+  }
+}
+</style>

+ 135 - 0
src/pages-task/task-form/components/MultipleSelect.vue

@@ -0,0 +1,135 @@
+<template>
+  <view class="multi-select">
+    <wd-cell
+      title-width="200rpx"
+      :placeholder="'请选择' + taskFieldConfig.taskFiledValue"
+      :title="taskFieldConfig.taskFiledValue"
+      :required="taskFieldConfig.isMustfill === '1'"
+      :value="cellValue"
+      is-link
+      @click="pickerClick"
+    />
+
+    <wd-select-picker
+      v-model="pickerValue"
+      v-model:visible="pickerShow"
+      :columns="dict"
+      @confirm="handleSelect"
+    />
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue'
+
+import type { DictItem } from '@/services/modules/common/type'
+import type { TaskFieldConfigItem, TaskFieldDictMap } from '@/services/modules/task/taskFrom/type'
+
+type OptionValue = string | number
+type FieldValue = OptionValue[] | OptionValue | undefined
+
+interface SelectPickerConfirmEvent {
+  value: OptionValue[]
+  selectedItems: DictItem[]
+}
+
+interface Props {
+  taskFieldConfig: TaskFieldConfigItem
+  taskFieldDict: TaskFieldDictMap
+  disabled?: boolean
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  disabled: false,
+})
+
+const label = defineModel<string | undefined>('label')
+const value = defineModel<FieldValue>('value')
+
+const pickerShow = ref(false)
+const pickerValue = ref<OptionValue[]>([])
+
+const isDisabled = computed(() => props.disabled)
+
+const dict = computed<DictItem[]>(() => {
+  const dictName = props.taskFieldConfig.dictGroupName
+  return dictName ? (props.taskFieldDict[dictName] ?? []) : []
+})
+
+const cellValue = computed(() => label.value ?? '')
+
+const pickerClick = () => {
+  if (isDisabled.value) return
+  pickerShow.value = true
+}
+
+const normalizeValue = (rawValue: FieldValue): OptionValue[] => {
+  if (Array.isArray(rawValue)) {
+    return rawValue.filter(
+      (item): item is OptionValue => item !== undefined && item !== null && item !== ''
+    )
+  }
+
+  if (rawValue === undefined || rawValue === null || rawValue === '') {
+    return []
+  }
+
+  if (typeof rawValue === 'string' && rawValue.includes(',')) {
+    return rawValue
+      .split(',')
+      .map((item) => item.trim())
+      .filter((item): item is string => item.length > 0)
+  }
+
+  return [rawValue]
+}
+
+const isSameValue = (left: unknown, right: unknown) => {
+  return String(left) === String(right)
+}
+
+const getSelectedItemsByValues = (selectedValues: OptionValue[]) => {
+  return selectedValues
+    .map((selectedValue) => dict.value.find((item) => isSameValue(item.value, selectedValue)))
+    .filter((item): item is DictItem => Boolean(item))
+}
+
+const syncLabelByValue = (selectedValues: OptionValue[]) => {
+  const selectedItems = getSelectedItemsByValues(selectedValues)
+
+  label.value = selectedItems.map((item) => item.label).join(',')
+}
+
+const syncPickerValueByModelValue = () => {
+  const normalizedValues = normalizeValue(value.value)
+
+  const matchedValues = normalizedValues
+    .map((selectedValue) => dict.value.find((item) => isSameValue(item.value, selectedValue)))
+    .filter((item): item is DictItem => Boolean(item))
+    .map((item) => item.value as OptionValue)
+
+  pickerValue.value = matchedValues
+
+  syncLabelByValue(matchedValues)
+}
+
+const handleSelect = (option: SelectPickerConfirmEvent) => {
+  const selectedValues = option.value
+  const selectedItems = option.selectedItems
+
+  pickerValue.value = selectedValues
+  value.value = selectedValues.join(',')
+  label.value = selectedItems.map((item) => item.label).join(',')
+}
+
+watch(
+  [() => value.value, dict],
+  () => {
+    syncPickerValueByModelValue()
+  },
+  {
+    immediate: true,
+    deep: true,
+  }
+)
+</script>

+ 106 - 0
src/pages-task/task-form/components/Sign.vue

@@ -0,0 +1,106 @@
+<template>
+  <view class="sign">
+    <FormField
+      :title="taskFieldConfig.taskFiledValue"
+      :required="taskFieldConfig.isMustfill === '1'"
+      layout="vertical"
+    >
+      <view
+        class="signature-box"
+        :class="{
+          'is-disabled': disabled,
+          'has-value': Boolean(value),
+        }"
+        @click.stop="handleSignClick"
+      >
+        <text class="signature-text">
+          {{ value ? '已 签 名' : '签 名 区' }}
+        </text>
+      </view>
+    </FormField>
+  </view>
+</template>
+
+<script setup lang="ts">
+import type { TaskFieldConfigItem } from '@/services/modules/task/taskFrom/type'
+
+type FieldValue = string | null
+
+interface Props {
+  taskFieldConfig: TaskFieldConfigItem
+  disabled?: boolean
+  watermarkText?: string
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  disabled: false,
+  watermarkText: '',
+})
+
+const value = defineModel<FieldValue>('value', {
+  default: null,
+})
+
+const label = defineModel<FieldValue>('label', {
+  default: null,
+})
+
+const handleSignClick = () => {
+  if (props.disabled) return
+
+  uni.navigateTo({
+    url: `/pages-common/signature/index?type=TASK_FORM_SIGN`,
+    events: {
+      taskFormSign: (imgUrl: string) => {
+        label.value = imgUrl
+        value.value = imgUrl
+      },
+    },
+  })
+}
+</script>
+
+<style lang="scss" scoped>
+.sign {
+  width: 100%;
+  background: #fff;
+}
+
+.signature-box {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 90%;
+  margin: 0 auto;
+  height: 164rpx;
+  margin-top: 20rpx;
+  box-sizing: border-box;
+  border: 2rpx solid #d6d6d6;
+  border-radius: 30rpx;
+  background: #fff;
+}
+
+.signature-box:active {
+  background: #f8f8f8;
+}
+
+.signature-box.is-disabled {
+  opacity: 0.65;
+}
+
+.signature-box.has-value {
+  border-color: #c8c8c8;
+}
+
+.signature-text {
+  font-size: 52rpx;
+  line-height: 1;
+  font-weight: 500;
+  letter-spacing: 10rpx;
+  color: #cfcfcf;
+}
+
+.signature-box.has-value .signature-text {
+  color: #999;
+}
+</style>

+ 2 - 3
src/pages-task/task-form/components/SingleSelect.vue

@@ -1,6 +1,7 @@
 <template>
   <view class="single-select">
     <wd-cell
+      title-width="200rpx"
       :placeholder="'请选择' + taskFieldConfig.taskFiledValue"
       :title="taskFieldConfig.taskFiledValue"
       :required="taskFieldConfig.isMustfill === '1'"
@@ -34,12 +35,10 @@ interface Props {
   taskFieldConfig: TaskFieldConfigItem
   taskFieldDict: TaskFieldDictMap
   disabled?: boolean
-  selectDisabled?: boolean
 }
 
 const props = withDefaults(defineProps<Props>(), {
   disabled: false,
-  selectDisabled: false,
 })
 
 const label = defineModel<FieldValue>('label')
@@ -49,7 +48,7 @@ const dict = ref<DictItem[]>([])
 const pickerShow = ref(false)
 const pickerValue = ref<(string | number)[]>([])
 
-const isDisabled = computed(() => props.disabled || props.selectDisabled)
+const isDisabled = computed(() => props.disabled)
 
 const cellValue = computed(() => label.value ?? '')
 

+ 74 - 5
src/pages-task/task-form/components/SingleText.vue

@@ -1,16 +1,21 @@
 <template>
   <view class="single-text">
-    <wd-cell :title="taskFieldConfig.taskFiledValue" :required="taskFieldConfig.isMustfill === '1'">
+    <wd-cell
+      title-width="200rpx"
+      :title="taskFieldConfig.taskFiledValue"
+      :required="taskFieldConfig.isMustfill === '1'"
+    >
       <wd-input
         v-model="inputValue"
-        type="text"
+        :type="inputType"
         :placeholder="'请输入' + taskFieldConfig.taskFiledValue"
-        clearable
+        :clearable="!props.disabled && !props.readonly"
         clear-trigger="focus"
         align-right
         :disabled="props.disabled"
         :readonly="props.readonly"
         custom-style="--wot-input-padding: 0; padding: 0;"
+        @blur="handleBlur"
       />
     </wd-cell>
   </view>
@@ -21,6 +26,9 @@ import { computed } from 'vue'
 
 import type { TaskFieldConfigItem } from '@/services/modules/task/taskFrom/type'
 
+type FieldValue = string | number | null | undefined
+type InputType = 'text' | 'number' | 'digit'
+
 interface Props {
   taskFieldConfig: TaskFieldConfigItem
   disabled?: boolean
@@ -35,13 +43,74 @@ const props = withDefaults(defineProps<Props>(), {
 const label = defineModel<FieldValue>('label')
 const value = defineModel<FieldValue>('value')
 
+const inputType = computed<InputType>(() => {
+  switch (props.taskFieldConfig.taskFiledType) {
+    case 'money':
+      return 'digit'
+    case 'number':
+      return 'number'
+    default:
+      return 'text'
+  }
+})
+
 const inputValue = computed<string>({
   get() {
     return String(value.value ?? label.value ?? '')
   },
   set(newValue) {
-    value.value = newValue
-    label.value = newValue
+    const sanitizedValue = sanitizeInputValue(newValue, props.taskFieldConfig.taskFiledType)
+
+    value.value = sanitizedValue
+    label.value = sanitizedValue
   },
 })
+
+const sanitizeInputValue = (rawValue: string, taskFiledType: string): string => {
+  switch (taskFiledType) {
+    case 'number':
+      return sanitizeInteger(rawValue)
+
+    case 'money':
+      return sanitizeDecimal(rawValue, 2)
+
+    default:
+      return rawValue
+  }
+}
+
+const sanitizeInteger = (rawValue: string): string => {
+  return rawValue.replace(/\D/g, '')
+}
+
+const sanitizeDecimal = (rawValue: string, decimalPlaces: number): string => {
+  const normalizedValue = rawValue.replace(/[,,]/g, '.').replace(/[^\d.]/g, '')
+
+  const dotIndex = normalizedValue.indexOf('.')
+
+  if (dotIndex === -1) {
+    return normalizedValue
+  }
+
+  const integerPart = normalizedValue.slice(0, dotIndex).replace(/\./g, '')
+  const decimalPart = normalizedValue
+    .slice(dotIndex + 1)
+    .replace(/\./g, '')
+    .slice(0, decimalPlaces)
+
+  return `${integerPart || '0'}.${decimalPart}`
+}
+
+const handleBlur = () => {
+  if (props.taskFieldConfig.taskFiledType !== 'money') return
+
+  const currentValue = String(value.value ?? '')
+
+  if (!currentValue.endsWith('.')) return
+
+  const normalizedValue = currentValue.slice(0, -1)
+
+  value.value = normalizedValue
+  label.value = normalizedValue
+}
 </script>

+ 48 - 0
src/pages-task/task-form/components/TaskLocation.vue

@@ -0,0 +1,48 @@
+<template>
+  <view class="task-location">
+    <wd-cell
+      title-width="200rpx"
+      :placeholder="'请选择' + taskFieldConfig.taskFiledValue"
+      :title="taskFieldConfig.taskFiledValue"
+      :required="taskFieldConfig.isMustfill === '1'"
+      :value="cellValue"
+      :is-link="!isDisabled"
+      @click="taskLocationClick"
+    />
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+
+import type { TaskFieldConfigItem } from '@/services/modules/task/taskFrom/type'
+
+interface Props {
+  taskFieldConfig: TaskFieldConfigItem
+  disabled?: boolean
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  disabled: false,
+})
+
+const value = defineModel<FieldValue>('value')
+const label = defineModel<FieldValue>('label')
+
+const isDisabled = computed(() => props.disabled)
+
+const cellValue = computed(() => label.value || value.value || '')
+
+const taskLocationClick = () => {
+  if (isDisabled.value) return
+  uni.navigateTo({
+    url: `/pages-common/location-select/index?type=${props.taskFieldConfig.taskFiledType}`,
+    events: {
+      locationSelect: (location: string) => {
+        label.value = location
+        value.value = location
+      },
+    },
+  })
+}
+</script>

+ 1 - 1
src/pages-task/task-form/composables/useTaskForm.ts

@@ -26,7 +26,7 @@ export const useTaskForm = () => {
     const value: DynamicFormState['value'] = {}
 
     config.forEach((item) => {
-      value[item.id] = {
+      value[String(item.id)] = {
         label: '',
         value: '',
       }

+ 109 - 0
src/pages-task/task-form/composables/useTaskFormSafeArea.ts

@@ -0,0 +1,109 @@
+import { computed, type CSSProperties, ref } from 'vue'
+
+import { onUnload } from '@dcloudio/uni-app'
+
+type KeyboardHeightChangeResult = {
+  height?: number
+}
+
+type KeyboardHeightChangeCallback = (result: KeyboardHeightChangeResult) => void
+
+type MiniProgramKeyboardApi = {
+  onKeyboardHeightChange?: (callback: KeyboardHeightChangeCallback) => void
+  offKeyboardHeightChange?: (callback: KeyboardHeightChangeCallback) => void
+}
+
+type MiniProgramSystemInfo = UniApp.GetSystemInfoResult & {
+  safeAreaInsets?: {
+    bottom?: number
+  }
+  safeArea?: {
+    bottom?: number
+  }
+}
+
+const FOOTER_TOP_PADDING_RPX = 20
+const FOOTER_BOTTOM_PADDING_RPX = 20
+const FOOTER_BUTTON_HEIGHT_RPX = 88
+const CONTENT_EXTRA_BOTTOM_RPX = 24
+
+export const useTaskFormSafeArea = () => {
+  const keyboardApi = uni as unknown as MiniProgramKeyboardApi
+
+  const windowWidth = ref(375)
+  const safeAreaBottom = ref(0)
+  const keyboardHeight = ref(0)
+
+  const rpxToPx = (rpx: number) => {
+    return (windowWidth.value / 750) * rpx
+  }
+
+  const activeSafeAreaBottom = computed(() => {
+    return keyboardHeight.value > 0 ? 0 : safeAreaBottom.value
+  })
+
+  const footerHeightPx = computed(() => {
+    return (
+      rpxToPx(FOOTER_TOP_PADDING_RPX + FOOTER_BUTTON_HEIGHT_RPX + FOOTER_BOTTOM_PADDING_RPX) +
+      activeSafeAreaBottom.value
+    )
+  })
+
+  const contentStyle = computed<CSSProperties>(() => {
+    const paddingBottom =
+      footerHeightPx.value + keyboardHeight.value + rpxToPx(CONTENT_EXTRA_BOTTOM_RPX)
+
+    return {
+      paddingBottom: `${paddingBottom}px`,
+    }
+  })
+
+  const footerStyle = computed<CSSProperties>(() => {
+    return {
+      bottom: `${keyboardHeight.value}px`,
+      paddingBottom: `${rpxToPx(FOOTER_BOTTOM_PADDING_RPX) + activeSafeAreaBottom.value}px`,
+    }
+  })
+
+  const initSystemLayout = () => {
+    const systemInfo = uni.getSystemInfoSync() as MiniProgramSystemInfo
+
+    windowWidth.value = systemInfo.windowWidth || 375
+
+    if (typeof systemInfo.safeAreaInsets?.bottom === 'number') {
+      safeAreaBottom.value = systemInfo.safeAreaInsets.bottom
+      return
+    }
+
+    if (
+      typeof systemInfo.safeArea?.bottom === 'number' &&
+      typeof systemInfo.screenHeight === 'number'
+    ) {
+      safeAreaBottom.value = Math.max(systemInfo.screenHeight - systemInfo.safeArea.bottom, 0)
+      return
+    }
+
+    safeAreaBottom.value = 0
+  }
+
+  const handleKeyboardHeightChange: KeyboardHeightChangeCallback = (result) => {
+    const height = Number(result.height ?? 0)
+
+    keyboardHeight.value = Number.isFinite(height) && height > 0 ? height : 0
+  }
+
+  const initSafeArea = () => {
+    initSystemLayout()
+    keyboardApi.onKeyboardHeightChange?.(handleKeyboardHeightChange)
+  }
+
+  onUnload(() => {
+    keyboardApi.offKeyboardHeightChange?.(handleKeyboardHeightChange)
+  })
+
+  return {
+    contentStyle,
+    footerStyle,
+    initSafeArea,
+  }
+}

+ 337 - 53
src/pages-task/task-form/index.vue

@@ -1,101 +1,385 @@
 <template>
   <view class="task-form">
-    <view class="workbench-notice" v-if="showNotice">
-      <wd-notice-bar
-        :text="noticeText"
-        prefix="warn-bold"
-        type="info"
-        color="#f90"
-        background-color="#fdf6ec "
-      />
-    </view>
-    <view class="task-form-fields">
-      <view class="task-form-field" v-for="item in taskFieldConfigList" :key="item.id">
-        <template v-if="isSingleSelect(item.taskFiledType)">
-          <SingleSelect
-            :key="item.id"
-            :taskFieldConfig="item"
-            :taskFieldDict="taskFieldDict"
-            v-model:label="form.value[item.id].label"
-            v-model:value="form.value[item.id].value"
-          />
-        </template>
-        <template v-if="isTextInput(item.taskFiledType)">
-          <SingleText
-            :key="item.id"
-            :taskFieldConfig="item"
-            :readonly="item.taskFiledType === 'readonlytext'"
-            v-model:label="form.value[item.id].label"
-            v-model:value="form.value[item.id].value"
-          />
-        </template>
-        <template v-if="isDateTimePicker(item.taskFiledType)">
-          <DateTimePicker
-            :key="item.id"
-            :taskFieldConfig="item"
-            v-model:label="form.value[item.id].label"
-            v-model:value="form.value[item.id].value"
+    <scroll-view
+      class="task-form__scroll"
+      scroll-y
+      enhanced
+      :show-scrollbar="false"
+      :bounces="false"
+    >
+      <view class="task-form__content" :style="contentStyle">
+        <view class="workbench-notice" v-if="showNotice">
+          <wd-notice-bar
+            :text="noticeText"
+            prefix="warn-bold"
+            type="info"
+            color="#f90"
+            background-color="#fdf6ec"
+            custom-style="border-radius: 12rpx; min-height: 64rpx; padding: 0 20rpx;"
           />
-        </template>
+        </view>
+
+        <view class="task-form-fields">
+          <view class="task-form-field" v-for="item in taskFieldConfigList" :key="item.id">
+            <template v-if="isSingleSelect(item.taskFiledType)">
+              <SingleSelect
+                :taskFieldConfig="item"
+                :taskFieldDict="taskFieldDict"
+                v-model:label="form.value[item.id].label"
+                v-model:value="form.value[item.id].value"
+              />
+            </template>
+
+            <template v-else-if="isTextInput(item.taskFiledType)">
+              <SingleText
+                :taskFieldConfig="item"
+                :readonly="item.taskFiledType === 'readonlytext'"
+                v-model:label="form.value[item.id].label"
+                v-model:value="form.value[item.id].value"
+              />
+            </template>
+
+            <template v-else-if="isDateTime(item.taskFiledType)">
+              <DateTimePicker
+                :taskFieldConfig="item"
+                v-model:label="form.value[item.id].label"
+                v-model:value="form.value[item.id].value"
+              />
+            </template>
+
+            <template v-else-if="isLocation(item.taskFiledType)">
+              <TaskLocation
+                :taskFieldConfig="item"
+                v-model:label="form.value[item.id].label"
+                v-model:value="form.value[item.id].value"
+              />
+            </template>
+
+            <template v-else-if="isImgUpload(item.taskFiledType)">
+              <view class="line" />
+              <ImgUpload
+                :taskFieldConfig="item"
+                v-model:label="form.value[item.id].label"
+                v-model:value="form.value[item.id].value"
+              />
+            </template>
+
+            <template v-else-if="isSign(item.taskFiledType)">
+              <view class="line" />
+              <Sign
+                :taskFieldConfig="item"
+                v-model:label="form.value[item.id].label"
+                v-model:value="form.value[item.id].value"
+              />
+            </template>
+
+            <template v-else-if="isMultipleSelect(item.taskFiledType)">
+              <MultipleSelect
+                :taskFieldConfig="item"
+                :taskFieldDict="taskFieldDict"
+                v-model:label="form.value[item.id].label"
+                v-model:value="form.value[item.id].value"
+              />
+            </template>
+
+            <template v-else-if="isInputAutoSelect(item.taskFiledType)">
+              <InputAutoSelect
+                :taskFieldConfig="item"
+                v-model:label="form.value[item.id].label"
+                v-model:value="form.value[item.id].value"
+              />
+            </template>
+
+            <template v-else-if="isDateTimeRange(item.taskFiledType)">
+              <DataTimeRange
+                :taskFieldConfig="item"
+                v-model:label="form.value[item.id].label"
+                v-model:value="form.value[item.id].value"
+              />
+            </template>
+
+            <template v-else-if="isLongText(item.taskFiledType)">
+              <view class="line" />
+              <LongText
+                :taskFieldConfig="item"
+                v-model:label="form.value[item.id].label"
+                v-model:value="form.value[item.id].value"
+              />
+              <view class="line" />
+            </template>
+
+            <template v-else-if="isArea(item.taskFiledType)">
+              <Area
+                :taskFieldConfig="item"
+                v-model:label="form.value[item.id].label"
+                v-model:value="form.value[item.id].value"
+              />
+            </template>
+          </view>
+        </view>
       </view>
-    </view>
+    </scroll-view>
 
-    <wd-button @click="submit">主要按钮</wd-button>
+    <view class="task-form-footer" :style="footerStyle">
+      <button
+        class="task-form-footer__button task-form-footer__button--save"
+        hover-class="task-form-footer__button--hover"
+        :disabled="actionLoading !== null"
+        @click="handleSave"
+      >
+        保存
+      </button>
+
+      <button
+        class="task-form-footer__button task-form-footer__button--submit"
+        hover-class="task-form-footer__button--hover"
+        :disabled="actionLoading !== null"
+        @click="handleSubmit"
+      >
+        提交
+      </button>
+    </view>
   </view>
 </template>
 
 <script setup lang="ts">
+import { ref } from 'vue'
+
 import { onLoad } from '@dcloudio/uni-app'
 
+import { saveTaskContentApi } from '@/services/modules/task/taskFrom'
+import type { SaveTaskContentBody } from '@/services/modules/task/taskFrom/type'
+
+import Area from './components/Area.vue'
+import DataTimeRange from './components/DataTimeRange.vue'
 import DateTimePicker from './components/DateTimePicker.vue'
+import ImgUpload from './components/ImgUpload.vue'
+import InputAutoSelect from './components/InputAutoSelect.vue'
+import LongText from './components/LongText.vue'
+import MultipleSelect from './components/MultipleSelect.vue'
+import Sign from './components/Sign.vue'
 import SingleSelect from './components/SingleSelect.vue'
 import SingleText from './components/SingleText.vue'
+import TaskLocation from './components/TaskLocation.vue'
 import { useNavigation } from './composables/useNavigation'
 import { useTaskForm } from './composables/useTaskForm'
+import { useTaskFormSafeArea } from './composables/useTaskFormSafeArea'
 import { useTaskNotice } from './composables/useTaskNotice'
 
-const { setNavigationTitle } = useNavigation()
+type PageLoadOptions = {
+  taskTitle?: string
+  taskId?: string
+}
 
-const { showNotice, noticeText, setNotice } = useTaskNotice()
+type FormAction = 'save' | 'submit'
 
+const { setNavigationTitle } = useNavigation()
+const { showNotice, noticeText, setNotice } = useTaskNotice()
 const { form, taskFieldConfigList, taskFieldDict, loadTaskForm } = useTaskForm()
+const { contentStyle, footerStyle, initSafeArea } = useTaskFormSafeArea()
+
+const actionLoading = ref<FormAction | null>(null)
 
 const isSingleSelect = (taskFiledType: string) => {
   return ['domain', 'select'].includes(taskFiledType)
 }
+
 const isTextInput = (taskFiledType: string) => {
-  return ['text', 'readonlytext'].includes(taskFiledType)
+  return ['text', 'readonlytext', 'money', 'number'].includes(taskFiledType)
 }
-const isDateTimePicker = (taskFiledType: string) => {
+
+const isDateTime = (taskFiledType: string) => {
   return ['datetime'].includes(taskFiledType)
 }
 
-type PageLoadOptions = {
-  taskTitle?: string
-  taskId?: string
+const isLocation = (taskFiledType: string) => {
+  return ['map', 'mapwithimg', 'currPosi'].includes(taskFiledType)
+}
+
+const isImgUpload = (taskFiledType: string) => {
+  return ['img'].includes(taskFiledType)
+}
+
+const isSign = (taskFiledType: string) => {
+  return ['sign'].includes(taskFiledType)
+}
+
+const isMultipleSelect = (taskFiledType: string) => {
+  return ['multiple_select'].includes(taskFiledType)
+}
+
+const isInputAutoSelect = (taskFiledType: string) => {
+  return ['inputautoselect'].includes(taskFiledType)
+}
+
+const isDateTimeRange = (taskFiledType: string) => {
+  return ['datatimerange'].includes(taskFiledType)
+}
+
+const isLongText = (taskFiledType: string) => {
+  return ['longtext'].includes(taskFiledType)
+}
+
+const isArea = (taskFiledType: string) => {
+  return ['area'].includes(taskFiledType)
+}
+
+const handleSave = () => {
+  void handleFormAction('save')
+}
+
+const handleSubmit = () => {
+  void handleFormAction('submit')
+}
+
+const buildSubmitPayload = (): SaveTaskContentBody => {
+  const payload: SaveTaskContentBody = {}
+
+  for (const field of taskFieldConfigList.value) {
+    const fieldValue = form.value.value[String(field.id)]
+
+    payload[field.taskFiledValue] = {
+      seq: field.seq ?? 0,
+      ...(field.isMustfill === '1' ? { required: true } : {}),
+      items: [
+        {
+          label: String(fieldValue?.label ?? ''),
+          type: field.taskFiledType,
+          value: String(fieldValue?.value ?? ''),
+        },
+      ],
+    }
+  }
+
+  return payload
 }
 
-const submit = () => {
-  console.log('form.value', form.value)
+const handleFormAction = async (action: FormAction) => {
+  if (actionLoading.value !== null) return
+
+  actionLoading.value = action
+
+  try {
+    console.log(`${action}: form.value`, form.value)
+
+    const payload = buildSubmitPayload()
+    console.log('payload', payload)
+
+    if (action === 'save') {
+      // TODO: 调保存接口
+      return
+    }
+
+    const saveTaskContentQuery = {
+      taskTypeId: '46',
+      packageId: '8612',
+    }
+    const res = await saveTaskContentApi(saveTaskContentQuery, payload)
+    console.log('res', res)
+
+    // TODO: 调提交接口
+  } finally {
+    actionLoading.value = null
+  }
 }
 
 onLoad(async (options: PageLoadOptions = {}) => {
+  initSafeArea()
+
   setNavigationTitle(options.taskTitle)
+
   const taskId = options.taskId ?? ''
+
   setNotice(taskId)
-  await loadTaskForm('99999')
+
+  await loadTaskForm(taskId)
 })
 </script>
 
 <style lang="scss" scoped>
 .task-form {
+  position: relative;
+  width: 100%;
+  min-height: 100vh;
+  overflow: hidden;
   background: #f2f2f2;
+}
+
+.task-form__scroll {
+  width: 100%;
+  height: 100vh;
+}
+
+.task-form__content {
   min-height: 100vh;
+  box-sizing: border-box;
+}
 
-  .workbench-notice {
-    background-color: #fff;
-    padding: 0 10rpx;
-    margin: 20rpx 0;
-  }
+.workbench-notice {
+  padding: 20rpx 20rpx 0;
+  background: #f2f2f2;
+  box-sizing: border-box;
+}
+
+.task-form-fields {
+  padding-top: 20rpx;
+}
+
+.task-form-field {
+  background: #fff;
+}
+
+.line {
+  width: 100%;
+  height: 20rpx;
+  background: #f4f4f4;
+}
+
+.task-form-footer {
+  position: fixed;
+  right: 0;
+  left: 0;
+  z-index: 99;
+  display: flex;
+  gap: 16rpx;
+  padding: 20rpx 28rpx 20rpx;
+  background: #fff;
+  box-sizing: border-box;
+  box-shadow: 0 -8rpx 24rpx rgba(0, 0, 0, 0.06);
+}
+
+.task-form-footer__button {
+  flex: 1;
+  height: 88rpx;
+  margin: 0;
+  padding: 0;
+  border: 0;
+  border-radius: 12rpx;
+  font-size: 32rpx;
+  font-weight: 400;
+  line-height: 88rpx;
+  box-sizing: border-box;
+}
+
+.task-form-footer__button::after {
+  border: 0;
+}
+
+.task-form-footer__button[disabled] {
+  opacity: 0.6;
+}
+
+.task-form-footer__button--hover {
+  opacity: 0.85;
+}
+
+.task-form-footer__button--save {
+  color: #222;
+  background: #f7f7f7;
+}
+
+.task-form-footer__button--submit {
+  color: #fff;
+  background: #3b9bed;
 }
 </style>

+ 7 - 0
src/pages.json

@@ -74,6 +74,13 @@
             "navigationBarTitleText": "详情",
             "navigationStyle": "default"
           }
+        },
+        {
+          "path": "location-select/index",
+          "style": {
+            "navigationBarTitleText": "位置选择",
+            "navigationStyle": "default"
+          }
         }
       ]
     },

+ 15 - 1
src/services/modules/task/taskFrom/index.ts

@@ -1,8 +1,22 @@
 import http from '../../../index'
-import type { TaskContentConfigByTaskTypeIdResponse } from './type'
+import type {
+  SaveTaskContentBody,
+  SaveTaskContentQuery,
+  TaskContentConfigByTaskTypeIdResponse,
+} from './type'
 
 export const getTaskContentConfigByTaskTypeIdApi = (id: string) => {
   return http.get<TaskContentConfigByTaskTypeIdResponse>(
     `/admin/api/getTaskContentConfigByTaskTypeId/${id}`
   )
 }
+
+export const saveTaskContentApi = (
+  saveTaskContentQuery: SaveTaskContentQuery,
+  saveTaskContentBody: SaveTaskContentBody
+) => {
+  return http.post<TaskContentConfigByTaskTypeIdResponse>(
+    `/admin/api/saveTaskContent/?packageId=${saveTaskContentQuery.packageId}&taskTypeId=${saveTaskContentQuery.taskTypeId}`,
+    saveTaskContentBody
+  )
+}

+ 19 - 0
src/services/modules/task/taskFrom/type.d.ts

@@ -34,3 +34,22 @@ export interface TaskFieldConfigItem {
 export interface TaskFieldDictMap {
   [dictGroupName: string]: DictItem[]
 }
+
+export interface SaveTaskContentQuery {
+  taskTypeId: string
+  packageId: string
+}
+
+export interface SaveTaskContentItem {
+  label: string
+  type: string
+  value: string
+}
+
+export interface SaveTaskContentField {
+  seq: number
+  required?: boolean
+  items: SaveTaskContentItem[]
+}
+
+export type SaveTaskContentBody = Record<string, SaveTaskContentField>

BIN
src/static/images/task/watermarkIcon.png