Преглед изворни кода

feat(patient-family): 新增患者家属个人中心及相关信息编辑功能

- 为患者家属新增独立的个人中心页面,包含头像、昵称、ID等展示
- 新增家属基本信息编辑页面,支持头像上传、昵称、年龄、手机号、性别及地址设置
- 完善家属端 tab bar 配置,将“我的”指向专属 profile 页面
- 移除未登录状态下的“健康”tab 项
- 路由配置中增加家属个人中心及信息编辑相关页面路径
- 表单提交逻辑中加入字段校验(如姓名格式、年龄范围、手机号格式等)
- 头像选择兼容不同平台事件格式,并优化防重复点击处理
- 地址选择支持 picker 组件及通过经纬度自动获取当前位置
- 提交成功后跳转回家属个人中心主页并提示更新成功
- 页面样式统一使用 rpx 单位适配多端,增强视觉一致性与用户体验
mcbaiyun пре 1 месец
родитељ
комит
ff4a4f8f10

+ 1 - 2
src/components/tab-bar.vue

@@ -26,7 +26,6 @@ const roleTabs: RoleTabs = {
   // 未登录
   null: [
     { icon: '/static/icons/remixicon/home-3-line.svg', text: '首页', url: '/pages/public/index/index' },
-    { icon: '/static/icons/remixicon/line-chart-line.svg', text: '健康', url: '/pages/public/health/index' },
     { icon: '/static/icons/remixicon/account-circle-line.svg', text: '我的', url: '/pages/public/profile/index' }
   ],
   // 医生
@@ -44,7 +43,7 @@ const roleTabs: RoleTabs = {
   // 患者家属
   4: [
     { icon: '/static/icons/remixicon/home-3-line.svg', text: '首页', url: '/pages/patient-family/index/index' },
-    { icon: '/static/icons/remixicon/account-circle-line.svg', text: '我的', url: '/pages/public/profile/index' } // 暂时使用公共profile,后续可创建家属profile
+    { icon: '/static/icons/remixicon/account-circle-line.svg', text: '我的', url: '/pages/patient-family/profile/index' } // 暂时使用公共profile,后续可创建家属profile
   ]
 }
 

+ 18 - 6
src/pages.json

@@ -73,6 +73,19 @@
 				"navigationBarTitleText": "个人中心-医生端"
 			}
 		},
+		{
+			"path": "pages/patient-family/profile/index",
+			"style": {
+				"navigationBarTitleText": "个人中心-家属端"
+			}
+		},
+		
+		{
+			"path": "pages/patient-family/profile/infos/base-info",
+			"style": {
+				"navigationBarTitleText": "基本信息设定-家属端"
+			}
+		},
 		{
 			"path": "pages/patient/profile/infos/base-info",
 			"style": {
@@ -84,7 +97,7 @@
 			"style": {
 				"navigationBarTitleText": "基本信息设定"
 			}
-		}
+		},
 		{
 			"path": "pages/patient/profile/infos/patient-filing",
 			"style": {
@@ -97,7 +110,6 @@
 				"navigationBarTitleText": "医生首页"
 			}
 		},
-		,
 		{
 			"path": "pages/doctor/manage/index",
 			"style": {
@@ -136,10 +148,6 @@
 				"pagePath": "pages/patient/index/index",
 				"text": "患者端首页"
 			},
-			{
-				"pagePath": "pages/public/health/index",
-				"text": "健康数据"
-			},
 			{
 				"pagePath": "pages/patient/health/index",
 				"text": "健康数据"
@@ -167,6 +175,10 @@
 			{
 				"pagePath": "pages/doctor/manage/index",
 				"text": "后台管理"
+			},
+			{
+				"pagePath": "pages/patient-family/profile/index",
+				"text": "家属个人中心"
 			}
 		]
 	}

+ 280 - 0
src/pages/patient-family/profile/index.vue

