challenge-manage.vue 25 KB

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