family-members.vue 32 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039
  1. <template>
  2. <view class="container">
  3. <!-- ===== 家庭信息头部 ===== -->
  4. <view class="family-header">
  5. <view class="family-header-content">
  6. <text class="family-header-icon">🏠</text>
  7. <view class="family-header-text">
  8. <text class="family-name">{{ familyName || '我的家庭' }}</text>
  9. <text class="family-member-count">{{ filteredMembers.length }} 位成员</text>
  10. </view>
  11. </view>
  12. </view>
  13. <!-- ===== 加入申请(我分享的邀请,由我确认) ===== -->
  14. <view class="join-request-section" v-if="joinRequests.length > 0">
  15. <view class="jr-header">
  16. <text class="jr-title">📨 加入申请</text>
  17. <text class="jr-count">{{ joinRequests.length }} 条待确认</text>
  18. </view>
  19. <view class="jr-item" v-for="r in joinRequests" :key="r.id">
  20. <image class="jr-avatar" :src="r.requesterAvatar || '/static/default-avatar.png'" mode="aspectFill"></image>
  21. <view class="jr-info">
  22. <text class="jr-name">{{ r.requesterNickname || '微信用户' }}</text>
  23. <text class="jr-meta">希望以「{{ r.familyRole || '成员' }}」身份加入</text>
  24. </view>
  25. <view class="jr-actions">
  26. <button class="jr-btn jr-accept" @click="onHandleJoinRequest(r, true)">同意</button>
  27. <button class="jr-btn jr-reject" @click="onHandleJoinRequest(r, false)">拒绝</button>
  28. </view>
  29. </view>
  30. </view>
  31. <!-- ===== 成员卡片列表 ===== -->
  32. <view class="member-list" v-if="filteredMembers.length > 0">
  33. <view class="member-card" v-for="m in filteredMembers" :key="m.id" @click="showMemberDetail(m)">
  34. <image class="member-avatar" :src="m.avatar || '/static/default-avatar.png'" mode="aspectFill"></image>
  35. <view class="member-info">
  36. <text class="member-name">{{ m.nickname || m.name || '成员' }}</text>
  37. <text class="member-role-tag">{{ m.relationship || m.relativeLabel || m.roleLabel || '成员' }}</text>
  38. </view>
  39. <text class="member-arrow">›</text>
  40. </view>
  41. </view>
  42. <!-- ===== 空状态 ===== -->
  43. <view v-if="filteredMembers.length === 0" class="empty-state">
  44. <text class="empty-icon">👨‍👩‍👧‍👦</text>
  45. <text class="empty-title">还没有家庭成员</text>
  46. <text class="empty-desc">邀请家人加入,一起记录成长</text>
  47. <button class="empty-invite-btn" open-type="share">📨 邀请家人</button>
  48. </view>
  49. <!-- ===== 成员详情弹窗 ===== -->
  50. <view class="modal-mask" v-if="showMemberDetailModal" @click="closeMemberDetail">
  51. <view class="member-detail-card" @click.stop>
  52. <view class="detail-header">
  53. <image class="detail-avatar" :src="detailMember && (detailMember.avatar || '/static/default-avatar.png')" mode="aspectFill"></image>
  54. <view class="detail-info">
  55. <text class="detail-name">{{ detailMember && (detailMember.nickname || detailMember.name || '成员') || '' }}</text>
  56. <text class="detail-relation">{{ detailMember && (detailMember.relationship || detailMember.relativeLabel || detailMember.roleLabel || '成员') }}</text>
  57. </view>
  58. <text class="detail-close" @click="closeMemberDetail">×</text>
  59. </view>
  60. <!-- 详情:性别/年龄/手机号/角色/辈分/积分/来源 -->
  61. <view class="detail-meta">
  62. <text class="detail-meta-item" v-if="detailMember && detailMember.gender">性别: {{ detailMember.gender === 'male' ? '男' : '女' }}</text>
  63. <text class="detail-meta-item" v-if="detailMember && detailMember.age">{{ detailMember.age }}岁</text>
  64. <text class="detail-meta-item" v-if="detailMember && detailMember.phone">{{ detailMember.phone }}</text>
  65. <text class="detail-meta-item" v-if="detailMember && detailMember.roleLabel">角色: {{ detailMember.roleLabel }}</text>
  66. <text class="detail-meta-item" v-if="detailMember && detailMember.generation !== null && detailMember.generation !== undefined && generationLabel(detailMember.generation)">辈分: {{ generationLabel(detailMember.generation) }}</text>
  67. <text class="detail-meta-item" v-if="detailMember && detailMember.totalPoints !== null && detailMember.totalPoints !== undefined">积分: {{ detailMember.totalPoints }}</text>
  68. <text class="detail-meta-item" v-if="detailMember && detailMember.source">类型: {{ detailMember.source === 'parent' ? '家长账号' : '家庭成员' }}</text>
  69. </view>
  70. <!-- 三维评分展示 -->
  71. <view class="detail-scores">
  72. <text class="detail-section-title">关系质量</text>
  73. <view class="detail-score-item">
  74. <text class="detail-score-label">信任</text>
  75. <view class="detail-score-bar-bg">
  76. <view class="detail-score-bar-fill trust" :style="{width: (detailMemberScores.trustScore || 0) + '%'}"></view>
  77. </view>
  78. <text class="detail-score-value">{{ Math.round(detailMemberScores.trustScore || 0) }}</text>
  79. </view>
  80. <view class="detail-score-item">
  81. <text class="detail-score-label">亲密</text>
  82. <view class="detail-score-bar-bg">
  83. <view class="detail-score-bar-fill intimacy" :style="{width: (detailMemberScores.intimacyScore || 0) + '%'}"></view>
  84. </view>
  85. <text class="detail-score-value">{{ Math.round(detailMemberScores.intimacyScore || 0) }}</text>
  86. </view>
  87. <view class="detail-score-item">
  88. <text class="detail-score-label">沟通</text>
  89. <view class="detail-score-bar-bg">
  90. <view class="detail-score-bar-fill communication" :style="{width: (detailMemberScores.communicationScore || 0) + '%'}"></view>
  91. </view>
  92. <text class="detail-score-value">{{ Math.round(detailMemberScores.communicationScore || 0) }}</text>
  93. </view>
  94. </view>
  95. <!-- 操作按钮 -->
  96. <view class="detail-actions">
  97. <button class="detail-action-btn action-switch" @click="onSwitchMember(detailMember)" v-if="detailMember && detailMember.isSwitchable && isSelfView">切换身份</button>
  98. <button class="detail-action-btn action-edit" @click="onEditRelation(detailMember)" v-if="canEditMember(detailMember)">编辑资料</button>
  99. <button class="detail-action-btn action-questionnaire" @click="doQuestionnaire(detailMember)">关系问卷</button>
  100. <button class="detail-action-btn action-interaction" @click="recordInteraction(detailMember)">记录互动</button>
  101. <button class="detail-action-btn action-kick" @click="confirmKick(detailMember)" v-if="isAdmin && detailMember && !detailMember.isSelf">删除成员</button>
  102. </view>
  103. </view>
  104. </view>
  105. <!-- ===== 底部操作栏 ===== -->
  106. <view class="bottom-bar">
  107. <button class="bottom-btn btn-add" @click="onAddMember">+ 添加成员</button>
  108. <button class="bottom-btn btn-invite" open-type="share">📨 邀请成员</button>
  109. </view>
  110. <!-- ===== 加载状态 ===== -->
  111. <view v-if="loading" class="loading-wrap">
  112. <text class="loading-text">加载中...</text>
  113. </view>
  114. <!-- ===== 分享卡片画布(离屏,仅用于生成分享缩略图) ===== -->
  115. <canvas canvas-id="familyShareCard" id="familyShareCard" class="share-card-canvas"></canvas>
  116. </view>
  117. </template>
  118. <script>
  119. import {
  120. getFamilyMemberList,
  121. switchToFamilyMember,
  122. kickFamilyMemberById,
  123. generateFamilyInvite,
  124. updateFamilyMember,
  125. getPendingJoinRequests,
  126. approveJoinRequest,
  127. rejectJoinRequest, reportAppEnter
  128. } from '../../utils/api.js'
  129. import store from '../../store/index.js'
  130. import GenerationPicker from '@/components/GenerationPicker.vue'
  131. export default {
  132. components: { GenerationPicker },
  133. data() {
  134. return {
  135. userId: 0,
  136. familyId: 0,
  137. familyName: '',
  138. creatorId: null,
  139. members: [],
  140. isAdmin: false,
  141. loading: false,
  142. showMemberDetailModal: false,
  143. detailMember: null,
  144. memberScores: {},
  145. userNickname: '',
  146. userAvatar: '',
  147. inviteToken: '',
  148. joinRequests: [],
  149. shareCardImage: '',
  150. }
  151. },
  152. computed: {
  153. isSelfView() {
  154. // 成员视角(已切换到不可登录成员)下不再显示切换入口,只能退出切换
  155. return store.state.currentView !== 'member'
  156. },
  157. // 列表过滤掉"我"(当前登录用户对应的成员),避免自己和自己互动
  158. filteredMembers() {
  159. // 过滤掉自己,只展示其他家庭成员
  160. var self = this
  161. return (this.members || []).filter(function(m) {
  162. var isSelf = m.isSelf === true
  163. if (!isSelf && m.userId && String(m.userId) === String(self.userId)) {
  164. isSelf = true
  165. }
  166. return !isSelf
  167. })
  168. },
  169. detailMemberScores() {
  170. if (!this.detailMember) return {}
  171. return this.memberScores[this.detailMember.id] || {}
  172. }
  173. },
  174. onShow() {
  175. reportAppEnter('members');
  176. var userInfo = uni.getStorageSync('userInfo')
  177. // storage 里的 key 是 userId(utils/api.js 设的是 userId),切勿写 .id(永远为 0)
  178. this.userId = (userInfo && userInfo.userId) || (uni.getStorageSync('userId')) || 0
  179. this.userNickname = uni.getStorageSync('nickname') || (userInfo && userInfo.nickname) || ''
  180. this.userAvatar = uni.getStorageSync('avatar') || (userInfo && userInfo.avatar) || ''
  181. // 家庭在用户注册时已由后端创建(登录接口另有兜底),此处不再触发建家庭逻辑;
  182. // 数据加载统一入口:单次 includeFamily 请求同时获取成员列表 + 家庭信息
  183. this.loadMembers()
  184. // 预取邀请令牌(后端复用同家庭未用尽的有效令牌),保证点击分享时立即可用
  185. this.prepareInviteToken()
  186. // 拉取待我确认的加入申请(后端按邀请人过滤,无需管理员身份)
  187. this.loadJoinRequests()
  188. },
  189. // 页面级分享:转发邀请卡片,接收人点击直接进入加入家庭页(信息补充、家庭申请)
  190. onShareAppMessage: function() {
  191. var self = this
  192. var title = '邀请你加入「' + (this.familyName || '我的家庭') + '」'
  193. var buildResult = function(token, imageUrl) {
  194. return {
  195. title: title,
  196. path: '/pages/invite/join?token=' + (token || ''),
  197. imageUrl: imageUrl || self.shareCardImage || ''
  198. }
  199. }
  200. if (this.inviteToken && this.shareCardImage) {
  201. return buildResult(this.inviteToken, this.shareCardImage)
  202. }
  203. // 令牌/卡片未就绪时异步获取
  204. return {
  205. title: title,
  206. path: '/pages/index/index',
  207. promise: new Promise(function(resolve) {
  208. function done(token, image) {
  209. resolve(buildResult(token || '', image || ''))
  210. }
  211. // 并行:令牌 + 分享卡片
  212. var tokenDone = false
  213. var cardDone = false
  214. var tryResolve = function() {
  215. if (!tokenDone || !cardDone) return
  216. if (!self.inviteToken) { done('', self.shareCardImage) }
  217. else { done(self.inviteToken, self.shareCardImage) }
  218. }
  219. self.prepareInviteToken(function(token) {
  220. tokenDone = true
  221. tryResolve()
  222. })
  223. self.drawShareCard(function(image) {
  224. cardDone = true
  225. self.shareCardImage = image
  226. tryResolve()
  227. })
  228. })
  229. }
  230. },
  231. onShareTimeline: function() {
  232. var self = this
  233. var title = '邀请你加入「' + (this.familyName || '我的家庭') + '」'
  234. var buildResult = function(token, imageUrl) {
  235. return {
  236. title: title,
  237. query: 'token=' + (token || ''),
  238. imageUrl: imageUrl || self.shareCardImage || ''
  239. }
  240. }
  241. if (this.inviteToken && this.shareCardImage) {
  242. return buildResult(this.inviteToken, this.shareCardImage)
  243. }
  244. return {
  245. title: title,
  246. query: '',
  247. imageUrl: '',
  248. promise: new Promise(function(resolve) {
  249. var tokenDone = false
  250. var cardDone = false
  251. var tryResolve = function() {
  252. if (!tokenDone || !cardDone) return
  253. var token = self.inviteToken || ''
  254. var image = self.shareCardImage || ''
  255. resolve(buildResult(token, image))
  256. }
  257. self.prepareInviteToken(function() { tokenDone = true; tryResolve() })
  258. self.drawShareCard(function(image) {
  259. cardDone = true
  260. self.shareCardImage = image
  261. tryResolve()
  262. })
  263. })
  264. }
  265. },
  266. methods: {
  267. async loadMembers() {
  268. this.loading = true
  269. try {
  270. var res = await getFamilyMemberList({ includeFamily: true })
  271. if (res.code === 200 && res.data) {
  272. var data = res.data
  273. this.familyId = data.familyId
  274. this.familyName = data.familyName
  275. this.creatorId = data.creatorId
  276. // creatorId 来自后端 JSON(数字),userId 来自 storage(可能为字符串),用 String 归一避免 === 恒 false 导致管理员按钮不显示
  277. this.isAdmin = String(this.creatorId) === String(this.userId)
  278. var arr = data.members
  279. if (!(arr instanceof Array)) {
  280. // 后端旧版本未返回 members 字段时的兼容:补一次无参请求
  281. var legacy = await getFamilyMemberList()
  282. arr = (legacy && legacy.code === 200 && legacy.data instanceof Array) ? legacy.data : []
  283. }
  284. this.members = arr || []
  285. this.buildScoreMap(this.members)
  286. }
  287. } catch (e) {
  288. uni.showToast({ title: '加载失败', icon: 'none' })
  289. } finally {
  290. this.loading = false
  291. }
  292. },
  293. buildScoreMap(members) {
  294. var map = {}
  295. if (members && members.length) {
  296. for (var i = 0; i < members.length; i++) {
  297. var m = members[i]
  298. map[m.id] = {
  299. trustScore: m.trustScore || 0,
  300. intimacyScore: m.intimacyScore || 0,
  301. communicationScore: m.communicationScore || 0
  302. }
  303. }
  304. }
  305. this.memberScores = map
  306. },
  307. showMemberDetail(member) {
  308. this.detailMember = member
  309. this.showMemberDetailModal = true
  310. },
  311. canEditMember(member) {
  312. if (!this.isAdmin) return false
  313. // 家长账号(users 表成员)与手写家庭成员均可编辑资料(后端 updateMember/updateParentMember 分别处理)
  314. return member.source === 'family_member' || member.source === 'parent'
  315. },
  316. onAddMember() {
  317. uni.navigateTo({ url: '/pages/family/add-member' })
  318. },
  319. async onSwitchMember(member) {
  320. this.showMemberDetailModal = false
  321. try {
  322. var res = await switchToFamilyMember(member.id, member.source)
  323. if (res.code === 200 && res.data) {
  324. store.commit('switchToMember', {
  325. memberId: res.data.memberId,
  326. effectiveRole: res.data.effectiveRole
  327. })
  328. uni.showToast({ title: '已切换到' + (member.nickname || '该成员'), icon: 'success' })
  329. setTimeout(function() {
  330. uni.reLaunch({ url: '/pages/index-home/index' })
  331. }, 1000)
  332. } else {
  333. uni.showToast({ title: res.message || '切换失败', icon: 'none' })
  334. }
  335. } catch (e) {
  336. uni.showToast({ title: '切换失败', icon: 'none' })
  337. }
  338. },
  339. confirmKick(member) {
  340. var self = this
  341. uni.showModal({
  342. title: '删除成员',
  343. content: '确定要删除 ' + (member.nickname || '该成员') + ' 吗?删除后成员将回到自己的家庭。',
  344. success: function(res) {
  345. if (res.confirm) {
  346. self.onKickMember(member)
  347. }
  348. }
  349. })
  350. },
  351. async onKickMember(member) {
  352. this.showMemberDetailModal = false
  353. try {
  354. var res = await kickFamilyMemberById(member.id)
  355. if (res.code === 200) {
  356. uni.showToast({ title: '已移除', icon: 'success' })
  357. this.loadMembers()
  358. } else {
  359. uni.showToast({ title: res.message || '操作失败', icon: 'none' })
  360. }
  361. } catch (e) {
  362. uni.showToast({ title: '操作失败', icon: 'none' })
  363. }
  364. },
  365. // 生成/复用邀请令牌(小程序原生分享卡片路径使用),完成后回调 callback(token)
  366. prepareInviteToken(callback) {
  367. var self = this
  368. var done = function(token) {
  369. if (typeof callback === 'function') {
  370. callback(token)
  371. }
  372. }
  373. if (this.inviteToken) {
  374. done(this.inviteToken)
  375. return
  376. }
  377. generateFamilyInvite().then(function(res) {
  378. if (res && res.code === 200 && res.data && res.data.token) {
  379. self.inviteToken = res.data.token
  380. done(self.inviteToken)
  381. } else {
  382. uni.showToast({ title: (res && res.message) || '生成邀请失败', icon: 'none' })
  383. done('')
  384. }
  385. }).catch(function() {
  386. uni.showToast({ title: '生成邀请失败', icon: 'none' })
  387. done('')
  388. })
  389. },
  390. // 生成家庭邀请分享卡片图片(5:4 比例,500x400px),返回 tempFilePath
  391. drawShareCard: function(callback) {
  392. var self = this
  393. var W = 500
  394. var H = 400
  395. var canvasId = 'familyShareCard'
  396. var members = self.members || []
  397. var roundRect = function(ctx, x, y, w, h, r) {
  398. ctx.beginPath()
  399. ctx.moveTo(x + r, y)
  400. ctx.arcTo(x + w, y, x + w, y + r, r)
  401. ctx.arcTo(x + w, y + h, x + w - r, y + h, r)
  402. ctx.arcTo(x, y + h, x, y + h - r, r)
  403. ctx.arcTo(x, y, x + r, y, r)
  404. ctx.closePath()
  405. }
  406. var ctx = uni.createCanvasContext(canvasId, self)
  407. // 暖橙渐变背景
  408. var bgGrad = ctx.createLinearGradient(0, 0, W * 0.6, H)
  409. bgGrad.addColorStop(0, '#FF8C42')
  410. bgGrad.addColorStop(1, '#FFB366')
  411. ctx.setFillStyle(bgGrad)
  412. ctx.fillRect(0, 0, W, H)
  413. // 白色内容卡片
  414. var cx = 30, cy = 70, cw = W - 60, ch = 250, cr = 20
  415. ctx.setFillStyle('rgba(0,0,0,0.04)')
  416. roundRect(ctx, cx + 2, cy + 4, cw, ch, cr)
  417. ctx.fill()
  418. ctx.setFillStyle('#FFFCF5')
  419. roundRect(ctx, cx, cy, cw, ch, cr)
  420. ctx.fill()
  421. // 四角装饰框
  422. var bl = 14, br = 10
  423. ctx.setStrokeStyle('rgba(249,115,22,0.5)')
  424. ctx.setLineWidth(1.5)
  425. ctx.beginPath(); ctx.moveTo(cx+br, cy+br+bl); ctx.lineTo(cx+br, cy+br); ctx.lineTo(cx+br+bl, cy+br); ctx.stroke()
  426. ctx.beginPath(); ctx.moveTo(cx+cw-br-bl, cy+br); ctx.lineTo(cx+cw-br, cy+br); ctx.lineTo(cx+cw-br, cy+br+bl); ctx.stroke()
  427. ctx.beginPath(); ctx.moveTo(cx+br, cy+ch-br-bl); ctx.lineTo(cx+br, cy+ch-br); ctx.lineTo(cx+br+bl, cy+ch-br); ctx.stroke()
  428. ctx.beginPath(); ctx.moveTo(cx+cw-br-bl, cy+ch-br); ctx.lineTo(cx+cw-br, cy+ch-br); ctx.lineTo(cx+cw-br, cy+ch-br-bl); ctx.stroke()
  429. // 主文案
  430. var title = '邀请你加入「' + (self.familyName || '我的家庭') + '」'
  431. ctx.setFillStyle('#C2410C')
  432. ctx.setFontSize(26)
  433. ctx.setTextAlign('center')
  434. ctx.setTextBaseline('top')
  435. ctx.fillText(title, W / 2, cy + 36)
  436. // 成员头像(最多3个,首字母占位)
  437. var avatarY = cy + 115
  438. var avatarR = 30
  439. var avatarGap = 20
  440. var avatars = []
  441. var initials = []
  442. for (var i = 0; i < Math.min(3, members.length); i++) {
  443. avatars.push(members[i].avatar || '')
  444. var name = members[i].nickname || ''
  445. initials.push(name.substring(0, 1) || '?')
  446. }
  447. var totalW = avatars.length * (avatarR * 2) + Math.max(0, avatars.length - 1) * avatarGap
  448. var startX = (W - totalW) / 2
  449. for (var a = 0; a < avatars.length; a++) {
  450. var ax = startX + a * (avatarR * 2 + avatarGap) + avatarR
  451. ctx.save()
  452. ctx.beginPath()
  453. ctx.arc(ax, avatarY, avatarR, 0, 2 * Math.PI)
  454. ctx.setFillStyle('#FFF0E0')
  455. ctx.fill()
  456. ctx.restore()
  457. ctx.save()
  458. ctx.beginPath()
  459. ctx.arc(ax, avatarY, avatarR, 0, 2 * Math.PI)
  460. ctx.clip()
  461. ctx.setFillStyle('#FF8C42')
  462. ctx.setFontSize(28)
  463. ctx.setTextAlign('center')
  464. ctx.setTextBaseline('middle')
  465. ctx.fillText(initials[a] || '?', ax, avatarY)
  466. ctx.restore()
  467. }
  468. // 成员数量
  469. ctx.setFillStyle('#999999')
  470. ctx.setFontSize(16)
  471. ctx.setTextAlign('center')
  472. ctx.fillText(members.length + ' 位家庭成员', W / 2, avatarY + avatarR + 18)
  473. // CTA 按钮
  474. var btnW = 200, btnH = 52
  475. var btnX = (W - btnW) / 2
  476. var btnY = cy + ch - 70
  477. var btnGrad = ctx.createLinearGradient(btnX, btnY, btnX + btnW, btnY)
  478. btnGrad.addColorStop(0, '#FF8C42')
  479. btnGrad.addColorStop(1, '#FFB366')
  480. ctx.setFillStyle(btnGrad)
  481. roundRect(ctx, btnX, btnY, btnW, btnH, 26)
  482. ctx.fill()
  483. ctx.setFillStyle('#FFFFFF')
  484. ctx.setFontSize(20)
  485. ctx.setTextAlign('center')
  486. ctx.setTextBaseline('middle')
  487. ctx.fillText('点击加入', W / 2, btnY + btnH / 2)
  488. // 底部品牌
  489. ctx.setFillStyle('rgba(255,255,255,0.85)')
  490. ctx.setFontSize(13)
  491. ctx.fillText('浠艾福 · 家庭成长俱乐部', W / 2, H - 30)
  492. // 五维点装饰
  493. var dotColors = ['#FF8C42', '#6366F1', '#F59E0B', '#10B981', '#FF6B9D']
  494. var dotY = H - 16
  495. for (var d = 0; d < dotColors.length; d++) {
  496. ctx.setFillStyle(dotColors[d])
  497. ctx.beginPath()
  498. ctx.arc(W / 2 + (d - 2) * 16, dotY, 3, 0, 2 * Math.PI)
  499. ctx.fill()
  500. }
  501. ctx.draw(false, function() {
  502. uni.canvasToTempFilePath({
  503. canvasId: canvasId,
  504. success: function(res) {
  505. self.shareCardImage = res.tempFilePath
  506. if (typeof callback === 'function') callback(res.tempFilePath)
  507. },
  508. fail: function() {
  509. if (typeof callback === 'function') callback('')
  510. }
  511. }, self)
  512. })
  513. },
  514. // 根据辈分值生成辈分等级字符串(与 utils/generationLevel.js 对应)
  515. generationToLevel(gen) {
  516. var map = { 3: 'great_grandparent', 2: 'grandparent', 1: 'parent', 0: 'peer', '-1': 'child', '-2': 'grandchild', '-3': 'great_grandchild' }
  517. return map[gen] || ''
  518. },
  519. // 辈分数值 → 中文标签(弹窗展示用,与 utils/generationLevel.js GENERATION_LEVELS 对应)
  520. generationLabel(gen) {
  521. var map = { 3: '曾祖辈', 2: '祖辈', 1: '父辈', 0: '同辈', '-1': '子辈', '-2': '孙辈', '-3': '曾孙辈' }
  522. return map[gen] || ''
  523. },
  524. onEditRelation(member) {
  525. this.showMemberDetailModal = false
  526. var params = 'memberId=' + member.id + '&nickname=' + encodeURIComponent(member.nickname || '')
  527. if (member.gender) params += '&gender=' + member.gender
  528. if (member.phone) params += '&phone=' + member.phone
  529. if (member.birthday) params += '&birthday=' + member.birthday
  530. // 传递辈分关系,编辑页可回显并修改
  531. if (member.generation !== null && member.generation !== undefined) {
  532. params += '&generationLevel=' + this.generationToLevel(member.generation)
  533. }
  534. if (member.isSpouse) params += '&peerType=spouse'
  535. uni.navigateTo({ url: '/pages/family/add-member?' + params })
  536. },
  537. doQuestionnaire(member) {
  538. this.showMemberDetailModal = false
  539. uni.navigateTo({ url: '/pages/action-detail/relationship-questionnaire?memberId=' + member.id })
  540. },
  541. recordInteraction(member) {
  542. this.showMemberDetailModal = false
  543. uni.navigateTo({ url: '/pages/action-detail/interaction-log?memberId=' + member.id })
  544. },
  545. closeMemberDetail() {
  546. this.showMemberDetailModal = false
  547. },
  548. async loadJoinRequests() {
  549. try {
  550. var res = await getPendingJoinRequests()
  551. if (res && res.code === 200 && res.data instanceof Array) {
  552. this.joinRequests = res.data
  553. } else {
  554. this.joinRequests = []
  555. }
  556. } catch (e) {
  557. this.joinRequests = []
  558. }
  559. },
  560. onHandleJoinRequest(r, approve) {
  561. var self = this
  562. if (!r) {
  563. return
  564. }
  565. var nickname = r.requesterNickname || '该用户'
  566. uni.showModal({
  567. title: approve ? '同意加入' : '拒绝申请',
  568. content: approve ? ('确定同意 ' + nickname + ' 加入家庭吗?') : ('确定拒绝 ' + nickname + ' 的加入申请吗?'),
  569. success: function(mr) {
  570. if (mr.confirm) {
  571. self.doHandleJoinRequest(r, approve)
  572. }
  573. }
  574. })
  575. },
  576. async doHandleJoinRequest(r, approve) {
  577. var self = this
  578. try {
  579. var action = approve ? approveJoinRequest : rejectJoinRequest
  580. var res = await action(r.id, '')
  581. if (res && res.code === 200) {
  582. uni.showToast({ title: approve ? '已同意加入' : '已拒绝', icon: 'success' })
  583. self.loadJoinRequests()
  584. if (approve) {
  585. self.loadMembers()
  586. }
  587. } else {
  588. uni.showToast({ title: (res && res.message) || '操作失败', icon: 'none' })
  589. self.loadJoinRequests()
  590. }
  591. } catch (e) {
  592. uni.showToast({ title: e.message || '操作失败', icon: 'none' })
  593. }
  594. }
  595. }
  596. }
  597. </script>
  598. <style>
  599. .container {
  600. padding: 30rpx 30rpx 140rpx;
  601. min-height: 100vh;
  602. background: #F5F0EB;
  603. }
  604. /* ===== 家庭信息头部 ===== */
  605. .family-header {
  606. background: linear-gradient(135deg, #F97316, #EA580C);
  607. border-radius: 20rpx;
  608. padding: 36rpx 30rpx;
  609. margin-bottom: 24rpx;
  610. color: #fff;
  611. text-align: center;
  612. }
  613. .family-header-content {
  614. display: flex;
  615. align-items: center;
  616. justify-content: center;
  617. flex-direction: column;
  618. }
  619. .family-header-icon {
  620. font-size: 48rpx;
  621. margin-bottom: 8rpx;
  622. }
  623. .family-header-text {
  624. text-align: center;
  625. }
  626. .family-name {
  627. font-size: 36rpx;
  628. font-weight: bold;
  629. display: block;
  630. }
  631. .family-member-count {
  632. font-size: 24rpx;
  633. opacity: 0.85;
  634. margin-top: 4rpx;
  635. display: block;
  636. }
  637. /* ===== 成员卡片 ===== */
  638. .member-list {
  639. padding-bottom: 20rpx;
  640. }
  641. .member-card {
  642. display: flex;
  643. align-items: center;
  644. background: #fff;
  645. border-radius: 16rpx;
  646. padding: 24rpx 28rpx;
  647. margin-bottom: 16rpx;
  648. box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
  649. }
  650. .member-card:active {
  651. background: #FFFCF5;
  652. }
  653. /* ===== 加入申请区块 ===== */
  654. .join-request-section {
  655. background: #fff;
  656. border-radius: 16rpx;
  657. padding: 24rpx 28rpx;
  658. margin-bottom: 24rpx;
  659. box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
  660. }
  661. .jr-header {
  662. display: flex;
  663. justify-content: space-between;
  664. align-items: center;
  665. margin-bottom: 20rpx;
  666. }
  667. .jr-title {
  668. font-size: 30rpx;
  669. font-weight: 600;
  670. color: #333;
  671. }
  672. .jr-count {
  673. font-size: 24rpx;
  674. color: #EA580C;
  675. }
  676. .jr-item {
  677. display: flex;
  678. align-items: center;
  679. padding: 16rpx 0;
  680. border-top: 1rpx solid #F0EAE2;
  681. }
  682. .jr-avatar {
  683. width: 72rpx;
  684. height: 72rpx;
  685. border-radius: 50%;
  686. background: #f0f0f0;
  687. flex-shrink: 0;
  688. margin-right: 18rpx;
  689. }
  690. .jr-info {
  691. flex: 1;
  692. min-width: 0;
  693. }
  694. .jr-name {
  695. font-size: 28rpx;
  696. font-weight: 600;
  697. color: #333;
  698. display: block;
  699. }
  700. .jr-meta {
  701. font-size: 22rpx;
  702. color: #999;
  703. margin-top: 4rpx;
  704. display: block;
  705. }
  706. .jr-actions {
  707. display: flex;
  708. align-items: center;
  709. flex-shrink: 0;
  710. }
  711. .jr-btn {
  712. font-size: 24rpx;
  713. padding: 10rpx 24rpx;
  714. border-radius: 999rpx;
  715. line-height: 1.4;
  716. border: none;
  717. margin-left: 12rpx;
  718. }
  719. .jr-accept {
  720. background: #F97316;
  721. color: #fff;
  722. }
  723. .jr-reject {
  724. background: #FFF1E8;
  725. color: #C2410C;
  726. }
  727. .member-avatar {
  728. width: 80rpx;
  729. height: 80rpx;
  730. border-radius: 50%;
  731. margin-right: 20rpx;
  732. background: #f0f0f0;
  733. flex-shrink: 0;
  734. }
  735. .member-info {
  736. flex: 1;
  737. min-width: 0;
  738. }
  739. .member-name {
  740. font-size: 30rpx;
  741. font-weight: 600;
  742. display: block;
  743. color: #333;
  744. }
  745. .member-role-tag {
  746. display: inline-block;
  747. font-size: 22rpx;
  748. color: #F97316;
  749. background: #FFF7ED;
  750. padding: 4rpx 14rpx;
  751. border-radius: 8rpx;
  752. margin-top: 6rpx;
  753. }
  754. .member-arrow {
  755. font-size: 36rpx;
  756. color: #ccc;
  757. margin-left: 12rpx;
  758. flex-shrink: 0;
  759. }
  760. /* ===== 空状态 ===== */
  761. .empty-state {
  762. text-align: center;
  763. padding: 100rpx 30rpx;
  764. }
  765. .empty-icon {
  766. font-size: 100rpx;
  767. display: block;
  768. margin-bottom: 20rpx;
  769. }
  770. .empty-title {
  771. font-size: 32rpx;
  772. color: #666;
  773. display: block;
  774. margin-bottom: 12rpx;
  775. }
  776. .empty-desc {
  777. font-size: 26rpx;
  778. color: #999;
  779. display: block;
  780. margin-bottom: 40rpx;
  781. }
  782. .empty-invite-btn {
  783. background: linear-gradient(135deg, #F97316, #EA580C);
  784. color: #fff;
  785. border: none;
  786. border-radius: 40rpx;
  787. padding: 20rpx 50rpx;
  788. font-size: 28rpx;
  789. line-height: 1.4;
  790. }
  791. .empty-invite-btn:active {
  792. opacity: 0.85;
  793. }
  794. /* ===== 底部操作栏 ===== */
  795. .bottom-bar {
  796. position: fixed;
  797. left: 0;
  798. right: 0;
  799. bottom: 0;
  800. padding: 16rpx 30rpx calc(16rpx + constant(safe-area-inset-bottom));
  801. padding: 16rpx 30rpx calc(16rpx + env(safe-area-inset-bottom));
  802. background: #fff;
  803. display: flex;
  804. gap: 20rpx;
  805. box-shadow: 0 -2rpx 12rpx rgba(0,0,0,0.06);
  806. z-index: 50;
  807. }
  808. .bottom-btn {
  809. flex: 1;
  810. height: 80rpx;
  811. line-height: 80rpx;
  812. border-radius: 40rpx;
  813. font-size: 28rpx;
  814. text-align: center;
  815. border: none;
  816. }
  817. /* 微信小程序 button 默认边框来自 ::after 伪元素,分享按钮同样需要重置 */
  818. .empty-invite-btn::after,
  819. .bottom-btn::after {
  820. border: none;
  821. }
  822. .bottom-btn:active {
  823. opacity: 0.85;
  824. }
  825. .btn-add {
  826. background: #fff;
  827. color: #F97316;
  828. border: 2rpx solid #F97316;
  829. }
  830. .btn-invite {
  831. background: linear-gradient(135deg, #F97316, #EA580C);
  832. color: #fff;
  833. }
  834. /* 分享卡片画布:离屏不可见 */
  835. .share-card-canvas {
  836. position: fixed;
  837. left: -9999px;
  838. top: -9999px;
  839. width: 500px;
  840. height: 400px;
  841. }
  842. /* ===== 弹窗遮罩 ===== */
  843. .modal-mask {
  844. position: fixed;
  845. top: 0;
  846. left: 0;
  847. right: 0;
  848. bottom: 0;
  849. background: rgba(0, 0, 0, 0.5);
  850. display: flex;
  851. align-items: center;
  852. justify-content: center;
  853. z-index: 100;
  854. }
  855. /* ===== 成员详情弹窗 ===== */
  856. .member-detail-card {
  857. background: #fff;
  858. border-radius: 24rpx;
  859. width: 600rpx;
  860. max-height: 80vh;
  861. overflow-y: auto;
  862. padding: 36rpx 32rpx 32rpx;
  863. }
  864. .detail-header {
  865. display: flex;
  866. align-items: center;
  867. position: relative;
  868. margin-bottom: 20rpx;
  869. }
  870. .detail-avatar {
  871. width: 100rpx;
  872. height: 100rpx;
  873. border-radius: 50%;
  874. margin-right: 20rpx;
  875. background: #f0f0f0;
  876. flex-shrink: 0;
  877. }
  878. .detail-info {
  879. flex: 1;
  880. }
  881. .detail-name {
  882. font-size: 34rpx;
  883. font-weight: bold;
  884. display: block;
  885. }
  886. .detail-relation {
  887. font-size: 26rpx;
  888. color: #F97316;
  889. margin-top: 4rpx;
  890. display: block;
  891. }
  892. .detail-close {
  893. font-size: 44rpx;
  894. color: #999;
  895. padding: 4rpx 8rpx;
  896. position: absolute;
  897. top: -12rpx;
  898. right: -8rpx;
  899. }
  900. .detail-meta {
  901. display: flex;
  902. gap: 16rpx;
  903. flex-wrap: wrap;
  904. margin-bottom: 20rpx;
  905. padding: 16rpx 0;
  906. border-bottom: 1rpx solid #f0f0f0;
  907. }
  908. .detail-meta-item {
  909. font-size: 24rpx;
  910. color: #888;
  911. }
  912. /* 详情三维评分 */
  913. .detail-scores {
  914. margin-bottom: 24rpx;
  915. }
  916. .detail-section-title {
  917. font-size: 28rpx;
  918. font-weight: 600;
  919. display: block;
  920. margin-bottom: 12rpx;
  921. padding-left: 12rpx;
  922. border-left: 4rpx solid #F97316;
  923. }
  924. .detail-score-item {
  925. display: flex;
  926. align-items: center;
  927. gap: 10rpx;
  928. margin-bottom: 8rpx;
  929. }
  930. .detail-score-label {
  931. width: 56rpx;
  932. font-size: 22rpx;
  933. color: #888;
  934. }
  935. .detail-score-bar-bg {
  936. flex: 1;
  937. height: 12rpx;
  938. background: #f0f0f0;
  939. border-radius: 6rpx;
  940. overflow: hidden;
  941. }
  942. .detail-score-bar-fill {
  943. height: 100%;
  944. border-radius: 6rpx;
  945. transition: width 0.3s ease;
  946. }
  947. .detail-score-bar-fill.trust { background: #10B981; }
  948. .detail-score-bar-fill.intimacy { background: #FF6B9D; }
  949. .detail-score-bar-fill.communication { background: #6366F1; }
  950. .detail-score-value {
  951. width: 44rpx;
  952. font-size: 22rpx;
  953. font-weight: 600;
  954. color: #555;
  955. text-align: right;
  956. }
  957. /* ===== 操作按钮区(2列flex网格) ===== */
  958. .detail-actions {
  959. display: flex;
  960. flex-wrap: wrap;
  961. gap: 16rpx;
  962. padding-top: 8rpx;
  963. border-top: 1rpx solid #f0f0f0;
  964. }
  965. .detail-action-btn {
  966. width: calc(50% - 8rpx);
  967. border: none;
  968. border-radius: 12rpx;
  969. padding: 20rpx 0;
  970. font-size: 26rpx;
  971. text-align: center;
  972. line-height: 1.4;
  973. }
  974. .detail-action-btn:active {
  975. opacity: 0.8;
  976. }
  977. .action-switch {
  978. background: #f0f9ff;
  979. color: #0EA5E9;
  980. }
  981. .action-edit {
  982. background: #f0f9eb;
  983. color: #67c23a;
  984. }
  985. .action-questionnaire {
  986. background: #EEF0FF;
  987. color: #6366F1;
  988. }
  989. .action-interaction {
  990. background: #fff7ed;
  991. color: #F97316;
  992. }
  993. .action-kick {
  994. background: #fef0f0;
  995. color: #f56c6c;
  996. }
  997. /* ===== 加载 ===== */
  998. .loading-wrap {
  999. text-align: center;
  1000. padding: 60rpx 0;
  1001. }
  1002. .loading-text {
  1003. font-size: 28rpx;
  1004. color: #999;
  1005. }
  1006. .empty-tip {
  1007. text-align: center;
  1008. padding: 60rpx 0;
  1009. font-size: 28rpx;
  1010. color: #999;
  1011. }
  1012. </style>