@@ -0,0 +1,280 @@
+<template>
+  <CustomNav title="个人中心" leftType="none" />
+  <view class="profile-container">
+    <view class="header">
+      <view class="avatar-section">
+        <view class="avatar">
+          <view class="avatar-frame">
+            <image class="avatar-img" :src="avatarSrc" mode="aspectFill" />
+          </view>
+        </view>
+        <view class="user-info">
+          <text class="username">{{ user.nickname || '家属' }}</text>
+          <text class="user-id" v-if="user.openid">ID: {{ user.openid }}</text>
+        </view>
+      </view>
+    </view>
+
+
+
+    <view class="menu-list">
+      <view class="menu-item" @click="onMenuClick('edit')">
+        <text class="menu-text">编辑信息</text>
+        <text class="menu-arrow"></text>
+      </view>
+      <view class="menu-item" @click="onMenuClick('settings')">
+        <text class="menu-text">设置</text>
+        <text class="menu-arrow"></text>
+      </view>
+      <view class="menu-item" @click="onMenuClick('about')">
+        <text class="menu-text">关于我们</text>
+        <text class="menu-arrow"></text>
+      </view>
+    </view>
+
+    <view class="logout-section">
+      <button class="logout-btn" @click="onLogout">退出登录</button>
+    </view>
+  </view>
+  <TabBar />
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import { computed } from 'vue'
+import { onShow } from '@dcloudio/uni-app'
+import CustomNav from '@/components/custom-nav.vue'
+import TabBar from '@/components/tab-bar.vue'
+import { isLoggedIn as checkLogin, getRole } from '@/composables/useAuth'
+
+const title = ref('个人中心')
+
+const user = ref<{ avatar?: string; nickname?: string; openid?: string }>({})
+
+const defaultAvatarUrl = 'https://mmbiz.qpic.cn/mmbiz/icTdbqWNOwNRna42FI242Lcia07jQodd2FJGIYQfG0LAJGFxM4FbnQP6yfMxBgJ0F3YRqJCJ1aPAK2dQagdusBZg/0'
+
+// 计算一个安全的 avatar src:优先使用有效的 user.avatar,否则使用默认头像
+const avatarSrc = computed(() => {
+  const a = user.value?.avatar
+  if (!a) return defaultAvatarUrl
+  try {
+    const s = String(a)
+    // 常见有效前缀:http(s), data:, wxfile://, file://, /static
+    if (/^(https?:\/\/|data:|wxfile:\/\/|file:\/\/|\/static\/)/i.test(s)) {
+      return s
+    }
+    // 有时候小程序临时路径也以 / 开头或以 temp 开头,尽量允许以 ./ 或 / 开头
+    if (/^(\.|\/|temp)/i.test(s)) return s
+  } catch (e) {
+    // fallback
+  }
+  return defaultAvatarUrl
+})
+
+const loadUser = () => {
+  try {
+    const u = uni.getStorageSync('user_info')
+    if (u) {
+      user.value = u
+    }
+  } catch (e) {
+    // ignore
+  }
+}
+
+const fetchUserInfo = async () => {
+  try {
+    const token = uni.getStorageSync('token')
+    if (!token) return
+    uni.showLoading({ title: '加载中...' })
+    const response = await uni.request({
+      url: 'https://wx.baiyun.work/user_info',
+      method: 'POST',
+      header: {
+        'Content-Type': 'application/json',
+        'Authorization': `Bearer ${token}`
+      },
+      data: {}
+    })
+    uni.hideLoading()
+    console.log('User info response:', response)
+    const resp = response.data as any
+    if (response.statusCode === 401) {
+      // Token 无效,清除并跳转登录
+      uni.removeStorageSync('token')
+      uni.removeStorageSync('role')
+      user.value = {}
+      uni.reLaunch({ url: '/pages/public/login/index' })
+      return
+    }
+    if (resp && resp.code === 200 && resp.data) {
+      user.value = resp.data
+      // 保存到本地存储
+      uni.setStorageSync('user_info', resp.data)
+    }
+  } catch (err) {
+    uni.hideLoading()
+    console.error('Fetch user info error:', err)
+  }
+}
+
+// 如果在微信小程序端且未登录,自动跳转到登录页
+onShow(() => {
+  const token = uni.getStorageSync('token')
+  if (!token) {
+    // 使用 uni.reLaunch 替代 navigateTo,确保页面栈被清空
+    uni.reLaunch({ url: '/pages/public/login/index' })
+  } else {
+    fetchUserInfo()
+    // 检查角色,如果是患者,跳转到患者个人中心
+    try {
+      const r = getRole()
+      if (r === 3) {
+        console.log('[profile/index] redirecting to patient profile, role=', r)
+        uni.reLaunch({ url: '/pages/patient/profile/index' })
+        return
+      }
+    } catch (err) {
+      console.error('检查角色时出错:', err)
+    }
+  }
+})
+
+const onMenuClick = (type: string) => {
+  console.log('Menu clicked:', type)
+  if (type === 'edit') {
+    uni.navigateTo({ url: '/pages/patient-family/profile/infos/base-info' })
+  } else {
+    uni.showToast({
+      title: `${type} 功能开发中`,
+      icon: 'none'
+    })
+  }
+}
+
+const onLogout = () => {
+  uni.showModal({
+    title: '提示',
+    content: '确定要退出登录吗?',
+    success: (res) => {
+      if (res.confirm) {
+        uni.removeStorageSync('token')
+        uni.removeStorageSync('role')
+        user.value = {}
+        uni.showToast({ title: '已退出登录', icon: 'none' })
+        uni.reLaunch({ url: '/pages/public/index/index' })
+      }
+    }
+  })
+}
+</script>
+
+<style>
+.profile-container {
+  min-height: 100vh;
+  background-color: #f5f5f5;
+  padding-top: calc(var(--status-bar-height) + 44px);
+}
+
+.header {
+  background-color: #fff;
+  padding: 40rpx;
+  margin-bottom: 20rpx;
+}
+
+.avatar-section {
+  display: flex;
+  align-items: center;
+}
+
+.avatar {
+  width: 120rpx;
+  height: 120rpx;
+  border-radius: 50%;
+  /* background-color: #007aff; */
+  border: 1px solid rgba(128, 128, 128, 0.5);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 30rpx;
+}
+
+.avatar-frame {
+  width: 100%;
+  height: 100%;
+  border-radius: 50%;
+  overflow: hidden; /* 裁切超出部分,避免溢出 */
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.avatar-img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover; /* 在部分平台下生效,保证图片填充同时裁切 */
+}
+
+.avatar-text {
+  color: #fff;
+  font-size: 32rpx;
+  font-weight: bold;
+}
+
+.user-info {
+  flex: 1;
+}
+
+.username {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #333;
+  display: block;
+  margin-bottom: 10rpx;
+}
+
+.user-id {
+  font-size: 28rpx;
+  color: #666;
+}
+
+.menu-list {
+  background-color: #fff;
+  margin-bottom: 20rpx;
+}
+
+.menu-item {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 30rpx 40rpx;
+  border-bottom: 1rpx solid #eee;
+}
+
+.menu-item:last-child {
+  border-bottom: none;
+}
+
+.menu-text {
+  font-size: 32rpx;
+  color: #333;
+}
+
+.menu-arrow {
+  font-size: 28rpx;
+  color: #ccc;
+}
+
+.logout-section {
+  padding: 40rpx;
+}
+
+.logout-btn {
+  width: 100%;
+  background-color: #ff4757;
+  color: #fff;
+  border-radius: 8rpx;
+  font-size: 32rpx;
+  line-height: 80rpx;
+}
+</style>

