challenge-manage.vue 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875
  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. this._onLoadFired = true
  238. },
  239. onShow() {
  240. if (this._onLoadFired) {
  241. this._onLoadFired = false
  242. } else {
  243. this.loadData()
  244. }
  245. },
  246. methods: {
  247. async loadData() {
  248. this.loading = true
  249. try {
  250. const [res1, res2] = await Promise.all([
  251. getChallengeList({}),
  252. getChallengeHistory({})
  253. ])
  254. this.activeChallenges = Array.isArray(res1.data) ? res1.data : []
  255. this.historyChallenges = Array.isArray(res2.data) ? res2.data : []
  256. } catch (e) {
  257. this.activeChallenges = []
  258. this.historyChallenges = []
  259. } finally {
  260. this.loading = false
  261. }
  262. },
  263. async loadTemplates() {
  264. try {
  265. const res = await getChallengeTemplates()
  266. if (res.code === 200) {
  267. this.templates = (res.data || []).map(t => ({
  268. ...t,
  269. emoji: this.getEmoji(t.type)
  270. }))
  271. }
  272. } catch (e) {
  273. this.templates = [
  274. { type: 'full_checkin', title: '全员打卡', emoji: '✅', targetMode: 'all_members', targetValue: 3, durationDays: 3, rewardPoints: 50 },
  275. { type: 'sports', title: '运动PK', emoji: '🏃', targetMode: 'aggregate', targetValue: 300, durationDays: 7, rewardPoints: 100 },
  276. { type: 'reading', title: '阅读挑战', emoji: '📚', targetMode: 'aggregate', targetValue: 200, durationDays: 7, rewardPoints: 80 },
  277. { type: 'gratitude', title: '感恩日记', emoji: '🙏', targetMode: 'all_members', targetValue: 7, durationDays: 7, rewardPoints: 40 }
  278. ]
  279. }
  280. },
  281. getEmoji(type) {
  282. const map = {
  283. full_checkin: '✅', sports: '🏃', reading: '📚',
  284. gratitude: '🙏', no_screen: '📵', custom: '✏️'
  285. }
  286. return map[type] || '🎯'
  287. },
  288. async loadMembers() {
  289. try {
  290. const res = await getFamilyMemberList()
  291. this.familyMembers = Array.isArray(res.data) ? res.data : []
  292. this.selectedMemberIds = (this.familyMembers || []).map(m => m.id)
  293. } catch (e) {
  294. this.familyMembers = []
  295. this.selectedMemberIds = []
  296. }
  297. },
  298. async checkMembership() {
  299. try {
  300. const res = await getMyMembership()
  301. const m = res.data || {}
  302. const level = m.memberLevel || m.levelCode || ''
  303. // 免费会员:level 为空或 FREE
  304. this.isFreeMember = !level || level === 'FREE'
  305. } catch (e) {
  306. this.isFreeMember = true // 默认按免费处理,保守策略
  307. }
  308. },
  309. isMemberSelected(id) {
  310. return this.selectedMemberIds.indexOf(id) >= 0
  311. },
  312. toggleMember(id) {
  313. var idx = this.selectedMemberIds.indexOf(id)
  314. if (idx >= 0) this.selectedMemberIds.splice(idx, 1)
  315. else this.selectedMemberIds.push(id)
  316. },
  317. selectTemplate(tpl) {
  318. this.form.challengeType = tpl ? tpl.type : 'custom'
  319. if (tpl) {
  320. this.form.targetMode = tpl.targetMode || 'all_members'
  321. this.form.targetValue = String(tpl.targetValue || 7)
  322. this.form.durationDays = String(tpl.durationDays || 7)
  323. this.form.rewardPoints = String(tpl.rewardPoints || 50)
  324. this.form.title = tpl.title || ''
  325. this.form.description = tpl.description || ''
  326. } else {
  327. this.form.targetMode = 'all_members'
  328. this.form.targetValue = '7'
  329. this.form.durationDays = '7'
  330. this.form.rewardPoints = '50'
  331. this.form.title = ''
  332. this.form.description = ''
  333. }
  334. },
  335. openCreateModal() {
  336. // 免费会员且只有1个成员(自己)时,引导先添加孩子
  337. if (this.familyMembers.length < 2) {
  338. if (this.isFreeMember && this.familyMembers.length === 1) {
  339. this.showAddChildPrompt = true
  340. } else {
  341. this.showMemberPrompt = true
  342. }
  343. return
  344. }
  345. this.editMode = false
  346. this.editingId = null
  347. this.form = {
  348. title: '', description: '', challengeType: 'custom',
  349. targetMode: 'all_members', targetValue: '7', durationDays: '7', rewardPoints: '50'
  350. }
  351. this.selectedMemberIds = this.familyMembers.map(m => m.id)
  352. this.showModal = true
  353. },
  354. openEditModal(ch) {
  355. this.editMode = true
  356. this.editingId = ch.id
  357. this.form = {
  358. title: ch.title || '',
  359. description: ch.description || '',
  360. challengeType: ch.challengeType || 'custom',
  361. targetMode: ch.targetMode || 'all_members',
  362. targetValue: String(ch.targetValue || 7),
  363. durationDays: String(ch.durationDays || 7),
  364. rewardPoints: String(ch.rewardPoints || 50)
  365. }
  366. this.showModal = true
  367. },
  368. closeModal() {
  369. this.showModal = false
  370. },
  371. async submitForm() {
  372. if (!this.form.title.trim()) {
  373. uni.showToast({ title: '请填写挑战标题', icon: 'none' })
  374. return
  375. }
  376. var targetValue = parseInt(this.form.targetValue || 0, 10)
  377. if (targetValue <= 0) {
  378. uni.showToast({ title: '目标值必须大于0', icon: 'none' })
  379. return
  380. }
  381. if (!this.editMode && this.selectedMemberIds.length < 2) {
  382. uni.showToast({ title: '家庭挑战至少需要选择2名成员参与', icon: 'none' })
  383. return
  384. }
  385. var data = {
  386. title: this.form.title.trim(),
  387. description: this.form.description.trim(),
  388. challengeType: this.form.challengeType,
  389. targetMode: this.form.targetMode,
  390. targetValue: targetValue,
  391. durationDays: parseInt(this.form.durationDays || 7, 10),
  392. rewardPoints: parseInt(this.form.rewardPoints || 0, 10)
  393. }
  394. if (!this.editMode) data.participantMemberIds = this.selectedMemberIds
  395. this.loading = true
  396. try {
  397. var promise
  398. if (this.editMode) {
  399. promise = updateChallenge(this.editingId, data)
  400. } else {
  401. promise = createChallenge(data)
  402. }
  403. var res = await promise
  404. if (res.code === 200) {
  405. uni.showToast({ title: this.editMode ? '保存成功' : '创建成功', icon: 'success' })
  406. this.closeModal()
  407. this.loadData()
  408. } else {
  409. uni.showToast({ title: res.message || '操作失败', icon: 'none' })
  410. }
  411. } catch (e) {
  412. uni.showToast({ title: '操作失败', icon: 'none' })
  413. } finally {
  414. this.loading = false
  415. }
  416. },
  417. deleteChallenge(id) {
  418. uni.showModal({
  419. title: '确认删除',
  420. content: '确定要取消这个挑战吗?',
  421. success: (r) => {
  422. if (r.confirm) {
  423. deleteChallenge(id).then((res) => {
  424. if (res.code === 200) {
  425. uni.showToast({ title: '已删除', icon: 'success' })
  426. this.loadData()
  427. } else {
  428. uni.showToast({ title: res.message || '删除失败', icon: 'none' })
  429. }
  430. }).catch(() => uni.showToast({ title: '删除失败', icon: 'none' }))
  431. }
  432. }
  433. })
  434. },
  435. viewChallenge(ch) {
  436. // 跳转到挑战详情
  437. uni.navigateTo({ url: '/pages/activity/index' })
  438. },
  439. goToAddMember() {
  440. this.showMemberPrompt = false
  441. uni.navigateTo({ url: '/pages/profile-extra/family-members' })
  442. },
  443. goToAddChild() {
  444. this.showAddChildPrompt = false
  445. uni.navigateTo({ url: '/pages/family/add-member' })
  446. }
  447. }
  448. }
  449. </script>
  450. <style scoped>
  451. .page {
  452. min-height: 100vh;
  453. background: #F5F7FA;
  454. padding: 24rpx;
  455. padding-bottom: 60rpx;
  456. }
  457. .content {
  458. display: flex;
  459. flex-direction: column;
  460. gap: 24rpx;
  461. }
  462. /* 概览卡片 */
  463. .overview-card {
  464. background: linear-gradient(135deg, #FFF7ED, #FFEDD5);
  465. border-radius: 20rpx;
  466. padding: 28rpx;
  467. border: 2rpx solid #FED7AA;
  468. }
  469. .overview-header {
  470. display: flex;
  471. justify-content: space-between;
  472. align-items: center;
  473. margin-bottom: 20rpx;
  474. }
  475. .overview-title {
  476. font-size: 30rpx;
  477. font-weight: 700;
  478. color: #92400E;
  479. }
  480. .overview-count {
  481. font-size: 24rpx;
  482. color: #B45309;
  483. }
  484. .active-list {
  485. display: flex;
  486. flex-direction: column;
  487. gap: 16rpx;
  488. }
  489. .active-item {
  490. display: flex;
  491. justify-content: space-between;
  492. align-items: center;
  493. background: rgba(255,255,255,0.7);
  494. border-radius: 12rpx;
  495. padding: 16rpx 20rpx;
  496. }
  497. .active-item-title {
  498. font-size: 28rpx;
  499. font-weight: 600;
  500. color: #7C2D12;
  501. }
  502. .active-item-meta {
  503. font-size: 24rpx;
  504. color: #B45309;
  505. }
  506. /* 操作栏 */
  507. .action-bar {
  508. display: flex;
  509. justify-content: flex-end;
  510. }
  511. .btn-new {
  512. display: flex;
  513. align-items: center;
  514. gap: 8rpx;
  515. background: linear-gradient(135deg, #10B981, #34D399);
  516. color: #fff;
  517. font-size: 26rpx;
  518. font-weight: 600;
  519. padding: 16rpx 32rpx;
  520. border-radius: 40rpx;
  521. border: none;
  522. }
  523. .btn-new-icon {
  524. font-size: 32rpx;
  525. line-height: 1;
  526. }
  527. /* 列表区 */
  528. .section {
  529. display: flex;
  530. flex-direction: column;
  531. gap: 20rpx;
  532. }
  533. .section-header {
  534. display: flex;
  535. justify-content: space-between;
  536. align-items: center;
  537. }
  538. .section-title {
  539. font-size: 30rpx;
  540. font-weight: 700;
  541. color: #1E293B;
  542. }
  543. .group-label {
  544. font-size: 26rpx;
  545. font-weight: 600;
  546. color: #64748B;
  547. margin-bottom: 12rpx;
  548. }
  549. .card-list {
  550. display: flex;
  551. flex-direction: column;
  552. gap: 16rpx;
  553. }
  554. .card {
  555. background: #fff;
  556. border-radius: 16rpx;
  557. padding: 24rpx;
  558. box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.06);
  559. display: flex;
  560. justify-content: space-between;
  561. align-items: center;
  562. }
  563. .card-main {
  564. flex: 1;
  565. }
  566. .card-header {
  567. display: flex;
  568. justify-content: space-between;
  569. align-items: center;
  570. margin-bottom: 10rpx;
  571. }
  572. .card-title {
  573. font-size: 28rpx;
  574. font-weight: 700;
  575. color: #1E293B;
  576. }
  577. .card-status {
  578. font-size: 22rpx;
  579. padding: 4rpx 12rpx;
  580. border-radius: 20rpx;
  581. }
  582. .status-active {
  583. background: #DCFCE7;
  584. color: #16A34A;
  585. }
  586. .status-done {
  587. background: #F1F5F9;
  588. color: #64748B;
  589. }
  590. .card-meta {
  591. display: flex;
  592. gap: 16rpx;
  593. flex-wrap: wrap;
  594. }
  595. .meta-item {
  596. font-size: 22rpx;
  597. color: #64748B;
  598. background: #F8FAFC;
  599. padding: 4rpx 12rpx;
  600. border-radius: 8rpx;
  601. }
  602. .card-actions {
  603. display: flex;
  604. gap: 12rpx;
  605. margin-left: 16rpx;
  606. }
  607. .action-btn {
  608. font-size: 24rpx;
  609. padding: 8rpx 16rpx;
  610. border-radius: 8rpx;
  611. }
  612. .action-btn.edit {
  613. color: #5B9BD5;
  614. background: rgba(91,155,213,0.1);
  615. }
  616. .action-btn.del {
  617. color: #EF4444;
  618. background: rgba(239,68,68,0.1);
  619. }
  620. /* 空状态 */
  621. .empty {
  622. display: flex;
  623. flex-direction: column;
  624. align-items: center;
  625. padding: 80rpx 0;
  626. }
  627. .empty-icon { font-size: 80rpx; margin-bottom: 16rpx; }
  628. .empty-title { font-size: 32rpx; font-weight: 700; color: #1E293B; }
  629. .empty-desc { font-size: 26rpx; color: #94A3B8; margin: 8rpx 0 32rpx; }
  630. .btn-empty {
  631. background: #10B981;
  632. color: #fff;
  633. font-size: 28rpx;
  634. font-weight: 600;
  635. padding: 16rpx 48rpx;
  636. border-radius: 40rpx;
  637. border: none;
  638. }
  639. /* 弹窗 */
  640. .overlay {
  641. position: fixed;
  642. top: 0; left: 0; right: 0; bottom: 0;
  643. background: rgba(0,0,0,0.5);
  644. display: flex;
  645. align-items: flex-end;
  646. z-index: 999;
  647. }
  648. .modal {
  649. width: 100%;
  650. background: #fff;
  651. border-radius: 32rpx 32rpx 0 0;
  652. padding: 32rpx 28rpx 40rpx;
  653. box-sizing: border-box;
  654. max-height: 85vh;
  655. display: flex;
  656. flex-direction: column;
  657. }
  658. .modal-header {
  659. display: flex;
  660. justify-content: space-between;
  661. align-items: center;
  662. margin-bottom: 24rpx;
  663. }
  664. .modal-title {
  665. font-size: 32rpx;
  666. font-weight: 700;
  667. color: #1E293B;
  668. }
  669. .modal-close {
  670. font-size: 44rpx;
  671. color: #94A3B8;
  672. line-height: 1;
  673. }
  674. .form-body {
  675. display: flex;
  676. flex-direction: column;
  677. gap: 20rpx;
  678. flex: 1;
  679. overflow-y: auto;
  680. margin-bottom: 24rpx;
  681. }
  682. /* 模板行 */
  683. .template-row {
  684. display: flex;
  685. align-items: center;
  686. gap: 12rpx;
  687. margin-bottom: 4rpx;
  688. }
  689. .tpl-label {
  690. font-size: 26rpx;
  691. color: #475569;
  692. font-weight: 600;
  693. white-space: nowrap;
  694. }
  695. .tpl-scroll {
  696. flex: 1;
  697. }
  698. .tpl-list {
  699. display: flex;
  700. gap: 12rpx;
  701. }
  702. .tpl-item {
  703. flex-shrink: 0;
  704. display: flex;
  705. flex-direction: column;
  706. align-items: center;
  707. gap: 6rpx;
  708. padding: 14rpx 18rpx;
  709. background: #F8FAFC;
  710. border-radius: 14rpx;
  711. border: 2rpx solid transparent;
  712. }
  713. .tpl-item.active {
  714. border-color: #10B981;
  715. background: #DCFCE7;
  716. }
  717. .tpl-icon { font-size: 32rpx; }
  718. .tpl-name { font-size: 22rpx; color: #475569; font-weight: 500; }
  719. .tpl-item.active .tpl-name { color: #16A34A; }
  720. /* 表单字段 */
  721. .field {
  722. display: flex;
  723. flex-direction: column;
  724. gap: 8rpx;
  725. }
  726. .field-label {
  727. font-size: 26rpx;
  728. color: #475569;
  729. font-weight: 600;
  730. }
  731. .required { color: #EF4444; }
  732. .field-input {
  733. background: #F8FAFC;
  734. border: 1rpx solid #E2E8F0;
  735. border-radius: 12rpx;
  736. padding: 16rpx 18rpx;
  737. font-size: 28rpx;
  738. color: #1E293B;
  739. }
  740. .field-textarea {
  741. background: #F8FAFC;
  742. border: 1rpx solid #E2E8F0;
  743. border-radius: 12rpx;
  744. padding: 16rpx 18rpx;
  745. font-size: 28rpx;
  746. color: #1E293B;
  747. height: 100rpx;
  748. width: 100%;
  749. box-sizing: border-box;
  750. }
  751. .field-row {
  752. display: flex;
  753. gap: 16rpx;
  754. }
  755. .field.half { flex: 1; }
  756. /* 单选组 */
  757. .radio-group {
  758. display: flex;
  759. gap: 12rpx;
  760. }
  761. .radio-btn {
  762. flex: 1;
  763. text-align: center;
  764. padding: 12rpx 0;
  765. border-radius: 12rpx;
  766. font-size: 26rpx;
  767. color: #475569;
  768. background: #F1F5F9;
  769. }
  770. .radio-btn.active {
  771. background: #10B981;
  772. color: #fff;
  773. font-weight: 600;
  774. }
  775. /* 成员选择 */
  776. .member-grid {
  777. display: flex;
  778. flex-wrap: wrap;
  779. gap: 12rpx;
  780. }
  781. .member-item {
  782. padding: 10rpx 22rpx;
  783. border-radius: 24rpx;
  784. font-size: 24rpx;
  785. color: #475569;
  786. background: #F1F5F9;
  787. border: 2rpx solid transparent;
  788. }
  789. .member-item.active {
  790. background: #DCFCE7;
  791. color: #16A34A;
  792. border-color: #10B981;
  793. }
  794. /* 底部按钮 */
  795. .modal-footer {
  796. display: flex;
  797. gap: 16rpx;
  798. padding-top: 8rpx;
  799. }
  800. .btn-cancel, .btn-submit {
  801. flex: 1;
  802. height: 84rpx;
  803. line-height: 84rpx;
  804. border-radius: 42rpx;
  805. font-size: 30rpx;
  806. font-weight: 600;
  807. border: none;
  808. }
  809. .btn-cancel {
  810. background: #F1F5F9;
  811. color: #64748B;
  812. }
  813. .btn-submit {
  814. background: linear-gradient(135deg, #10B981, #34D399);
  815. color: #fff;
  816. }
  817. /* 家庭成员不足提示弹窗 */
  818. .prompt-modal {
  819. max-height: 60vh;
  820. padding: 32rpx 28rpx 40rpx;
  821. }
  822. .prompt-content {
  823. display: flex;
  824. flex-direction: column;
  825. align-items: center;
  826. padding: 24rpx 0 16rpx;
  827. }
  828. .prompt-icon {
  829. font-size: 80rpx;
  830. margin-bottom: 16rpx;
  831. }
  832. .prompt-title {
  833. font-size: 32rpx;
  834. font-weight: 700;
  835. color: #1E293B;
  836. margin-bottom: 12rpx;
  837. }
  838. .prompt-desc {
  839. font-size: 26rpx;
  840. color: #64748B;
  841. text-align: center;
  842. line-height: 1.6;
  843. }
  844. </style>