| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701 |
- <template>
- <!-- 能力图 - 径向布局 Canvas 组件 -->
- <view class="ability-graph-wrapper" v-if="abilities && abilities.length > 0" :style="{ height: canvasHeight + 'px' }">
- <canvas
- class="ability-graph-canvas"
- canvas-id="abilityGraphCanvas"
- id="abilityGraphCanvas"
- type="2d"
- :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
- @tap="onCanvasTap" />
- <!-- 点击节点时显示详情弹窗 -->
- <view class="ability-detail-popup" v-if="selectedNode" :style="popupPosition">
- <view class="popup-content">
- <view class="popup-header">
- <image class="popup-avatar" :src="selectedNode.avatar || defaultAvatar" mode="aspectFill" />
- <view class="popup-info">
- <text class="popup-name">{{ selectedNode.name }}</text>
- <text class="popup-relation" v-if="selectedNode.relationshipType">{{ selectedNode.relationshipType }}</text>
- </view>
- </view>
- <view class="popup-skills-section">
- <text class="popup-skills-title">技能 x{{ selectedNode.skillCount || 0 }}</text>
- <view class="popup-skills-list">
- <text class="popup-skill-item" v-for="(sk, idx) in displaySkills" :key="idx">{{ sk }}</text>
- <text class="popup-skill-more" v-if="selectedNode.skills && selectedNode.skills.length > 8">+{{ selectedNode.skills.length - 8 }} 项</text>
- </view>
- </view>
- <view class="popup-time" v-if="selectedNode.lastHelpedAt">
- <text class="popup-time-label">最近帮助:</text>
- <text class="popup-time-value">{{ formatDate(selectedNode.lastHelpedAt) }}</text>
- </view>
- <view class="popup-arrow" :style="arrowPosition"></view>
- </view>
- </view>
- </view>
- <!-- 空状态 -->
- <view class="ability-graph-empty" v-else-if="abilities && abilities.length === 0">
- <text class="empty-icon">🛠️</text>
- <text class="empty-text">暂无能力图数据</text>
- <text class="empty-hint">记录技能类帮助后,这里会画出大家的能力</text>
- </view>
- <!-- 加载状态 -->
- <view class="ability-loading" v-else>
- <text class="loading-text">加载能力图...</text>
- </view>
- </template>
- <script>
- /**
- * AbilityDiagram - 能力图 Canvas 组件
- *
- * 径向布局,展示"谁有哪项能力、曾帮助过谁":
- * - 中心节点 = 当前用户"我"(受助人,静态)
- * - 外围节点 = 提供过 SKILL 类帮助的人
- * - 节点大小 = 技能数量(skillCount)
- * - 边标注 = 技能名称
- * - 点击节点 → 显示详情弹窗(技能列表、最近帮助时间)
- *
- * 数据源:父组件通过 abilities prop 传入
- */
- export default {
- name: 'AbilityDiagram',
- props: {
- /** 当前用户ID */
- selfId: {
- type: [Number, String],
- default: null
- },
- /** 能力数据数组或 null(null=加载中) */
- abilities: {
- type: Array,
- default: null
- },
- /** 画布宽度 px */
- canvasWidth: {
- type: Number,
- default: 340
- },
- /** 画布高度 px */
- canvasHeight: {
- type: Number,
- default: 280
- },
- /** 是否允许交互(点击节点查看明细) */
- interactive: {
- type: Boolean,
- default: true
- }
- },
- data: function() {
- return {
- dpr: 1,
- ctx: null,
- canvasReady: false,
- nodeData: [],
- edges: [],
- selectedNode: null,
- selectedNodePos: null,
- defaultAvatar: '/static/default-avatar.png',
- _destroyed: false,
- _canvasRect: null,
- // 节点颜色:技能统一金色
- skillColor: '#F59E0B',
- // 边颜色
- edgeColor: '#CBD5E1'
- }
- },
- computed: {
- popupPosition: function() {
- if (!this.selectedNodePos) return { display: 'none' }
- return {
- left: this.selectedNodePos.x + 'px',
- top: this.selectedNodePos.y + 'px'
- }
- },
- arrowPosition: function() {
- return {}
- },
- displaySkills: function() {
- if (!this.selectedNode || !this.selectedNode.skills) return []
- return this.selectedNode.skills.slice(0, 8)
- }
- },
- watch: {
- abilities: {
- handler: function() {
- this.buildNodesAndEdges()
- this.draw()
- },
- deep: true
- },
- canvasWidth: function() {
- this.buildNodesAndEdges()
- this.draw()
- },
- canvasHeight: function() {
- this.buildNodesAndEdges()
- this.draw()
- }
- },
- mounted: function() {
- this._destroyed = false
- var self = this
- try {
- this.dpr = uni.getWindowInfo().pixelRatio || 1
- } catch (e) {
- this.dpr = 1
- }
- // 延迟初始化 canvas(等待 DOM 布局完成)
- var retryCount = 0
- var maxRetry = 5
- function tryInit() {
- if (self._destroyed) return
- self.initCanvas(function(success) {
- if (self._destroyed) return
- if (!success && retryCount < maxRetry) {
- retryCount++
- setTimeout(tryInit, 60)
- } else if (!success) {
- console.log('[AbilityDiagram] canvas 初始化失败,重试 ' + maxRetry + ' 次后放弃')
- }
- })
- }
- this.$nextTick(function() {
- if (self._destroyed) return
- self.$nextTick(function() {
- if (!self._destroyed) {
- tryInit()
- }
- })
- })
- },
- beforeDestroy: function() {
- this._destroyed = true
- },
- methods: {
- /* ===========================
- * 数据构建
- * =========================== */
- buildNodesAndEdges: function() {
- if (!this.abilities || this.abilities.length === 0) {
- this.nodeData = []
- this.edges = []
- return
- }
- var centerX = this.canvasWidth / 2
- var centerY = this.canvasHeight / 2
- var n = this.abilities.length
- var self = this
- // 径向分布半径(确保不超出画布,留边距)
- var maxRadius = Math.min(this.canvasWidth, this.canvasHeight) * 0.38
- // 如果节点多,缩小半径避免重叠(最小 0.2)
- var layoutRadius = maxRadius
- if (n > 6) {
- layoutRadius = maxRadius * Math.max(0.22, 1 - (n - 6) * 0.06)
- }
- // 1. 中心节点(自己)
- var centerNode = {
- id: 'center',
- isCenter: true,
- displayName: '我',
- radius: 30,
- x: centerX,
- y: centerY
- }
- // 2. 能力节点(提供技能帮助的人)
- var abilityNodes = this.abilities.map(function(a, idx) {
- var angle = (2 * Math.PI * idx) / n
- var nodeRadius = 24 + Math.min(12, (a.skillCount - 1) * 2)
- return {
- id: 'ability_' + a.contactId,
- contactId: a.contactId,
- name: a.name,
- displayName: a.name ? a.name.charAt(0) : '?',
- avatar: a.avatar,
- relationshipType: a.relationshipType,
- skillCount: a.skillCount || 0,
- skills: a.skills || [],
- lastHelpedAt: a.lastHelpedAt,
- radius: nodeRadius,
- color: self.skillColor,
- x: centerX + layoutRadius * Math.cos(angle),
- y: centerY + layoutRadius * Math.sin(angle)
- }
- })
- this.nodeData = [centerNode].concat(abilityNodes)
- // 3. 边:中心连接到每个能力节点
- this.edges = abilityNodes.map(function(n) {
- return {
- source: centerNode,
- target: n,
- skills: n.skills
- }
- })
- },
- /* ===========================
- * 渲染
- * =========================== */
- draw: function() {
- if (!this.ctx) return
- var ctx = this.ctx
- var w = this.canvasWidth
- var h = this.canvasHeight
- ctx.clearRect(0, 0, w, h)
- // 1. 绘制边(连接线 + 箭头 + 技能标注)
- this._drawEdges(ctx)
- // 2. 绘制节点
- for (var i = 0; i < this.nodeData.length; i++) {
- this._drawNode(ctx, this.nodeData[i])
- }
- },
- _drawEdges: function(ctx) {
- var self = this
- var centerR = 30 // 中心节点半径
- this.edges.forEach(function(e) {
- var source = e.source
- var target = e.target
- // 计算方向
- var dx = target.x - source.x
- var dy = target.y - source.y
- var dist = Math.sqrt(dx * dx + dy * dy) || 1
- var nx = dx / dist
- var ny = dy / dist
- // 线段起点(从中心节点边缘)
- var sx = source.x + nx * centerR
- var sy = source.y + ny * centerR
- // 线段终点(到能力节点边缘)
- var tx = target.x - nx * target.radius
- var ty = target.y - ny * target.radius
- // 绘制线段
- ctx.beginPath()
- ctx.moveTo(sx, sy)
- ctx.lineTo(tx, ty)
- ctx.strokeStyle = self.edgeColor
- ctx.lineWidth = 1.5
- ctx.stroke()
- // 箭头(靠近中心侧)
- var arrowLen = 8
- var arrowWidth = 4
- // 箭头位置:在终点偏回一点
- var ax = tx
- var ay = ty
- ctx.beginPath()
- ctx.moveTo(ax, ay)
- ctx.lineTo(ax - nx * arrowLen + ny * arrowWidth, ay - ny * arrowLen - nx * arrowWidth)
- ctx.lineTo(ax - nx * arrowLen - ny * arrowWidth, ay - ny * arrowLen + nx * arrowWidth)
- ctx.closePath()
- ctx.fillStyle = self.edgeColor
- ctx.fill()
- // 技能标注(在能力节点附近)
- var skills = e.skills || []
- if (skills.length > 0) {
- var label = self._buildSkillLabel(skills)
- var labelX = target.x - nx * (target.radius + 10)
- var labelY = target.y - ny * (target.radius + 10)
- // 微调偏移让文字不压在连线上
- var perpX = -ny
- var perpY = nx
- labelX += perpX * 12
- labelY += perpY * 12
- ctx.font = '11px PingFang SC, sans-serif'
- ctx.fillStyle = '#475569'
- ctx.textAlign = 'center'
- ctx.textBaseline = 'middle'
- ctx.fillText(label, labelX, labelY)
- }
- })
- },
- _buildSkillLabel: function(skills) {
- if (!skills || skills.length === 0) return ''
- if (skills.length === 1) return skills[0]
- // 最多显示 2 个技能名
- var first = skills[0]
- var second = skills[1]
- var combined = first + '、' + second
- if (combined.length <= 12) {
- if (skills.length > 2) {
- return combined + ' +' + (skills.length - 2)
- }
- return combined
- }
- // 名字太长,只显示第一个 + 数量
- return first + ' +' + (skills.length - 1)
- },
- _drawNode: function(ctx, node) {
- var r = node.radius
- var x = node.x
- var y = node.y
- // 裁剪到画布范围
- if (x - r > this.canvasWidth || x + r < 0 || y - r > this.canvasHeight || y + r < 0) {
- return
- }
- if (node.isCenter) {
- // 中心节点:实心圆 + "我" 字
- ctx.beginPath()
- ctx.arc(x, y, r, 0, Math.PI * 2)
- ctx.fillStyle = '#10B981'
- ctx.fill()
- ctx.strokeStyle = '#FFFFFF'
- ctx.lineWidth = 2
- ctx.stroke()
- ctx.font = 'bold 18px PingFang SC, sans-serif'
- ctx.fillStyle = '#FFFFFF'
- ctx.textAlign = 'center'
- ctx.textBaseline = 'middle'
- ctx.fillText('我', x, y + 1)
- } else {
- // 能力节点:实心圆 + 头像(异步加载,兜底首字)
- ctx.beginPath()
- ctx.arc(x, y, r, 0, Math.PI * 2)
- ctx.fillStyle = node.color
- ctx.fill()
- // 白边
- ctx.strokeStyle = '#FFFFFF'
- ctx.lineWidth = 2
- ctx.stroke()
- // 首字(先画兜底,头像加载成功后覆盖)
- ctx.font = 'bold ' + Math.max(14, r * 0.7) + 'px PingFang SC, sans-serif'
- ctx.fillStyle = '#FFFFFF'
- ctx.textAlign = 'center'
- ctx.textBaseline = 'middle'
- ctx.fillText(node.displayName, x, y + 1)
- // 头像:圆形裁剪绘制
- if (node.avatar && this._canvasNode && this._canvasNode.createImage) {
- var img = this._canvasNode.createImage()
- var self = this
- img.onload = function() {
- if (self._destroyed || !self.ctx) return
- var ctx2 = self.ctx
- ctx2.save()
- ctx2.beginPath()
- ctx2.arc(x, y, r, 0, Math.PI * 2)
- ctx2.clip()
- ctx2.drawImage(img, x - r, y - r, r * 2, r * 2)
- ctx2.restore()
- // 重绘白边(覆盖裁剪边缘)
- ctx2.beginPath()
- ctx2.arc(x, y, r, 0, Math.PI * 2)
- ctx2.strokeStyle = '#FFFFFF'
- ctx2.lineWidth = 2
- ctx2.stroke()
- }
- img.onerror = function() {
- // 头像加载失败,保留首字兜底
- }
- img.src = node.avatar
- }
- }
- },
- /* ===========================
- * 交互
- * =========================== */
- onCanvasTap: function(e) {
- if (!this.interactive) return
- var touches = e.touches || e.changedTouches
- if (!touches || touches.length === 0) return
- var touch = touches[0]
- var canvasRect = this._getCanvasRect()
- if (!canvasRect) return
- var x = touch.clientX - canvasRect.left
- var y = touch.clientY - canvasRect.top
- var hitNode = this._hitTest(x, y)
- if (hitNode && !hitNode.isCenter) {
- this.selectedNode = hitNode
- this.selectedNodePos = { x: hitNode.x, y: hitNode.y }
- } else {
- this.selectedNode = null
- this.selectedNodePos = null
- }
- },
- _hitTest: function(x, y) {
- for (var i = this.nodeData.length - 1; i >= 0; i--) {
- var node = this.nodeData[i]
- var dx = x - node.x
- var dy = y - node.y
- if (dx * dx + dy * dy <= node.radius * node.radius) {
- return node
- }
- }
- return null
- },
- /* ===========================
- * 工具方法
- * =========================== */
- _getCanvasRect: function() {
- if (this._canvasRect) {
- return {
- left: this._canvasRect.left,
- top: this._canvasRect.top,
- width: this.canvasWidth,
- height: this.canvasHeight
- }
- }
- return {
- left: 0,
- top: 0,
- width: this.canvasWidth,
- height: this.canvasHeight
- }
- },
- formatDate: function(dateStr) {
- if (!dateStr) return ''
- // 直接截取前 10 位 yyyy-MM-dd,无需 Date 解析
- return String(dateStr).substring(0, 10)
- },
- /* ===========================
- * Canvas 初始化
- * =========================== */
- initCanvas: function(cb) {
- var self = this
- uni.createSelectorQuery().in(this)
- .select('#' + 'abilityGraphCanvas')
- .fields({ node: true, size: true, rect: true })
- .exec(function(res) {
- try {
- if (self._destroyed) return
- if (!res || !res[0]) {
- if (cb) cb(false)
- return
- }
- var info = res[0]
- if (!info.node) {
- if (cb) cb(false)
- return
- }
- var canvas = info.node
- var width = info.width
- var height = info.height
- if (!width || !height) {
- var winWidth = 375
- try {
- var winInfo = uni.getWindowInfo()
- winWidth = winInfo.windowWidth || 375
- } catch (e2) {}
- width = Math.round((self.canvasWidth || 340) * winWidth / 750)
- height = Math.round((self.canvasHeight || 280) * winWidth / 750)
- }
- var pixelRatio = 2
- try {
- var system = uni.getWindowInfo()
- pixelRatio = system.pixelRatio || 2
- } catch (e) {}
- canvas.width = width * pixelRatio
- canvas.height = height * pixelRatio
- var ctx = canvas.getContext('2d')
- if (!ctx) {
- if (cb) cb(false)
- return
- }
- ctx.scale(pixelRatio, pixelRatio)
- self.ctx = ctx
- self._canvasNode = canvas
- self.canvasWidth = width
- self.canvasHeight = height
- self.dpr = pixelRatio
- self.canvasReady = true
- // 存储 canvas 在页面中的真实位置(触摸坐标转换用)
- if (info.rect) {
- self._canvasRect = { left: info.rect.left || 0, top: info.rect.top || 0 }
- }
- self.buildNodesAndEdges()
- self.draw()
- if (cb) cb(true)
- } catch (e) {
- if (cb) cb(false)
- }
- })
- }
- }
- }
- </script>
- <style scoped>
- .ability-graph-wrapper {
- position: relative;
- width: 100%;
- min-height: 200px;
- height: auto;
- margin: 10rpx 0;
- background: #FFFFFF;
- border-radius: 24rpx;
- overflow: hidden;
- }
- .ability-graph-canvas {
- position: absolute;
- top: 0;
- left: 0;
- z-index: 1;
- }
- /* 详情弹窗 */
- .ability-detail-popup {
- position: absolute;
- z-index: 10;
- pointer-events: none;
- transform: translate(-50%, -100%);
- margin-top: -16rpx;
- }
- .popup-content {
- background: #FFFFFF;
- border-radius: 16rpx;
- padding: 20rpx;
- box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.12);
- min-width: 200rpx;
- max-width: 320rpx;
- pointer-events: auto;
- }
- .popup-header {
- display: flex;
- align-items: center;
- margin-bottom: 16rpx;
- }
- .popup-avatar {
- width: 56rpx;
- height: 56rpx;
- border-radius: 50%;
- margin-right: 12rpx;
- }
- .popup-info {
- display: flex;
- flex-direction: column;
- }
- .popup-name {
- font-size: 28rpx;
- font-weight: 600;
- color: #1E293B;
- }
- .popup-relation {
- font-size: 22rpx;
- color: #64748B;
- margin-top: 2rpx;
- }
- .popup-skills-section {
- padding: 12rpx 0;
- border-top: 1rpx solid #F1F5F9;
- border-bottom: 1rpx solid #F1F5F9;
- margin-bottom: 12rpx;
- }
- .popup-skills-title {
- font-size: 24rpx;
- font-weight: 700;
- color: #F59E0B;
- display: block;
- margin-bottom: 8rpx;
- }
- .popup-skills-list {
- display: flex;
- flex-wrap: wrap;
- gap: 8rpx;
- }
- .popup-skill-item {
- font-size: 22rpx;
- color: #475569;
- background: #FEF3C7;
- border-radius: 8rpx;
- padding: 4rpx 10rpx;
- }
- .popup-skill-more {
- font-size: 22rpx;
- color: #94A3B8;
- padding: 4rpx 6rpx;
- }
- .popup-time {
- display: flex;
- align-items: center;
- margin-top: 4rpx;
- }
- .popup-time-label {
- font-size: 22rpx;
- color: #94A3B8;
- }
- .popup-time-value {
- font-size: 22rpx;
- color: #475569;
- }
- .popup-arrow {
- position: absolute;
- bottom: -8rpx;
- left: 50%;
- margin-left: -8rpx;
- width: 0;
- height: 0;
- border-left: 8rpx solid transparent;
- border-right: 8rpx solid transparent;
- border-top: 8rpx solid #FFFFFF;
- }
- /* 空状态 */
- .ability-graph-empty,
- .ability-loading {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- padding: 60rpx 20rpx;
- background: #FFFFFF;
- border-radius: 24rpx;
- margin: 10rpx 0;
- }
- .empty-icon {
- font-size: 80rpx;
- margin-bottom: 16rpx;
- }
- .empty-text {
- font-size: 28rpx;
- font-weight: 600;
- color: #1E293B;
- margin-bottom: 8rpx;
- }
- .empty-hint {
- font-size: 24rpx;
- color: #94A3B8;
- margin-bottom: 24rpx;
- }
- .loading-text {
- font-size: 28rpx;
- color: #64748B;
- }
- </style>
|