family-members.vue 31 KB

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