| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635 |
- <template>
- <!-- 章鱼图 - 关键人拓展图谱 -->
- <view class="octopus-wrapper" v-if="effects && effects.list && effects.list.length > 0" :style="{ height: canvasHeight + 'px' }">
- <canvas
- class="octopus-canvas"
- canvas-id="octopusCanvas"
- id="octopusCanvas"
- type="2d"
- :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
- @touchstart="onTouchStart"
- @touchmove="onTouchMove"
- @touchend="onTouchEnd" />
- <!-- 成效节点点击弹窗(显示关键人列表) -->
- <view class="effect-popup" v-if="selectedEffect" :style="effectPopupPosition">
- <view class="popup-content">
- <view class="popup-header">
- <text class="popup-name">{{ selectedEffect.name }}</text>
- <text class="popup-tags">
- <text class="popup-tag" v-for="t in selectedEffect.effectTypeList" :key="t">{{ t }}</text>
- </text>
- </view>
- <view class="popup-keypersons" v-if="keyPersons[selectedEffect.id] && keyPersons[selectedEffect.id].length > 0">
- <text class="popup-title">关键人</text>
- <text class="kp-item" v-for="kp in keyPersons[selectedEffect.id]" :key="kp.id">
- {{ kp.name }} · {{ kp.title || kp.organization || '' }}
- </text>
- </view>
- <view class="popup-empty" v-else>
- <text class="empty-text">暂无关键人,点击添加</text>
- </view>
- <view class="popup-actions">
- <text class="action-btn action-btn-primary" @tap="onAddKeyPerson(selectedEffect)">添加关键人</text>
- </view>
- </view>
- </view>
- <!-- 重新分析按钮 -->
- <view class="reanalyze-btn" @tap="$emit('reanalyze')">
- <text>🔄 重新分析</text>
- </view>
- </view>
- <!-- 空状态 -->
- <view class="octopus-empty" v-else-if="effects && effects.list && effects.list.length === 0">
- <text class="empty-icon">🧭</text>
- <text class="empty-text">暂无成效记录</text>
- <text class="empty-hint">记录你的成交成效,画出章鱼图</text>
- <text class="action-btn action-btn-primary" @tap="$emit('add-effect')">+ 记录成效</text>
- </view>
- <!-- 加载状态 -->
- <view class="octopus-loading" v-else>
- <text class="loading-text">加载章鱼图...</text>
- </view>
- </template>
- <script>
- /**
- * OctopusDiagram - 章鱼图(关键人拓展)
- *
- * 三层结构:
- * 1. 中心节点:"我"(当前用户)
- * 2. 第1层:成效记录节点(标签:金额大/频次高/新成交)
- * 3. 第2层:拓展方向节点(虚线,≤8个)
- */
- export default {
- name: 'OctopusDiagram',
- props: {
- selfId: { type: [Number, String], default: null },
- effects: { type: Object, default: null },
- expansions: { type: Array, default: function() { return [] } },
- canvasWidth: { type: Number, default: 340 },
- canvasHeight: { type: Number, default: 420 },
- interactive: { type: Boolean, default: true }
- },
- data: function() {
- return {
- dpr: 1,
- ctx: null,
- _canvasNode: null,
- _canvasRect: null,
- canvasReady: false,
- _destroyed: false,
- // 节点数据
- effectNodes: [],
- expansionNodes: [],
- centerNode: null,
- // 选中状态
- selectedEffect: null,
- _touchStartPos: null,
- // 关键人缓存(effectId -> [{...}])
- keyPersons: {},
- // 颜色
- effectColors: { '金额大': '#F97316', '频次高': '#F59E0B', '新成交': '#10B981' },
- expansionColor: '#6366F1'
- }
- },
- computed: {
- effectPopupPosition: function() {
- if (!this.selectedEffect) return { display: 'none' }
- var px = this.selectedEffect.x
- var py = this.selectedEffect.y
- var halfPopup = 120
- var left = Math.max(halfPopup, Math.min(this.canvasWidth - halfPopup, px))
- return { left: left + 'px', top: (py - 20) + 'px' }
- }
- },
- watch: {
- effects: {
- handler: function() {
- this.selectedEffect = null
- this.buildLayout()
- if (this.canvasReady) this.drawAll()
- },
- deep: true
- },
- expansions: {
- handler: function() {
- this.buildExpansionNodes()
- if (this.canvasReady) this.drawAll()
- },
- deep: true
- }
- },
- mounted: function() {
- var self = this
- this._destroyed = false
- try {
- this.dpr = uni.getWindowInfo().pixelRatio || 1
- } catch (e) {
- this.dpr = 1
- }
- 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)
- }
- })
- }
- this.$nextTick(function() {
- if (!self._destroyed) tryInit()
- })
- },
- beforeDestroy: function() {
- this._destroyed = true
- },
- methods: {
- buildLayout: function() {
- this.effectNodes = []
- this.expansionNodes = []
- if (!this.effects || !this.effects.list) return
- var cx = this.canvasWidth / 2
- var cy = this.canvasHeight / 2
- var minDim = Math.min(this.canvasWidth, this.canvasHeight)
- var effects = this.effects.list
- var n = effects.length
- // 成效节点均匀分布在上方半圆
- for (var i = 0; i < n; i++) {
- var angle = -Math.PI / 2 - (Math.PI / (n + 1)) * (i + 1)
- var r = minDim * 0.30
- this.effectNodes.push({
- id: effects[i].id,
- name: '成交 #' + effects[i].id,
- displayName: 'E' + (i + 1),
- x: cx + r * Math.cos(angle),
- y: cy + r * Math.sin(angle),
- radius: 26,
- effectType: effects[i].effectType,
- effectTypeList: effects[i].effectTypeList || [],
- effectAmount: effects[i].effectAmount,
- color: this._getTypeColor(effects[i].effectType)
- })
- }
- },
- buildExpansionNodes: function() {
- if (!this.expansions || this.expansions.length === 0) {
- this.expansionNodes = []
- return
- }
- var cx = this.canvasWidth / 2
- var cy = this.canvasHeight / 2
- var minDim = Math.min(this.canvasWidth, this.canvasHeight)
- var n = this.expansions.length
- var baseRadius = minDim * 0.38
- for (var i = 0; i < n; i++) {
- var angle = -Math.PI / 2 - (Math.PI / (n + 1)) * (i + 1)
- var r = baseRadius + 40
- this.expansionNodes.push({
- id: 'exp_' + i,
- name: this.expansions[i].keyword,
- displayName: this.expansions[i].keyword.substring(0, 3),
- x: cx + r * Math.cos(angle),
- y: cy + r * Math.sin(angle),
- radius: 20,
- dimension: this.expansions[i].dimension,
- isExpansion: true
- })
- }
- },
- drawAll: function() {
- if (!this.ctx) return
- var ctx = this.ctx
- var w = this.canvasWidth
- var h = this.canvasHeight
- ctx.clearRect(0, 0, w, h)
- this._drawCenterNode(ctx)
- this._drawEdges(ctx)
- for (var i = 0; i < this.effectNodes.length; i++) {
- this._drawEffectNode(ctx, this.effectNodes[i])
- }
- for (var j = 0; j < this.expansionNodes.length; j++) {
- this._drawExpansionNode(ctx, this.expansionNodes[j])
- }
- this._drawLabels(ctx)
- },
- _drawCenterNode: function(ctx) {
- var cx = this.canvasWidth / 2
- var cy = this.canvasHeight / 2
- var r = 30
- ctx.beginPath()
- ctx.arc(cx, cy, r + 4, 0, Math.PI * 2)
- ctx.fillStyle = this._hexToRgba('#10B981', 0.15)
- ctx.fill()
- ctx.beginPath()
- ctx.arc(cx, cy, r, 0, Math.PI * 2)
- ctx.fillStyle = '#10B981'
- ctx.fill()
- ctx.strokeStyle = '#FFFFFF'
- ctx.lineWidth = 3
- ctx.stroke()
- ctx.font = 'bold 18px PingFang SC, sans-serif'
- ctx.fillStyle = '#FFFFFF'
- ctx.textAlign = 'center'
- ctx.textBaseline = 'middle'
- ctx.fillText('我', cx, cy + 1)
- },
- _drawEffectNode: function(ctx, node) {
- var x = node.x, y = node.y, r = node.radius
- if (x - r > this.canvasWidth || x + r < 0 || y - r > this.canvasHeight || y + r < 0) return
- // 光晕
- ctx.beginPath()
- ctx.arc(x, y, r + 3, 0, Math.PI * 2)
- ctx.fillStyle = this._hexToRgba(node.color, 0.12)
- ctx.fill()
- // 主体
- 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)
- },
- _drawExpansionNode: function(ctx, node) {
- var x = node.x, y = node.y, r = node.radius
- if (x - r > this.canvasWidth || x + r < 0 || y - r > this.canvasHeight || y + r < 0) return
- // 虚线圆
- ctx.beginPath()
- ctx.arc(x, y, r, 0, Math.PI * 2)
- ctx.strokeStyle = this.expansionColor
- ctx.lineWidth = 2
- ctx.setLineDash([4, 4])
- ctx.stroke()
- ctx.setLineDash([])
- // 填充
- ctx.beginPath()
- ctx.arc(x, y, r - 1, 0, Math.PI * 2)
- ctx.fillStyle = this._hexToRgba(this.expansionColor, 0.15)
- ctx.fill()
- // 文字
- ctx.font = '11px PingFang SC, sans-serif'
- ctx.fillStyle = this.expansionColor
- ctx.textAlign = 'center'
- ctx.textBaseline = 'middle'
- ctx.fillText(node.displayName, x, y + 1)
- },
- _drawEdges: function(ctx) {
- var cx = this.canvasWidth / 2
- var cy = this.canvasHeight / 2
- for (var i = 0; i < this.effectNodes.length; i++) {
- var node = this.effectNodes[i]
- ctx.beginPath()
- ctx.moveTo(cx, cy)
- ctx.lineTo(node.x, node.y)
- ctx.strokeStyle = this._hexToRgba(node.color, 0.3)
- ctx.lineWidth = 1.5
- ctx.stroke()
- }
- // 成效节点到拓展方向节点的边(虚线)
- for (var i = 0; i < this.effectNodes.length && i < this.expansionNodes.length; i++) {
- var e1 = this.effectNodes[i]
- var e2 = this.expansionNodes[i]
- ctx.beginPath()
- ctx.moveTo(e1.x, e1.y)
- ctx.lineTo(e2.x, e2.y)
- ctx.strokeStyle = this._hexToRgba(this.expansionColor, 0.2)
- ctx.lineWidth = 1
- ctx.setLineDash([3, 3])
- ctx.stroke()
- ctx.setLineDash([])
- }
- },
- _drawLabels: function(ctx) {
- var cx = this.canvasWidth / 2
- var cy = this.canvasHeight / 2
- // 成效标签(每侧显示)
- for (var i = 0; i < this.effectNodes.length; i++) {
- var node = this.effectNodes[i]
- var dx = node.x - cx
- var dy = node.y - cy
- var dist = Math.sqrt(dx * dx + dy * dy) || 1
- var lx = node.x + (dx / dist) * 36
- var ly = node.y + (dy / dist) * 20
- ctx.font = '11px PingFang SC, sans-serif'
- ctx.fillStyle = '#64748B'
- ctx.textAlign = 'center'
- ctx.textBaseline = 'middle'
- var label = ''
- if (node.effectTypeList && node.effectTypeList.length > 0) {
- label = node.effectTypeList.slice(0, 2).join('/')
- }
- if (label) ctx.fillText(label, lx, ly)
- }
- },
- onTouchStart: function(e) {
- if (!this.interactive) return
- var touches = e.touches || e.changedTouches
- if (!touches || touches.length === 0) return
- var touch = touches[0]
- var rect = this._getCanvasRect()
- if (!rect) return
- var x = touch.clientX - rect.left
- var y = touch.clientY - rect.top
- this._touchStartPos = { x: x, y: y }
- var hit = this.hitTest(x, y)
- if (!hit) {
- this.selectedEffect = null
- }
- },
- onTouchMove: function(e) {
- if (!this._touchStartPos) return
- var touches = e.touches || e.changedTouches
- if (!touches || touches.length === 0) return
- var touch = touches[0]
- var rect = this._getCanvasRect()
- if (!rect) return
- var x = touch.clientX - rect.left
- var y = touch.clientY - rect.top
- var dx = x - this._touchStartPos.x
- var dy = y - this._touchStartPos.y
- if (dx * dx + dy * dy > 100) {
- this._touchStartPos = null
- }
- },
- onTouchEnd: function(e) {
- if (!this._touchStartPos) return
- var touches = e.changedTouches
- if (!touches || touches.length === 0) {
- this._touchStartPos = null
- return
- }
- var touch = touches[0]
- var rect = this._getCanvasRect()
- if (!rect) { this._touchStartPos = null; return }
- var x = touch.clientX - rect.left
- var y = touch.clientY - rect.top
- var dx = x - this._touchStartPos.x
- var dy = y - this._touchStartPos.y
- this._touchStartPos = null
- if (dx * dx + dy * dy > 100) return
- var hit = this.hitTest(x, y)
- if (hit) {
- this.selectedEffect = hit
- } else {
- this.selectedEffect = null
- }
- },
- hitTest: function(x, y) {
- // 先检查成效节点
- for (var i = 0; i < this.effectNodes.length; i++) {
- var n = this.effectNodes[i]
- var dx = x - n.x
- var dy = y - n.y
- if (dx * dx + dy * dy <= n.radius * n.radius) return n
- }
- // 再检查拓展方向节点
- for (var j = 0; j < this.expansionNodes.length; j++) {
- var n = this.expansionNodes[j]
- var dx = x - n.x
- var dy = y - n.y
- if (dx * dx + dy * dy <= n.radius * n.radius) {
- // 拓展节点 → 触发 expansion-click 事件
- this.$emit('expansion-click', n)
- return n
- }
- }
- return null
- },
- onAddKeyPerson: function(effect) {
- this.$emit('effect-click', effect)
- // 如果有关键人列表则显示,否则触发 add-key-person
- if (!this.keyPersons[effect.id] || this.keyPersons[effect.id].length === 0) {
- this.$emit('add-key-person')
- }
- },
- initCanvas: function(cb) {
- var self = this
- uni.createSelectorQuery().in(this)
- .select('#octopusCanvas')
- .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 wi = uni.getWindowInfo()
- winWidth = wi.windowWidth || 375
- } catch (e) {}
- width = Math.round((self.canvasWidth || 340) * winWidth / 750)
- height = Math.round((self.canvasHeight || 420) * winWidth / 750)
- }
- var pixelRatio = 2
- try {
- var sys = uni.getWindowInfo()
- pixelRatio = sys.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
- if (info.rect) {
- self._canvasRect = { left: info.rect.left || 0, top: info.rect.top || 0 }
- }
- self.buildLayout()
- self.buildExpansionNodes()
- self.drawAll()
- if (cb) cb(true)
- } catch (e) {
- if (cb) cb(false)
- }
- })
- },
- _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 }
- },
- _hexToRgba: function(hex, alpha) {
- var r = parseInt(hex.slice(1, 3), 16)
- var g = parseInt(hex.slice(3, 5), 16)
- var b = parseInt(hex.slice(5, 7), 16)
- return 'rgba(' + r + ',' + g + ',' + b + ',' + alpha + ')'
- },
- _getTypeColor: function(type) {
- if (!type) return '#64748B'
- // 返回第一个类型对应的颜色
- var list = []
- if (type & 1) list.push('金额大')
- if (type & 2) list.push('频次高')
- if (type & 4) list.push('新成交')
- if (list.length === 0) return '#64748B'
- return this.effectColors[list[0]] || '#64748B'
- },
- formatDate: function(dateStr) {
- if (!dateStr) return ''
- return String(dateStr).substring(0, 10)
- }
- }
- }
- </script>
- <style scoped>
- .octopus-wrapper {
- position: relative;
- width: 100%;
- min-height: 200px;
- height: auto;
- margin: 10rpx 0;
- background: #FFFFFF;
- border-radius: 24rpx;
- overflow: hidden;
- }
- .octopus-canvas {
- position: absolute;
- top: 0;
- left: 0;
- z-index: 1;
- }
- .octopus-empty, .octopus-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;
- }
- .action-btn {
- display: inline-block;
- padding: 12rpx 32rpx;
- border-radius: 20rpx;
- font-size: 26rpx;
- font-weight: 600;
- }
- .action-btn-primary {
- background: #F97316;
- color: #FFFFFF;
- }
- .reanalyze-btn {
- position: absolute;
- top: 12rpx;
- right: 16rpx;
- z-index: 10;
- background: rgba(255,255,255,0.9);
- border-radius: 20rpx;
- padding: 8rpx 16rpx;
- font-size: 22rpx;
- color: #64748B;
- }
- /* 成效弹窗 */
- .effect-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: 12rpx;
- }
- .popup-name {
- font-size: 26rpx;
- font-weight: 600;
- color: #1E293B;
- }
- .popup-tags {
- display: flex;
- flex-wrap: wrap;
- margin-top: 4rpx;
- }
- .popup-tag {
- font-size: 18rpx;
- padding: 2rpx 10rpx;
- border-radius: 6rpx;
- margin-right: 8rpx;
- margin-top: 4rpx;
- background: #FFF7ED;
- color: #F97316;
- }
- .popup-keypersons {
- border-top: 1rpx solid #F1F5F9;
- padding-top: 10rpx;
- margin-top: 8rpx;
- }
- .popup-title {
- font-size: 22rpx;
- color: #94A3B8;
- display: block;
- margin-bottom: 6rpx;
- }
- .kp-item {
- font-size: 22rpx;
- color: #475569;
- display: block;
- margin-bottom: 4rpx;
- }
- .popup-empty {
- padding: 8rpx 0;
- }
- .empty-text {
- font-size: 22rpx;
- color: #94A3B8;
- }
- .popup-actions {
- display: flex;
- justify-content: center;
- margin-top: 12rpx;
- padding-top: 12rpx;
- border-top: 1rpx solid #F1F5F9;
- }
- </style>
|