challenge-manage.vue 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870
  1. <template>
  2. <view class="page">
  3. <BaseLoading :loading="loading" text="加载中..." />
  4. <view v-if="!loading" class="content">
  5. <!-- 首页挑战状态概览 -->
  6. <view class="overview-card" v-if="activeChallenges.length > 0">
  7. <view class="overview-header">
  8. <text class="overview-title">🔥 进行中</text>
  9. <text class="overview-count">{{ activeChallenges.length }} 个挑战</text>
  10. </view>
  11. <view class="active-list">
  12. <view class="active-item" v-for="ch in activeChallenges.slice(0, 2)" :key="ch.id" @click="viewChallenge(ch)">
  13. <text class="active-item-title">{{ ch.title }}</text>
  14. <text class="active-item-meta">{{ ch.targetValue }}/{{ ch.durationDays }}天</text>
  15. </view>
  16. </view>
  17. </view>
  18. <!-- 操作栏 -->
  19. <view class="action-bar">
  20. <button class="btn-new" @click="openCreateModal">
  21. <text class="btn-new-icon">+</text>
  22. <text>新建挑战</text>
  23. </button>
  24. </view>
  25. <!-- 挑战分类列表 -->
  26. <view class="section">
  27. <view class="section-header">
  28. <text class="section-title">我的挑战</text>
  29. </view>
  30. <!-- 进行中 -->
  31. <view v-if="activeChallenges.length > 0" class="group">
  32. <view class="group-label">进行中</view>
  33. <view class="card-list">
  34. <view class="card" v-for="ch in activeChallenges" :key="ch.id" @click="viewChallenge(ch)">
  35. <view class="card-main">
  36. <view class="card-header">
  37. <text class="card-title">{{ ch.title }}</text>
  38. <text class="card-status status-active">进行中</text>
  39. </view>
  40. <view class="card-meta">
  41. <text class="meta-item">{{ ch.targetMode === 'aggregate' ? '全家合计' : '每人目标' }}</text>
  42. <text class="meta-item">{{ ch.durationDays }}天</text>
  43. <text class="meta-item" v-if="ch.rewardPoints">+{{ ch.rewardPoints }}分</text>
  44. </view>
  45. </view>
  46. <view class="card-actions">
  47. <text class="action-btn edit" @click.stop="openEditModal(ch)">编辑</text>
  48. <text class="action-btn del" @click.stop="deleteChallenge(ch.id)">删除</text>
  49. </view>
  50. </view>
  51. </view>
  52. </view>
  53. <!-- 已完成 -->
  54. <view v-if="historyChallenges.length > 0" class="group">
  55. <view class="group-label">已完成</view>
  56. <view class="card-list">
  57. <view class="card" v-for="ch in historyChallenges" :key="ch.id" @click="viewChallenge(ch)">
  58. <view class="card-main">
  59. <view class="card-header">
  60. <text class="card-title">{{ ch.title }}</text>
  61. <text class="card-status status-done">已完成</text>
  62. </view>
  63. <view class="card-meta">
  64. <text class="meta-item">{{ ch.durationDays }}天</text>
  65. </view>
  66. </view>
  67. </view>
  68. </view>
  69. </view>
  70. <!-- 空状态 -->
  71. <view v-if="activeChallenges.length === 0 && historyChallenges.length === 0" class="empty">
  72. <text class="empty-icon">🎯</text>
  73. <text class="empty-title">还没有挑战</text>
  74. <text class="empty-desc">创建挑战,让全家一起动起来!</text>
  75. <button class="btn-empty" @click="openCreateModal">立即创建</button>
  76. </view>
  77. </view>
  78. </view>
  79. <!-- 创建/编辑弹窗 -->
  80. <view class="overlay" v-if="showModal" @click="closeModal">
  81. <view class="modal" @click.stop>
  82. <view class="modal-header">
  83. <text class="modal-title">{{ editMode ? '编辑挑战' : '新建挑战' }}</text>
  84. <text class="modal-close" @click="closeModal">×</text>
  85. </view>
  86. <!-- 模板选择(仅创建时) -->
  87. <view v-if="!editMode" class="template-row">
  88. <text class="tpl-label">快速模板</text>
  89. <scroll-view scroll-x class="tpl-scroll">
  90. <view class="tpl-list">
  91. <view
  92. class="tpl-item"
  93. :class="{ active: form.challengeType === 'custom' }"
  94. @click="selectTemplate(null)"
  95. >
  96. <text class="tpl-icon">✏️</text>
  97. <text class="tpl-name">自定义</text>
  98. </view>
  99. <view
  100. class="tpl-item"
  101. v-for="tpl in templates"
  102. :key="tpl.type"
  103. :class="{ active: form.challengeType === tpl.type }"
  104. @click="selectTemplate(tpl)"
  105. >
  106. <text class="tpl-icon">{{ tpl.emoji }}</text>
  107. <text class="tpl-name">{{ tpl.title }}</text>
  108. </view>
  109. </view>
  110. </scroll-view>
  111. </view>
  112. <view class="form-body">
  113. <view class="field">
  114. <text class="field-label">挑战标题 <text class="required">*</text></text>
  115. <input class="field-input" v-model="form.title" placeholder="给挑战起个名字" maxlength="50" />
  116. </view>
  117. <view class="field">
  118. <text class="field-label">挑战描述</text>
  119. <textarea class="field-textarea" v-model="form.description" placeholder="描述挑战内容(可选)" maxlength="200" />
  120. </view>
  121. <view class="field-row">
  122. <view class="field half">
  123. <text class="field-label">目标模式</text>
  124. <view class="radio-group">
  125. <view class="radio-btn" :class="{ active: form.targetMode === 'aggregate' }" @click="form.targetMode = 'aggregate'">全家合计</view>
  126. <view class="radio-btn" :class="{ active: form.targetMode === 'all_members' }" @click="form.targetMode = 'all_members'">每人目标</view>
  127. </view>
  128. </view>
  129. <view class="field half">
  130. <text class="field-label">目标值 <text class="required">*</text></text>
  131. <input class="field-input" type="digit" v-model="form.targetValue" placeholder="如: 300" />
  132. </view>
  133. </view>
  134. <view class="field-row">
  135. <view class="field half">
  136. <text class="field-label">持续天数</text>
  137. <input class="field-input" type="number" v-model="form.durationDays" placeholder="7" />
  138. </view>
  139. <view class="field half">
  140. <text class="field-label">奖励积分</text>
  141. <input class="field-input" type="number" v-model="form.rewardPoints" placeholder="50" />
  142. </view>
  143. </view>
  144. <view class="field" v-if="!editMode">
  145. <text class="field-label">参与成员</text>
  146. <view class="member-grid">
  147. <view
  148. class="member-item"
  149. :class="{ active: isMemberSelected(m.id) }"
  150. v-for="m in familyMembers"
  151. :key="m.id"
  152. @click="toggleMember(m.id)"
  153. >
  154. <text>{{ m.nickname || m.phone || '成员' }}</text>
  155. </view>
  156. </view>
  157. </view>
  158. </view>
  159. <view class="modal-footer">
  160. <button class="btn-cancel" @click="closeModal">取消</button>
  161. <button class="btn-submit" @click="submitForm">{{ editMode ? '保存' : '创建' }}</button>
  162. </view>
  163. </view>
  164. </view>
  165. <!-- 家庭成员不足提示弹窗 -->
  166. <view class="overlay" v-if="showMemberPrompt" @click="showMemberPrompt = false">
  167. <view class="modal prompt-modal" @click.stop>
  168. <view class="modal-header">
  169. <text class="modal-title">无法创建挑战</text>
  170. <text class="modal-close" @click="showMemberPrompt = false">×</text>
  171. </view>
  172. <view class="prompt-content">
  173. <text class="prompt-icon">👨‍👩‍👧‍👦</text>
  174. <text class="prompt-title">家庭成员不足</text>
  175. <text class="prompt-desc">家庭挑战至少需要 2 名成员参与,请先添加家庭成员</text>
  176. </view>
  177. <view class="modal-footer">
  178. <button class="btn-cancel" @click="showMemberPrompt = false">暂不添加</button>
  179. <button class="btn-submit" @click="goToAddMember">去添加成员</button>
  180. </view>
  181. </view>
  182. </view>
  183. <!-- 免费会员引导添加孩子弹窗 -->
  184. <view class="overlay" v-if="showAddChildPrompt" @click="showAddChildPrompt = false">
  185. <view class="modal prompt-modal" @click.stop>
  186. <view class="modal-header">
  187. <text class="modal-title">免费会员专属提示</text>
  188. <text class="modal-close" @click="showAddChildPrompt = false">×</text>
  189. </view>
  190. <view class="prompt-content">
  191. <text class="prompt-icon">👶</text>
  192. <text class="prompt-title">需先添加孩子成员</text>
  193. <text class="prompt-desc">免费会员只能添加 1 位孩子成员,添加后即可创建家庭挑战。<br/>挑战创建后,可切换到孩子身份同意参与。</text>
  194. </view>
  195. <view class="modal-footer">
  196. <button class="btn-cancel" @click="showAddChildPrompt = false">暂不添加</button>
  197. <button class="btn-submit" @click="goToAddChild">去添加孩子</button>
  198. </view>
  199. </view>
  200. </view>
  201. </view>
  202. </template>
  203. <script>
  204. import { getChallengeList, getChallengeHistory, createChallenge, updateChallenge, deleteChallenge, getChallengeTemplates, getFamilyMemberList, getMyMembership } from '../../utils/api.js'
  205. import store from '../../store/index.js'
  206. export default {
  207. data() {
  208. return {
  209. loading: true,
  210. templates: [],
  211. activeChallenges: [],
  212. historyChallenges: [],
  213. familyMembers: [],
  214. selectedMemberIds: [],
  215. showModal: false,
  216. showMemberPrompt: false,
  217. showAddChildPrompt: false,
  218. editMode: false,
  219. editingId: null,
  220. form: {
  221. title: '',
  222. description: '',
  223. challengeType: 'custom',
  224. targetMode: 'all_members',
  225. targetValue: '7',
  226. durationDays: '7',
  227. rewardPoints: '50'
  228. },
  229. isFreeMember: false
  230. }
  231. },
  232. onLoad() {
  233. this.loadTemplates()
  234. this.loadMembers()
  235. this.checkMembership()
  236. this.loadData()
  237. },
  238. onShow() {
  239. this.loadData()
  240. },
  241. methods: {
  242. async loadData() {
  243. this.loading = true
  244. try {
  245. const [res1, res2] = await Promise.all([
  246. getChallengeList({}),
  247. getChallengeHistory({})
  248. ])
  249. this.activeChallenges = Array.isArray(res1.data) ? res1.data : []
  250. this.historyChallenges = Array.isArray(res2.data) ? res2.data : []
  251. } catch (e) {
  252. this.activeChallenges = []
  253. this.historyChallenges = []
  254. } finally {
  255. this.loading = false
  256. }
  257. },
  258. async loadTemplates() {
  259. try {
  260. const res = await getChallengeTemplates()
  261. if (res.code === 200) {
  262. this.templates = (res.data || []).map(t => ({
  263. ...t,
  264. emoji: this.getEmoji(t.type)
  265. }))
  266. }
  267. } catch (e) {
  268. this.templates = [
  269. { type: 'full_checkin', title: '全员打卡', emoji: '✅', targetMode: 'all_members', targetValue: 3, durationDays: 3, rewardPoints: 50 },
  270. { type: 'sports', title: '运动PK', emoji: '🏃', targetMode: 'aggregate', targetValue: 300, durationDays: 7, rewardPoints: 100 },
  271. { type: 'reading', title: '阅读挑战', emoji: '📚', targetMode: 'aggregate', targetValue: 200, durationDays: 7, rewardPoints: 80 },
  272. { type: 'gratitude', title: '感恩日记', emoji: '🙏', targetMode: 'all_members', targetValue: 7, durationDays: 7, rewardPoints: 40 }
  273. ]
  274. }
  275. },
  276. getEmoji(type) {
  277. const map = {
  278. full_checkin: '✅', sports: '🏃', reading: '📚',
  279. gratitude: '🙏', no_screen: '📵', custom: '✏️'
  280. }
  281. return map[type] || '🎯'
  282. },
  283. async loadMembers() {
  284. try {
  285. const res = await getFamilyMemberList()
  286. this.familyMembers = Array.isArray(res.data) ? res.data : []
  287. this.selectedMemberIds = (this.familyMembers || []).map(m => m.id)
  288. } catch (e) {
  289. this.familyMembers = []
  290. this.selectedMemberIds = []
  291. }
  292. },
  293. async checkMembership() {
  294. try {
  295. const res = await getMyMembership()
  296. const m = res.data || {}
  297. const level = m.memberLevel || m.levelCode || ''
  298. // 免费会员:level 为空或 FREE
  299. this.isFreeMember = !level || level === 'FREE'
  300. } catch (e) {
  301. this.isFreeMember = true // 默认按免费处理,保守策略
  302. }
  303. },
  304. isMemberSelected(id) {
  305. return this.selectedMemberIds.indexOf(id) >= 0
  306. },
  307. toggleMember(id) {
  308. var idx = this.selectedMemberIds.indexOf(id)
  309. if (idx >= 0) this.selectedMemberIds.splice(idx, 1)
  310. else this.selectedMemberIds.push(id)
  311. },
  312. selectTemplate(tpl) {
  313. this.form.challengeType = tpl ? tpl.type : 'custom'
  314. if (tpl) {
  315. this.form.targetMode = tpl.targetMode || 'all_members'
  316. this.form.targetValue = String(tpl.targetValue || 7)
  317. this.form.durationDays = String(tpl.durationDays || 7)
  318. this.form.rewardPoints = String(tpl.rewardPoints || 50)
  319. this.form.title = tpl.title || ''
  320. this.form.description = tpl.description || ''
  321. } else {
  322. this.form.targetMode = 'all_members'
  323. this.form.targetValue = '7'
  324. this.form.durationDays = '7'
  325. this.form.rewardPoints = '50'
  326. this.form.title = ''
  327. this.form.description = ''
  328. }
  329. },
  330. openCreateModal() {
  331. // 免费会员且只有1个成员(自己)时,引导先添加孩子
  332. if (this.familyMembers.length < 2) {
  333. if (this.isFreeMember && this.familyMembers.length === 1) {
  334. this.showAddChildPrompt = true
  335. } else {
  336. this.showMemberPrompt = true
  337. }
  338. return
  339. }
  340. this.editMode = false
  341. this.editingId = null
  342. this.form = {
  343. title: '', description: '', challengeType: 'custom',
  344. targetMode: 'all_members', targetValue: '7', durationDays: '7', rewardPoints: '50'
  345. }
  346. this.selectedMemberIds = this.familyMembers.map(m => m.id)
  347. this.showModal = true
  348. },
  349. openEditModal(ch) {
  350. this.editMode = true
  351. this.editingId = ch.id
  352. this.form = {
  353. title: ch.title || '',
  354. description: ch.description || '',
  355. challengeType: ch.challengeType || 'custom',
  356. targetMode: ch.targetMode || 'all_members',
  357. targetValue: String(ch.targetValue || 7),
  358. durationDays: String(ch.durationDays || 7),
  359. rewardPoints: String(ch.rewardPoints || 50)
  360. }
  361. this.showModal = true
  362. },
  363. closeModal() {
  364. this.showModal = false
  365. },
  366. async submitForm() {
  367. if (!this.form.title.trim()) {
  368. uni.showToast({ title: '请填写挑战标题', icon: 'none' })
  369. return
  370. }
  371. var targetValue = parseInt(this.form.targetValue || 0, 10)
  372. if (targetValue <= 0) {
  373. uni.showToast({ title: '目标值必须大于0', icon: 'none' })
  374. return
  375. }
  376. if (!this.editMode && this.selectedMemberIds.length < 2) {
  377. uni.showToast({ title: '家庭挑战至少需要选择2名成员参与', icon: 'none' })
  378. return
  379. }
  380. var data = {
  381. title: this.form.title.trim(),
  382. description: this.form.description.trim(),
  383. challengeType: this.form.challengeType,
  384. targetMode: this.form.targetMode,
  385. targetValue: targetValue,
  386. durationDays: parseInt(this.form.durationDays || 7, 10),
  387. rewardPoints: parseInt(this.form.rewardPoints || 0, 10)
  388. }
  389. if (!this.editMode) data.participantMemberIds = this.selectedMemberIds
  390. this.loading = true
  391. try {
  392. var promise
  393. if (this.editMode) {
  394. promise = updateChallenge(this.editingId, data)
  395. } else {
  396. promise = createChallenge(data)
  397. }
  398. var res = await promise
  399. if (res.code === 200) {
  400. uni.showToast({ title: this.editMode ? '保存成功' : '创建成功', icon: 'success' })
  401. this.closeModal()
  402. this.loadData()
  403. } else {
  404. uni.showToast({ title: res.message || '操作失败', icon: 'none' })
  405. }
  406. } catch (e) {
  407. uni.showToast({ title: '操作失败', icon: 'none' })
  408. } finally {
  409. this.loading = false
  410. }
  411. },
  412. deleteChallenge(id) {
  413. uni.showModal({
  414. title: '确认删除',
  415. content: '确定要取消这个挑战吗?',
  416. success: (r) => {
  417. if (r.confirm) {
  418. deleteChallenge(id).then((res) => {
  419. if (res.code === 200) {
  420. uni.showToast({ title: '已删除', icon: 'success' })
  421. this.loadData()
  422. } else {
  423. uni.showToast({ title: res.message || '删除失败', icon: 'none' })
  424. }
  425. }).catch(() => uni.showToast({ title: '删除失败', icon: 'none' }))
  426. }
  427. }
  428. })
  429. },
  430. viewChallenge(ch) {
  431. // 跳转到挑战详情
  432. uni.navigateTo({ url: '/pages/activity/index' })
  433. },
  434. goToAddMember() {
  435. this.showMemberPrompt = false
  436. uni.navigateTo({ url: '/pages/profile-extra/family-members' })
  437. },
  438. goToAddChild() {
  439. this.showAddChildPrompt = false
  440. uni.navigateTo({ url: '/pages/family/add-member' })
  441. }
  442. }
  443. }
  444. </script>
  445. <style scoped>
  446. .page {
  447. min-height: 100vh;
  448. background: #F5F7FA;
  449. padding: 24rpx;
  450. padding-bottom: 60rpx;
  451. }
  452. .content {
  453. display: flex;
  454. flex-direction: column;
  455. gap: 24rpx;
  456. }
  457. /* 概览卡片 */
  458. .overview-card {
  459. background: linear-gradient(135deg, #FFF7ED, #FFEDD5);
  460. border-radius: 20rpx;
  461. padding: 28rpx;
  462. border: 2rpx solid #FED7AA;
  463. }
  464. .overview-header {
  465. display: flex;
  466. justify-content: space-between;
  467. align-items: center;
  468. margin-bottom: 20rpx;
  469. }
  470. .overview-title {
  471. font-size: 30rpx;
  472. font-weight: 700;
  473. color: #92400E;
  474. }
  475. .overview-count {
  476. font-size: 24rpx;
  477. color: #B45309;
  478. }
  479. .active-list {
  480. display: flex;
  481. flex-direction: column;
  482. gap: 16rpx;
  483. }
  484. .active-item {
  485. display: flex;
  486. justify-content: space-between;
  487. align-items: center;
  488. background: rgba(255,255,255,0.7);
  489. border-radius: 12rpx;
  490. padding: 16rpx 20rpx;
  491. }
  492. .active-item-title {
  493. font-size: 28rpx;
  494. font-weight: 600;
  495. color: #7C2D12;
  496. }
  497. .active-item-meta {
  498. font-size: 24rpx;
  499. color: #B45309;
  500. }
  501. /* 操作栏 */
  502. .action-bar {
  503. display: flex;
  504. justify-content: flex-end;
  505. }
  506. .btn-new {
  507. display: flex;
  508. align-items: center;
  509. gap: 8rpx;
  510. background: linear-gradient(135deg, #10B981, #34D399);
  511. color: #fff;
  512. font-size: 26rpx;
  513. font-weight: 600;
  514. padding: 16rpx 32rpx;
  515. border-radius: 40rpx;
  516. border: none;
  517. }
  518. .btn-new-icon {
  519. font-size: 32rpx;
  520. line-height: 1;
  521. }
  522. /* 列表区 */
  523. .section {
  524. display: flex;
  525. flex-direction: column;
  526. gap: 20rpx;
  527. }
  528. .section-header {
  529. display: flex;
  530. justify-content: space-between;
  531. align-items: center;
  532. }
  533. .section-title {
  534. font-size: 30rpx;
  535. font-weight: 700;
  536. color: #1E293B;
  537. }
  538. .group-label {
  539. font-size: 26rpx;
  540. font-weight: 600;
  541. color: #64748B;
  542. margin-bottom: 12rpx;
  543. }
  544. .card-list {
  545. display: flex;
  546. flex-direction: column;
  547. gap: 16rpx;
  548. }
  549. .card {
  550. background: #fff;
  551. border-radius: 16rpx;
  552. padding: 24rpx;
  553. box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
  554. display: flex;
  555. justify-content: space-between;
  556. align-items: center;
  557. }
  558. .card-main {
  559. flex: 1;
  560. }
  561. .card-header {
  562. display: flex;
  563. justify-content: space-between;
  564. align-items: center;
  565. margin-bottom: 10rpx;
  566. }
  567. .card-title {
  568. font-size: 28rpx;
  569. font-weight: 700;
  570. color: #1E293B;
  571. }
  572. .card-status {
  573. font-size: 22rpx;
  574. padding: 4rpx 12rpx;
  575. border-radius: 20rpx;
  576. }
  577. .status-active {
  578. background: #DCFCE7;
  579. color: #16A34A;
  580. }
  581. .status-done {
  582. background: #F1F5F9;
  583. color: #64748B;
  584. }
  585. .card-meta {
  586. display: flex;
  587. gap: 16rpx;
  588. flex-wrap: wrap;
  589. }
  590. .meta-item {
  591. font-size: 22rpx;
  592. color: #64748B;
  593. background: #F8FAFC;
  594. padding: 4rpx 12rpx;
  595. border-radius: 8rpx;
  596. }
  597. .card-actions {
  598. display: flex;
  599. gap: 12rpx;
  600. margin-left: 16rpx;
  601. }
  602. .action-btn {
  603. font-size: 24rpx;
  604. padding: 8rpx 16rpx;
  605. border-radius: 8rpx;
  606. }
  607. .action-btn.edit {
  608. color: #5B9BD5;
  609. background: rgba(91,155,213,0.1);
  610. }
  611. .action-btn.del {
  612. color: #EF4444;
  613. background: rgba(239,68,68,0.1);
  614. }
  615. /* 空状态 */
  616. .empty {
  617. display: flex;
  618. flex-direction: column;
  619. align-items: center;
  620. padding: 80rpx 0;
  621. }
  622. .empty-icon { font-size: 80rpx; margin-bottom: 16rpx; }
  623. .empty-title { font-size: 32rpx; font-weight: 700; color: #1E293B; }
  624. .empty-desc { font-size: 26rpx; color: #94A3B8; margin: 8rpx 0 32rpx; }
  625. .btn-empty {
  626. background: #10B981;
  627. color: #fff;
  628. font-size: 28rpx;
  629. font-weight: 600;
  630. padding: 16rpx 48rpx;
  631. border-radius: 40rpx;
  632. border: none;
  633. }
  634. /* 弹窗 */
  635. .overlay {
  636. position: fixed;
  637. top: 0; left: 0; right: 0; bottom: 0;
  638. background: rgba(0,0,0,0.5);
  639. display: flex;
  640. align-items: flex-end;
  641. z-index: 999;
  642. }
  643. .modal {
  644. width: 100%;
  645. background: #fff;
  646. border-radius: 32rpx 32rpx 0 0;
  647. padding: 32rpx 28rpx 40rpx;
  648. box-sizing: border-box;
  649. max-height: 85vh;
  650. display: flex;
  651. flex-direction: column;
  652. }
  653. .modal-header {
  654. display: flex;
  655. justify-content: space-between;
  656. align-items: center;
  657. margin-bottom: 24rpx;
  658. }
  659. .modal-title {
  660. font-size: 32rpx;
  661. font-weight: 700;
  662. color: #1E293B;
  663. }
  664. .modal-close {
  665. font-size: 44rpx;
  666. color: #94A3B8;
  667. line-height: 1;
  668. }
  669. .form-body {
  670. display: flex;
  671. flex-direction: column;
  672. gap: 20rpx;
  673. flex: 1;
  674. overflow-y: auto;
  675. margin-bottom: 24rpx;
  676. }
  677. /* 模板行 */
  678. .template-row {
  679. display: flex;
  680. align-items: center;
  681. gap: 12rpx;
  682. margin-bottom: 4rpx;
  683. }
  684. .tpl-label {
  685. font-size: 26rpx;
  686. color: #475569;
  687. font-weight: 600;
  688. white-space: nowrap;
  689. }
  690. .tpl-scroll {
  691. flex: 1;
  692. }
  693. .tpl-list {
  694. display: flex;
  695. gap: 12rpx;
  696. }
  697. .tpl-item {
  698. flex-shrink: 0;
  699. display: flex;
  700. flex-direction: column;
  701. align-items: center;
  702. gap: 6rpx;
  703. padding: 14rpx 18rpx;
  704. background: #F8FAFC;
  705. border-radius: 14rpx;
  706. border: 2rpx solid transparent;
  707. }
  708. .tpl-item.active {
  709. border-color: #10B981;
  710. background: #DCFCE7;
  711. }
  712. .tpl-icon { font-size: 32rpx; }
  713. .tpl-name { font-size: 22rpx; color: #475569; font-weight: 500; }
  714. .tpl-item.active .tpl-name { color: #16A34A; }
  715. /* 表单字段 */
  716. .field {
  717. display: flex;
  718. flex-direction: column;
  719. gap: 8rpx;
  720. }
  721. .field-label {
  722. font-size: 26rpx;
  723. color: #475569;
  724. font-weight: 600;
  725. }
  726. .required { color: #EF4444; }
  727. .field-input {
  728. background: #F8FAFC;
  729. border: 1rpx solid #E2E8F0;
  730. border-radius: 12rpx;
  731. padding: 16rpx 18rpx;
  732. font-size: 28rpx;
  733. color: #1E293B;
  734. }
  735. .field-textarea {
  736. background: #F8FAFC;
  737. border: 1rpx solid #E2E8F0;
  738. border-radius: 12rpx;
  739. padding: 16rpx 18rpx;
  740. font-size: 28rpx;
  741. color: #1E293B;
  742. height: 100rpx;
  743. width: 100%;
  744. box-sizing: border-box;
  745. }
  746. .field-row {
  747. display: flex;
  748. gap: 16rpx;
  749. }
  750. .field.half { flex: 1; }
  751. /* 单选组 */
  752. .radio-group {
  753. display: flex;
  754. gap: 12rpx;
  755. }
  756. .radio-btn {
  757. flex: 1;
  758. text-align: center;
  759. padding: 12rpx 0;
  760. border-radius: 12rpx;
  761. font-size: 26rpx;
  762. color: #475569;
  763. background: #F1F5F9;
  764. }
  765. .radio-btn.active {
  766. background: #10B981;
  767. color: #fff;
  768. font-weight: 600;
  769. }
  770. /* 成员选择 */
  771. .member-grid {
  772. display: flex;
  773. flex-wrap: wrap;
  774. gap: 12rpx;
  775. }
  776. .member-item {
  777. padding: 10rpx 22rpx;
  778. border-radius: 24rpx;
  779. font-size: 24rpx;
  780. color: #475569;
  781. background: #F1F5F9;
  782. border: 2rpx solid transparent;
  783. }
  784. .member-item.active {
  785. background: #DCFCE7;
  786. color: #16A34A;
  787. border-color: #10B981;
  788. }
  789. /* 底部按钮 */
  790. .modal-footer {
  791. display: flex;
  792. gap: 16rpx;
  793. padding-top: 8rpx;
  794. }
  795. .btn-cancel, .btn-submit {
  796. flex: 1;
  797. height: 84rpx;
  798. line-height: 84rpx;
  799. border-radius: 42rpx;
  800. font-size: 30rpx;
  801. font-weight: 600;
  802. border: none;
  803. }
  804. .btn-cancel {
  805. background: #F1F5F9;
  806. color: #64748B;
  807. }
  808. .btn-submit {
  809. background: linear-gradient(135deg, #10B981, #34D399);
  810. color: #fff;
  811. }
  812. /* 家庭成员不足提示弹窗 */
  813. .prompt-modal {
  814. max-height: 60vh;
  815. padding: 32rpx 28rpx 40rpx;
  816. }
  817. .prompt-content {
  818. display: flex;
  819. flex-direction: column;
  820. align-items: center;
  821. padding: 24rpx 0 16rpx;
  822. }
  823. .prompt-icon {
  824. font-size: 80rpx;
  825. margin-bottom: 16rpx;
  826. }
  827. .prompt-title {
  828. font-size: 32rpx;
  829. font-weight: 700;
  830. color: #1E293B;
  831. margin-bottom: 12rpx;
  832. }
  833. .prompt-desc {
  834. font-size: 26rpx;
  835. color: #64748B;
  836. text-align: center;
  837. line-height: 1.6;
  838. }
  839. </style>