|
|
@@ -0,0 +1,969 @@
|
|
|
+<template>
|
|
|
+ <!--
|
|
|
+ 统一家庭成员选择器(T25 统一成员选择器)
|
|
|
+
|
|
|
+ 形态(variant):
|
|
|
+ · strip —— 替代 FamilyMemberStrip(大卡 + 长按操作菜单:移除/切换/编辑/问卷/互动/电话)
|
|
|
+ · tabs —— 替代 DimensionMemberTabs(轻 chip,选中态按维度色着色)
|
|
|
+ · minimal —— 替代各页面内联 member-bar / member-chip(家庭总览 / 个人 / 各成员)
|
|
|
+
|
|
|
+ 统一约定:
|
|
|
+ · 渲染前一律经 utils/memberNormalize.js 规范化(折叠同 userId 多行,isSelf 唯一)
|
|
|
+ · 身份键统一为 familyMemberId(= family_members.id),userId 仅作折叠依据
|
|
|
+ · emit('select', member) 携带规范化后的 canonical memberId
|
|
|
+
|
|
|
+ 边界说明(设计文档 §一.3):
|
|
|
+ 前端折叠仅为防御层。重复的 family_members 行各自挂着不同外键数据,
|
|
|
+ 彻底消除需 T24-C 数据合并(需单独授权),本组件不做任何数据写操作。
|
|
|
+ -->
|
|
|
+ <view class="member-selector">
|
|
|
+ <!-- ================================================================
|
|
|
+ strip 变体:大卡 + 长按操作菜单
|
|
|
+ ================================================================ -->
|
|
|
+ <view v-if="variant === 'strip'" class="family-member-strip">
|
|
|
+ <view v-if="renderedMembers && renderedMembers.length > 0">
|
|
|
+ <scroll-view
|
|
|
+ class="strip-scroll"
|
|
|
+ scroll-x
|
|
|
+ :scroll-with-animation="true"
|
|
|
+ :enhanced="true"
|
|
|
+ show-scrollbar="false"
|
|
|
+ v-if="renderedMembers.length >= 4"
|
|
|
+ >
|
|
|
+ <view class="strip-inner scroll-mode">
|
|
|
+ <FamilyMemberCard
|
|
|
+ v-for="member in renderedMembers"
|
|
|
+ :key="member.selectorKey"
|
|
|
+ :member="member"
|
|
|
+ :isSelected="isSameMember(member, selectedMemberId)"
|
|
|
+ :energyScore="getMemberEnergy(member)"
|
|
|
+ :pendingCount="pendingCounts[member.id] || 0"
|
|
|
+ @select="onSelect"
|
|
|
+ @longpress="onLongPress"
|
|
|
+ />
|
|
|
+ </view>
|
|
|
+ </scroll-view>
|
|
|
+ <view class="strip-inner center-mode" v-else>
|
|
|
+ <FamilyMemberCard
|
|
|
+ v-for="member in renderedMembers"
|
|
|
+ :key="member.selectorKey"
|
|
|
+ :member="member"
|
|
|
+ :isSelected="isSameMember(member, selectedMemberId)"
|
|
|
+ :energyScore="getMemberEnergy(member)"
|
|
|
+ :pendingCount="pendingCounts[member.id] || 0"
|
|
|
+ @select="onSelect"
|
|
|
+ @longpress="onLongPress"
|
|
|
+ />
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 长按操作菜单 -->
|
|
|
+ <view class="action-overlay" v-if="showActionMenu" @click="closeMenu"></view>
|
|
|
+ <view class="action-sheet" v-if="showActionMenu">
|
|
|
+ <view class="action-header">
|
|
|
+ <image class="action-avatar" :src="actionMember ? (actionMember.avatar || defaultAvatar) : ''" mode="aspectFill" />
|
|
|
+ <view class="action-header-info">
|
|
|
+ <text class="action-title">{{ actionMember ? actionMember.nickname : '' }}</text>
|
|
|
+ <text class="action-relation">{{ actionMember ? (actionMember.relationship || actionMember.relativeLabel || actionMember.roleLabel || '') : '' }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ <!-- 关系现状区块 -->
|
|
|
+ <view class="action-scores" v-if="actionMember">
|
|
|
+ <view class="score-row">
|
|
|
+ <text class="score-label">信任度</text>
|
|
|
+ <view class="score-bar-track">
|
|
|
+ <view class="score-bar-fill trust" :style="{ width: scoreWidths[0] }"></view>
|
|
|
+ </view>
|
|
|
+ <text class="score-value">{{ scoreValues[0] }}</text>
|
|
|
+ </view>
|
|
|
+ <view class="score-row">
|
|
|
+ <text class="score-label">亲密度</text>
|
|
|
+ <view class="score-bar-track">
|
|
|
+ <view class="score-bar-fill intimacy" :style="{ width: scoreWidths[1] }"></view>
|
|
|
+ </view>
|
|
|
+ <text class="score-value">{{ scoreValues[1] }}</text>
|
|
|
+ </view>
|
|
|
+ <view class="score-row">
|
|
|
+ <text class="score-label">沟通质量</text>
|
|
|
+ <view class="score-bar-track">
|
|
|
+ <view class="score-bar-fill communication" :style="{ width: scoreWidths[2] }"></view>
|
|
|
+ </view>
|
|
|
+ <text class="score-value">{{ scoreValues[2] }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ <view class="action-divider"></view>
|
|
|
+ <view class="action-item" @click="addMember" v-if="actionMember && actionMember.isSelf">
|
|
|
+ <text class="action-item-icon">➕</text>
|
|
|
+ <text class="action-item-text">添加成员</text>
|
|
|
+ </view>
|
|
|
+ <view class="action-item" @click="inviteMember" v-if="actionMember && actionMember.isSelf">
|
|
|
+ <text class="action-item-icon">📨</text>
|
|
|
+ <text class="action-item-text">邀请成员</text>
|
|
|
+ </view>
|
|
|
+ <view class="action-item" @click="switchToMember" v-if="canSwitch">
|
|
|
+ <text class="action-item-icon">🔄</text>
|
|
|
+ <text class="action-item-text">切换到该成员视角</text>
|
|
|
+ </view>
|
|
|
+ <view class="action-item" @click="editMember" v-if="canEdit">
|
|
|
+ <text class="action-item-icon">✏️</text>
|
|
|
+ <text class="action-item-text">编辑资料</text>
|
|
|
+ </view>
|
|
|
+ <view class="action-item" @click="questionnaireMember">
|
|
|
+ <text class="action-item-icon">📋</text>
|
|
|
+ <text class="action-item-text">关系问卷</text>
|
|
|
+ <text class="action-time" v-if="lastQuestionnaireText"> · 上次填写 {{ lastQuestionnaireText }}</text>
|
|
|
+ </view>
|
|
|
+ <view class="action-item" @click="interactionMember">
|
|
|
+ <text class="action-item-icon">💬</text>
|
|
|
+ <text class="action-item-text">记录互动</text>
|
|
|
+ </view>
|
|
|
+ <view class="action-item" @click="callMember" v-if="actionMember && actionMember.phone">
|
|
|
+ <text class="action-item-icon">📞</text>
|
|
|
+ <text class="action-item-text">拨打电话</text>
|
|
|
+ </view>
|
|
|
+ <view class="action-item action-danger" @click="removeMember" v-if="canRemove">
|
|
|
+ <text class="action-item-icon">🗑️</text>
|
|
|
+ <text class="action-item-text">移除成员</text>
|
|
|
+ </view>
|
|
|
+ <view class="action-item action-cancel" @click="closeMenu">
|
|
|
+ <text class="action-item-text">取消</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 未创建家庭引导 -->
|
|
|
+ <view class="family-strip-empty" v-else>
|
|
|
+ <view class="empty-content">
|
|
|
+ <text class="empty-icon">🏠</text>
|
|
|
+ <text class="empty-title">还未创建家庭</text>
|
|
|
+ <text class="empty-desc">创建家庭,开启全家幸福之旅</text>
|
|
|
+ <view class="empty-btn" @click="onCreateFamily">+ 创建家庭</view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- ================================================================
|
|
|
+ tabs 变体:轻 chip(五维页筛选器)
|
|
|
+ ================================================================ -->
|
|
|
+ <view v-else-if="variant === 'tabs'" class="member-tabs" :class="'dim-' + (dimensionCode || 'body')">
|
|
|
+ <scroll-view
|
|
|
+ class="tabs-scroll"
|
|
|
+ scroll-x
|
|
|
+ :enhanced="true"
|
|
|
+ :scroll-with-animation="true"
|
|
|
+ show-scrollbar="false"
|
|
|
+ v-if="normalizedMembers && normalizedMembers.length > 0"
|
|
|
+ >
|
|
|
+ <view class="tabs-inner">
|
|
|
+ <view
|
|
|
+ v-for="m in decoratedMembers"
|
|
|
+ :key="m.selectorKey"
|
|
|
+ class="member-tab"
|
|
|
+ :class="{ 'is-selected': m._selected, 'is-self': m.isSelf }"
|
|
|
+ @click="onTap(m)"
|
|
|
+ @longpress="onTabLongPress(m)"
|
|
|
+ >
|
|
|
+ <view class="tab-avatar-wrap">
|
|
|
+ <image class="tab-avatar" :src="m.avatar || defaultAvatar" mode="aspectFill" />
|
|
|
+ <view class="tab-self-mark" v-if="m.isSelf">我</view>
|
|
|
+ </view>
|
|
|
+ <view class="tab-body">
|
|
|
+ <text class="tab-name">{{ m.nickname || '成员' }}</text>
|
|
|
+ <view class="tab-meta">
|
|
|
+ <text class="tab-relation">{{ relationText(m) }}</text>
|
|
|
+ <text class="tab-score" v-if="scoreOf(m) !== null">{{ scoreOf(m) }}<text class="tab-score-unit">分</text></text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 管理入口(折叠后同一用户仅占一项,故按规范化后的数量判断) -->
|
|
|
+ <view class="tab-manage" @click="onManage" v-if="normalizedMembers.length > 1">
|
|
|
+ <text class="tab-manage-icon">管理</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </scroll-view>
|
|
|
+
|
|
|
+ <!-- 空态:未创建家庭 / 无可见成员 -->
|
|
|
+ <view class="tabs-empty" v-else>
|
|
|
+ <text class="tabs-empty-text">创建家庭后,可在这里切换查看每位家人的维度数值</text>
|
|
|
+ <view class="tabs-empty-btn" @click="onManage">+ 管理家庭成员</view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- ================================================================
|
|
|
+ minimal 变体:轻量 chip 条(替代各页面内联 member-bar)
|
|
|
+ 形态:家庭总览(可选聚合项) / 个人(= isSelf 行) / 各成员
|
|
|
+ ================================================================ -->
|
|
|
+ <view v-else class="member-minimal" :class="{ 'is-embedded': embedded }">
|
|
|
+ <scroll-view
|
|
|
+ class="minimal-scroll"
|
|
|
+ scroll-x
|
|
|
+ :enhanced="true"
|
|
|
+ :scroll-with-animation="true"
|
|
|
+ show-scrollbar="false"
|
|
|
+ >
|
|
|
+ <view class="minimal-inner">
|
|
|
+ <!-- 家庭总览(聚合视图,非某个成员) -->
|
|
|
+ <view
|
|
|
+ v-if="showOverview"
|
|
|
+ class="minimal-chip"
|
|
|
+ :class="{ 'is-active': overviewActive }"
|
|
|
+ @click="onOverview"
|
|
|
+ >
|
|
|
+ <text class="minimal-chip-text">{{ overviewLabel }}</text>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 个人(= isSelf 行,canonical memberId;同一用户至多一项) -->
|
|
|
+ <view
|
|
|
+ v-if="showSelf && selfMember"
|
|
|
+ class="minimal-chip"
|
|
|
+ :class="{ 'is-active': selfChipActive }"
|
|
|
+ @click="onSelect(selfMember)"
|
|
|
+ >
|
|
|
+ <text class="minimal-chip-text">{{ selfLabel }}</text>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <!-- 各成员(排除本人行,避免同一人出现两次) -->
|
|
|
+ <view
|
|
|
+ v-for="m in otherMembers"
|
|
|
+ :key="m.selectorKey"
|
|
|
+ class="minimal-chip"
|
|
|
+ :class="{ 'is-active': m._selected }"
|
|
|
+ @click="onSelect(m)"
|
|
|
+ >
|
|
|
+ <text class="minimal-chip-text">{{ m.nickname || m.roleLabel || '成员' }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </scroll-view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+import FamilyMemberCard from '@/components/FamilyMemberCard'
|
|
|
+import { kickFamilyMemberById, createFamily, getPairwiseRelationships, getFamilyMemberList } from '@/utils/api'
|
|
|
+import { parseDate } from '@/utils/format.js'
|
|
|
+import { normalizeMembers, findSelfMember, membersExceptSelf } from '@/utils/memberNormalize.js'
|
|
|
+
|
|
|
+export default {
|
|
|
+ name: 'MemberSelector',
|
|
|
+ components: { FamilyMemberCard },
|
|
|
+ props: {
|
|
|
+ // 形态:strip / tabs / minimal
|
|
|
+ variant: { type: String, default: 'strip' },
|
|
|
+ // 原始成员列表(可选:缺省时组件自行拉取可见成员)
|
|
|
+ members: { type: Array, default: function() { return [] } },
|
|
|
+ // 当前选中成员的统一身份键(familyMemberId)
|
|
|
+ selectedMemberId: { type: [Number, String], default: null },
|
|
|
+ // tabs 专用:所属维度码,决定选中态配色
|
|
|
+ dimensionCode: { type: String, default: 'body' },
|
|
|
+ // tabs 专用:{ memberId: score }
|
|
|
+ scoreMap: { type: Object, default: function() { return {} } },
|
|
|
+ // minimal 专用:是否展示「家庭总览」聚合项
|
|
|
+ showOverview: { type: Boolean, default: false },
|
|
|
+ // minimal 专用:家庭总览项文案
|
|
|
+ overviewLabel: { type: String, default: '👨👩👧 家庭总览' },
|
|
|
+ // minimal 专用:家庭总览是否处于选中态
|
|
|
+ overviewActive: { type: Boolean, default: false },
|
|
|
+ // minimal 专用:是否展示「个人」项(= isSelf 行)
|
|
|
+ showSelf: { type: Boolean, default: true },
|
|
|
+ // minimal 专用:「个人」项文案
|
|
|
+ selfLabel: { type: String, default: '👤 个人' },
|
|
|
+ // minimal 专用:「个人」项强制选中态(用于「未显式选择即视为本人」的页面)
|
|
|
+ selfActive: { type: Boolean, default: false },
|
|
|
+ // minimal 专用:嵌入模式(去掉卡片底色/分隔线,用于页头等容器内)
|
|
|
+ embedded: { type: Boolean, default: false },
|
|
|
+ // strip 专用:{ memberId: energyScore }
|
|
|
+ energyMap: { type: Object, default: function() { return {} } },
|
|
|
+ // strip 专用:当前登录者是否为家庭管理员(决定长按菜单中的编辑/移除)
|
|
|
+ isParent: { type: Boolean, default: false },
|
|
|
+ // strip 专用:以该成员为中心加载关系成员
|
|
|
+ centerMemberId: { type: [Number, String], default: null },
|
|
|
+ // strip 专用:{ memberId: pendingCount }
|
|
|
+ pendingCounts: { type: Object, default: function() { return {} } }
|
|
|
+ },
|
|
|
+ data: function() {
|
|
|
+ return {
|
|
|
+ showActionMenu: false,
|
|
|
+ actionMember: null,
|
|
|
+ defaultAvatar: '/static/default-avatar.png',
|
|
|
+ creatingFamily: false,
|
|
|
+ // 组件自取到的成员(父组件未传 members 时的兜底)
|
|
|
+ selfLoadedMembers: [],
|
|
|
+ // centerMemberId 场景下按关系加载的成员
|
|
|
+ pairwiseMembers: [],
|
|
|
+ // 当前查看者(用于规范化时定位 isSelf 行)
|
|
|
+ viewer: { userId: null, memberId: null }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ computed: {
|
|
|
+ // 有效成员来源:父组件传入优先,否则用组件自取结果
|
|
|
+ effectiveMembers: function() {
|
|
|
+ if (this.members && this.members.length > 0) return this.members
|
|
|
+ return this.selfLoadedMembers
|
|
|
+ },
|
|
|
+ // 渲染前统一规范化(折叠同 userId 多行 → canonical 行;isSelf 唯一)
|
|
|
+ normalizedMembers: function() {
|
|
|
+ return normalizeMembers(this.effectiveMembers, this.viewer)
|
|
|
+ },
|
|
|
+ // minimal 变体:「个人」= isSelf 行
|
|
|
+ selfMember: function() {
|
|
|
+ return findSelfMember(this.normalizedMembers)
|
|
|
+ },
|
|
|
+ // 渲染用成员:附带 _selected 标记(避免在 :class 中调方法,mp 模板编译器不支持)
|
|
|
+ decoratedMembers: function() {
|
|
|
+ var self = this
|
|
|
+ var out = []
|
|
|
+ var list = this.normalizedMembers || []
|
|
|
+ for (var i = 0; i < list.length; i++) {
|
|
|
+ var copy = Object.assign({}, list[i], { _selected: self.isSameMember(list[i], self.selectedMemberId) })
|
|
|
+ out.push(copy)
|
|
|
+ }
|
|
|
+ return out
|
|
|
+ },
|
|
|
+ // minimal 变体:其余成员(不含本人行,同一用户至多出现一次)
|
|
|
+ otherMembers: function() {
|
|
|
+ return membersExceptSelf(this.decoratedMembers)
|
|
|
+ },
|
|
|
+ // minimal 变体:「个人」项选中态(selfActive 兜底「未显式选择即视为本人」)
|
|
|
+ selfChipActive: function() {
|
|
|
+ return this.selfActive || this.isSameMember(this.selfMember, this.selectedMemberId)
|
|
|
+ },
|
|
|
+ // strip 变体渲染列表:centerMemberId 场景优先用关系成员
|
|
|
+ renderedMembers: function() {
|
|
|
+ if (this.centerMemberId && this.pairwiseMembers && this.pairwiseMembers.length > 0) {
|
|
|
+ return this.pairwiseMembers
|
|
|
+ }
|
|
|
+ return this.normalizedMembers
|
|
|
+ },
|
|
|
+ inSelfView: function() {
|
|
|
+ // 成员视角(已切换到不可登录成员)下不再允许切换其他成员,只能退出切换
|
|
|
+ return !(this.$store && this.$store.state && this.$store.state.currentView === 'member')
|
|
|
+ },
|
|
|
+ canSwitch: function() {
|
|
|
+ // 仅本人视角(self)可切换;目标仅限不可登录成员(isSwitchable 由后端统一计算)
|
|
|
+ return this.inSelfView && this.actionMember && this.actionMember.isSwitchable
|
|
|
+ },
|
|
|
+ canEdit: function() {
|
|
|
+ return this.isParent && this.actionMember && !this.actionMember.userId && this.actionMember.source === 'family_member'
|
|
|
+ },
|
|
|
+ canRemove: function() {
|
|
|
+ // 家长可移除非自己的成员(kick 接口已支持 family_member 与 parent 两种来源)
|
|
|
+ return this.isParent && this.actionMember && !this.actionMember.isSelf
|
|
|
+ },
|
|
|
+ // 关系现状三项宽度/数值(下标 0=信任度 1=亲密度 2=沟通质量)
|
|
|
+ actionScores: function() {
|
|
|
+ var m = this.actionMember
|
|
|
+ if (!m) return []
|
|
|
+ var keys = ['trustScore', 'intimacyScore', 'communicationScore']
|
|
|
+ return keys.map(function(k) {
|
|
|
+ var v = Number(m[k]) || 0
|
|
|
+ return { width: Math.max(0, Math.min(100, v)) + '%', value: Math.round(v) }
|
|
|
+ })
|
|
|
+ },
|
|
|
+ scoreWidths: function() {
|
|
|
+ return this.actionScores.map(function(s) { return s.width })
|
|
|
+ },
|
|
|
+ scoreValues: function() {
|
|
|
+ return this.actionScores.map(function(s) { return s.value })
|
|
|
+ },
|
|
|
+ // 上次填写时间(问卷提交时 updateMemberScores 已更新 lastInteractionAt)
|
|
|
+ lastQuestionnaireText: function() {
|
|
|
+ var m = this.actionMember
|
|
|
+ if (!m || !m.lastInteractionAt) return ''
|
|
|
+ var d = parseDate(m.lastInteractionAt)
|
|
|
+ if (!d) return ''
|
|
|
+ var now = new Date()
|
|
|
+ var diffMs = now.getTime() - d.getTime()
|
|
|
+ var diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
|
|
+ if (diffDays === 0) return '今天'
|
|
|
+ if (diffDays === 1) return '昨天'
|
|
|
+ if (diffDays < 7) return diffDays + '天前'
|
|
|
+ if (diffDays < 30) return Math.floor(diffDays / 7) + '周前'
|
|
|
+ return Math.floor(diffDays / 30) + '个月前'
|
|
|
+ }
|
|
|
+ },
|
|
|
+ watch: {
|
|
|
+ centerMemberId: {
|
|
|
+ handler: function(val) {
|
|
|
+ if (val) {
|
|
|
+ this.loadPairwise(val)
|
|
|
+ }
|
|
|
+ },
|
|
|
+ immediate: true
|
|
|
+ }
|
|
|
+ },
|
|
|
+ created: function() {
|
|
|
+ // 当前查看者:登录账号 userId(isSelf 定位);memberId 缺失时后端 isSelf 标记兜底
|
|
|
+ var storedUserId = uni.getStorageSync('userId')
|
|
|
+ this.viewer = {
|
|
|
+ userId: (storedUserId === '' || storedUserId === null || storedUserId === undefined) ? null : storedUserId,
|
|
|
+ memberId: null
|
|
|
+ }
|
|
|
+ // 未显式传入 members 时自动拉取可见家庭成员(与原 FamilyMemberStrip 行为一致)
|
|
|
+ if (!this.members || this.members.length === 0) {
|
|
|
+ this.loadVisibleMembers()
|
|
|
+ }
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ // 统一身份键比较(familyMemberId 优先,回退 id)
|
|
|
+ isSameMember: function(member, id) {
|
|
|
+ if (!member || id === null || id === undefined || id === '') return false
|
|
|
+ var key = member.memberId !== null && member.memberId !== undefined ? member.memberId : member.id
|
|
|
+ return String(key) === String(id)
|
|
|
+ },
|
|
|
+ onSelect: function(member) {
|
|
|
+ this.$emit('select', member)
|
|
|
+ },
|
|
|
+ onOverview: function() {
|
|
|
+ this.$emit('overview')
|
|
|
+ },
|
|
|
+ onLongPress: function(member) {
|
|
|
+ this.actionMember = member
|
|
|
+ this.showActionMenu = true
|
|
|
+ },
|
|
|
+ onTap: function(m) {
|
|
|
+ if (this.isSameMember(m, this.selectedMemberId)) return
|
|
|
+ this.$emit('select', m)
|
|
|
+ },
|
|
|
+ onTabLongPress: function(m) {
|
|
|
+ this.$emit('longpress', m)
|
|
|
+ },
|
|
|
+ onManage: function() {
|
|
|
+ this.$emit('manage')
|
|
|
+ },
|
|
|
+ closeMenu: function() {
|
|
|
+ this.showActionMenu = false
|
|
|
+ this.actionMember = null
|
|
|
+ },
|
|
|
+ switchToMember: function() {
|
|
|
+ var member = this.actionMember
|
|
|
+ this.closeMenu()
|
|
|
+ if (this.inSelfView && member && member.isSwitchable) {
|
|
|
+ this.$emit('switch', member)
|
|
|
+ }
|
|
|
+ },
|
|
|
+ editMember: function() {
|
|
|
+ var member = this.actionMember
|
|
|
+ this.closeMenu()
|
|
|
+ if (member) {
|
|
|
+ uni.navigateTo({ url: '/pages/family/add-member?memberId=' + member.id })
|
|
|
+ }
|
|
|
+ },
|
|
|
+ questionnaireMember: function() {
|
|
|
+ var member = this.actionMember
|
|
|
+ this.closeMenu()
|
|
|
+ if (member) {
|
|
|
+ uni.navigateTo({ url: '/pages/action-detail/relationship-questionnaire?memberId=' + member.id })
|
|
|
+ }
|
|
|
+ },
|
|
|
+ interactionMember: function() {
|
|
|
+ var member = this.actionMember
|
|
|
+ this.closeMenu()
|
|
|
+ if (member) {
|
|
|
+ uni.navigateTo({ url: '/pages/action-detail/interaction-log?memberId=' + member.id })
|
|
|
+ }
|
|
|
+ },
|
|
|
+ callMember: function() {
|
|
|
+ var member = this.actionMember
|
|
|
+ this.closeMenu()
|
|
|
+ if (member && member.phone) {
|
|
|
+ uni.makePhoneCall({ phoneNumber: member.phone })
|
|
|
+ }
|
|
|
+ },
|
|
|
+ removeMember: function() {
|
|
|
+ var self = this
|
|
|
+ var member = this.actionMember
|
|
|
+ this.closeMenu()
|
|
|
+ if (member) {
|
|
|
+ uni.showModal({
|
|
|
+ title: '确认移除',
|
|
|
+ content: '确定要移除 ' + member.nickname + ' 吗?',
|
|
|
+ success: function(res) {
|
|
|
+ if (res.confirm) {
|
|
|
+ uni.showLoading({ title: '移除中...' })
|
|
|
+ kickFamilyMemberById(member.id)
|
|
|
+ .then(function() {
|
|
|
+ uni.hideLoading()
|
|
|
+ uni.showToast({ title: '已移除', icon: 'success' })
|
|
|
+ // 通知父组件刷新成员列表
|
|
|
+ self.$emit('removed', member)
|
|
|
+ })
|
|
|
+ .catch(function(err) {
|
|
|
+ uni.hideLoading()
|
|
|
+ uni.showToast({ title: (err && err.message) || '移除失败', icon: 'none' })
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+ }
|
|
|
+ },
|
|
|
+ getMemberEnergy: function(member) {
|
|
|
+ if (!member || !this.energyMap) return null
|
|
|
+ return this.energyMap[member.id] || this.energyMap[member.memberId] || null
|
|
|
+ },
|
|
|
+ relationText: function(m) {
|
|
|
+ if (!m) return '成员'
|
|
|
+ return m.relationship || m.relativeLabel || m.roleLabel || (m.isSelf ? '本人' : '成员')
|
|
|
+ },
|
|
|
+ scoreOf: function(m) {
|
|
|
+ if (!m || !this.scoreMap) return null
|
|
|
+ var v = this.scoreMap[m.id]
|
|
|
+ if (v === undefined || v === null || v === '') return null
|
|
|
+ var n = Number(v)
|
|
|
+ return isNaN(n) ? null : Math.round(n)
|
|
|
+ },
|
|
|
+ // 自我加载可见家庭成员(父组件未传 members 时的兜底)
|
|
|
+ loadVisibleMembers: function() {
|
|
|
+ var self = this
|
|
|
+ var token = uni.getStorageSync('token')
|
|
|
+ if (!token) return
|
|
|
+ getFamilyMemberList({ visibleOnly: true }).then(function(res) {
|
|
|
+ if (res && res.code === 200) {
|
|
|
+ var arr = res.data || []
|
|
|
+ if (arr instanceof Array && arr.length > 0) {
|
|
|
+ self.selfLoadedMembers = arr
|
|
|
+ self.$emit('loaded', arr)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }).catch(function() {
|
|
|
+ // 静默失败,保持空态
|
|
|
+ })
|
|
|
+ },
|
|
|
+ onCreateFamily: function() {
|
|
|
+ var self = this
|
|
|
+ if (this.creatingFamily) return
|
|
|
+ this.creatingFamily = true
|
|
|
+ // 家庭名称使用当前用户昵称 + '的家庭'(与 user-edit.vue createFamilyAction 一致)
|
|
|
+ var userInfo = uni.getStorageSync('userInfo')
|
|
|
+ var nickname = uni.getStorageSync('nickname') || (userInfo && userInfo.nickname) || '我的'
|
|
|
+ uni.showLoading({ title: '创建中...' })
|
|
|
+ createFamily(nickname + '的家庭')
|
|
|
+ .then(function(res) {
|
|
|
+ uni.hideLoading()
|
|
|
+ self.creatingFamily = false
|
|
|
+ if (res.code === 200) {
|
|
|
+ uni.showToast({ title: '家庭创建成功', icon: 'success' })
|
|
|
+ self.$emit('created')
|
|
|
+ } else {
|
|
|
+ uni.showToast({ title: res.message || '创建失败', icon: 'none' })
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .catch(function(err) {
|
|
|
+ uni.hideLoading()
|
|
|
+ self.creatingFamily = false
|
|
|
+ uni.showToast({ title: (err && err.message) || '创建失败', icon: 'none' })
|
|
|
+ })
|
|
|
+ },
|
|
|
+ addMember: function() {
|
|
|
+ this.closeMenu()
|
|
|
+ uni.navigateTo({ url: '/pages/family/add-member' })
|
|
|
+ },
|
|
|
+ // 邀请成员:跳转家庭成员管理页
|
|
|
+ inviteMember: function() {
|
|
|
+ this.closeMenu()
|
|
|
+ uni.navigateTo({ url: '/pages/profile-extra/family-members' })
|
|
|
+ },
|
|
|
+ loadPairwise: function(centerMemberId) {
|
|
|
+ var self = this
|
|
|
+ getPairwiseRelationships(centerMemberId).then(function(res) {
|
|
|
+ if (res.code === 200 && res.data) {
|
|
|
+ var arr = res.data instanceof Array ? res.data : ((res.data && res.data.list) || [])
|
|
|
+ // 关系成员同样做规范化(防御:关系图成员也可能带重复 userId)
|
|
|
+ self.pairwiseMembers = normalizeMembers(arr, self.viewer)
|
|
|
+ }
|
|
|
+ }).catch(function() {})
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped lang="scss">
|
|
|
+/* ================================================================
|
|
|
+ strip 变体(原 FamilyMemberStrip 样式)
|
|
|
+ ================================================================ */
|
|
|
+.family-member-strip {
|
|
|
+ margin: 16rpx 0;
|
|
|
+ padding: 0 30rpx;
|
|
|
+}
|
|
|
+.strip-scroll {
|
|
|
+ width: 100%;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+.strip-inner {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: row;
|
|
|
+}
|
|
|
+.strip-inner.scroll-mode {
|
|
|
+ gap: 16rpx;
|
|
|
+}
|
|
|
+.strip-inner.center-mode {
|
|
|
+ justify-content: center;
|
|
|
+ gap: 24rpx;
|
|
|
+ flex-wrap: wrap;
|
|
|
+}
|
|
|
+.strip-scroll .strip-inner.scroll-mode {
|
|
|
+ display: inline-flex;
|
|
|
+}
|
|
|
+/* 操作菜单 */
|
|
|
+.action-overlay {
|
|
|
+ position: fixed;
|
|
|
+ top: 0; left: 0; right: 0; bottom: 0;
|
|
|
+ background: rgba(0,0,0,0.4);
|
|
|
+ z-index: 999;
|
|
|
+}
|
|
|
+.action-sheet {
|
|
|
+ position: fixed;
|
|
|
+ bottom: 0;
|
|
|
+ left: 0;
|
|
|
+ right: 0;
|
|
|
+ background: $app-surface;
|
|
|
+ border-radius: 24rpx 24rpx 0 0;
|
|
|
+ z-index: 1000;
|
|
|
+ padding: 20rpx 0;
|
|
|
+ padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
|
|
|
+}
|
|
|
+.action-title {
|
|
|
+ font-size: calc(30rpx * var(--font-scale, 1));
|
|
|
+ font-weight: 600;
|
|
|
+ color: $facade-text-gray-800;
|
|
|
+}
|
|
|
+.action-relation {
|
|
|
+ font-size: calc(24rpx * var(--font-scale, 1));
|
|
|
+ color: $facade-text-gray-400;
|
|
|
+ margin-top: 4rpx;
|
|
|
+}
|
|
|
+.action-item {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ padding: 24rpx 30rpx;
|
|
|
+ border-bottom: 1rpx solid $facade-fill-gray-50;
|
|
|
+}
|
|
|
+.action-item:active {
|
|
|
+ background: $facade-fill-gray-100;
|
|
|
+}
|
|
|
+.action-item-icon {
|
|
|
+ font-size: 32rpx;
|
|
|
+ margin-right: 16rpx;
|
|
|
+}
|
|
|
+.action-item-text {
|
|
|
+ font-size: calc(28rpx * var(--font-scale, 1));
|
|
|
+ color: $facade-text-gray-700;
|
|
|
+}
|
|
|
+.action-danger .action-item-text {
|
|
|
+ color: $status-danger;
|
|
|
+}
|
|
|
+.action-cancel {
|
|
|
+ justify-content: center;
|
|
|
+ margin-top: 10rpx;
|
|
|
+ border-bottom: none;
|
|
|
+}
|
|
|
+.action-cancel .action-item-text {
|
|
|
+ color: $facade-text-slate;
|
|
|
+}
|
|
|
+.action-time {
|
|
|
+ font-size: calc(22rpx * var(--font-scale, 1));
|
|
|
+ color: $facade-text-gray-400;
|
|
|
+ margin-left: 4rpx;
|
|
|
+}
|
|
|
+/* 空状态 */
|
|
|
+.family-strip-empty {
|
|
|
+ margin: 16rpx 0;
|
|
|
+ padding: 40rpx 30rpx;
|
|
|
+ background: $app-surface;
|
|
|
+ border-radius: 24rpx;
|
|
|
+ display: flex;
|
|
|
+ justify-content: center;
|
|
|
+}
|
|
|
+.empty-content {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+}
|
|
|
+.empty-icon { font-size: 56rpx; }
|
|
|
+.empty-title { font-size: calc(30rpx * var(--font-scale, 1)); font-weight: 600; color: $facade-text-gray-800; margin-top: 12rpx; }
|
|
|
+.empty-desc { font-size: calc(24rpx * var(--font-scale, 1)); color: $facade-text-gray-400; margin-top: 8rpx; }
|
|
|
+.empty-btn {
|
|
|
+ margin-top: 24rpx;
|
|
|
+ padding: 16rpx 48rpx;
|
|
|
+ background: $brand-primary;
|
|
|
+ color: $app-surface;
|
|
|
+ font-size: 28rpx;
|
|
|
+ border-radius: 40rpx;
|
|
|
+}
|
|
|
+/* Action Sheet 头部 */
|
|
|
+.action-header {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: row;
|
|
|
+ align-items: center;
|
|
|
+ padding: 24rpx 30rpx;
|
|
|
+ border-bottom: 1rpx solid $facade-fill-gray-100;
|
|
|
+}
|
|
|
+.action-avatar {
|
|
|
+ width: 88rpx;
|
|
|
+ height: 88rpx;
|
|
|
+ border-radius: 50%;
|
|
|
+ margin-right: 20rpx;
|
|
|
+ background: $facade-fill-gray-100;
|
|
|
+}
|
|
|
+.action-header-info {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+}
|
|
|
+/* 关系现状 */
|
|
|
+.action-scores {
|
|
|
+ padding: 20rpx 30rpx;
|
|
|
+}
|
|
|
+.score-row {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: row;
|
|
|
+ align-items: center;
|
|
|
+ margin-bottom: 12rpx;
|
|
|
+}
|
|
|
+.score-label {
|
|
|
+ width: 110rpx;
|
|
|
+ font-size: calc(24rpx * var(--font-scale, 1));
|
|
|
+ color: $facade-text-slate;
|
|
|
+}
|
|
|
+.score-bar-track {
|
|
|
+ flex: 1;
|
|
|
+ height: 14rpx;
|
|
|
+ background: $facade-fill-gray-100;
|
|
|
+ border-radius: 7rpx;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+.score-bar-fill {
|
|
|
+ height: 100%;
|
|
|
+ border-radius: 7rpx;
|
|
|
+}
|
|
|
+.score-bar-fill.trust { background: $status-success; }
|
|
|
+.score-bar-fill.intimacy { background: $status-info; }
|
|
|
+.score-bar-fill.communication { background: $dim-wealth; }
|
|
|
+.score-value {
|
|
|
+ width: 70rpx;
|
|
|
+ text-align: right;
|
|
|
+ font-size: calc(24rpx * var(--font-scale, 1));
|
|
|
+ color: $facade-text-gray-800;
|
|
|
+ font-weight: 600;
|
|
|
+}
|
|
|
+.action-divider {
|
|
|
+ height: 1rpx;
|
|
|
+ background: $facade-fill-gray-100;
|
|
|
+ margin: 0 30rpx;
|
|
|
+}
|
|
|
+
|
|
|
+/* ================================================================
|
|
|
+ tabs 变体(原 DimensionMemberTabs 样式)
|
|
|
+ ================================================================ */
|
|
|
+// 维度色映射:选中态渐变用主色 -> 深阶,五维统一从 uni.scss 取令牌
|
|
|
+$tab-dims: (
|
|
|
+ body: $dim-body,
|
|
|
+ mind: $dim-mind,
|
|
|
+ wisdom: $dim-wisdom,
|
|
|
+ wealth: $dim-wealth,
|
|
|
+ action: $dim-action
|
|
|
+);
|
|
|
+
|
|
|
+.member-tabs {
|
|
|
+ padding: 8rpx 30rpx 4rpx;
|
|
|
+}
|
|
|
+.tabs-scroll {
|
|
|
+ width: 100%;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+.tabs-inner {
|
|
|
+ display: inline-flex;
|
|
|
+ flex-direction: row;
|
|
|
+ align-items: center;
|
|
|
+ gap: 16rpx;
|
|
|
+ padding: 4rpx 0;
|
|
|
+}
|
|
|
+/* ---- 成员标签(chip) ---- */
|
|
|
+.member-tab {
|
|
|
+ display: inline-flex;
|
|
|
+ flex-direction: row;
|
|
|
+ align-items: center;
|
|
|
+ min-height: 88rpx;
|
|
|
+ padding: 10rpx 22rpx 10rpx 12rpx;
|
|
|
+ background: $app-surface;
|
|
|
+ border: 2rpx solid $app-border-subtle;
|
|
|
+ border-radius: 44rpx;
|
|
|
+ transition: border-color $dur-state $ease-standard, background $dur-state $ease-standard, transform $dur-press $ease-standard;
|
|
|
+ flex-shrink: 0;
|
|
|
+
|
|
|
+ &:active {
|
|
|
+ transform: scale(0.97);
|
|
|
+ opacity: $press-opacity;
|
|
|
+ }
|
|
|
+}
|
|
|
+.tab-avatar-wrap {
|
|
|
+ position: relative;
|
|
|
+ flex-shrink: 0;
|
|
|
+}
|
|
|
+.tab-avatar {
|
|
|
+ width: 64rpx;
|
|
|
+ height: 64rpx;
|
|
|
+ border-radius: 50%;
|
|
|
+ background: $app-surface-subtle;
|
|
|
+}
|
|
|
+.tab-self-mark {
|
|
|
+ position: absolute;
|
|
|
+ right: -6rpx;
|
|
|
+ bottom: -6rpx;
|
|
|
+ min-width: 36rpx;
|
|
|
+ height: 36rpx;
|
|
|
+ padding: 0 8rpx;
|
|
|
+ border-radius: 18rpx;
|
|
|
+ background: $brand-primary;
|
|
|
+ color: $app-surface;
|
|
|
+ font-size: 20rpx;
|
|
|
+ font-weight: 600;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+}
|
|
|
+.tab-body {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ margin-left: 14rpx;
|
|
|
+ max-width: 200rpx;
|
|
|
+}
|
|
|
+.tab-name {
|
|
|
+ font-size: 26rpx;
|
|
|
+ font-weight: 600;
|
|
|
+ color: $app-text;
|
|
|
+ line-height: 1.3;
|
|
|
+ overflow: hidden;
|
|
|
+ text-overflow: ellipsis;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+.tab-meta {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: row;
|
|
|
+ align-items: center;
|
|
|
+ margin-top: 2rpx;
|
|
|
+ gap: 10rpx;
|
|
|
+}
|
|
|
+.tab-relation {
|
|
|
+ font-size: 20rpx;
|
|
|
+ color: $app-text-muted;
|
|
|
+ line-height: 1.2;
|
|
|
+}
|
|
|
+.tab-score {
|
|
|
+ font-size: 22rpx;
|
|
|
+ font-weight: 700;
|
|
|
+ color: $app-text-secondary;
|
|
|
+ line-height: 1.2;
|
|
|
+}
|
|
|
+.tab-score-unit {
|
|
|
+ font-size: 18rpx;
|
|
|
+ font-weight: 400;
|
|
|
+ margin-left: 1rpx;
|
|
|
+}
|
|
|
+/* ---- 管理入口 ---- */
|
|
|
+.tab-manage {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ min-height: 88rpx;
|
|
|
+ min-width: 88rpx;
|
|
|
+ padding: 0 20rpx;
|
|
|
+ border-radius: 44rpx;
|
|
|
+ background: $app-surface-subtle;
|
|
|
+ flex-shrink: 0;
|
|
|
+
|
|
|
+ &:active {
|
|
|
+ opacity: $press-opacity;
|
|
|
+ }
|
|
|
+}
|
|
|
+.tab-manage-icon {
|
|
|
+ font-size: 24rpx;
|
|
|
+ color: $app-text-secondary;
|
|
|
+ font-weight: 500;
|
|
|
+}
|
|
|
+/* ---- 空态 ---- */
|
|
|
+.tabs-empty {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: flex-start;
|
|
|
+ padding: 24rpx 28rpx;
|
|
|
+ background: $app-surface;
|
|
|
+ border-radius: 16rpx;
|
|
|
+ border: 2rpx dashed $app-border;
|
|
|
+}
|
|
|
+.tabs-empty-text {
|
|
|
+ font-size: calc(24rpx * var(--font-scale, 1));
|
|
|
+ color: $app-text-secondary;
|
|
|
+ line-height: 1.5;
|
|
|
+}
|
|
|
+.tabs-empty-btn {
|
|
|
+ margin-top: 16rpx;
|
|
|
+ padding: 12rpx 32rpx;
|
|
|
+ border-radius: 40rpx;
|
|
|
+ background: $brand-primary;
|
|
|
+ color: $app-surface;
|
|
|
+ font-size: 26rpx;
|
|
|
+ font-weight: 500;
|
|
|
+
|
|
|
+ &:active {
|
|
|
+ opacity: $press-opacity;
|
|
|
+ }
|
|
|
+}
|
|
|
+/* ---- 选中态:按维度着色 ---- */
|
|
|
+@each $code, $color in $tab-dims {
|
|
|
+ .dim-#{$code} .member-tab.is-selected {
|
|
|
+ background: linear-gradient(135deg, $color, rgba(red($color), green($color), blue($color), 0.82));
|
|
|
+ border-color: $color;
|
|
|
+
|
|
|
+ .tab-name,
|
|
|
+ .tab-relation,
|
|
|
+ .tab-score {
|
|
|
+ color: $app-surface;
|
|
|
+ }
|
|
|
+
|
|
|
+ .tab-avatar {
|
|
|
+ border: 2rpx solid $app-surface;
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/* ================================================================
|
|
|
+ minimal 变体(轻量 chip 条,替代各页面内联 member-bar)
|
|
|
+ ================================================================ */
|
|
|
+.member-minimal {
|
|
|
+ padding: 16rpx 30rpx 8rpx;
|
|
|
+ background: $app-surface;
|
|
|
+ border-bottom: 1rpx solid $facade-fill;
|
|
|
+}
|
|
|
+.member-minimal.is-embedded {
|
|
|
+ /* 嵌入模式:用于页头等容器内(去掉卡片底色/分隔线) */
|
|
|
+ padding: 4rpx 0;
|
|
|
+ background: transparent;
|
|
|
+ border-bottom: none;
|
|
|
+}
|
|
|
+.minimal-scroll {
|
|
|
+ width: 100%;
|
|
|
+ white-space: nowrap;
|
|
|
+}
|
|
|
+.minimal-inner {
|
|
|
+ display: inline-flex;
|
|
|
+ flex-direction: row;
|
|
|
+ align-items: center;
|
|
|
+ gap: 16rpx;
|
|
|
+}
|
|
|
+.minimal-chip {
|
|
|
+ flex-shrink: 0;
|
|
|
+ padding: 10rpx 24rpx;
|
|
|
+ border-radius: 30rpx;
|
|
|
+ background: $facade-fill-2;
|
|
|
+ border: 2rpx solid transparent;
|
|
|
+}
|
|
|
+.minimal-chip.is-active {
|
|
|
+ background: linear-gradient(135deg, $dim-body, $dim-body-light);
|
|
|
+ color: $app-surface;
|
|
|
+}
|
|
|
+.minimal-chip-text {
|
|
|
+ font-size: calc(26rpx * var(--font-scale, 1));
|
|
|
+ font-weight: 500;
|
|
|
+ color: inherit;
|
|
|
+}
|
|
|
+</style>
|