+ 492 - 0
src/pages/patient-family/profile/infos/base-info.vue

@@ -0,0 +1,492 @@
+<template>
+  <CustomNav title="完善基本信息" leftType="home" />
+  <view class="complete-container">
+    <view class="form-section">
+      <view class="avatar-section">
+        <view class="avatar">
+          <button
+            class="avatar-wrapper"
+            :class="{ disabled: isChoosing }"
+            :disabled="isChoosing"
+            open-type="chooseAvatar"
+            @tap="startChooseAvatar"
+            @chooseavatar="onChooseAvatar"
+          >
+            <view class="avatar-frame">
+              <image v-if="form.avatar" class="avatar-img" :src="form.avatar" mode="aspectFill" />
+              <text v-else class="avatar-placeholder">点击选择头像</text>
+            </view>
+          </button>
+        </view>
+      </view>
+
+      <view class="form-item">
+        <text class="label">姓名</text>
+        <input class="input" type="nickname" v-model="form.nickname" placeholder="请输入姓名" />
+      </view>
+
+      <view class="form-item">
+        <text class="label">年龄</text>
+        <input class="input" v-model="form.age" placeholder="请输入年龄" type="number" />
+      </view>
+
+      <view class="form-item">
+        <text class="label">手机号</text>
+        <input class="input" v-model="form.phone" placeholder="请输入手机号" type="number" />
+      </view>
+
+      <view class="form-item">
+        <text class="label">性别</text>
+        <view class="radio-group">
+          <label class="radio-item" @click="form.sex = 1">
+            <radio :checked="form.sex === 1" color="#07C160" />
+            <text>男</text>
+          </label>
+          <label class="radio-item" @click="form.sex = 2">
+            <radio :checked="form.sex === 2" color="#07C160" />
+            <text>女</text>
+          </label>
+        </view>
+      </view>
+
+      <view class="form-item">
+        <text class="label">省/市</text>
+        <view class="location-section">
+          <picker mode="region" :value="region" @change="onRegionChange">
+            <view class="picker">{{ region.join(' ') || '请选择省/市' }}</view>
+          </picker>
+          <button class="get-location-btn" @click="getCurrentLocation" :disabled="gettingLocation">
+            {{ gettingLocation ? '获取中...' : '使用当前定位' }}
+          </button>
+        </view>
+      </view>
+
+      <view class="submit-section">
+        <button class="submit-btn" @click="onSubmit" :disabled="submitting">{{ submitting ? '提交中...' : '提交' }}</button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import { onShow } from '@dcloudio/uni-app'
+import CustomNav from '@/components/custom-nav.vue'
+
+const form = ref({
+  avatar: '',
+  nickname: '',
+  phone: '',
+  age: '',
+  sex: 0,
+  address: ''
+})
+
+const region = ref<string[]>([])
+const submitting = ref(false)
+const isChoosing = ref(false)
+const gettingLocation = ref(false)
+
+// 从后端拉取已有用户信息并填充表单
+const fetchUserInfo = async () => {
+  try {
+    const token = uni.getStorageSync('token')
+    if (!token) return
+    uni.showLoading({ title: '加载中...' })
+    const response = await uni.request({
+      url: 'https://wx.baiyun.work/user_info',
+      method: 'POST',
+      header: {
+        'Content-Type': 'application/json',
+        'Authorization': `Bearer ${token}`
+      },
+      data: {}
+    })
+    uni.hideLoading()
+    console.log('fetchUserInfo response:', response)
+    if (response.statusCode === 401) {
+      // 未授权,跳转登录
+      uni.removeStorageSync('token')
+      uni.reLaunch({ url: '/pages/public/login/index' })
+      return
+    }
+    const resp = response.data as any
+    if (resp && resp.code === 200 && resp.data) {
+      const d = resp.data
+      // 填充基本字段
+      form.value.avatar = d.avatar || form.value.avatar
+      form.value.nickname = d.nickname || form.value.nickname
+      form.value.phone = d.phone || form.value.phone
+      form.value.age = d.age || form.value.age
+      // sex 可能为 'MALE'/'FEMALE' 或数字/1/2
+      if (d.sex) {
+        if (typeof d.sex === 'string') {
+          const s = d.sex.toUpperCase()
+          form.value.sex = s === 'MALE' ? 1 : s === 'FEMALE' ? 2 : form.value.sex
+        } else if (typeof d.sex === 'number') {
+          form.value.sex = d.sex
+        }
+      }
+      // address 可能为 '重庆市 重庆市' 或包含空格,用空格或中文空格分割
+      if (d.address) {
+        const parts = String(d.address).split(/\s+/).filter(Boolean)
+        region.value = parts
+        form.value.address = parts.join(' ')
+      }
+    }
+  } catch (err) {
+    uni.hideLoading()
+    console.error('fetchUserInfo error:', err)
+  }
+}
+
+// 在页面显示时尝试填充
+onShow(() => {
+  fetchUserInfo()
+})
+
+const onChooseAvatar = (e: any) => {
+  console.log('onChooseAvatar called with event:', e)
+  try {
+    const detail = e?.detail
+    console.log('Event detail:', detail)
+    let url = ''
+    if (!detail) {
+      url = typeof e === 'string' ? e : ''
+      console.log('No detail, using event as string:', url)
+    } else if (typeof detail === 'string') {
+      url = detail
+      console.log('Detail is string:', url)
+    } else if (detail.avatarUrl) {
+      url = detail.avatarUrl
+      console.log('Using detail.avatarUrl:', url)
+    } else if (Array.isArray(detail) && detail[0]) {
+      url = detail[0]
+      console.log('Using first element of array:', url)
+    }
+    if (url) {
+      form.value.avatar = url
+      console.log('Avatar URL updated to:', form.value.avatar)
+    }
+    if (detail && detail.nickName && !form.value.nickname) {
+      form.value.nickname = detail.nickName
+      console.log('Nickname updated from detail:', form.value.nickname)
+    }
+  } catch (err) {
+    console.error('Error in onChooseAvatar:', err)
+  } finally {
+    isChoosing.value = false
+    console.log('isChoosing reset to false')
+  }
+}
+
+const startChooseAvatar = () => {
+  console.log('startChooseAvatar called, current isChoosing:', isChoosing.value)
+  if (isChoosing.value) {
+    console.log('Already choosing, ignoring')
+    return
+  }
+  isChoosing.value = true
+  console.log('isChoosing set to true')
+  setTimeout(() => {
+    isChoosing.value = false
+    console.log('Timeout: isChoosing reset to false')
+  }, 3000)
+}
+
+const onRegionChange = (e: any) => {
+  region.value = e.detail.value
+  form.value.address = region.value.join(' ')
+}
+
+const getCurrentLocation = async () => {
+  if (gettingLocation.value) return
+  gettingLocation.value = true
+  try {
+    // 获取经纬度
+    const locationRes = await uni.getLocation({ type: 'wgs84' })
+    const { latitude, longitude } = locationRes
+    console.log('getLocation success:', locationRes)
+
+    // 调用用户自己的地理编码接口
+    const token = uni.getStorageSync('token')
+    const geocodeRes = await uni.request({
+      url: `https://wx.baiyun.work/geo/nearest?latitude=${latitude}&longitude=${longitude}`,
+      method: 'GET',
+      header: {
+        'Authorization': `Bearer ${token}`
+      }
+    })
+
+    const data = geocodeRes.data as any
+    if (data && data.code === 200 && data.data) {
+      // 解析 data 字段中的 JSON 字符串
+      const addressData = JSON.parse(data.data)
+      if (addressData && addressData.province && addressData.city) {
+        region.value = [addressData.province, addressData.city, addressData.district].filter(Boolean)
+        form.value.address = region.value.join(' ')
+        uni.showToast({ title: '位置获取成功', icon: 'success' })
+      } else {
+        throw new Error('Invalid address data')
+      }
+    } else {
+      throw new Error('Geocode failed')
+    }
+  } catch (err) {
+    console.error('Get location error:', err)
+    uni.showToast({ title: '获取位置失败,请检查定位权限和网络', icon: 'none' })
+  } finally {
+    gettingLocation.value = false
+  }
+}
+
+const onSubmit = async () => {
+  if (submitting.value) return
+
+  // 检查所有必需字段
+  if (!form.value.avatar) {
+    uni.showToast({ title: '请选择头像', icon: 'none' })
+    return
+  }
+  if (!form.value.nickname) {
+    uni.showToast({ title: '请输入姓名', icon: 'none' })
+    return
+  }
+  // 姓名格式校验:2-30 个中文、字母、·或空格
+  const nameRegex = /^[\u4e00-\u9fa5A-Za-z·\s]{2,10}$/
+  if (!nameRegex.test(String(form.value.nickname))) {
+    uni.showToast({ title: '姓名格式不正确,请输入2-10位中文或字母', icon: 'none' })
+    return
+  }
+  if (!form.value.age) {
+    uni.showToast({ title: '请输入年龄', icon: 'none' })
+    return
+  }
+  // 年龄为 1-120 的整数
+  const ageNum = Number(form.value.age)
+  if (!Number.isInteger(ageNum) || ageNum < 1 || ageNum > 120) {
+    uni.showToast({ title: '年龄请输入1到120之间的整数', icon: 'none' })
+    return
+  }
+  if (!form.value.phone) {
+    uni.showToast({ title: '请输入手机号', icon: 'none' })
+    return
+  }
+  // 手机号格式(中国)校验
+  const phoneRegex = /^1[3-9]\d{9}$/
+  if (!phoneRegex.test(String(form.value.phone))) {
+    uni.showToast({ title: '手机号格式不正确', icon: 'none' })
+    return
+  }
+  if (!form.value.sex) {
+    uni.showToast({ title: '请选择性别', icon: 'none' })
+    return
+  }
+  if (!form.value.address) {
+    uni.showToast({ title: '请选择或自动获取省/市', icon: 'none' })
+    return
+  }
+
+  submitting.value = true
+  try {
+    const token = uni.getStorageSync('token')
+    const response = await uni.request({
+      url: 'https://wx.baiyun.work/update_user_info',
+      method: 'POST',
+      header: {
+        'Content-Type': 'application/json',
+        'Authorization': `Bearer ${token}`
+      },
+      data: form.value
+    })
+    console.log('Update response:', response)
+    const resp = response.data as any
+    if (resp && resp.code === 200) {
+      uni.showToast({ title: '信息更新成功', icon: 'success' })
+      setTimeout(() => {
+        uni.switchTab({ url: '/pages/patient-family/profile/index' })
+      }, 1500)
+    } else {
+      throw new Error('Update failed')
+    }
+  } catch (err) {
+    console.error('Update error:', err)
+    uni.showToast({ title: '更新失败', icon: 'error' })
+  } finally {
+    submitting.value = false
+  }
+}
+</script>
+
+<style>
+.complete-container {
+  min-height: 100vh;
+  /* 为固定在顶部的 CustomNav 留出空间(状态栏 + 导航栏 44px) */
+  padding-top: calc(var(--status-bar-height) + 44px + 40rpx);
+  /* 保留侧边与内部间距,使用 border-box 避免计算误差 */
+  padding-right: 40rpx;
+  padding-left: 40rpx;
+  /* 底部安全区:使用项目中声明的 --window-bottom 或 fallback */
+  padding-bottom: calc(var(--window-bottom, 0px) + 40rpx);
+  box-sizing: border-box;
+  background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+}
+
+.form-section {
+  background-color: #fff;
+  border-radius: 20rpx;
+  padding: 40rpx;
+  width: 100%;
+  max-width: 600rpx;
+  box-shadow: 0 10rpx 30rpx rgba(0, 0, 0, 0.1);
+}
+
+.avatar-section {
+  display: flex;
+  justify-content: center;
+  margin-bottom: 40rpx;
+}
+
+.avatar {
+  width: 160rpx;
+  height: 160rpx;
+  border-radius: 90rpx;
+  background-color: #eee;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.avatar-wrapper {
+  width: 160rpx;
+  height: 160rpx;
+  border-radius: 90rpx;
+  overflow: hidden;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0;
+  border: 2rpx solid #07C160;
+  background: #fff;
+}
+
+.avatar-wrapper.disabled {
+  opacity: 0.5;
+}
+
+.avatar-frame {
+  width: 100%;
+  height: 100%;
+  border-radius: 60rpx;
+  overflow: hidden;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.avatar-img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+
+.avatar-placeholder {
+  color: #999;
+  font-size: 24rpx;
+}
+
+.form-item {
+  margin-bottom: 30rpx;
+}
+
+.label {
+  display: block;
+  font-size: 32rpx;
+  color: #333;
+  margin-bottom: 10rpx;
+}
+
+.input {
+  width: 100%;
+  height: 80rpx;
+  border: 1rpx solid #ddd;
+  border-radius: 8rpx;
+  padding: 0 20rpx;
+  font-size: 32rpx;
+  box-sizing: border-box;
+  max-width: 100%;
+}
+
+.radio-group {
+  display: flex;
+  gap: 40rpx;
+}
+
+.radio-item {
+  display: flex;
+  align-items: center;
+  font-size: 32rpx;
+  color: #333;
+}
+
+picker {
+  width: 100%;
+  height: 80rpx;
+  border: 1rpx solid #ddd;
+  border-radius: 8rpx;
+  padding: 0 20rpx;
+  display: flex;
+  align-items: center;
+  font-size: 32rpx;
+  color: hsl(0, 0%, 20%);
+  box-sizing: border-box;
+  max-width: 100%;
+}
+
+.form-item, .picker, .input, .get-location-btn, .submit-btn {
+  box-sizing: border-box;
+}
+
+.location-section {
+  display: flex;
+  align-items: center;
+  gap: 20rpx;
+}
+
+.get-location-btn {
+  flex-shrink: 0;
+  height: 80rpx;
+  background: linear-gradient(135deg, #07C160 0%, #00A854 100%);
+  color: #fff;
+  border-radius: 8rpx;
+  font-size: 28rpx;
+  padding: 0 10rpx;
+  border: none;
+}
+
+.get-location-btn:disabled {
+  opacity: 0.5;
+}
+
+.submit-section {
+  margin-top: 60rpx;
+}
+
+.submit-btn {
+  width: 100%;
+  background: linear-gradient(135deg, #07C160 0%, #00A854 100%);
+  color: #fff;
+  border-radius: 12rpx;
+  font-size: 32rpx;
+  line-height: 80rpx;
+  border: none;
+}
+
+.submit-btn:disabled {
+  opacity: 0.5;
+}
+</style>