MemberSelector.vue 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971
  1. <template>
  2. <!--
  3. 统一家庭成员选择器(T25 统一成员选择器)
  4. 形态(variant):
  5. · strip —— 替代 FamilyMemberStrip(大卡 + 长按操作菜单:移除/切换/编辑/问卷/互动/电话)
  6. · tabs —— 替代 DimensionMemberTabs(轻 chip,选中态按维度色着色)
  7. · minimal —— 替代各页面内联 member-bar / member-chip(家庭总览 / 个人 / 各成员)
  8. 统一约定:
  9. · 渲染前一律经 utils/memberNormalize.js 规范化(折叠同 userId 多行,isSelf 唯一)
  10. · 身份键统一为 familyMemberId(= family_members.id),userId 仅作折叠依据
  11. · emit('select', member) 携带规范化后的 canonical memberId
  12. 边界说明(设计文档 §一.3):
  13. 前端折叠仅为防御层。重复的 family_members 行各自挂着不同外键数据,
  14. 彻底消除需 T24-C 数据合并(需单独授权),本组件不做任何数据写操作。
  15. -->
  16. <view class="member-selector">
  17. <!-- ================================================================
  18. strip 变体:大卡 + 长按操作菜单
  19. ================================================================ -->
  20. <view v-if="variant === 'strip'" class="family-member-strip">
  21. <view v-if="renderedMembers && renderedMembers.length > 0">
  22. <scroll-view
  23. class="strip-scroll"
  24. scroll-x
  25. :scroll-with-animation="true"
  26. :enhanced="true"
  27. show-scrollbar="false"
  28. v-if="renderedMembers.length >= 4"
  29. >
  30. <view class="strip-inner scroll-mode">
  31. <FamilyMemberCard
  32. v-for="member in renderedMembers"
  33. :key="member.selectorKey"
  34. :member="member"
  35. :isSelected="isSameMember(member, selectedMemberId)"
  36. :energyScore="getMemberEnergy(member)"
  37. :pendingCount="pendingCounts[member.id] || 0"
  38. @select="onSelect"
  39. @longpress="onLongPress"
  40. />
  41. </view>
  42. </scroll-view>
  43. <view class="strip-inner center-mode" v-else>
  44. <FamilyMemberCard
  45. v-for="member in renderedMembers"
  46. :key="member.selectorKey"
  47. :member="member"
  48. :isSelected="isSameMember(member, selectedMemberId)"
  49. :energyScore="getMemberEnergy(member)"
  50. :pendingCount="pendingCounts[member.id] || 0"
  51. @select="onSelect"
  52. @longpress="onLongPress"
  53. />
  54. </view>
  55. <!-- 长按操作菜单 -->
  56. <view class="action-overlay" v-if="showActionMenu" @click="closeMenu"></view>
  57. <view class="action-sheet" v-if="showActionMenu">
  58. <view class="action-header">
  59. <image class="action-avatar" :src="actionMember ? (actionMember.avatar || defaultAvatar) : ''" mode="aspectFill" />
  60. <view class="action-header-info">
  61. <text class="action-title">{{ actionMember ? actionMember.nickname : '' }}</text>
  62. <text class="action-relation">{{ actionMember ? (actionMember.relationship || actionMember.relativeLabel || actionMember.roleLabel || '') : '' }}</text>
  63. </view>
  64. </view>
  65. <!-- 关系现状区块 -->
  66. <view class="action-scores" v-if="actionMember">
  67. <view class="score-row">
  68. <text class="score-label">信任度</text>
  69. <view class="score-bar-track">
  70. <view class="score-bar-fill trust" :style="{ width: scoreWidths[0] }"></view>
  71. </view>
  72. <text class="score-value">{{ scoreValues[0] }}</text>
  73. </view>
  74. <view class="score-row">
  75. <text class="score-label">亲密度</text>
  76. <view class="score-bar-track">
  77. <view class="score-bar-fill intimacy" :style="{ width: scoreWidths[1] }"></view>
  78. </view>
  79. <text class="score-value">{{ scoreValues[1] }}</text>
  80. </view>
  81. <view class="score-row">
  82. <text class="score-label">沟通质量</text>
  83. <view class="score-bar-track">
  84. <view class="score-bar-fill communication" :style="{ width: scoreWidths[2] }"></view>
  85. </view>
  86. <text class="score-value">{{ scoreValues[2] }}</text>
  87. </view>
  88. </view>
  89. <view class="action-divider"></view>
  90. <view class="action-item" @click="addMember" v-if="actionMember && actionMember.isSelf">
  91. <text class="action-item-icon">➕</text>
  92. <text class="action-item-text">添加成员</text>
  93. </view>
  94. <view class="action-item" @click="inviteMember" v-if="actionMember && actionMember.isSelf">
  95. <text class="action-item-icon">📨</text>
  96. <text class="action-item-text">邀请成员</text>
  97. </view>
  98. <view class="action-item" @click="switchToMember" v-if="canSwitch">
  99. <text class="action-item-icon">🔄</text>
  100. <text class="action-item-text">切换到该成员视角</text>
  101. </view>
  102. <view class="action-item" @click="editMember" v-if="canEdit">
  103. <text class="action-item-icon">✏️</text>
  104. <text class="action-item-text">编辑资料</text>
  105. </view>
  106. <view class="action-item" @click="questionnaireMember">
  107. <text class="action-item-icon">📋</text>
  108. <text class="action-item-text">关系问卷</text>
  109. <text class="action-time" v-if="lastQuestionnaireText"> · 上次填写 {{ lastQuestionnaireText }}</text>
  110. </view>
  111. <view class="action-item" @click="interactionMember">
  112. <text class="action-item-icon">💬</text>
  113. <text class="action-item-text">记录互动</text>
  114. </view>
  115. <view class="action-item" @click="callMember" v-if="actionMember && actionMember.phone">
  116. <text class="action-item-icon">📞</text>
  117. <text class="action-item-text">拨打电话</text>
  118. </view>
  119. <view class="action-item action-danger" @click="removeMember" v-if="canRemove">
  120. <text class="action-item-icon">🗑️</text>
  121. <text class="action-item-text">移除成员</text>
  122. </view>
  123. <view class="action-item action-cancel" @click="closeMenu">
  124. <text class="action-item-text">取消</text>
  125. </view>
  126. </view>
  127. </view>
  128. <!-- 未创建家庭引导 -->
  129. <view class="family-strip-empty" v-else>
  130. <view class="empty-content">
  131. <text class="empty-icon">🏠</text>
  132. <text class="empty-title">还未创建家庭</text>
  133. <text class="empty-desc">创建家庭,全家幸福从今天开始</text>
  134. <view class="empty-btn" @click="onCreateFamily">+ 创建家庭</view>
  135. </view>
  136. </view>
  137. </view>
  138. <!-- ================================================================
  139. tabs 变体:轻 chip(五维页筛选器)
  140. ================================================================ -->
  141. <view v-else-if="variant === 'tabs'" class="member-tabs" :class="'dim-' + (dimensionCode || 'body')">
  142. <scroll-view
  143. class="tabs-scroll"
  144. scroll-x
  145. :enhanced="true"
  146. :scroll-with-animation="true"
  147. show-scrollbar="false"
  148. v-if="normalizedMembers && normalizedMembers.length > 0"
  149. >
  150. <view class="tabs-inner">
  151. <view
  152. v-for="m in decoratedMembers"
  153. :key="m.selectorKey"
  154. class="member-tab"
  155. :class="{ 'is-selected': m._selected, 'is-self': m.isSelf }"
  156. @click="onTap(m)"
  157. @longpress="onTabLongPress(m)"
  158. >
  159. <view class="tab-avatar-wrap">
  160. <image class="tab-avatar" :src="m.avatar || defaultAvatar" mode="aspectFill" />
  161. <view class="tab-self-mark" v-if="m.isSelf">我</view>
  162. </view>
  163. <view class="tab-body">
  164. <text class="tab-name">{{ m.nickname || '成员' }}</text>
  165. <view class="tab-meta">
  166. <text class="tab-relation">{{ relationText(m) }}</text>
  167. <text class="tab-score" v-if="scoreOf(m) !== null">{{ scoreOf(m) }}<text class="tab-score-unit">分</text></text>
  168. </view>
  169. </view>
  170. </view>
  171. <!-- 管理入口(折叠后同一用户仅占一项,故按规范化后的数量判断) -->
  172. <view class="tab-manage" @click="onManage" v-if="normalizedMembers.length > 1">
  173. <text class="tab-manage-icon">管理</text>
  174. </view>
  175. </view>
  176. </scroll-view>
  177. <!-- 空态:未创建家庭 / 无可见成员 -->
  178. <view class="tabs-empty" v-else>
  179. <text class="tabs-empty-text">创建家庭后,可在这里切换查看每位家人的维度数值</text>
  180. <view class="tabs-empty-btn" @click="onManage">+ 管理家庭成员</view>
  181. </view>
  182. </view>
  183. <!-- ================================================================
  184. minimal 变体:轻量 chip 条(替代各页面内联 member-bar)
  185. 形态:家庭总览(可选聚合项) / 个人(= isSelf 行) / 各成员
  186. ================================================================ -->
  187. <view v-else class="member-minimal" :class="{ 'is-embedded': embedded }">
  188. <scroll-view
  189. class="minimal-scroll"
  190. scroll-x
  191. :enhanced="true"
  192. :scroll-with-animation="true"
  193. show-scrollbar="false"
  194. >
  195. <view class="minimal-inner">
  196. <!-- 家庭总览(聚合视图,非某个成员) -->
  197. <view
  198. v-if="showOverview"
  199. class="minimal-chip"
  200. :class="{ 'is-active': overviewActive }"
  201. @click="onOverview"
  202. >
  203. <text class="minimal-chip-text">{{ overviewLabel }}</text>
  204. </view>
  205. <!-- 个人(= isSelf 行,canonical memberId;同一用户至多一项) -->
  206. <view
  207. v-if="showSelf && selfMember"
  208. class="minimal-chip"
  209. :class="{ 'is-active': selfChipActive }"
  210. @click="onSelect(selfMember)"
  211. >
  212. <text class="minimal-chip-text">{{ selfLabel }}</text>
  213. </view>
  214. <!-- 各成员(排除本人行,避免同一人出现两次) -->
  215. <view
  216. v-for="m in otherMembers"
  217. :key="m.selectorKey"
  218. class="minimal-chip"
  219. :class="{ 'is-active': m._selected }"
  220. @click="onSelect(m)"
  221. >
  222. <text class="minimal-chip-text">{{ m.nickname || m.roleLabel || '成员' }}</text>
  223. </view>
  224. </view>
  225. </scroll-view>
  226. </view>
  227. </view>
  228. </template>
  229. <script>
  230. import FamilyMemberCard from '@/components/FamilyMemberCard'
  231. import { kickFamilyMemberById, createFamily, getPairwiseRelationships, getFamilyMemberList } from '@/utils/api'
  232. import { parseDate } from '@/utils/format.js'
  233. import { normalizeMembers, findSelfMember, membersExceptSelf } from '@/utils/memberNormalize.js'
  234. export default {
  235. name: 'MemberSelector',
  236. components: { FamilyMemberCard },
  237. props: {
  238. // 形态:strip / tabs / minimal
  239. variant: { type: String, default: 'strip' },
  240. // 原始成员列表(可选:缺省时组件自行拉取可见成员)
  241. members: { type: Array, default: function() { return [] } },
  242. // 当前选中成员的统一身份键(familyMemberId)
  243. selectedMemberId: { type: [Number, String], default: null },
  244. // tabs 专用:所属维度码,决定选中态配色
  245. dimensionCode: { type: String, default: 'body' },
  246. // tabs 专用:{ memberId: score }
  247. scoreMap: { type: Object, default: function() { return {} } },
  248. // minimal 专用:是否展示「家庭总览」聚合项
  249. showOverview: { type: Boolean, default: false },
  250. // minimal 专用:家庭总览项文案
  251. overviewLabel: { type: String, default: '👨‍👩‍👧 家庭总览' },
  252. // minimal 专用:家庭总览是否处于选中态
  253. overviewActive: { type: Boolean, default: false },
  254. // minimal 专用:是否展示「个人」项(= isSelf 行)
  255. showSelf: { type: Boolean, default: true },
  256. // minimal 专用:「个人」项文案
  257. selfLabel: { type: String, default: '👤 个人' },
  258. // minimal 专用:「个人」项强制选中态(用于「未显式选择即视为本人」的页面)
  259. selfActive: { type: Boolean, default: false },
  260. // minimal 专用:嵌入模式(去掉卡片底色/分隔线,用于页头等容器内)
  261. embedded: { type: Boolean, default: false },
  262. // strip 专用:{ memberId: energyScore }
  263. energyMap: { type: Object, default: function() { return {} } },
  264. // strip 专用:当前登录者是否为家庭管理员(决定长按菜单中的编辑/移除)
  265. isParent: { type: Boolean, default: false },
  266. // strip 专用:以该成员为中心加载关系成员
  267. centerMemberId: { type: [Number, String], default: null },
  268. // strip 专用:{ memberId: pendingCount }
  269. pendingCounts: { type: Object, default: function() { return {} } }
  270. },
  271. data: function() {
  272. return {
  273. showActionMenu: false,
  274. actionMember: null,
  275. defaultAvatar: '/static/default-avatar.png',
  276. creatingFamily: false,
  277. // 组件自取到的成员(父组件未传 members 时的兜底)
  278. selfLoadedMembers: [],
  279. // centerMemberId 场景下按关系加载的成员
  280. pairwiseMembers: [],
  281. // 当前查看者(用于规范化时定位 isSelf 行)
  282. viewer: { userId: null, memberId: null }
  283. }
  284. },
  285. computed: {
  286. // 有效成员来源:父组件传入优先,否则用组件自取结果
  287. effectiveMembers: function() {
  288. if (this.members && this.members.length > 0) return this.members
  289. return this.selfLoadedMembers
  290. },
  291. // 渲染前统一规范化(折叠同 userId 多行 → canonical 行;isSelf 唯一)
  292. normalizedMembers: function() {
  293. return normalizeMembers(this.effectiveMembers, this.viewer)
  294. },
  295. // minimal 变体:「个人」= isSelf 行
  296. selfMember: function() {
  297. return findSelfMember(this.normalizedMembers)
  298. },
  299. // 渲染用成员:附带 _selected 标记(避免在 :class 中调方法,mp 模板编译器不支持)
  300. decoratedMembers: function() {
  301. var self = this
  302. var out = []
  303. var list = this.normalizedMembers || []
  304. for (var i = 0; i < list.length; i++) {
  305. var copy = Object.assign({}, list[i], { _selected: self.isSameMember(list[i], self.selectedMemberId) })
  306. out.push(copy)
  307. }
  308. return out
  309. },
  310. // minimal 变体:各成员 chip 列表。
  311. // showSelf=true 时排除本人行(本人已由「个人」项承载,避免同一人出现两次);
  312. // showSelf=false 时保留全部成员(含本人,按昵称展示,保持原页面语义)。
  313. otherMembers: function() {
  314. return this.showSelf ? membersExceptSelf(this.decoratedMembers) : this.decoratedMembers
  315. },
  316. // minimal 变体:「个人」项选中态(selfActive 兜底「未显式选择即视为本人」)
  317. selfChipActive: function() {
  318. return this.selfActive || this.isSameMember(this.selfMember, this.selectedMemberId)
  319. },
  320. // strip 变体渲染列表:centerMemberId 场景优先用关系成员
  321. renderedMembers: function() {
  322. if (this.centerMemberId && this.pairwiseMembers && this.pairwiseMembers.length > 0) {
  323. return this.pairwiseMembers
  324. }
  325. return this.normalizedMembers
  326. },
  327. inSelfView: function() {
  328. // 成员视角(已切换到不可登录成员)下不再允许切换其他成员,只能退出切换
  329. return !(this.$store && this.$store.state && this.$store.state.currentView === 'member')
  330. },
  331. canSwitch: function() {
  332. // 仅本人视角(self)可切换;目标仅限不可登录成员(isSwitchable 由后端统一计算)
  333. return this.inSelfView && this.actionMember && this.actionMember.isSwitchable
  334. },
  335. canEdit: function() {
  336. return this.isParent && this.actionMember && !this.actionMember.userId && this.actionMember.source === 'family_member'
  337. },
  338. canRemove: function() {
  339. // 家庭主账号可移除非自己的成员(kick 接口已支持 family_member 与 parent 两种来源)
  340. return this.isParent && this.actionMember && !this.actionMember.isSelf
  341. },
  342. // 关系现状三项宽度/数值(下标 0=信任度 1=亲密度 2=沟通质量)
  343. actionScores: function() {
  344. var m = this.actionMember
  345. if (!m) return []
  346. var keys = ['trustScore', 'intimacyScore', 'communicationScore']
  347. return keys.map(function(k) {
  348. var v = Number(m[k]) || 0
  349. return { width: Math.max(0, Math.min(100, v)) + '%', value: Math.round(v) }
  350. })
  351. },
  352. scoreWidths: function() {
  353. return this.actionScores.map(function(s) { return s.width })
  354. },
  355. scoreValues: function() {
  356. return this.actionScores.map(function(s) { return s.value })
  357. },
  358. // 上次填写时间(问卷提交时 updateMemberScores 已更新 lastInteractionAt)
  359. lastQuestionnaireText: function() {
  360. var m = this.actionMember
  361. if (!m || !m.lastInteractionAt) return ''
  362. var d = parseDate(m.lastInteractionAt)
  363. if (!d) return ''
  364. var now = new Date()
  365. var diffMs = now.getTime() - d.getTime()
  366. var diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
  367. if (diffDays === 0) return '今天'
  368. if (diffDays === 1) return '昨天'
  369. if (diffDays < 7) return diffDays + '天前'
  370. if (diffDays < 30) return Math.floor(diffDays / 7) + '周前'
  371. return Math.floor(diffDays / 30) + '个月前'
  372. }
  373. },
  374. watch: {
  375. centerMemberId: {
  376. handler: function(val) {
  377. if (val) {
  378. this.loadPairwise(val)
  379. }
  380. },
  381. immediate: true
  382. }
  383. },
  384. created: function() {
  385. // 当前查看者:登录账号 userId(isSelf 定位);memberId 缺失时后端 isSelf 标记兜底
  386. var storedUserId = uni.getStorageSync('userId')
  387. this.viewer = {
  388. userId: (storedUserId === '' || storedUserId === null || storedUserId === undefined) ? null : storedUserId,
  389. memberId: null
  390. }
  391. // 未显式传入 members 时自动拉取可见家庭成员(与原 FamilyMemberStrip 行为一致)
  392. if (!this.members || this.members.length === 0) {
  393. this.loadVisibleMembers()
  394. }
  395. },
  396. methods: {
  397. // 统一身份键比较(familyMemberId 优先,回退 id)
  398. isSameMember: function(member, id) {
  399. if (!member || id === null || id === undefined || id === '') return false
  400. var key = member.memberId !== null && member.memberId !== undefined ? member.memberId : member.id
  401. return String(key) === String(id)
  402. },
  403. onSelect: function(member) {
  404. this.$emit('select', member)
  405. },
  406. onOverview: function() {
  407. this.$emit('overview')
  408. },
  409. onLongPress: function(member) {
  410. this.actionMember = member
  411. this.showActionMenu = true
  412. },
  413. onTap: function(m) {
  414. if (this.isSameMember(m, this.selectedMemberId)) return
  415. this.$emit('select', m)
  416. },
  417. onTabLongPress: function(m) {
  418. this.$emit('longpress', m)
  419. },
  420. onManage: function() {
  421. this.$emit('manage')
  422. },
  423. closeMenu: function() {
  424. this.showActionMenu = false
  425. this.actionMember = null
  426. },
  427. switchToMember: function() {
  428. var member = this.actionMember
  429. this.closeMenu()
  430. if (this.inSelfView && member && member.isSwitchable) {
  431. this.$emit('switch', member)
  432. }
  433. },
  434. editMember: function() {
  435. var member = this.actionMember
  436. this.closeMenu()
  437. if (member) {
  438. uni.navigateTo({ url: '/pages/family/add-member?memberId=' + member.id })
  439. }
  440. },
  441. questionnaireMember: function() {
  442. var member = this.actionMember
  443. this.closeMenu()
  444. if (member) {
  445. uni.navigateTo({ url: '/pages/action-detail/relationship-questionnaire?memberId=' + member.id })
  446. }
  447. },
  448. interactionMember: function() {
  449. var member = this.actionMember
  450. this.closeMenu()
  451. if (member) {
  452. uni.navigateTo({ url: '/pages/action-detail/interaction-log?memberId=' + member.id })
  453. }
  454. },
  455. callMember: function() {
  456. var member = this.actionMember
  457. this.closeMenu()
  458. if (member && member.phone) {
  459. uni.makePhoneCall({ phoneNumber: member.phone })
  460. }
  461. },
  462. removeMember: function() {
  463. var self = this
  464. var member = this.actionMember
  465. this.closeMenu()
  466. if (member) {
  467. uni.showModal({
  468. title: '确认移除',
  469. content: '确定要移除 ' + member.nickname + ' 吗?',
  470. success: function(res) {
  471. if (res.confirm) {
  472. uni.showLoading({ title: '移除中...' })
  473. kickFamilyMemberById(member.id)
  474. .then(function() {
  475. uni.hideLoading()
  476. uni.showToast({ title: '已移除', icon: 'success' })
  477. // 通知父组件刷新成员列表
  478. self.$emit('removed', member)
  479. })
  480. .catch(function(err) {
  481. uni.hideLoading()
  482. uni.showToast({ title: (err && err.message) || '移除失败', icon: 'none' })
  483. })
  484. }
  485. }
  486. })
  487. }
  488. },
  489. getMemberEnergy: function(member) {
  490. if (!member || !this.energyMap) return null
  491. return this.energyMap[member.id] || this.energyMap[member.memberId] || null
  492. },
  493. relationText: function(m) {
  494. if (!m) return '成员'
  495. return m.relationship || m.relativeLabel || m.roleLabel || (m.isSelf ? '本人' : '成员')
  496. },
  497. scoreOf: function(m) {
  498. if (!m || !this.scoreMap) return null
  499. var v = this.scoreMap[m.id]
  500. if (v === undefined || v === null || v === '') return null
  501. var n = Number(v)
  502. return isNaN(n) ? null : Math.round(n)
  503. },
  504. // 自我加载可见家庭成员(父组件未传 members 时的兜底)
  505. loadVisibleMembers: function() {
  506. var self = this
  507. var token = uni.getStorageSync('token')
  508. if (!token) return
  509. getFamilyMemberList({ visibleOnly: true }).then(function(res) {
  510. if (res && res.code === 200) {
  511. var arr = res.data || []
  512. if (arr instanceof Array && arr.length > 0) {
  513. self.selfLoadedMembers = arr
  514. self.$emit('loaded', arr)
  515. }
  516. }
  517. }).catch(function() {
  518. // 静默失败,保持空态
  519. })
  520. },
  521. onCreateFamily: function() {
  522. var self = this
  523. if (this.creatingFamily) return
  524. this.creatingFamily = true
  525. // 家庭名称使用当前用户昵称 + '的家庭'(与 user-edit.vue createFamilyAction 一致)
  526. var userInfo = uni.getStorageSync('userInfo')
  527. var nickname = uni.getStorageSync('nickname') || (userInfo && userInfo.nickname) || '我的'
  528. uni.showLoading({ title: '创建中...' })
  529. createFamily(nickname + '的家庭')
  530. .then(function(res) {
  531. uni.hideLoading()
  532. self.creatingFamily = false
  533. if (res.code === 200) {
  534. uni.showToast({ title: '家庭创建成功', icon: 'success' })
  535. self.$emit('created')
  536. } else {
  537. uni.showToast({ title: res.message || '创建失败', icon: 'none' })
  538. }
  539. })
  540. .catch(function(err) {
  541. uni.hideLoading()
  542. self.creatingFamily = false
  543. uni.showToast({ title: (err && err.message) || '创建失败', icon: 'none' })
  544. })
  545. },
  546. addMember: function() {
  547. this.closeMenu()
  548. uni.navigateTo({ url: '/pages/family/add-member' })
  549. },
  550. // 邀请成员:跳转家庭成员管理页
  551. inviteMember: function() {
  552. this.closeMenu()
  553. uni.navigateTo({ url: '/pages/profile-extra/family-members' })
  554. },
  555. loadPairwise: function(centerMemberId) {
  556. var self = this
  557. getPairwiseRelationships(centerMemberId).then(function(res) {
  558. if (res.code === 200 && res.data) {
  559. var arr = res.data instanceof Array ? res.data : ((res.data && res.data.list) || [])
  560. // 关系成员同样做规范化(防御:关系图成员也可能带重复 userId)
  561. self.pairwiseMembers = normalizeMembers(arr, self.viewer)
  562. }
  563. }).catch(function() {})
  564. }
  565. }
  566. }
  567. </script>
  568. <style scoped lang="scss">
  569. /* ================================================================
  570. strip 变体(原 FamilyMemberStrip 样式)
  571. ================================================================ */
  572. .family-member-strip {
  573. margin: 16rpx 0;
  574. padding: 0 $uni-space-page;
  575. }
  576. .strip-scroll {
  577. width: 100%;
  578. white-space: nowrap;
  579. }
  580. .strip-inner {
  581. display: flex;
  582. flex-direction: row;
  583. }
  584. .strip-inner.scroll-mode {
  585. gap: 16rpx;
  586. }
  587. .strip-inner.center-mode {
  588. justify-content: center;
  589. gap: 24rpx;
  590. flex-wrap: wrap;
  591. }
  592. .strip-scroll .strip-inner.scroll-mode {
  593. display: inline-flex;
  594. }
  595. /* 操作菜单 */
  596. .action-overlay {
  597. position: fixed;
  598. top: 0; left: 0; right: 0; bottom: 0;
  599. background: rgba(0,0,0,0.4);
  600. z-index: 999;
  601. }
  602. .action-sheet {
  603. position: fixed;
  604. bottom: 0;
  605. left: 0;
  606. right: 0;
  607. background: $app-surface;
  608. border-radius: 24rpx 24rpx 0 0;
  609. z-index: 1000;
  610. padding: 20rpx 0;
  611. padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
  612. }
  613. .action-title {
  614. font-size: calc(30rpx * var(--font-scale, 1));
  615. font-weight: 600;
  616. color: $facade-text-gray-800;
  617. }
  618. .action-relation {
  619. font-size: calc(24rpx * var(--font-scale, 1));
  620. color: $facade-text-gray-400;
  621. margin-top: 4rpx;
  622. }
  623. .action-item {
  624. display: flex;
  625. align-items: center;
  626. padding: 24rpx $uni-space-page;
  627. border-bottom: 1rpx solid $facade-fill-gray-50;
  628. }
  629. .action-item:active {
  630. background: $facade-fill-gray-100;
  631. }
  632. .action-item-icon {
  633. font-size: calc(32rpx * var(--font-scale, 1));
  634. margin-right: 16rpx;
  635. }
  636. .action-item-text {
  637. font-size: calc(28rpx * var(--font-scale, 1));
  638. color: $facade-text-gray-700;
  639. }
  640. .action-danger .action-item-text {
  641. color: $status-danger;
  642. }
  643. .action-cancel {
  644. justify-content: center;
  645. margin-top: 10rpx;
  646. border-bottom: none;
  647. }
  648. .action-cancel .action-item-text {
  649. color: $facade-text-slate;
  650. }
  651. .action-time {
  652. font-size: calc(22rpx * var(--font-scale, 1));
  653. color: $facade-text-gray-400;
  654. margin-left: 4rpx;
  655. }
  656. /* 空状态 */
  657. .family-strip-empty {
  658. margin: 16rpx 0;
  659. padding: 40rpx $uni-space-page;
  660. background: $app-surface;
  661. border-radius: 24rpx;
  662. display: flex;
  663. justify-content: center;
  664. }
  665. .empty-content {
  666. display: flex;
  667. flex-direction: column;
  668. align-items: center;
  669. }
  670. .empty-icon { font-size: calc($uni-font-size-display * var(--font-scale, 1)); }
  671. .empty-title { font-size: calc(30rpx * var(--font-scale, 1)); font-weight: 600; color: $facade-text-gray-800; margin-top: 12rpx; }
  672. .empty-desc { font-size: calc(24rpx * var(--font-scale, 1)); color: $facade-text-gray-400; margin-top: 8rpx; }
  673. .empty-btn {
  674. margin-top: 24rpx;
  675. padding: 16rpx 48rpx;
  676. background: $brand-primary;
  677. color: $app-surface;
  678. font-size: calc(28rpx * var(--font-scale, 1));
  679. border-radius: 24rpx;
  680. }
  681. /* Action Sheet 头部 */
  682. .action-header {
  683. display: flex;
  684. flex-direction: row;
  685. align-items: center;
  686. padding: 24rpx $uni-space-page;
  687. border-bottom: 1rpx solid $facade-fill-gray-100;
  688. }
  689. .action-avatar {
  690. width: 88rpx;
  691. height: 88rpx;
  692. border-radius: 50%;
  693. margin-right: 20rpx;
  694. background: $facade-fill-gray-100;
  695. }
  696. .action-header-info {
  697. display: flex;
  698. flex-direction: column;
  699. }
  700. /* 关系现状 */
  701. .action-scores {
  702. padding: 20rpx $uni-space-page;
  703. }
  704. .score-row {
  705. display: flex;
  706. flex-direction: row;
  707. align-items: center;
  708. margin-bottom: 12rpx;
  709. }
  710. .score-label {
  711. width: 110rpx;
  712. font-size: calc(24rpx * var(--font-scale, 1));
  713. color: $facade-text-slate;
  714. }
  715. .score-bar-track {
  716. flex: 1;
  717. height: 14rpx;
  718. background: $facade-fill-gray-100;
  719. border-radius: 8rpx;
  720. overflow: hidden;
  721. }
  722. .score-bar-fill {
  723. height: 100%;
  724. border-radius: 8rpx;
  725. }
  726. .score-bar-fill.trust { background: $status-success; }
  727. .score-bar-fill.intimacy { background: $status-info; }
  728. .score-bar-fill.communication { background: $dim-wealth; }
  729. .score-value {
  730. width: 70rpx;
  731. text-align: right;
  732. font-size: calc(24rpx * var(--font-scale, 1));
  733. color: $facade-text-gray-800;
  734. font-weight: 600;
  735. }
  736. .action-divider {
  737. height: 1rpx;
  738. background: $facade-fill-gray-100;
  739. margin: 0 $uni-space-lg;
  740. }
  741. /* ================================================================
  742. tabs 变体(原 DimensionMemberTabs 样式)
  743. ================================================================ */
  744. // 维度色映射:选中态渐变用主色 -> 深阶,五维统一从 uni.scss 取令牌
  745. $tab-dims: (
  746. body: $dim-body,
  747. mind: $dim-mind,
  748. wisdom: $dim-wisdom,
  749. wealth: $dim-wealth,
  750. action: $dim-action
  751. );
  752. .member-tabs {
  753. padding: 8rpx $uni-space-page 4rpx;
  754. }
  755. .tabs-scroll {
  756. width: 100%;
  757. white-space: nowrap;
  758. }
  759. .tabs-inner {
  760. display: inline-flex;
  761. flex-direction: row;
  762. align-items: center;
  763. gap: 16rpx;
  764. padding: 4rpx 0;
  765. }
  766. /* ---- 成员标签(chip) ---- */
  767. .member-tab {
  768. display: inline-flex;
  769. flex-direction: row;
  770. align-items: center;
  771. min-height: 88rpx;
  772. padding: 10rpx 22rpx 10rpx 12rpx;
  773. background: $app-surface;
  774. border: 2rpx solid $app-border-subtle;
  775. border-radius: 24rpx;
  776. transition: border-color $dur-state $ease-standard, background $dur-state $ease-standard, transform $dur-press $ease-standard;
  777. flex-shrink: 0;
  778. &:active {
  779. transform: scale(0.97);
  780. opacity: $press-opacity;
  781. }
  782. }
  783. .tab-avatar-wrap {
  784. position: relative;
  785. flex-shrink: 0;
  786. }
  787. .tab-avatar {
  788. width: 64rpx;
  789. height: 64rpx;
  790. border-radius: 50%;
  791. background: $app-surface-subtle;
  792. }
  793. .tab-self-mark {
  794. position: absolute;
  795. right: -6rpx;
  796. bottom: -6rpx;
  797. min-width: 36rpx;
  798. height: 36rpx;
  799. padding: 0 8rpx;
  800. border-radius: 16rpx;
  801. background: $brand-primary;
  802. color: $app-surface;
  803. font-size: calc(20rpx * var(--font-scale, 1));
  804. font-weight: 600;
  805. display: flex;
  806. align-items: center;
  807. justify-content: center;
  808. }
  809. .tab-body {
  810. display: flex;
  811. flex-direction: column;
  812. margin-left: 14rpx;
  813. max-width: 200rpx;
  814. }
  815. .tab-name {
  816. font-size: calc(26rpx * var(--font-scale, 1));
  817. font-weight: 600;
  818. color: $app-text;
  819. line-height: 1.3;
  820. overflow: hidden;
  821. text-overflow: ellipsis;
  822. white-space: nowrap;
  823. }
  824. .tab-meta {
  825. display: flex;
  826. flex-direction: row;
  827. align-items: center;
  828. margin-top: 2rpx;
  829. gap: 10rpx;
  830. }
  831. .tab-relation {
  832. font-size: calc(20rpx * var(--font-scale, 1));
  833. color: $app-text-muted;
  834. line-height: 1.2;
  835. }
  836. .tab-score {
  837. font-size: calc(22rpx * var(--font-scale, 1));
  838. font-weight: 700;
  839. color: $app-text-secondary;
  840. line-height: 1.2;
  841. }
  842. .tab-score-unit {
  843. font-size: calc(18rpx * var(--font-scale, 1));
  844. font-weight: 400;
  845. margin-left: 1rpx;
  846. }
  847. /* ---- 管理入口 ---- */
  848. .tab-manage {
  849. display: inline-flex;
  850. align-items: center;
  851. justify-content: center;
  852. min-height: 88rpx;
  853. min-width: 88rpx;
  854. padding: 0 20rpx;
  855. border-radius: 24rpx;
  856. background: $app-surface-subtle;
  857. flex-shrink: 0;
  858. &:active {
  859. opacity: $press-opacity;
  860. }
  861. }
  862. .tab-manage-icon {
  863. font-size: calc(24rpx * var(--font-scale, 1));
  864. color: $app-text-secondary;
  865. font-weight: 500;
  866. }
  867. /* ---- 空态 ---- */
  868. .tabs-empty {
  869. display: flex;
  870. flex-direction: column;
  871. align-items: flex-start;
  872. padding: 24rpx 28rpx;
  873. background: $app-surface;
  874. border-radius: 16rpx;
  875. border: 2rpx dashed $app-border;
  876. }
  877. .tabs-empty-text {
  878. font-size: calc(24rpx * var(--font-scale, 1));
  879. color: $app-text-secondary;
  880. line-height: 1.5;
  881. }
  882. .tabs-empty-btn {
  883. margin-top: 16rpx;
  884. padding: 12rpx 32rpx;
  885. border-radius: 24rpx;
  886. background: $brand-primary;
  887. color: $app-surface;
  888. font-size: calc(26rpx * var(--font-scale, 1));
  889. font-weight: 500;
  890. &:active {
  891. opacity: $press-opacity;
  892. }
  893. }
  894. /* ---- 选中态:按维度着色 ---- */
  895. @each $code, $color in $tab-dims {
  896. .dim-#{$code} .member-tab.is-selected {
  897. background: linear-gradient(135deg, $color, rgba(red($color), green($color), blue($color), 0.82));
  898. border-color: $color;
  899. .tab-name,
  900. .tab-relation,
  901. .tab-score {
  902. color: $app-surface;
  903. }
  904. .tab-avatar {
  905. border: 2rpx solid $app-surface;
  906. }
  907. }
  908. }
  909. /* ================================================================
  910. minimal 变体(轻量 chip 条,替代各页面内联 member-bar)
  911. ================================================================ */
  912. .member-minimal {
  913. padding: 16rpx $uni-space-page 8rpx;
  914. background: $app-surface;
  915. border-bottom: 1rpx solid $facade-fill;
  916. }
  917. .member-minimal.is-embedded {
  918. /* 嵌入模式:用于页头等容器内(去掉卡片底色/分隔线) */
  919. padding: 4rpx 0;
  920. background: transparent;
  921. border-bottom: none;
  922. }
  923. .minimal-scroll {
  924. width: 100%;
  925. white-space: nowrap;
  926. }
  927. .minimal-inner {
  928. display: inline-flex;
  929. flex-direction: row;
  930. align-items: center;
  931. gap: 16rpx;
  932. }
  933. .minimal-chip {
  934. flex-shrink: 0;
  935. padding: 10rpx 24rpx;
  936. border-radius: 24rpx;
  937. background: $facade-fill-2;
  938. border: 2rpx solid transparent;
  939. }
  940. .minimal-chip.is-active {
  941. background: linear-gradient(135deg, $dim-body, $dim-body-light);
  942. color: $app-surface;
  943. }
  944. .minimal-chip-text {
  945. font-size: calc(26rpx * var(--font-scale, 1));
  946. font-weight: 500;
  947. color: inherit;
  948. }
  949. </style>