xinli/xinlidsj/pages/index/index.vue
2026-02-26 18:18:03 +08:00

3475 lines
108 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<!-- 主内容区域 - 登录时禁用交互 -->
<view class="page" v-if="!isH5" :class="{ 'page-disabled': showLoginModal }">
<view class="section">
<view class="section-title">核心功能</view>
<view class="grid">
<view class="card card-core card-warning" @tap="goWarning">
<view class="card-accent"></view>
<view class="card-icon card-icon-solid">
<uni-icons class="card-icon-inner" type="notification" size="28" color="#FFFFFF"></uni-icons>
</view>
<view class="card-title">预警中心</view>
<view class="card-desc">查看预警、处置与跟进</view>
</view>
<view class="card card-core card-profile" @tap="goProfile">
<view class="card-accent"></view>
<view class="card-icon card-icon-solid">
<uni-icons class="card-icon-inner" type="person" size="28" color="#FFFFFF"></uni-icons>
</view>
<view class="card-title">个体画像</view>
<view class="card-desc">多量表趋势与风险提示</view>
</view>
</view>
</view>
<view class="section">
<view class="section-title">分析工具</view>
<view class="grid">
<view class="card card-sub card-analysis card-purple" @tap="goSentenceCrimeAnalysis">
<view class="card-accent card-accent-purple"></view>
<view class="card-icon">
<uni-icons class="card-icon-inner" type="list" size="26" color="#6F63D9"></uni-icons>
</view>
<view class="card-title">刑期 / 罪名分析</view>
<view class="card-desc">对比曲线与阈值趋势</view>
</view>
<view class="card card-sub card-analysis card-blue" @tap="goComprehensive">
<view class="card-accent card-accent-blue"></view>
<view class="card-icon">
<uni-icons class="card-icon-inner" type="paperplane" size="26" color="#3B82F6"></uni-icons>
</view>
<view class="card-title">综合报告</view>
<view class="card-desc">报表汇总与折叠查看</view>
</view>
</view>
</view>
<view class="section">
<view class="section-title">工具 & 基础功能</view>
<view class="grid">
<view class="card card-sub card-tools" @tap="goVoice">
<view class="card-accent card-accent-primary"></view>
<view class="card-icon">
<uni-icons class="card-icon-inner" type="mic" size="26" color="#1677ff"></uni-icons>
</view>
<view class="card-title">语音助手</view>
<view class="card-desc">按住说话,转写执行</view>
<view class="fold" @tap.stop="toggleVoiceTips">
<view class="fold-header">
<view class="fold-title">快捷指令示例</view>
<uni-icons :type="voiceTipsOpen ? 'top' : 'bottom'" size="14" color="#64748B"></uni-icons>
</view>
<view v-if="voiceTipsOpen" class="fold-body">
<view class="fold-item">“打开预警中心”</view>
<view class="fold-item">“查看3号监区本月数据”</view>
<view class="fold-item">“查询高风险人员名单”</view>
</view>
</view>
</view>
<view class="card card-sub card-tools card-orange" @tap="goChartTemplates">
<view class="card-accent card-accent-orange"></view>
<view class="card-icon">
<uni-icons class="card-icon-inner" type="bars" size="26" color="#F59E0B"></uni-icons>
</view>
<view class="card-title">自定义图表</view>
<view class="card-desc">维度选择、模板保存与下钻</view>
</view>
<view class="card card-sub card-group card-wide" @tap="goInterventionTasks">
<view class="card-accent card-accent-slate"></view>
<view class="card-icon">
<uni-icons class="card-icon-inner" type="flag" size="26" color="#6366F1"></uni-icons>
</view>
<view class="card-title">任务 & 筛选</view>
<view class="card-desc">任务处置、标签筛人、监区概览</view>
<view class="mini-actions" @tap.stop>
<view class="mini" @tap="goInterventionTasks">
<uni-icons type="notification" size="16" color="#6366F1"></uni-icons>
<view class="mini-text">干预任务</view>
</view>
<view class="mini" @tap="goTagFilter">
<uni-icons type="compose" size="16" color="#6366F1"></uni-icons>
<view class="mini-text">标签筛选</view>
</view>
<view class="mini" @tap="goDashboard">
<uni-icons type="home" size="16" color="#6366F1"></uni-icons>
<view class="mini-text">监区看板</view>
</view>
</view>
</view>
</view>
</view>
</view>
<view v-else class="page big">
<view class="big-top">
<view class="big-title"><text class="big-title-text">AI心理大数据平台</text></view>
</view>
<view class="big-top-actions">
<view class="big-top-action-row">
<view class="big-top-action-item" @tap="goWarning">
<image class="big-top-action-ico" src="/static/5.png" mode="aspectFit" />
</view>
<view class="big-top-action-item" @tap="goProfile">
<image class="big-top-action-ico" src="/static/4.png" mode="aspectFit" />
</view>
<view class="big-top-action-item" @tap="goComprehensive">
<image class="big-top-action-ico" src="/static/3.png" mode="aspectFit" />
</view>
<view class="big-top-action-item" @tap="goTagFilter">
<image class="big-top-action-ico" src="/static/1.png" mode="aspectFit" />
</view>
<view class="big-top-action-item" @tap="goInterventionTasks">
<image class="big-top-action-ico" src="/static/6.png" mode="aspectFit" />
</view>
<view class="big-top-action-item" @tap="goNotice">
<image class="big-top-action-ico" src="/static/7.png" mode="aspectFit" />
</view>
</view>
</view>
<view v-if="bigErrorMsg" class="big-error">{{ bigErrorMsg }}</view>
<view class="big-grid">
<view class="big-col">
<view class="big-panel">
<view class="big-panel-head">
<view class="big-panel-title">分析工具</view>
<view class="big-panel-sub">刑期 / 罪名分析</view>
</view>
<view class="big-chart big-chart-xl">
<qiun-data-charts v-if="isMounted" :key="'bigLine-' + chartKey" type="line" :opts="bigLineOpts" :chartData="bigLineData" canvasId="bigLine" />
</view>
</view>
<view class="big-panel">
<view class="big-panel-head">
<view class="big-panel-title">数据</view>
</view>
<view class="big-kpis">
<view class="big-kpi">
<view class="big-kpi-num">{{ bigKpi.totalAssessments }}</view>
<view class="big-kpi-lab">测评总数</view>
</view>
<view class="big-kpi">
<view class="big-kpi-num-row">
<view class="big-kpi-num">{{ bigKpi.warningReports }}</view>
<image class="big-kpi-ico" src="/static/yujing.png" mode="aspectFit" />
</view>
<view class="big-kpi-lab">预警报告</view>
</view>
<view class="big-kpi">
<view class="big-kpi-num">{{ bigKpi.tasks }}</view>
<view class="big-kpi-lab">干预任务</view>
</view>
<view class="big-kpi">
<view class="big-kpi-num">{{ bigKpi.pending }}</view>
<view class="big-kpi-lab">待处理</view>
</view>
</view>
</view>
<view class="big-panel big-panel-core">
<view class="big-panel-head">
<view class="big-panel-title">核心功能</view>
</view>
<view class="big-metrics">
<view class="big-metric">
<view class="big-metric-label">各监区量表测试总数</view>
<view class="big-metric-value">{{ formatBigNumber(bigDeptAssessTotal) }}</view>
</view>
</view>
<view class="big-ring">
<qiun-data-charts v-if="isMounted" :key="'bigRing-' + chartKey" type="ring" :opts="bigRingOpts" :chartData="bigRingData" canvasId="bigRing" />
<view class="big-ring-center" @tap="goWarning">
</view>
</view>
</view>
</view>
<view class="big-center">
<view class="big-center-media">
<video
v-if="centerVideoUrl"
class="big-center-video"
:src="centerVideoUrl"
controls
autoplay
loop
muted
playsinline
show-mute-btn
></video>
<view v-else class="big-center-bg" aria-hidden="true"></view>
</view>
<view class="big-center-text">
<view class="big-center-text-title">{{ bigCenterTextTitle }}</view>
<view class="big-center-text-desc">{{ bigCenterTextDesc }}</view>
</view>
<view :class="['big-panel', 'big-panel-ai', aiChatOpen ? 'is-open' : 'is-collapsed']">
<view class="big-panel-head big-panel-head-ai" @tap="toggleAiChat">
<view class="big-panel-title">AI 对话</view>
<view class="big-ai-head-actions">
<view class="big-panel-clear" @tap.stop="clearAiChat">清空</view>
<view class="big-panel-sub">{{ aiChatOpen ? '收起' : '展开' }}</view>
</view>
</view>
<view v-if="aiChatOpen" class="big-ai-body">
<scroll-view
scroll-y
:scroll-into-view="aiChatScrollInto"
class="big-ai-messages"
>
<view v-if="!aiChatMessages.length" class="big-ai-empty">请输入问题AI 将为你生成分析建议</view>
<view
v-for="(m, idx) in aiChatMessages"
:key="m.id || idx"
:class="['big-ai-msg', m.role === 'user' ? 'is-user' : 'is-ai']"
>
<view class="big-ai-bubble">{{ m.content }}</view>
</view>
<view :id="aiChatBottomId" class="big-ai-bottom"></view>
</scroll-view>
<view class="big-ai-input">
<view
class="big-ai-voice-btn"
:class="{ active: voiceMode }"
@tap="toggleVoiceMode"
>
<uni-icons type="mic-filled" size="20" :color="voiceMode ? '#00f0ff' : '#64748B'"></uni-icons>
</view>
<input
class="big-ai-text"
v-model="aiChatInput"
:disabled="aiChatSending"
confirm-type="send"
placeholder="问:打开张三的报告"
@confirm="sendAiChat"
/>
<view
class="big-ai-send"
:class="{ disabled: aiChatSending || !aiChatInputTrim }"
@tap="sendAiChat"
>
发送
</view>
</view>
</view>
</view>
</view>
<view class="big-col">
<view class="big-panel">
<view class="big-panel-head">
<view class="big-panel-title">工具&基础功能</view>
</view>
<view class="big-tools">
<view class="big-tool" @tap="goVoice">
<view class="big-tool-icon">
<uni-icons type="mic" size="22" color="#b7f4ff"></uni-icons>
</view>
<view class="big-tool-text">语音助手</view>
</view>
<view class="big-tool" @tap="goChartTemplates">
<view class="big-tool-icon">
<uni-icons type="bars" size="22" color="#b7f4ff"></uni-icons>
</view>
<view class="big-tool-text">自定义图表</view>
</view>
</view>
<view class="big-chart">
<view v-if="bigLoading" class="big-loading">加载中...</view>
<qiun-data-charts v-else-if="isMounted" :key="'bigLine2-' + chartKey" type="line" :opts="bigLineOpts" :chartData="bigLineData2" canvasId="bigLine2" />
</view>
</view>
<view class="big-panel big-panel-portrait">
<view class="big-panel-head">
<view class="big-panel-title">个体画像</view>
</view>
<view class="big-chart">
<view class="big-portrait-cloud">
<view
v-for="(w, idx) in bigPortraitWords"
:key="w.text + '-' + idx"
class="big-portrait-word"
:style="getPortraitWordStyle(w, idx)"
>
{{ w.text }}
</view>
</view>
</view>
</view>
<view class="big-panel big-panel-report">
<view class="big-panel-head">
<view class="big-panel-title">综合报告</view>
</view>
<view class="big-chart big-chart-xl">
<qiun-data-charts v-if="isMounted" :key="'bigBarMid-' + chartKey" type="column" :opts="bigBarOpts" :chartData="bigBarData" canvasId="bigBarMid" />
</view>
</view>
</view>
</view>
</view>
<!-- 登录弹窗 - 放在最后确保在最上层 -->
<LoginModal :show="showLoginModal" :closable="false" @success="onLoginSuccess" @close="showLoginModal = false" />
</template>
<script>
import { getToken } from '../../utils/auth'
import { getUnreadNoticeTop, markNoticeRead } from '../../api/app/notice'
import { getUnreadMessageList, markMessageRead } from '../../api/app/message'
import { openLink, getMessageWsUrl } from '../../utils/link'
import { request, setAuthFailureCallback } from '../../utils/request'
import { getBaseUrl } from '../../utils/config'
import { getReport, listReport } from '../../api/psychology/report'
import { getStudentOptions, getUserAssessmentSummary } from '../../api/psychology/assessment'
import QiunDataCharts from '../../uni_modules/qiun-data-charts/components/qiun-data-charts/qiun-data-charts.vue'
import UniIcons from '@/uni_modules/uni-icons/components/uni-icons/uni-icons.vue'
import LoginModal from '../../components/LoginModal.vue'
export default {
components: {
QiunDataCharts,
UniIcons,
LoginModal
},
// 捕获子组件错误,防止图表错误影响整个应用
errorCaptured(err, vm, info) {
// 如果是图表相关的错误,静默处理
if (err && err.message && (
err.message.includes('firstElementChild') ||
err.message.includes('Cannot destructure') ||
err.message.includes('qiun-data-charts')
)) {
console.warn('图表渲染警告(已处理):', err.message)
// 返回 false 阻止错误继续传播
return false
}
// 其他错误继续传播
return true
},
data() {
return {
showLoginModal: false,
hasCheckedLogin: false,
isMounted: false,
chartKey: 0,
resizeTimer: null,
socketOpen: false,
connecting: false,
voiceTipsOpen: false,
voiceMode: false,
recorder: null,
mediaRecorder: null,
mediaStream: null,
audioChunks: [],
isH5: false,
centerVideoUrl: '',
aiChatOpen: true,
aiChatSending: false,
aiChatInput: '',
aiChatMessages: [],
aiChatScrollInto: '',
aiChatBottomId: 'aiChatBottom',
bigLoading: false,
bigErrorMsg: '',
bigInboxLoading: false,
bigInboxList: [],
bigDeptAssessTotal: 12872,
bigDeptStats: [],
bigRingOpts: {
timing: 'easeOut',
duration: 900,
color: ['#2B6BFF', '#00F0FF'],
padding: [2, 2, 2, 2],
legend: { show: false },
extra: {
ring: {
ringWidth: 16,
offsetAngle: -90,
labelShow: false,
activeRadius: 6,
border: true,
borderWidth: 2,
borderColor: 'rgba(255,255,255,0.12)'
}
}
},
bigLineOpts: {
timing: 'easeOut',
duration: 1000,
color: ['#00F0FF', '#2B6BFF'],
padding: [16, 14, 12, 12],
legend: {
show: false,
position: 'bottom',
float: 'center',
fontSize: 12,
fontColor: 'rgba(220, 250, 255, 0.85)'
},
xAxis: {
disableGrid: false,
gridType: 'dash',
dashLength: 6,
axisLine: true,
axisLineColor: 'rgba(0, 240, 255, 0.12)',
fontSize: 12,
fontColor: 'rgba(220, 250, 255, 0.70)'
},
yAxis: {
min: 0,
splitNumber: 4,
gridType: 'dash',
dashLength: 6,
fontSize: 12,
fontColor: 'rgba(220, 250, 255, 0.70)',
gridColor: 'rgba(0, 240, 255, 0.06)'
},
extra: {
line: {
type: 'curve',
width: 2,
activeType: 'hollow',
linearType: 'custom',
linearOpacity: 0.55,
dataPointShape: 'circle',
pointSize: 3
}
}
},
bigPortraitOpts: {
timing: 'easeOut',
duration: 1000,
color: ['#00F0FF'],
padding: [12, 10, 8, 8],
legend: { show: false },
xAxis: {
disableGrid: false,
gridType: 'dash',
dashLength: 6,
axisLine: true,
axisLineColor: 'rgba(0, 240, 255, 0.10)',
fontSize: 11,
fontColor: 'rgba(220, 250, 255, 0.66)'
},
yAxis: {
min: 0,
splitNumber: 3,
gridType: 'dash',
dashLength: 6,
fontSize: 11,
fontColor: 'rgba(220, 250, 255, 0.66)',
gridColor: 'rgba(0, 240, 255, 0.05)'
},
extra: {
line: {
type: 'curve',
width: 2,
activeType: 'hollow',
linearType: 'custom',
linearOpacity: 0.78,
dataPointShape: 'circle',
pointSize: 2
}
}
},
bigPortraitWords: [
{ text: '自信', size: 44, x: 18, y: 24, color: 'rgba(88, 230, 255, 0.86)' },
{ text: '积极', size: 56, x: 70, y: 28, color: 'rgba(120, 150, 255, 0.56)' },
{ text: '乐观', size: 46, x: 56, y: 42, color: 'rgba(197, 142, 255, 0.78)' },
{ text: '成长', size: 40, x: 34, y: 38, color: 'rgba(88, 230, 255, 0.82)' },
{ text: '勇气', size: 36, x: 20, y: 46, color: 'rgba(120, 150, 255, 0.48)' },
{ text: '专注', size: 34, x: 80, y: 44, color: 'rgba(88, 230, 255, 0.72)' },
{ text: '自律', size: 36, x: 82, y: 58, color: 'rgba(197, 142, 255, 0.64)' },
{ text: '感恩', size: 32, x: 62, y: 56, color: 'rgba(120, 150, 255, 0.44)' },
{ text: '善意', size: 34, x: 24, y: 68, color: 'rgba(88, 230, 255, 0.70)' },
{ text: '同理心', size: 38, x: 44, y: 64, color: 'rgba(197, 142, 255, 0.70)' },
{ text: '沟通', size: 30, x: 14, y: 80, color: 'rgba(120, 150, 255, 0.40)' },
{ text: '放松', size: 32, x: 78, y: 76, color: 'rgba(88, 230, 255, 0.64)' },
{ text: '睡眠', size: 28, x: 68, y: 88, color: 'rgba(120, 150, 255, 0.34)' },
{ text: '运动', size: 30, x: 34, y: 88, color: 'rgba(88, 230, 255, 0.62)' },
{ text: '心理韧性', size: 40, x: 52, y: 80, color: 'rgba(197, 142, 255, 0.72)' },
{ text: '边界感', size: 28, x: 52, y: 26, color: 'rgba(120, 150, 255, 0.34)' },
{ text: '情绪稳定', size: 34, x: 40, y: 50, color: 'rgba(120, 150, 255, 0.40)' },
{ text: '目标感', size: 30, x: 60, y: 34, color: 'rgba(88, 230, 255, 0.58)' },
{ text: '安全感', size: 30, x: 18, y: 92, color: 'rgba(120, 150, 255, 0.30)' },
{ text: '自我接纳', size: 32, x: 78, y: 92, color: 'rgba(197, 142, 255, 0.46)' }
],
bigBarOpts: {
timing: 'easeOut',
duration: 900,
color: ['#00F0FF'],
padding: [18, 22, 14, 18],
fontColor: 'rgba(242, 252, 255, 0.92)',
dataLabel: true,
legend: {
show: false
},
xAxis: {
disableGrid: true,
axisLine: true,
axisLineColor: 'rgba(0, 240, 255, 0.12)',
fontSize: 13,
fontColor: 'rgba(242, 252, 255, 0.90)'
},
yAxis: {
min: 0,
splitNumber: 4,
gridType: 'dash',
dashLength: 4,
fontSize: 12,
fontColor: 'rgba(242, 252, 255, 0.78)',
gridColor: 'rgba(0, 240, 255, 0.08)'
},
extra: {
column: {
width: 18,
activeBgColor: '#00F0FF',
activeBgOpacity: 0.06,
barBorderRadius: [14, 14, 4, 4],
linearType: 'custom',
linearOpacity: 0.95,
customColor: ['#2B6BFF'],
colorStop: 0.38
}
}
},
bigRingData: { series: [{ data: [{ name: '预警', value: 0 }, { name: '正常', value: 0 }] }] },
bigLineData: { categories: [' '], series: [{ name: '测评次数', data: [0] }] },
bigLineData2: { categories: [' '], series: [{ name: '报告数', data: [0] }] },
bigBarData: { categories: [' '], series: [{ name: '报告数', data: [0] }] },
bigCenterTextTitle: 'AI 心理大数据平台 · 实时态势',
bigCenterTextDesc: '汇聚测评、预警、干预、画像等核心数据,支持实时研判与联动处置。',
bigKpi: {
totalAssessments: 0,
warningReports: 0,
tasks: 0,
pending: 0
}
}
},
computed: {
aiChatInputTrim() {
return String(this.aiChatInput || '').trim()
}
},
onLoad() {
console.log('=== onLoad 触发 ===')
// 注册认证失败回调 - 显示登录弹窗
setAuthFailureCallback(() => {
console.log('认证失败回调触发,显示登录弹窗')
this.showLoginModal = true
this.bigErrorMsg = ''
})
try {
const info = uni.getSystemInfoSync()
console.log('系统信息:', info)
this.isH5 = info && info.uniPlatform === 'web'
console.log('isH5:', this.isH5)
} catch (e) {
console.error('获取系统信息失败:', e)
this.isH5 = false
}
// 检查登录状态
this.checkLoginStatus()
},
onReady() {
// 组件渲染完成后延迟标记为已挂载确保DOM完全准备好
setTimeout(() => {
this.$nextTick(() => {
this.isMounted = true
})
}, 300)
// 监听窗口大小变化
// #ifdef H5
window.addEventListener('resize', this.handleResize)
// #endif
},
onUnload() {
// 移除窗口大小变化监听
// #ifdef H5
window.removeEventListener('resize', this.handleResize)
// #endif
// 清理定时器
if (this.resizeTimer) {
clearTimeout(this.resizeTimer)
}
},
onShow() {
console.log('=== onShow 触发 ===')
// 检查登录状态
this.checkLoginStatus()
},
methods: {
handleResize() {
// 防抖处理:窗口大小变化时,先隐藏图表,等待调整完成后再显示
if (this.resizeTimer) {
clearTimeout(this.resizeTimer)
}
try {
// 暂时隐藏图表避免resize时的错误
this.isMounted = false
// 1200ms后重新显示图表并更新key强制重新渲染
this.resizeTimer = setTimeout(() => {
try {
this.chartKey++
// 延迟300ms再显示确保DOM完全准备好
setTimeout(() => {
this.$nextTick(() => {
this.isMounted = true
})
}, 300)
} catch (e) {
console.error('图表重新渲染失败:', e)
// 即使出错也要恢复显示
this.isMounted = true
}
}, 1200)
} catch (e) {
console.error('处理窗口大小变化失败:', e)
// 出错时恢复显示
this.isMounted = true
}
},
checkLoginStatus() {
console.log('=== checkLoginStatus 开始 ===')
const token = getToken()
console.log('当前token:', token ? '存在' : '不存在')
console.log('token值:', token)
console.log('showLoginModal当前状态:', this.showLoginModal)
if (!token) {
// 没有token显示登录框
console.log('没有token准备显示登录框')
if (!this.showLoginModal) {
console.log('设置showLoginModal = true')
this.showLoginModal = true
this.hasCheckedLogin = true
} else {
console.log('登录框已经显示,跳过')
}
return false
}
console.log('有token继续初始化')
// 有token标记已检查过登录
this.hasCheckedLogin = true
// 如果是首次加载onLoad触发初始化应用
if (!this.socketOpen && !this.connecting) {
console.log('初始化应用')
this.initApp()
}
// 执行其他操作
this.checkUnreadNoticePopup()
this.checkUnreadMessagePopup()
if (this.isH5) {
this.fetchInboxList()
}
return true
},
initApp() {
if (this.isH5) {
this.fetchCenterVideo()
}
this.initMessageChannel()
this.checkUnreadNoticePopup()
this.checkUnreadMessagePopup()
if (this.isH5) {
this.fetchBigData()
this.fetchInboxList()
}
},
onLoginSuccess() {
console.log('=== 登录成功回调 ===')
// 关闭登录弹窗
this.showLoginModal = false
// 重置登录检查标记
this.hasCheckedLogin = false
// 重新检查登录状态并初始化应用
this.checkLoginStatus()
},
getBailianConfig() {
const HARDCODED_BAILIAN_API_KEY = 'sk-f991fd13fb044abebeaea81b9848c22b'
let env = null
try {
env = (typeof import.meta !== 'undefined' && import.meta && import.meta.env) ? import.meta.env : null
} catch (e) {
env = null
}
const envKey = env && env.VITE_BAILIAN_API_KEY ? String(env.VITE_BAILIAN_API_KEY) : ''
const envUrl = env && env.VITE_BAILIAN_API_URL ? String(env.VITE_BAILIAN_API_URL) : ''
const envModel = env && env.VITE_BAILIAN_MODEL ? String(env.VITE_BAILIAN_MODEL) : ''
const apiKey = HARDCODED_BAILIAN_API_KEY || envKey || uni.getStorageSync('BAILIAN_API_KEY')
const apiUrl = (envUrl || uni.getStorageSync('BAILIAN_API_URL')) || 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions'
const model = (envModel || uni.getStorageSync('BAILIAN_MODEL')) || 'qwen-plus'
if (!apiKey) return null
return { apiKey, apiUrl, model }
},
getOllamaConfig() {
// 使用DeepSeek官方API
const apiUrl = 'https://api.deepseek.com/v1/chat/completions'
const apiKey = 'sk-c8e14faad3be4837a5401a3d02eaf43c'
const model = 'deepseek-chat'
return { apiUrl, apiKey, model }
},
callBailianChat({ model, apiUrl, apiKey, messages, temperature = 0.3, max_tokens = 1500 }) {
return new Promise((resolve, reject) => {
uni.request({
url: apiUrl,
method: 'POST',
header: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
data: {
model,
messages,
temperature,
max_tokens,
stream: false
},
success: (res) => {
const data = res && res.data ? res.data : null
const content = data && data.choices && data.choices[0] && data.choices[0].message
? data.choices[0].message.content
: ''
if (!content) {
reject(new Error('模型返回结果为空'))
return
}
resolve(String(content))
},
fail: (err) => reject(err)
})
})
},
callOllamaChat({ model, apiUrl, messages }) {
return new Promise((resolve, reject) => {
const msgList = Array.isArray(messages) ? messages : []
const extractPlainText = (input) => {
let s = String(input || '')
if (!s) return ''
s = s.replace(/<script[\s\S]*?<\/script>/gi, '')
s = s.replace(/<style[\s\S]*?<\/style>/gi, '')
s = s.replace(/<br\s*\/?>/gi, '\n')
s = s.replace(/<\/?p\b[^>]*>/gi, '\n')
s = s.replace(/<\/?div\b[^>]*>/gi, '\n')
s = s.replace(/<[^>]+>/g, '')
s = s
.replace(/&nbsp;/gi, ' ')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&amp;/gi, '&')
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'")
s = s.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
s = s.replace(/[ \t\f\v]+/g, ' ')
s = s.replace(/\n\s*\n\s*\n+/g, '\n\n')
return s.trim()
}
const compressMessage = (text, maxLen = 3200) => {
const s = String(text || '')
if (!s) return ''
if (s.length <= maxLen) return s
const headLen = Math.max(600, Math.floor(maxLen / 2) - 200)
const tailLen = maxLen - headLen
const head = s.slice(0, headLen)
const tail = s.slice(-tailLen)
return head + '\n...\n' + tail
}
const parseContent = (data) => {
if (typeof data === 'string') return data
if (!data || typeof data !== 'object') return ''
// 兼容后端 AjaxResult: { code, msg, data?, content? }
if (data && data.code != null && (data.content != null || data.data != null)) {
const c = data.content != null ? data.content : data.data
if (typeof c === 'string') return c
if (c && typeof c === 'object') {
return (
(c && c.content != null ? String(c.content) : '') ||
(c && c.message && c.message.content != null ? String(c.message.content) : '')
)
}
}
return (
(data && data.message && data.message.content != null ? String(data.message.content) : '') ||
(data && data.content != null ? String(data.content) : '') ||
(data && data.answer != null ? String(data.answer) : '') ||
(data && data.response != null ? String(data.response) : '') ||
(data && data.data != null ? String(data.data) : '')
)
}
const mergedMessageFull = msgList
.map((m) => {
const role = m && m.role ? String(m.role) : ''
const content = m && m.content != null ? String(m.content) : ''
if (!role) return content
return `${role}: ${content}`
})
.filter(Boolean)
.join('\n')
const mergedMessage = compressMessage(extractPlainText(mergedMessageFull))
uni.request({
url: apiUrl,
method: 'POST',
header: { 'Content-Type': 'application/json' },
data: {
model: String(model || ''),
message: mergedMessage
},
success: (res) => {
if (res && res.statusCode && res.statusCode >= 400) {
reject(new Error('模型请求失败: ' + res.statusCode))
return
}
const content = parseContent(res && res.data != null ? res.data : null)
if (!content) {
reject(new Error('模型返回为空'))
return
}
resolve(content)
},
fail: (err) => reject(err)
})
})
},
fetchCenterVideo() {
return request({ url: '/api/homepage/video', method: 'GET' })
.then((res) => {
const data = res && res.data ? res.data : null
if (!data || data.code !== 200) return
const url = data.url || (data.data && data.data.url ? data.data.url : '')
this.centerVideoUrl = url || ''
})
.catch(() => {})
},
toggleAiChat() {
this.aiChatOpen = !this.aiChatOpen
if (this.aiChatOpen) {
this.$nextTick(() => {
this.scrollAiChatToBottom()
})
}
},
toggleVoiceMode() {
this.voiceMode = !this.voiceMode
if (this.voiceMode) {
uni.showToast({
title: '语音对话已开启',
icon: 'none',
duration: 1500
})
this.startVoiceRecognition()
} else {
uni.showToast({
title: '语音对话已关闭',
icon: 'none',
duration: 1500
})
this.stopVoiceRecognition()
}
},
startVoiceRecognition() {
// H5环境下使用浏览器原生录音API
// #ifdef H5
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
uni.showToast({
title: '浏览器不支持录音功能',
icon: 'none',
duration: 2000
})
this.voiceMode = false
return
}
navigator.mediaDevices.getUserMedia({ audio: true })
.then(stream => {
console.log('获取麦克风权限成功')
this.mediaStream = stream
this.mediaRecorder = new MediaRecorder(stream)
this.audioChunks = []
this.mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
this.audioChunks.push(event.data)
}
}
this.mediaRecorder.onstop = () => {
console.log('录音停止')
const audioBlob = new Blob(this.audioChunks, { type: 'audio/webm' })
this.uploadAudioBlob(audioBlob)
// 停止所有音轨
if (this.mediaStream) {
this.mediaStream.getTracks().forEach(track => track.stop())
}
}
this.mediaRecorder.start()
console.log('开始录音...')
})
.catch(err => {
console.error('获取麦克风权限失败:', err)
uni.showToast({
title: '无法访问麦克风,请检查权限',
icon: 'none',
duration: 2000
})
this.voiceMode = false
})
// #endif
// #ifndef H5
// 检查是否支持录音
if (typeof uni.getRecorderManager !== 'function') {
uni.showToast({
title: '当前环境不支持录音功能',
icon: 'none',
duration: 2000
})
this.voiceMode = false
return
}
if (!this.recorder) {
this.recorder = uni.getRecorderManager()
this.recorder.onStop((res) => {
console.log('录音停止', res)
this.onVoiceRecorderStop(res)
})
this.recorder.onError((err) => {
console.error('录音错误', err)
this.voiceMode = false
const msg = (err && err.errMsg) ? err.errMsg : '录音失败'
uni.showToast({
title: msg,
icon: 'none'
})
})
this.recorder.onStart(() => {
console.log('录音开始')
})
}
console.log('开始录音...')
this.recorder.start({
duration: 60000,
sampleRate: 16000,
numberOfChannels: 1,
encodeBitRate: 96000,
format: 'mp3'
})
// #endif
},
stopVoiceRecognition() {
// #ifdef H5
if (this.mediaRecorder && this.mediaRecorder.state === 'recording') {
this.mediaRecorder.stop()
}
// #endif
// #ifndef H5
if (this.recorder && this.voiceMode) {
this.recorder.stop()
}
// #endif
},
onVoiceRecorderStop(res) {
if (!res || !res.tempFilePath) {
uni.showToast({
title: '未获取到录音文件',
icon: 'none'
})
return
}
this.uploadAndAsrForAiChat(res.tempFilePath)
},
uploadAndAsrForAiChat(tempFilePath) {
console.log('开始上传音频文件:', tempFilePath)
uni.showLoading({ title: '识别中...' })
const baseUrl = getBaseUrl()
console.log('上传地址:', baseUrl + '/voice/asr')
uni.uploadFile({
url: baseUrl + '/voice/asr',
filePath: tempFilePath,
name: 'file',
formData: { language: 'zh' },
header: {
'Authorization': getToken() || ''
},
success: (res) => {
console.log('上传成功,响应:', res)
uni.hideLoading()
let data = null
try {
data = JSON.parse(res.data)
} catch (e) {
console.error('解析响应失败:', e)
uni.showToast({
title: '服务返回格式错误',
icon: 'none'
})
return
}
console.log('解析后的数据:', data)
if (!data || data.code !== 200) {
uni.showToast({
title: (data && data.msg) ? data.msg : '识别失败',
icon: 'none'
})
return
}
const recognizedText = data.text || ''
if (!recognizedText) {
uni.showToast({
title: '未识别到有效文本',
icon: 'none'
})
return
}
console.log('识别文本:', recognizedText)
// 将识别的文本设置到输入框并自动发送
this.aiChatInput = recognizedText
this.sendAiChat()
},
fail: (e) => {
console.error('上传失败:', e)
uni.hideLoading()
uni.showToast({
title: e && e.errMsg ? e.errMsg : '上传失败',
icon: 'none'
})
}
})
},
uploadAudioBlob(audioBlob) {
console.log('开始上传音频Blob:', audioBlob)
uni.showLoading({ title: '识别中...' })
const baseUrl = getBaseUrl()
const formData = new FormData()
formData.append('file', audioBlob, 'audio.webm')
formData.append('language', 'zh')
fetch(baseUrl + '/voice/asr', {
method: 'POST',
headers: {
'Authorization': getToken() || ''
},
body: formData
})
.then(response => response.json())
.then(data => {
console.log('上传成功,响应:', data)
uni.hideLoading()
if (!data || data.code !== 200) {
uni.showToast({
title: (data && data.msg) ? data.msg : '识别失败',
icon: 'none'
})
return
}
const recognizedText = data.text || ''
if (!recognizedText) {
uni.showToast({
title: '未识别到有效文本',
icon: 'none'
})
return
}
console.log('识别文本:', recognizedText)
this.aiChatInput = recognizedText
this.sendAiChat()
})
.catch(err => {
console.error('上传失败:', err)
uni.hideLoading()
uni.showToast({
title: '上传失败: ' + err.message,
icon: 'none'
})
})
},
scrollAiChatToBottom() {
// scroll-into-view 需要一个变化的值触发
this.aiChatScrollInto = this.aiChatBottomId + '-' + Date.now()
this.$nextTick(() => {
this.aiChatScrollInto = this.aiChatBottomId
})
},
clearAiChat() {
this.aiChatMessages = []
this.aiChatInput = ''
this.aiChatSending = false
this.$nextTick(() => {
this.scrollAiChatToBottom()
})
},
matchAiNavigate(text) {
const raw = String(text || '').trim()
const s = raw.toLowerCase()
if (!s) return null
// 分析类问题不走本地跳转,交给模型分析逻辑
if (this.isAiAnalyzeQuery(raw)) return null
const reportOpenMatch = raw.match(/打开\s*([^\s的]{1,32})\s*的?报告/)
if (reportOpenMatch && reportOpenMatch[1]) {
const keyword = String(reportOpenMatch[1]).trim()
return { name: '报告列表', url: '/pages/report/index?keyword=' + encodeURIComponent(keyword) }
}
if (/(返回|返回上一页|后退)\s*$/i.test(raw)) {
return { name: '返回上一页', action: 'navigateBack' }
}
if (/(回首页|返回首页|打开首页|首页)\s*$/i.test(raw)) {
return { name: '首页', action: 'relaunchHome' }
}
if (/(清空对话|清空聊天|清空会话|清空记录|重置对话|重置聊天)/.test(raw)) {
return { name: '清空对话', action: 'clearAiChat' }
}
if (/(收起|展开)\s*(ai|助手|对话框)?/i.test(raw) || /(关闭ai|打开ai|关闭助手|打开助手)/i.test(raw)) {
return { name: '切换AI面板', action: 'toggleAiChat' }
}
const createTaskMatch = raw.match(/(?:创建|新建)\s*(?:预警)?\s*任务\s*(\d{1,18})/)
if (createTaskMatch && createTaskMatch[1]) {
return { name: '创建预警任务', url: '/pages/warning/createTask?warningId=' + encodeURIComponent(createTaskMatch[1]) }
}
if (s.includes('未处理预警') || s.includes('待处理预警')) {
return { name: '未处理预警', url: '/pages/warning/index?status=0' }
}
if (s.includes('处理中预警')) {
return { name: '处理中预警', url: '/pages/warning/index?status=1' }
}
if (s.includes('严重预警') || s.includes('高危预警')) {
return { name: '严重预警', url: '/pages/warning/index?warningLevel=严重' }
}
if (s.includes('高预警')) {
return { name: '高预警', url: '/pages/warning/index?warningLevel=高' }
}
if (s.includes('未完成任务') || s.includes('未完成干预') || s.includes('待处理任务')) {
return { name: '未完成干预任务', url: '/pages/interventionTask/index?status=0,1' }
}
if (s.includes('已完成任务') || s.includes('已完成干预')) {
return { name: '已完成干预任务', url: '/pages/interventionTask/index?status=2' }
}
if (s.includes('设置') || s.includes('我的') || s.includes('个人信息')) {
return { name: '设置', url: '/pages/settings/index' }
}
if (s.includes('收件箱') || s.includes('收件') || s.includes('未读消息') || s.includes('消息列表')) {
if (s.includes('未读')) return { name: '收件箱未读', url: '/pages/message/inbox?tab=unread' }
return { name: '收件箱', url: '/pages/message/inbox' }
}
if (s.includes('发件箱') || s.includes('发件')) {
return { name: '发件箱', url: '/pages/message/outbox' }
}
if (s.includes('通知') || s.includes('公告')) {
return { name: '通知公告', url: '/pages/message/notice' }
}
if (s.includes('综合报告历史') || s.includes('综合历史') || s.includes('历史综合报告')) {
return { name: '综合报告历史', url: '/pages/comprehensive/history' }
}
if (s.includes('测评报告')) {
return { name: '测评报告列表', url: '/pages/report/index?sourceType=assessment' }
}
if (s.includes('问卷报告')) {
return { name: '问卷报告列表', url: '/pages/report/index?sourceType=questionnaire' }
}
if (s.includes('标签筛选') || s.includes('标签过滤') || s.includes('标签')) {
return { name: '标签筛选', url: '/pages/profile/tagFilter' }
}
if (s.includes('消息发送') || s.includes('发送消息') || s.includes('群发')) {
return { name: '消息发送', url: '/pages/message/send' }
}
const rules = [
{ name: '预警中心', url: '/pages/warning/index', keys: ['预警', '风险', '告警', '预警中心'] },
{ name: '监区看板', url: '/pages/dashboard/index', keys: ['看板', '监区', 'dashboard'] },
{ name: '个体画像', url: '/pages/profile/index', keys: ['画像', '个体', '人员', '档案', 'profile'] },
{ name: '综合报告', url: '/pages/comprehensive/index', keys: ['综合报告', '综合', 'comprehensive'] },
{ name: '语音助手', url: '/pages/voice/index', keys: ['语音', '助手', 'voice'] },
{ name: '自定义图表', url: '/pages/chart/custom', keys: ['自定义图表', '自定义', 'custom'] },
{ name: '图表模板', url: '/pages/chart/templates', keys: ['图表模板', '模板', '图表', 'charts'] },
{ name: '干预任务', url: '/pages/interventionTask/index', keys: ['干预', '任务', 'intervention'] },
{ name: '测评列表', url: '/pages/assessment/index', keys: ['测评', '量表', 'assessment'] },
{ name: '报告列表', url: '/pages/report/index', keys: ['报告', 'report'] }
]
for (let i = 0; i < rules.length; i++) {
const r = rules[i]
for (let j = 0; j < r.keys.length; j++) {
const k = String(r.keys[j]).toLowerCase()
if (k && s.includes(k)) return { name: r.name, url: r.url }
}
}
return null
},
isAiAnalyzeQuery(text) {
const s = String(text || '').trim()
if (!s) return false
return /(分析|趋势|总结|汇总|研判|建议)/.test(s)
},
// AI意图识别 - 使用大模型理解用户输入并提取结构化指令
async parseUserIntentWithAI(userInput) {
const llmCfg = this.getBailianConfig() || this.getOllamaConfig()
if (!llmCfg) {
console.log('AI意图识别未配置大模型')
return null
}
// 定义系统可以执行的指令列表
const availableCommands = {
navigation: [
{ action: 'goWarning', params: ['status', 'warningLevel'], desc: '打开预警中心可选参数status(0=未处理,1=处理中,2=已完成), warningLevel(严重/高/中/低)' },
{ action: 'goProfile', params: ['keyword', 'userId'], desc: '打开个体画像可选参数keyword(姓名/编号), userId(用户ID)' },
{ action: 'goComprehensive', params: [], desc: '打开综合报告' },
{ action: 'goInterventionTask', params: ['status'], desc: '打开干预任务可选参数status(0,1=未完成,2=已完成)' },
{ action: 'goReport', params: ['keyword', 'reportId', 'sourceType'], desc: '打开报告列表或详情可选参数keyword(搜索关键词), reportId(报告ID), sourceType(assessment/questionnaire)' },
{ action: 'goMessage', params: ['tab'], desc: '打开消息/收件箱可选参数tab(unread=未读)' },
{ action: 'goNotice', params: [], desc: '打开通知公告' },
{ action: 'goDashboard', params: [], desc: '打开监区看板' },
{ action: 'goTagFilter', params: [], desc: '打开标签筛选' },
{ action: 'goVoice', params: [], desc: '打开语音助手' }
],
analysis: [
{ action: 'analyzeReport', params: ['keyword', 'reportId'], desc: '分析某人的测评报告/数据关键词分析、数据、报告。必需参数keyword(姓名/编号)或reportId(报告ID)' },
{ action: 'analyzeProfile', params: ['keyword', 'userId'], desc: '查看某人的个体画像/档案/情况关键词画像、档案、情况、了解。必需参数keyword(姓名/编号)或userId(用户ID)' },
{ action: 'analyzeData', params: ['dataType'], desc: '分析平台整体数据关键词平台数据、统计、趋势。可选参数dataType(overview=概览/trend=趋势/dept=监区统计)' }
],
control: [
{ action: 'clearChat', params: [], desc: '清空对话记录' },
{ action: 'toggleChat', params: [], desc: '展开或收起AI对话框' },
{ action: 'goBack', params: [], desc: '返回上一页' },
{ action: 'goHome', params: [], desc: '返回首页' }
]
}
const systemPrompt = `你是一个指令解析助手,负责理解用户的自然语言输入并转换为结构化的系统指令。
可用的指令类型:
${JSON.stringify(availableCommands, null, 2)}
重要区分:
- analyzeReport: 用于"分析XX的报告/数据",查看测评报告详情
- analyzeProfile: 用于"查看XX的画像/档案/情况",查看个体画像
- goReport: 用于"打开XX的报告",跳转到报告列表
你的任务:
1. 理解用户输入的意图
2. 从可用指令中选择最匹配的action
3. 提取用户输入中的参数值
4. 返回JSON格式的结构化指令
返回格式必须是有效的JSON
{
"action": "指令名称",
"params": {
"参数名": "参数值"
},
"confidence": 0.0-1.0,
"reasoning": "简短说明为什么选择这个指令"
}
如果无法识别用户意图,返回:
{
"action": null,
"confidence": 0,
"reasoning": "无法理解用户意图"
}
示例:
用户输入:"打开张三的报告"
返回:{"action":"goReport","params":{"keyword":"张三"},"confidence":0.95,"reasoning":"用户想查看张三的报告列表"}
用户输入:"分析李四的数据"
返回:{"action":"analyzeReport","params":{"keyword":"李四"},"confidence":0.9,"reasoning":"用户想分析李四的测评报告"}
用户输入:"查看王五的画像"
返回:{"action":"analyzeProfile","params":{"keyword":"王五"},"confidence":0.95,"reasoning":"用户想查看王五的个体画像"}
用户输入:"查看未处理的预警"
返回:{"action":"goWarning","params":{"status":"0"},"confidence":0.95,"reasoning":"用户想查看未处理状态的预警"}
重要只返回JSON不要有任何其他文字。`
try {
console.log('AI意图识别开始解析用户输入:', userInput)
const messages = [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userInput }
]
const result = await this.callBailianChat({
model: llmCfg.model,
apiUrl: llmCfg.apiUrl,
apiKey: llmCfg.apiKey,
messages,
temperature: 0.1, // 低温度,更确定性的输出
max_tokens: 500
})
// callBailianChat直接返回content字符串不是完整的result对象
let content = result || ''
console.log('AI意图识别原始响应:', content)
console.log('AI意图识别响应类型:', typeof content)
console.log('AI意图识别响应长度:', content.length)
// 提取JSON
let jsonStr = content.trim()
if (!jsonStr) {
console.log('AI意图识别响应为空')
return null
}
// 移除markdown代码块
jsonStr = jsonStr.replace(/```(?:json)?/g, '').replace(/```/g, '').trim()
// 提取第一个{到最后一个}之间的内容
const firstBrace = jsonStr.indexOf('{')
const lastBrace = jsonStr.lastIndexOf('}')
if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
jsonStr = jsonStr.substring(firstBrace, lastBrace + 1)
}
console.log('AI意图识别提取的JSON:', jsonStr)
// 解析JSON
let intent = null
try {
intent = JSON.parse(jsonStr)
} catch(e) {
console.error('JSON解析失败:', e.message)
console.log('失败的JSON字符串:', jsonStr)
return null
}
console.log('AI意图识别解析结果:', intent)
// 验证置信度
if (intent.confidence < 0.6) {
console.log('AI意图识别置信度过低忽略结果')
return null
}
return intent
} catch (e) {
console.error('AI意图识别失败:', e)
return null
}
},
parseAnalyzeReportId(text) {
const s = String(text || '').trim()
if (!s) return ''
// 支持多种格式分析123的报告、分析报告123、分析编号123、分析123
const m = s.match(/分析\s*(?:报告|编号)?\s*(\d{1,18})(?:\s*的?报告)?/) || s.match(/(\d{1,18})/)
return m && m[1] ? m[1] : ''
},
parseAnalyzeReportKeyword(text) {
const s = String(text || '').trim()
if (!s) return ''
// 支持多种格式:分析张三的报告/数据/画像/档案等
const m = s.match(/分析\s*([^\s的]{1,32})\s*的?(?:报告|数据|画像|档案|信息)/)
if (!m || !m[1]) return ''
const kw = String(m[1]).trim()
if (!kw || /^\d{1,18}$/.test(kw)) return ''
return kw
},
parseAnalyzeProfileKeyword(text) {
const s = String(text || '').trim()
if (!s) return ''
const m = s.match(/分析\s*([^\s的]{1,32})\s*的?画像/)
return m && m[1] ? String(m[1]).trim() : ''
},
buildAiAnalyzeContext() {
return {
overview: this.bigKpi,
lineAssessTrend: this.bigLineData,
lineReportTrend: this.bigLineData2,
deptReportBar: this.bigBarData,
deptAssessTotal: this.bigDeptAssessTotal
}
},
getAiPortraitFixedMessage() {
return '心理画像为固定值,无需分析。'
},
isPortraitQuery(text) {
const s = String(text || '').trim()
if (!s) return false
return /(心理画像|画像)/.test(s)
},
getAiRefuseMessage() {
return '抱歉,暂不提供此服务。\n你可以尝试\n- 输入“未处理预警/严重预警/未完成干预/未读消息”等指令\n- 输入“分析XX数据/分析XX的报告/分析报告ID 123/分析张三的画像”等平台内分析指令'
},
getAiNotFoundMessage() {
return '抱歉,没有查询到相关数据。请确认编号/姓名是否正确,或换一个查询条件。'
},
isAiInDomain(text) {
const s = String(text || '').trim()
if (!s) return false
return /(预警|风险|告警|干预|任务|测评|量表|报告|画像|综合|趋势|分析|总结|研判|建议|消息|通知|公告|监区|看板|人员|档案)/.test(s)
},
isAiNeedContext(text) {
const s = String(text || '').trim()
if (!s) return true
// 仅当属于分析类问题时才允许调用大模型
return this.isAiAnalyzeQuery(s)
},
buildAiSystemPrompt() {
return '你是“AI心理大数据平台”的内置助手仅服务于监狱管理员与心理管理员的业务场景。\n' +
'严格要求:\n' +
'1) 只能基于用户提供的上下文数据(JSON)进行分析与归纳;禁止编造数据。\n' +
'2) 只回答平台业务范围:预警、干预任务、测评、报告、消息通知、监区统计。\n' +
'3) 回复格式:只输出答案本身;不要输出“下一步/建议操作/引导用户去哪里点/平台外链接/参考资料”。\n' +
'4) 输出风格:中文;简洁;优先给出结论,并用少量要点说明依据;不要长篇铺陈。\n' +
'5) 心理画像为固定值:禁止对“心理画像/画像词云”等做分析、解释或延伸;如果用户提到心理画像,一律回复“心理画像为固定值,无需分析。”\n' +
'6) 对平台以外的问题(例如天气、新闻、股票、外部网站、百科、闲聊等)一律回复:\n' +
'“抱歉,暂不提供此服务。”\n' +
'7) 如果上下文缺失/无法得出结论,回复:\n' +
'“抱歉,没有查询到相关数据。”'
},
sanitizeAiAnswer(answer, userText) {
const a = String(answer || '').trim()
if (!a) return ''
// 只要用户问题不在平台域内,或回复包含明显平台外内容,则替换为拒答
const userInDomain = this.isAiInDomain(userText)
const outHint = /(http(s)?:\/\/|www\.|百度|谷歌|天气|新闻|股票|电影|音乐|游戏|菜谱|百科|维基)/i.test(a)
if (!userInDomain || outHint) {
return '抱歉,暂不提供此服务。'
}
return a
},
canCallAiForText(text) {
const s = String(text || '').trim()
if (!s) return false
if (!this.isAiInDomain(s)) return false
if (!this.isAiNeedContext(s)) return false
return true
},
// 执行AI识别出的指令
executeAIIntent(intent) {
if (!intent || !intent.action) {
return false
}
const action = intent.action
const params = intent.params || {}
console.log('执行AI指令:', action, params)
// 导航类指令
if (action === 'goWarning') {
let url = '/pages/warning/index'
const query = []
if (params.status) query.push(`status=${params.status}`)
if (params.warningLevel) query.push(`warningLevel=${encodeURIComponent(params.warningLevel)}`)
if (query.length) url += '?' + query.join('&')
uni.navigateTo({ url })
return true
}
if (action === 'goProfile') {
let url = '/pages/profile/index'
const query = []
if (params.keyword) query.push(`keyword=${encodeURIComponent(params.keyword)}`)
if (params.userId) query.push(`userId=${params.userId}`)
if (query.length) url += '?' + query.join('&')
uni.navigateTo({ url })
return true
}
if (action === 'goComprehensive') {
uni.navigateTo({ url: '/pages/comprehensive/index' })
return true
}
if (action === 'goInterventionTask') {
let url = '/pages/interventionTask/index'
if (params.status) url += `?status=${params.status}`
uni.navigateTo({ url })
return true
}
if (action === 'goReport') {
if (params.reportId) {
uni.navigateTo({ url: `/pages/report/detail?reportId=${params.reportId}&sourceType=${params.sourceType || ''}` })
} else {
let url = '/pages/report/index'
const query = []
if (params.keyword) query.push(`keyword=${encodeURIComponent(params.keyword)}`)
if (params.sourceType) query.push(`sourceType=${params.sourceType}`)
if (query.length) url += '?' + query.join('&')
uni.navigateTo({ url })
}
return true
}
if (action === 'goMessage') {
let url = '/pages/message/inbox'
if (params.tab) url += `?tab=${params.tab}`
uni.navigateTo({ url })
return true
}
if (action === 'goNotice') {
uni.navigateTo({ url: '/pages/message/notice' })
return true
}
if (action === 'goDashboard') {
uni.navigateTo({ url: '/pages/dashboard/index' })
return true
}
if (action === 'goTagFilter') {
uni.navigateTo({ url: '/pages/profile/tagFilter' })
return true
}
if (action === 'goVoice') {
uni.navigateTo({ url: '/pages/voice/index' })
return true
}
// 控制类指令
if (action === 'clearChat') {
this.clearAiChat()
return true
}
if (action === 'toggleChat') {
this.toggleAiChat()
return true
}
if (action === 'goBack') {
uni.navigateBack({ delta: 1 })
return true
}
if (action === 'goHome') {
uni.reLaunch({ url: '/pages/index/index' })
return true
}
// 分析类指令 - 让AI分析数据并返回结果而不是跳转页面
if (action === 'analyzeReport' || action === 'analyzeProfile' || action === 'analyzeData') {
// 返回false让后续的AI分析逻辑处理
// 这样会获取数据并调用AI进行分析
return false
}
return false
},
async sendAiChat() {
if (this.aiChatSending) return
const text = this.aiChatInputTrim
if (!text) return
this.aiChatInput = ''
const userMsg = { id: 'u_' + Date.now(), role: 'user', content: text }
this.aiChatMessages = [...this.aiChatMessages, userMsg]
this.aiChatSending = true
this.$nextTick(() => {
this.scrollAiChatToBottom()
})
// ===== 新增AI意图识别 =====
let aiRecognizedIntent = null
try {
console.log('尝试AI意图识别...')
const intent = await this.parseUserIntentWithAI(text)
if (intent && intent.action) {
console.log('AI识别到意图:', intent)
const executed = this.executeAIIntent(intent)
if (executed) {
// 导航类指令已执行,显示反馈
this.aiChatMessages = [...this.aiChatMessages, {
id: 'a_' + Date.now(),
role: 'ai',
content: `已执行:${intent.reasoning || intent.action}`
}]
this.aiChatSending = false
this.$nextTick(() => {
this.scrollAiChatToBottom()
})
return
}
// 分析类指令保存intent供后续使用
if (intent.action === 'analyzeReport' || intent.action === 'analyzeProfile' || intent.action === 'analyzeData') {
aiRecognizedIntent = intent
console.log('分析类指令保存AI识别的参数:', aiRecognizedIntent)
}
}
} catch (e) {
console.error('AI意图识别出错回退到正则匹配:', e)
}
// ===== AI意图识别结束 =====
const openReportIdMatch = String(text || '').trim().match(/打开\s*(?:报告\s*id\s*|报告\s*#\s*|报告#\s*)(\d{1,18})/i)
if (openReportIdMatch && openReportIdMatch[1]) {
const reportId = openReportIdMatch[1]
uni.navigateTo({ url: `/pages/report/detail?reportId=${encodeURIComponent(reportId)}&sourceType=` })
this.aiChatMessages = [...this.aiChatMessages, { id: 'a_' + Date.now(), role: 'ai', content: '已跳转:报告详情#' + reportId }]
this.aiChatSending = false
this.$nextTick(() => {
this.scrollAiChatToBottom()
})
return
}
const openInfoNumberMatch = String(text || '').trim().match(/打开\s*(\d{1,18})\s*的?报告/)
if (openInfoNumberMatch && openInfoNumberMatch[1]) {
const infoNumber = openInfoNumberMatch[1]
uni.navigateTo({ url: `/pages/report/index?keyword=${encodeURIComponent(infoNumber)}` })
this.aiChatMessages = [...this.aiChatMessages, { id: 'a_' + Date.now(), role: 'ai', content: '已跳转:报告列表(信息编号 ' + infoNumber + '' }]
this.aiChatSending = false
this.$nextTick(() => {
this.scrollAiChatToBottom()
})
return
}
const localMatch = this.matchAiNavigate(text)
if (localMatch && localMatch.action) {
if (localMatch.action === 'clearAiChat') {
this.clearAiChat()
return
}
if (localMatch.action === 'toggleAiChat') {
this.toggleAiChat()
this.aiChatSending = false
return
}
if (localMatch.action === 'navigateBack') {
uni.navigateBack({ delta: 1 })
this.aiChatSending = false
return
}
if (localMatch.action === 'relaunchHome') {
uni.reLaunch({ url: '/pages/index/index' })
this.aiChatSending = false
return
}
}
if (localMatch && localMatch.url) {
uni.navigateTo({ url: localMatch.url })
this.aiChatMessages = [...this.aiChatMessages, { id: 'a_' + Date.now(), role: 'ai', content: '已跳转:' + (localMatch.name || localMatch.url) }]
this.aiChatSending = false
this.$nextTick(() => {
this.scrollAiChatToBottom()
})
return
}
const bailianCfg = this.getBailianConfig()
const ollamaCfg = this.getOllamaConfig()
// DeepSeek官方API使用OpenAI兼容格式
const llmCfg = bailianCfg
? { type: 'bailian', ...bailianCfg }
: (ollamaCfg && ollamaCfg.apiKey
? { type: 'bailian', ...ollamaCfg } // DeepSeek API使用OpenAI格式
: (ollamaCfg ? { type: 'ollama', ...ollamaCfg } : null))
if (llmCfg) {
if (this.isPortraitQuery(text)) {
this.aiChatMessages = [...this.aiChatMessages, { id: 'a_' + Date.now(), role: 'ai', content: this.getAiPortraitFixedMessage() }]
this.aiChatSending = false
this.$nextTick(() => {
this.scrollAiChatToBottom()
})
return
}
if (!this.canCallAiForText(text)) {
this.aiChatMessages = [...this.aiChatMessages, { id: 'a_' + Date.now(), role: 'ai', content: this.getAiRefuseMessage() }]
this.aiChatSending = false
this.$nextTick(() => {
this.scrollAiChatToBottom()
})
return
}
// 优先使用AI识别的参数否则回退到正则匹配
let reportId = ''
let reportKeyword = ''
let profileKeyword = ''
if (aiRecognizedIntent) {
console.log('使用AI识别的参数:', aiRecognizedIntent.params)
if (aiRecognizedIntent.action === 'analyzeReport') {
reportId = aiRecognizedIntent.params.reportId || ''
reportKeyword = aiRecognizedIntent.params.keyword || ''
} else if (aiRecognizedIntent.action === 'analyzeProfile') {
profileKeyword = aiRecognizedIntent.params.keyword || aiRecognizedIntent.params.userId || ''
}
} else {
// 回退到正则匹配
console.log('回退到正则匹配')
reportId = this.parseAnalyzeReportId(text)
reportKeyword = this.parseAnalyzeReportKeyword(text)
profileKeyword = ''
}
console.log('最终使用的参数 - reportId:', reportId, 'reportKeyword:', reportKeyword, 'profileKeyword:', profileKeyword)
const baseContext = this.buildAiAnalyzeContext()
let report = null
let profile = null
const p1 = reportId
? listReport({ pageNum: 1, pageSize: 1, infoNumber: reportId }).then((res) => {
const data = res && res.data ? res.data : null
if (!data || data.code !== 200) return
const rows = data.rows || []
if (!Array.isArray(rows) || rows.length === 0) return
const rid = rows[0].reportId
if (!rid) return
return getReport(rid, rows[0].sourceType || '').then((rr) => {
const dd = rr && rr.data ? rr.data : null
if (!dd || dd.code !== 200) return
const r = dd.data || {}
report = {
reportId: rid,
reportTitle: r.reportTitle || r.reportType || '',
reportContent: r.reportContent || ''
}
}).catch(() => {})
}).catch(() => {})
: (reportKeyword
? getStudentOptions({ keyword: reportKeyword, limit: 1 }).then((res) => {
const data = res && res.data ? res.data : null
if (!data || data.code !== 200) return
const list = data.data || []
if (!Array.isArray(list) || list.length === 0) return
const userId = list[0].userId
if (!userId) return
return listReport({ pageNum: 1, pageSize: 1, userId }).then((lr) => {
const d = lr && lr.data ? lr.data : null
if (!d || d.code !== 200) return
const rows = d.rows || []
if (!Array.isArray(rows) || rows.length === 0) return
const rid = rows[0].reportId
if (!rid) return
return getReport(rid, '').then((rr) => {
const dd = rr && rr.data ? rr.data : null
if (!dd || dd.code !== 200) return
const r = dd.data || {}
report = {
reportId: rid,
reportTitle: r.reportTitle || r.reportType || '',
reportContent: r.reportContent || ''
}
}).catch(() => {})
}).catch(() => {})
}).catch(() => {})
: Promise.resolve())
const p2 = profileKeyword
? getStudentOptions({ keyword: profileKeyword, limit: 1 }).then((res) => {
const data = res && res.data ? res.data : null
if (!data || data.code !== 200) return
const list = data.data || []
if (!Array.isArray(list) || list.length === 0) return
const userId = list[0].userId
if (!userId) return
return getUserAssessmentSummary(userId).then((res2) => {
const d2 = res2 && res2.data ? res2.data : null
if (!d2 || d2.code !== 200) return
profile = { keyword: profileKeyword, userId, summary: d2.data || {} }
}).catch(() => {})
}).catch(() => {})
: Promise.resolve()
Promise.all([p1, p2]).then(() => {
// 如果既没有报告也没有画像/概览诉求,则不调用大模型
if (!reportId && !reportKeyword && !profileKeyword && !/(概览|看板|面板|仪表盘|当前面板|面板数据|当前数据|当前看板|总体|全局)/.test(String(text || ''))) {
throw new Error('__NO_CONTEXT__')
}
if ((reportId || reportKeyword) && report && !report.reportContent) {
throw new Error('__NOT_FOUND__')
}
if (profileKeyword && !profile) {
throw new Error('__NOT_FOUND__')
}
const systemPrompt = this.buildAiSystemPrompt()
const ctx = { ...baseContext, report, profile }
const userPayload =
'用户问题:' + text + '\n\n' +
'上下文数据(JSON)' + JSON.stringify(ctx)
const messages = [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPayload }
]
if (llmCfg.type === 'bailian') {
return this.callBailianChat({
model: llmCfg.model,
apiUrl: llmCfg.apiUrl,
apiKey: llmCfg.apiKey,
messages
})
}
return this.callOllamaChat({
model: llmCfg.model,
apiUrl: llmCfg.apiUrl,
messages
})
}).then((content) => {
const safe = this.sanitizeAiAnswer(content, text)
this.aiChatMessages = [...this.aiChatMessages, { id: 'a_' + Date.now(), role: 'ai', content: safe || this.getAiNotFoundMessage() }]
}).catch((e) => {
const msg = e && e.message ? e.message : ''
if (msg === '__NO_CONTEXT__') {
this.aiChatMessages = [...this.aiChatMessages, { id: 'a_' + Date.now(), role: 'ai', content: this.getAiRefuseMessage() }]
return
}
if (msg === '__NOT_FOUND__') {
this.aiChatMessages = [...this.aiChatMessages, { id: 'a_' + Date.now(), role: 'ai', content: this.getAiNotFoundMessage() }]
return
}
const safe = this.sanitizeAiAnswer(msg, text)
this.aiChatMessages = [...this.aiChatMessages, { id: 'a_' + Date.now(), role: 'ai', content: safe || '网络错误' }]
}).finally(() => {
this.aiChatSending = false
this.$nextTick(() => {
this.scrollAiChatToBottom()
})
})
return
}
if (this.isAiAnalyzeQuery(text)) {
if (!this.isAiInDomain(text)) {
this.aiChatMessages = [...this.aiChatMessages, { id: 'a_' + Date.now(), role: 'ai', content: this.getAiRefuseMessage() }]
this.aiChatSending = false
this.$nextTick(() => {
this.scrollAiChatToBottom()
})
return
}
const reportId = this.parseAnalyzeReportId(text)
const profileKeyword = this.parseAnalyzeProfileKeyword(text)
if (!reportId && !profileKeyword && !/(概览|看板|面板|仪表盘|当前面板|面板数据|当前数据|当前看板|总体|全局)/.test(String(text || ''))) {
this.aiChatMessages = [...this.aiChatMessages, { id: 'a_' + Date.now(), role: 'ai', content: this.getAiRefuseMessage() }]
this.aiChatSending = false
this.$nextTick(() => {
this.scrollAiChatToBottom()
})
return
}
const baseContext = this.buildAiAnalyzeContext()
let report = null
let profile = null
const p1 = reportId
? listReport({ pageNum: 1, pageSize: 1, infoNumber: reportId }).then((res) => {
const data = res && res.data ? res.data : null
if (!data || data.code !== 200) return
const rows = data.rows || []
if (!Array.isArray(rows) || rows.length === 0) return
const rid = rows[0].reportId
if (!rid) return
return getReport(rid, rows[0].sourceType || '').then((rr) => {
const dd = rr && rr.data ? rr.data : null
if (!dd || dd.code !== 200) return
const r = dd.data || {}
report = {
reportId: rid,
reportTitle: r.reportTitle || r.reportType || '',
reportContent: r.reportContent || ''
}
}).catch(() => {})
}).catch(() => {})
: Promise.resolve()
const p2 = profileKeyword
? getStudentOptions({ keyword: profileKeyword, limit: 1 }).then((res) => {
const data = res && res.data ? res.data : null
if (!data || data.code !== 200) return
const list = data.data || []
if (!Array.isArray(list) || list.length === 0) return
const userId = list[0].userId
if (!userId) return
return getUserAssessmentSummary(userId).then((res2) => {
const d2 = res2 && res2.data ? res2.data : null
if (!d2 || d2.code !== 200) return
profile = { keyword: profileKeyword, userId, summary: d2.data || {} }
}).catch(() => {})
}).catch(() => {})
: Promise.resolve()
Promise.all([p1, p2]).then(() => {
return request({
url: '/voice/analyze',
method: 'POST',
data: { text, context: { ...baseContext, report, profile } }
})
}).then((res) => {
const data = res && res.data ? res.data : null
if (!data || data.code !== 200) {
const msg = (data && data.msg) ? data.msg : 'AI 对话失败'
this.aiChatMessages = [...this.aiChatMessages, { id: 'a_' + Date.now(), role: 'ai', content: msg }]
return
}
const payload = data.payload || {}
const content = payload.result || payload.message || data.msg || ''
this.aiChatMessages = [...this.aiChatMessages, { id: 'a_' + Date.now(), role: 'ai', content: content || '暂无回复' }]
}).catch((e) => {
const msg = e && e.message ? e.message : '网络错误'
this.aiChatMessages = [...this.aiChatMessages, { id: 'a_' + Date.now(), role: 'ai', content: msg }]
}).finally(() => {
this.aiChatSending = false
this.$nextTick(() => {
this.scrollAiChatToBottom()
})
})
return
}
request({
url: '/voice/command',
method: 'POST',
data: { text }
}).then((res) => {
const data = res && res.data ? res.data : null
if (data && data.code === 200 && data.type === 'navigate' && data.payload && data.payload.url) {
uni.navigateTo({ url: data.payload.url })
this.aiChatMessages = [...this.aiChatMessages, { id: 'a_' + Date.now(), role: 'ai', content: '已跳转:' + data.payload.url }]
return true
}
return false
}).then((navigated) => {
if (navigated) return
this.aiChatMessages = [...this.aiChatMessages, { id: 'a_' + Date.now(), role: 'ai', content: '暂时无法回答请尝试“分析XX数据”或输入可跳转指令。' }]
}).catch((e) => {
const msg = e && e.message ? e.message : '网络错误'
this.aiChatMessages = [...this.aiChatMessages, { id: 'a_' + Date.now(), role: 'ai', content: msg }]
}).finally(() => {
this.aiChatSending = false
this.$nextTick(() => {
this.scrollAiChatToBottom()
})
})
},
getPortraitWordStyle(w, idx) {
const baseLeft = typeof w.x === 'number' ? w.x : 50
const baseTop = typeof w.y === 'number' ? w.y : 50
const size = typeof w.size === 'number' ? w.size : 28
const hue = idx % 3
const anim = (idx % 4) + 1
const delay = (idx % 7) * 0.18
const drift = 0.6 + (idx % 5) * 0.18
const fallbackColor =
hue === 0
? 'rgba(88, 230, 255, 0.86)'
: hue === 1
? 'rgba(197, 142, 255, 0.56)'
: 'rgba(120, 150, 255, 0.52)'
return {
left: baseLeft + '%',
top: baseTop + '%',
fontSize: size + 'rpx',
color: w.color || fallbackColor,
opacity: w.opacity != null ? w.opacity : 1,
transform: 'translate(-50%, -50%)',
animationName: 'portraitFloat' + anim,
animationDuration: drift.toFixed(2) + 's',
animationTimingFunction: 'ease-in-out',
animationIterationCount: 'infinite',
animationDelay: delay.toFixed(2) + 's'
}
},
fetchBigData() {
// 检查是否有token没有token不发起请求
const token = getToken()
if (!token) {
console.log('fetchBigData: 没有token跳过请求')
this.applyBigDemoData()
return
}
console.log('fetchBigData: 开始请求token存在')
if (this.bigLoading) return
this.bigLoading = true
this.bigErrorMsg = ''
Promise.all([
request({ url: '/psychology/assessment/analytics', method: 'GET' }),
request({ url: '/psychology/assessment/deptOverview?topN=7', method: 'GET' })
])
.then(([aRes, dRes]) => {
this.bigLoading = false
console.log('fetchBigData: analytics响应 code:', aRes.data?.code)
const aData = aRes && aRes.data ? aRes.data : null
const dData = dRes && dRes.data ? dRes.data : null
// 认证失败已在request.js中统一处理这里只处理其他错误
if (!aData || aData.code !== 200) {
const errorMsg = (aData && aData.msg) ? aData.msg : '概览加载失败'
console.error('fetchBigData: 请求失败code:', aData ? aData.code : 'null')
// 如果是认证失败不显示错误消息已在request.js中处理
if (aData && (aData.code === 401 || aData.code === 403)) {
this.bigErrorMsg = ''
} else {
this.bigErrorMsg = errorMsg
}
this.applyBigDemoData()
return
}
const analytics = aData.data || {}
const overview = analytics.overview || {}
this.bigRingData = this.buildRingData(overview)
this.bigLineData = this.buildTrendLineData(analytics)
this.bigLineData2 = this.buildTrendReportLineData(analytics)
const deptRows = (dData && dData.code === 200) ? (dData.data || []) : []
this.bigBarData = this.buildDeptBarData(deptRows)
this.bigDeptAssessTotal = this.buildDeptAssessTotal(deptRows, overview)
this.bigDeptStats = this.buildDeptStats(deptRows)
this.bigKpi = this.buildKpi(overview)
})
.catch((e) => {
this.bigLoading = false
console.error('fetchBigData: 请求异常', e)
this.bigErrorMsg = e && e.message ? e.message : '网络错误'
this.applyBigDemoData()
})
},
applyBigDemoData() {
const emptyOverview = {}
const emptyAnalytics = {}
this.bigRingData = this.buildRingData(emptyOverview)
this.bigLineData = this.buildTrendLineData(emptyAnalytics)
this.bigLineData2 = this.buildTrendReportLineData(emptyAnalytics)
this.bigBarData = this.buildDeptBarData([])
this.bigDeptAssessTotal = this.buildDeptAssessTotal([], emptyOverview)
this.bigDeptStats = this.buildDeptStats([])
this.bigKpi = this.buildKpi(emptyOverview)
},
buildDeptStats(rows) {
const list = Array.isArray(rows) ? rows : []
if (!list.length) {
return [
{ name: '一监区', value: 18 },
{ name: '二监区', value: 26 },
{ name: '三监区', value: 14 },
{ name: '四监区', value: 32 },
{ name: '五监区', value: 22 },
{ name: '六监区', value: 28 }
]
}
const mapped = list
.map((r) => {
const name = r.deptName || String(r.deptId || '')
const v = Number(r.totalAssessments || r.assessments || r.assessmentCount || r.testCount || r.total || r.generatedReports || 0)
return { name, value: isNaN(v) ? 0 : v }
})
.filter((it) => it.name)
return mapped.slice(0, 10)
},
buildDeptAssessTotal(rows, overview) {
const list = Array.isArray(rows) ? rows : []
if (list.length) {
const sum = list.reduce((acc, r) => {
const v = Number(
r.totalAssessments || r.assessments || r.assessmentCount || r.testCount || r.total || 0
)
return acc + (isNaN(v) ? 0 : v)
}, 0)
if (sum > 0) return sum
}
const fallback = Number(overview && (overview.totalAssessments || overview.assessmentTotal || overview.testTotal) || 0)
if (fallback > 0) return fallback
return 12872
},
formatBigNumber(n) {
const num = Number(n || 0)
if (!num) return '0'
return String(num).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
},
buildRingData(overview) {
const total = Number(overview.generatedReports || 0)
const warning = Number(overview.warningReports || 0)
const normal = Math.max(total - warning, 0)
if (total <= 0 && warning <= 0 && normal <= 0) {
return {
series: [{
data: [
{ name: '预警', value: 12 },
{ name: '正常', value: 88 }
]
}]
}
}
return {
series: [{
data: [
{ name: '预警', value: warning },
{ name: '正常', value: normal }
]
}]
}
},
buildTrendLineData(analytics) {
const list = analytics && analytics.monthlyTrend ? analytics.monthlyTrend : []
if (!list || !list.length) {
const categories = ['01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12']
const data = [32, 45, 38, 52, 48, 66, 58, 74, 60, 82, 76, 90]
return { categories, series: [{ name: '测评次数', data }] }
}
const categories = list.map((it) => it.label)
const data = list.map((it) => Number(it.value || 0))
return { categories, series: [{ name: '测评次数', data }] }
},
buildTrendReportLineData(analytics) {
const list = (analytics && analytics.monthlyReportTrend)
? analytics.monthlyReportTrend
: ((analytics && analytics.deptReportTrend) ? analytics.deptReportTrend : [])
if (!list || !list.length) {
const categories = ['01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12']
const data = [6, 10, 8, 12, 9, 14, 11, 16, 13, 18, 15, 20]
return { categories, series: [{ name: '报告数', data }] }
}
const categories = list.map((it) => it.label)
const data = list.map((it) => Number(it.value || 0))
return { categories, series: [{ name: '报告数', data }] }
},
buildDeptBarData(rows) {
const list = Array.isArray(rows) ? rows : []
if (!list.length) {
const categories = ['一监区', '二监区', '三监区', '四监区', '五监区', '六监区']
const data = [18, 26, 14, 32, 22, 28]
return { categories, series: [{ name: '报告数', data }] }
}
const categories = list.map((r) => r.deptName || String(r.deptId || ''))
const data = list.map((r) => Number(r.generatedReports || 0))
return { categories, series: [{ name: '报告数', data }] }
},
buildKpi(overview) {
const totalAssessments = Number(overview.totalAssessments || 0)
const warningReports = Number(overview.warningReports || 0)
const tasks = Number(overview.interventionTasks || 0)
const pending = Number(overview.pendingTasks || 0)
if (totalAssessments <= 0 && warningReports <= 0 && tasks <= 0 && pending <= 0) {
return {
totalAssessments: 1287,
warningReports: 36,
tasks: 19,
pending: 7
}
}
return { totalAssessments, warningReports, tasks, pending }
},
toggleVoiceTips() {
this.voiceTipsOpen = !this.voiceTipsOpen
},
fetchInboxList() {
// 检查是否有token没有token不发起请求
const token = getToken()
if (!token) {
console.log('fetchInboxList: 没有token跳过请求')
this.bigInboxList = []
return
}
if (this.bigInboxLoading) return
this.bigInboxLoading = true
getUnreadMessageList(6)
.then((res) => {
const data = res && res.data ? res.data : null
if (!data || data.code !== 200) return
const list = data.data || []
this.bigInboxList = Array.isArray(list) ? list.slice(0, 6) : []
})
.catch(() => {
this.bigInboxList = []
})
.finally(() => {
this.bigInboxLoading = false
})
},
openInboxItem(m) {
if (!m) return
const msgId = m.msgId
const title = m.title || '消息'
const linkUrl = m.linkUrl || ''
if (msgId) {
markMessageRead(msgId).catch(() => {})
}
if (linkUrl) {
openLink(linkUrl, { title })
return
}
this.showMessagePopup(m)
},
checkUnreadMessagePopup() {
getUnreadMessageList(1)
.then((res) => {
const data = res && res.data ? res.data : null
if (!data || data.code !== 200) return
const list = data.data || []
if (!Array.isArray(list) || !list.length) return
this.showMessagePopup(list[0])
})
.catch(() => {})
},
initMessageChannel() {
if (this.socketOpen || this.connecting) return
const token = getToken()
if (!token) return
const wsUrl = getMessageWsUrl(token)
if (!wsUrl) return
this.connecting = true
uni.connectSocket({ url: wsUrl })
uni.onSocketOpen(() => {
this.socketOpen = true
this.connecting = false
})
uni.onSocketClose(() => {
this.socketOpen = false
this.connecting = false
})
uni.onSocketError(() => {
this.socketOpen = false
this.connecting = false
})
uni.onSocketMessage((res) => {
this.handleSocketMessage(res)
})
},
handleSocketMessage(res) {
let data = null
try {
data = res && res.data ? JSON.parse(res.data) : null
} catch (e) {
data = null
}
if (!data || !data.type) return
if (data.type === 'notice:new') {
this.showNoticePopup(data.payload)
return
}
if (data.type === 'message:new') {
this.showMessagePopup(data.payload)
return
}
},
checkUnreadNoticePopup() {
getUnreadNoticeTop(1)
.then((res) => {
const data = res && res.data ? res.data : null
if (!data || data.code !== 200) return
const notice = data.data
if (!notice || !notice.noticeId) return
this.showNoticePopup(notice)
})
.catch(() => {})
},
showNoticePopup(notice) {
if (!notice) return
const noticeId = notice.noticeId
const title = notice.title || '通知'
const content = notice.content || ''
const linkUrl = notice.linkUrl || ''
uni.showModal({
title,
content,
confirmText: linkUrl ? '查看' : '知道了',
cancelText: '关闭',
success: (r) => {
if (noticeId) {
markNoticeRead(noticeId).catch(() => {})
}
if (r && r.confirm && linkUrl) {
openLink(linkUrl, { title })
}
}
})
},
showMessagePopup(msg) {
if (!msg) return
const msgId = msg.msgId
const title = msg.title || '消息'
const content = msg.content || ''
const linkUrl = msg.linkUrl || ''
const bizType = msg.bizType || ''
if (bizType === 'warning:urgent') {
try {
if (typeof window !== 'undefined' && window.AndroidTTS && typeof window.AndroidTTS.speak === 'function') {
window.AndroidTTS.speak(content)
}
} catch (e) {}
}
uni.showModal({
title,
content,
confirmText: linkUrl ? '查看' : '知道了',
cancelText: '关闭',
success: (r) => {
if (msgId) {
markMessageRead(msgId).catch(() => {})
}
if (r && r.confirm && linkUrl) {
openLink(linkUrl, { title })
}
}
})
},
goSentenceCrimeAnalysis() {
uni.navigateTo({
url: '/pages/chart/sentenceCrimeAnalysis'
})
},
goInterventionTasks() {
uni.navigateTo({
url: '/pages/interventionTask/index'
})
},
goChartTemplates() {
uni.navigateTo({
url: '/pages/chart/templates'
})
},
goTagFilter() {
uni.navigateTo({
url: '/pages/profile/tagFilter'
})
},
goComprehensive() {
uni.navigateTo({
url: '/pages/comprehensive/index'
})
},
goVoice() {
uni.navigateTo({
url: '/pages/voice/index'
})
},
goWarning() {
uni.navigateTo({
url: '/pages/warning/index'
})
},
goProfile() {
uni.navigateTo({
url: '/pages/profile/index'
})
},
goDashboard() {
uni.navigateTo({
url: '/pages/dashboard/index'
})
},
goNotice() {
uni.navigateTo({
url: '/pages/message/notice'
})
},
goSettings() {
uni.navigateTo({
url: '/pages/settings/index'
})
}
}
}
</script>
<style scoped>
/* 自适应字体大小变量 - 基于视口宽度 */
:root {
--font-xs: calc(22rpx + 0.3vw); /* 小字体 */
--font-sm: calc(24rpx + 0.35vw); /* 小字体 */
--font-base: calc(26rpx + 0.4vw); /* 基础字体 */
--font-md: calc(28rpx + 0.45vw); /* 中等字体 */
--font-lg: calc(32rpx + 0.6vw); /* 大字体 */
--font-xl: calc(38rpx + 0.75vw); /* 超大字体 */
--font-2xl: calc(56rpx + 1.2vw); /* 特大字体 */
--font-3xl: calc(60rpx + 1.5vw); /* 巨大字体 */
}
.page {
min-height: 100vh;
padding: 24rpx 24rpx 0;
box-sizing: border-box;
background: #F4F6FB;
--c-primary: #1677ff;
--c-danger: #E87A7A;
/* 页面级字体变量 */
--font-xs: calc(22rpx + 0.3vw);
--font-sm: calc(24rpx + 0.35vw);
--font-base: calc(26rpx + 0.4vw);
--font-md: calc(28rpx + 0.45vw);
--font-lg: calc(32rpx + 0.6vw);
--font-xl: calc(38rpx + 0.75vw);
--font-2xl: calc(56rpx + 1.2vw);
--font-3xl: calc(60rpx + 1.5vw);
}
.section {
margin-top: 14rpx;
}
.section-title {
font-size: var(--font-md);
font-weight: 700;
color: #111827;
margin: 10rpx 6rpx 14rpx;
}
.grid {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
}
.card {
width: 48%;
background: #FFFFFF;
border-radius: 16rpx;
padding: 22rpx 20rpx;
box-sizing: border-box;
margin-bottom: 18rpx;
border: 1px solid rgba(15, 23, 42, 0.06);
box-shadow: 0 10rpx 22rpx rgba(15, 23, 42, 0.05);
transition: transform 120ms ease, border-color 120ms ease;
}
.card-wide {
width: 100%;
}
.card:active {
transform: scale(0.98);
border-color: rgba(22, 119, 255, 0.35);
}
.card-core {
position: relative;
overflow: hidden;
padding: 26rpx 22rpx;
}
.card-sub {
position: relative;
overflow: hidden;
}
.card-warning {
background: #FFFFFF;
border: 1px solid rgba(232, 122, 122, 0.18);
box-shadow: 0 10rpx 22rpx rgba(15, 23, 42, 0.05);
}
.card-profile {
background: #FFFFFF;
border: 1px solid rgba(22, 119, 255, 0.18);
box-shadow: 0 10rpx 22rpx rgba(15, 23, 42, 0.05);
}
.card-accent {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 10rpx;
border-top-left-radius: 16rpx;
border-bottom-left-radius: 16rpx;
}
.card-warning .card-accent {
background: linear-gradient(180deg, rgba(232, 122, 122, 0.95) 0%, rgba(232, 122, 122, 0.55) 100%);
}
.card-profile .card-accent {
background: linear-gradient(180deg, rgba(22, 119, 255, 0.95) 0%, rgba(22, 119, 255, 0.55) 100%);
}
.card-accent-primary {
background: linear-gradient(180deg, rgba(22, 119, 255, 0.95) 0%, rgba(22, 119, 255, 0.55) 100%);
}
.card-accent-blue {
background: linear-gradient(180deg, rgba(59, 130, 246, 0.95) 0%, rgba(59, 130, 246, 0.55) 100%);
}
.card-accent-purple {
background: linear-gradient(180deg, rgba(111, 99, 217, 0.95) 0%, rgba(111, 99, 217, 0.55) 100%);
}
.card-accent-orange {
background: linear-gradient(180deg, rgba(245, 158, 11, 0.95) 0%, rgba(245, 158, 11, 0.55) 100%);
}
.card-accent-slate {
background: linear-gradient(180deg, rgba(99, 102, 241, 0.95) 0%, rgba(99, 102, 241, 0.55) 100%);
}
.card-analysis,
.card-tools,
.card-group {
background: #FFFFFF;
}
.card-icon {
width: calc(64rpx + 1.5vh);
height: calc(64rpx + 1.5vh);
border-radius: 16rpx;
margin-bottom: 16rpx;
display: flex;
align-items: center;
justify-content: center;
background: rgba(15, 23, 42, 0.06);
}
.card-icon-solid {
background: rgba(22, 119, 255, 0.92);
}
.card-warning .card-icon-solid {
background: rgba(232, 122, 122, 0.92);
}
.card-profile .card-icon-solid {
background: rgba(22, 119, 255, 0.92);
}
.card-purple .card-icon {
background: rgba(111, 99, 217, 0.12);
}
.card-blue .card-icon {
background: rgba(59, 130, 246, 0.12);
}
.card-tools .card-icon {
background: rgba(22, 119, 255, 0.10);
}
.card-orange .card-icon {
background: rgba(245, 158, 11, 0.12);
}
.card-group .card-icon {
background: rgba(99, 102, 241, 0.12);
}
.card-icon-inner {
line-height: 1;
}
.card-title {
font-size: var(--font-lg);
font-weight: 700;
color: #111827;
}
.card-desc {
margin-top: 10rpx;
font-size: var(--font-sm);
color: #6B7280;
}
.fold {
margin-top: 16rpx;
border-radius: 14rpx;
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(15, 23, 42, 0.06);
overflow: hidden;
}
.fold-header {
padding: 14rpx 14rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.fold-title {
font-size: var(--font-sm);
font-weight: 700;
color: #334155;
}
.fold-body {
padding: 0 14rpx 14rpx;
}
.fold-item {
font-size: var(--font-sm);
color: #475569;
line-height: 40rpx;
}
.mini-actions {
margin-top: 16rpx;
display: flex;
justify-content: flex-start;
flex-wrap: nowrap;
gap: 12rpx;
}
.mini {
flex: 1;
min-width: 0;
padding: 12rpx 10rpx;
border-radius: 14rpx;
background: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(15, 23, 42, 0.06);
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
}
.mini:active {
transform: scale(0.98);
}
.mini-text {
font-size: var(--font-xs);
color: #334155;
}
.card.disabled {
opacity: 0.6;
}
.page.big {
min-height: 100vh;
padding: 12rpx 12rpx 20rpx;
box-sizing: border-box;
position: relative;
overflow-x: hidden;
overflow-y: auto;
--neon-a: rgba(0, 240, 255, 0.95);
--neon-b: rgba(60, 140, 255, 0.95);
--neon-soft: rgba(0, 240, 255, 0.18);
--glass: rgba(7, 13, 28, 0.42);
--glass-2: rgba(10, 18, 38, 0.52);
background: #020610;
color: rgba(242, 252, 255, 0.96);
font-size: var(--font-sm);
line-height: 1.5;
text-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.55);
}
.big-center-media {
width: 100%;
height: calc(74vh - 360rpx);
max-height: calc(720rpx + 7vh);
border-radius: 18rpx;
overflow: hidden;
}
.big-center-video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.big-center-bg {
width: 100%;
height: 100%;
border-radius: 18rpx;
overflow: hidden;
pointer-events: none;
background-image: url('/static/bg.png');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
opacity: 0.96;
filter: saturate(1.08) contrast(1.08);
}
.big-center-text {
border-radius: 18rpx;
border: 1px solid rgba(0, 240, 255, 0.14);
background: linear-gradient(180deg, rgba(7, 13, 28, 0.62) 0%, rgba(7, 13, 28, 0.46) 100%);
box-shadow: 0 12rpx 24rpx rgba(0, 0, 0, 0.35);
backdrop-filter: blur(6px);
padding: 16rpx 18rpx;
}
.big-center-text-title {
font-size: var(--font-base);
font-weight: 900;
color: rgba(220, 250, 255, 0.94);
letter-spacing: 1rpx;
text-shadow: 0 0 16rpx rgba(0, 240, 255, 0.16);
}
.big-center-text-desc {
margin-top: 10rpx;
font-size: var(--font-xs);
line-height: 32rpx;
color: rgba(201, 242, 255, 0.72);
}
.big-panel-inbox {
margin-top: 16rpx;
min-height: calc(320rpx + 2vh);
}
.big-panel-portrait .big-chart {
height: calc(420rpx + 3vh);
}
.big-inbox-list {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.big-inbox-item {
border-radius: 14rpx;
padding: 14rpx 14rpx;
border: 1px solid rgba(116, 216, 255, 0.18);
background: rgba(7, 13, 28, 0.78);
box-sizing: border-box;
}
.big-inbox-title {
font-size: var(--font-sm);
font-weight: 900;
color: rgba(242, 252, 255, 0.92);
}
.big-inbox-desc {
margin-top: 8rpx;
font-size: var(--font-xs);
color: rgba(242, 252, 255, 0.84);
line-height: 28rpx;
max-height: calc(56rpx + 0.5vh);
overflow: hidden;
}
.page.big:before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
background-image:
linear-gradient(0deg, rgba(0, 240, 255, 0.12) 1px, rgba(0, 0, 0, 0) 1px),
linear-gradient(90deg, rgba(60, 140, 255, 0.10) 1px, rgba(0, 0, 0, 0) 1px),
linear-gradient(135deg, rgba(0, 240, 255, 0.10) 0%, rgba(165, 90, 255, 0.10) 100%),
radial-gradient(circle at 20% 18%, rgba(0, 240, 255, 0.18) 0%, rgba(0, 240, 255, 0) 46%),
radial-gradient(circle at 82% 70%, rgba(165, 90, 255, 0.14) 0%, rgba(165, 90, 255, 0) 52%);
background-size:
34rpx 2rpx,
2rpx 34rpx,
cover,
cover,
cover;
background-position:
14rpx 12rpx,
12rpx 14rpx,
calc(100% - 14rpx) 12rpx,
calc(100% - 12rpx) 14rpx,
14rpx calc(100% - 12rpx),
12rpx calc(100% - 14rpx),
calc(100% - 14rpx) calc(100% - 12rpx),
calc(100% - 12rpx) calc(100% - 14rpx);
background-repeat: no-repeat;
opacity: 0.65;
}
.page.big:after {
content: '';
position: absolute;
left: 14rpx;
right: 14rpx;
bottom: 0;
height: 1px;
background: linear-gradient(90deg, rgba(0, 240, 255, 0) 0%, rgba(0, 240, 255, 0.45) 50%, rgba(0, 240, 255, 0) 100%);
opacity: 0.75;
}
.big-top {
height: auto;
display: flex;
align-items: center;
justify-content: center;
position: relative;
padding: 12rpx 0;
}
.big-title {
position: relative;
z-index: 3;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: var(--font-3xl);
font-weight: 900;
letter-spacing: 2rpx;
color: rgba(242, 252, 255, 0.98);
min-height: calc(120rpx + 1.2vh);
width: 100%;
padding: 0;
box-sizing: border-box;
text-shadow:
0 2rpx 12rpx rgba(0, 0, 0, 0.65),
0 0 18rpx rgba(0, 240, 255, 0.26),
0 0 32rpx rgba(60, 140, 255, 0.18);
}
.big-title:before {
content: '';
position: absolute;
inset: 0;
background-image: url('/static/title.png');
background-size: 100% 100%;
background-repeat: no-repeat;
background-position: center;
pointer-events: none;
z-index: 2;
}
.big-title-text {
position: relative;
z-index: 3;
padding: 0 40rpx;
box-sizing: border-box;
font-size: calc(48rpx + 0.9vw);
}
.big-title {
min-height: calc(120rpx + 1.2vh);
width: 100%;
padding: 0;
}
.big-title i {
display: none;
}
.big-top-actions {
position: relative;
z-index: 1;
margin-top: 12rpx;
border-radius: 14rpx;
border: 1px solid rgba(0, 240, 255, 0.18);
background: linear-gradient(180deg, rgba(2, 10, 26, 0.88) 0%, rgba(2, 10, 26, 0.70) 100%);
box-shadow: 0 10rpx 22rpx rgba(0, 0, 0, 0.40);
backdrop-filter: blur(10px);
overflow: hidden;
padding: 10rpx 10rpx;
}
.big-top-action-row {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 12rpx;
align-items: center;
}
.big-top-action-item {
height: calc(140rpx + 1.5vh);
display: flex;
align-items: center;
justify-content: center;
border-radius: 14rpx;
border: 1px solid rgba(116, 216, 255, 0.16);
background: rgba(7, 13, 28, 0.38);
box-sizing: border-box;
}
.big-top-action-ico {
width: calc(124rpx + 1.2vh);
height: calc(124rpx + 1.2vh);
filter: drop-shadow(0 0 14rpx rgba(0, 240, 255, 0.26)) drop-shadow(0 0 26rpx rgba(60, 140, 255, 0.18));
}
.big-grid {
margin-top: 14rpx;
display: flex;
gap: 12rpx;
width: 100%;
max-width: none;
margin-left: 0;
margin-right: 0;
justify-content: space-between;
}
.big-col {
width: 27%;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.big-center {
flex: 0 0 46%;
width: 46%;
min-width: 0;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.big-panel {
position: relative;
border-radius: 18rpx;
padding: 18rpx 18rpx 16rpx;
min-height: calc(320rpx + 2vh);
border: 1px solid rgba(0, 240, 255, 0.16);
background-image:
linear-gradient(135deg, rgba(0, 240, 255, 0.08) 0%, rgba(60, 140, 255, 0.05) 35%, rgba(165, 90, 255, 0.06) 100%),
linear-gradient(180deg, rgba(7, 13, 28, 0.72) 0%, rgba(7, 13, 28, 0.56) 100%);
background-repeat: no-repeat;
background-size: cover;
backdrop-filter: blur(6px);
box-shadow: 0 14rpx 26rpx rgba(0, 0, 0, 0.42);
overflow: hidden;
}
.big-panel-core {
min-height: calc(460rpx + 3vh);
}
.big-panel-core .big-ring {
height: calc(440rpx + 3vh);
}
.big-panel-core .big-ring:before {
width: calc(440rpx + 3vh);
height: calc(440rpx + 3vh);
}
.big-panel-core .big-ring-center {
width: calc(250rpx + 2vh);
height: calc(250rpx + 2vh);
}
.big-metrics {
position: relative;
z-index: 3;
display: flex;
align-items: flex-end;
justify-content: center;
padding: 8rpx 0 16rpx;
margin-bottom: 8rpx;
}
.big-metric {
text-align: center;
min-width: 340rpx;
}
.big-metric-label {
font-size: var(--font-sm);
font-weight: 700;
color: rgba(220, 250, 255, 0.78);
letter-spacing: 1rpx;
margin-bottom: 6rpx;
}
.big-metric-value {
display: block;
font-size: var(--font-2xl);
font-weight: 900;
line-height: 1;
color: rgba(242, 252, 255, 0.96);
text-shadow:
0 10rpx 28rpx rgba(0, 0, 0, 0.55),
0 0 18rpx rgba(0, 240, 255, 0.22);
}
.big-panel-core .big-ring {
margin-top: 4rpx;
}
.big-panel:before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
background-image:
linear-gradient(90deg, rgba(0,240,255,0.65), rgba(0,240,255,0)),
linear-gradient(180deg, rgba(0,240,255,0.65), rgba(0,240,255,0)),
linear-gradient(270deg, rgba(0,240,255,0.65), rgba(0,240,255,0)),
linear-gradient(180deg, rgba(0,240,255,0.65), rgba(0,240,255,0)),
linear-gradient(90deg, rgba(60,140,255,0.35), rgba(60,140,255,0)),
linear-gradient(0deg, rgba(60,140,255,0.35), rgba(60,140,255,0)),
linear-gradient(270deg, rgba(60,140,255,0.35), rgba(60,140,255,0)),
linear-gradient(0deg, rgba(60,140,255,0.35), rgba(60,140,255,0));
background-size:
34rpx 2rpx,
2rpx 34rpx,
34rpx 2rpx,
2rpx 34rpx,
34rpx 2rpx,
2rpx 34rpx,
34rpx 2rpx,
2rpx 34rpx;
background-position:
14rpx 12rpx,
12rpx 14rpx,
calc(100% - 14rpx) 12rpx,
calc(100% - 12rpx) 14rpx,
14rpx calc(100% - 12rpx),
12rpx calc(100% - 14rpx),
calc(100% - 14rpx) calc(100% - 12rpx),
calc(100% - 12rpx) calc(100% - 14rpx);
background-repeat: no-repeat;
opacity: 0.65;
}
.big-panel:after {
content: '';
position: absolute;
left: 14rpx;
right: 14rpx;
bottom: 0;
height: 1px;
background: linear-gradient(90deg, rgba(0,240,255,0) 0%, rgba(0,240,255,0.45) 50%, rgba(0,240,255,0) 100%);
opacity: 0.75;
}
.big-panel-head {
position: relative;
z-index: 1;
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 14rpx;
}
.big-panel-head:after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: -10rpx;
height: 1px;
background: linear-gradient(90deg, rgba(0,240,255,0) 0%, rgba(0,240,255,0.22) 18%, rgba(0,240,255,0.55) 50%, rgba(0,240,255,0.22) 82%, rgba(0,240,255,0) 100%);
opacity: 0.95;
}
.big-panel-title {
font-size: var(--font-lg);
font-weight: 900;
color: rgba(242, 252, 255, 0.94);
padding-left: 14rpx;
letter-spacing: 1rpx;
}
.big-panel-report .big-panel-head {
margin-bottom: 18rpx;
padding: 8rpx 14rpx 12rpx;
border-radius: 14rpx;
background: linear-gradient(90deg, rgba(0, 240, 255, 0.12) 0%, rgba(2, 8, 22, 0.18) 45%, rgba(165, 90, 255, 0.10) 100%);
border: 1px solid rgba(0, 240, 255, 0.18);
box-shadow: 0 0 18rpx rgba(0, 240, 255, 0.10);
overflow: hidden;
}
.big-panel-report .big-panel-head:after {
bottom: 0;
height: 2rpx;
background: linear-gradient(90deg, rgba(0,240,255,0) 0%, rgba(0,240,255,0.65) 20%, rgba(165,90,255,0.55) 60%, rgba(0,240,255,0) 100%);
opacity: 1;
}
.big-panel-report .big-panel-head:before {
content: '';
position: absolute;
right: -40rpx;
top: -50rpx;
width: 160rpx;
height: 160rpx;
background: radial-gradient(circle at 30% 30%, rgba(0, 240, 255, 0.22) 0%, rgba(0, 240, 255, 0) 62%);
transform: rotate(12deg);
pointer-events: none;
opacity: 0.9;
}
.big-panel-report .big-panel-title {
padding-left: 20rpx;
text-shadow:
0 0 16rpx rgba(0, 240, 255, 0.22),
0 2rpx 10rpx rgba(0, 0, 0, 0.55);
}
.big-panel-report .big-panel-title:before {
left: 10rpx;
top: 50%;
transform: translateY(-50%);
height: calc(22rpx + 0.8vh);
background: linear-gradient(180deg, rgba(0, 240, 255, 0.95) 0%, rgba(39, 120, 255, 0.85) 55%, rgba(165, 90, 255, 0.85) 100%);
box-shadow: 0 0 14rpx rgba(0, 240, 255, 0.18);
}
.big-panel-portrait {
border-color: transparent;
box-shadow: none;
}
.big-panel-portrait:before,
.big-panel-portrait:after {
opacity: 0;
}
.big-panel-portrait .big-panel-head:after {
opacity: 0;
}
.big-panel-portrait .big-panel-title:before {
opacity: 0;
}
.big-panel-title:before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 6rpx;
height: calc(24rpx + 0.8vh);
border-radius: 6rpx;
background: linear-gradient(180deg, rgba(116, 216, 255, 0.95) 0%, rgba(43, 107, 255, 0.85) 100%);
}
.big-panel-sub {
font-size: var(--font-sm);
color: rgba(242, 252, 255, 0.84);
font-weight: 800;
}
.big-ai-head-actions {
display: flex;
align-items: center;
gap: 14rpx;
}
.big-panel-ai.is-collapsed {
padding-bottom: 10rpx;
}
.big-panel-ai.is-open {
padding-bottom: 22rpx;
}
.big-panel-clear {
padding: 6rpx 12rpx;
border-radius: 12rpx;
font-size: var(--font-xs);
font-weight: 900;
color: rgba(242, 252, 255, 0.86);
border: 1px solid rgba(116, 216, 255, 0.20);
background: rgba(7, 13, 28, 0.45);
box-sizing: border-box;
}
.big-panel-clear:active {
opacity: 0.85;
}
.big-panel-ai {
margin-top: 14rpx;
}
.big-panel-head-ai {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12rpx;
}
.big-ai-body {
position: relative;
z-index: 1;
}
.big-ai-messages {
height: calc(640rpx + 4vh);
padding: 8rpx 10rpx;
box-sizing: border-box;
border-radius: 14rpx;
border: 1px solid rgba(116, 216, 255, 0.14);
background: rgba(7, 13, 28, 0.55);
}
.big-ai-empty {
padding: 16rpx 10rpx;
font-size: var(--font-xs);
color: rgba(242, 252, 255, 0.62);
font-weight: 700;
line-height: 1.4;
}
.big-ai-msg {
display: flex;
margin: 10rpx 0;
}
.big-ai-msg.is-user {
justify-content: flex-end;
}
.big-ai-msg.is-ai {
justify-content: flex-start;
}
.big-ai-bubble {
max-width: 78%;
padding: 12rpx 14rpx;
border-radius: 14rpx;
font-size: var(--font-sm);
line-height: 1.45;
color: rgba(242, 252, 255, 0.92);
border: 1px solid rgba(0, 240, 255, 0.16);
background: linear-gradient(180deg, rgba(0, 188, 255, 0.10) 0%, rgba(2, 8, 22, 0.82) 70%, rgba(2, 8, 22, 0.92) 100%);
box-shadow: 0 0 16rpx rgba(0, 166, 255, 0.08);
word-break: break-all;
}
.big-ai-msg.is-user .big-ai-bubble {
border-color: rgba(43, 107, 255, 0.22);
background: linear-gradient(180deg, rgba(43, 107, 255, 0.16) 0%, rgba(2, 8, 22, 0.82) 70%, rgba(2, 8, 22, 0.92) 100%);
}
.big-ai-bottom {
height: 1px;
}
.big-ai-input {
margin-top: 12rpx;
display: flex;
align-items: center;
gap: 10rpx;
}
.big-ai-voice-btn {
width: calc(72rpx + 2vh);
height: calc(72rpx + 2vh);
border-radius: 14rpx;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(116, 216, 255, 0.18);
background: rgba(7, 13, 28, 0.78);
transition: all 0.3s ease;
}
.big-ai-voice-btn.active {
border-color: rgba(0, 240, 255, 0.6);
background: rgba(0, 240, 255, 0.12);
box-shadow: 0 0 20rpx rgba(0, 240, 255, 0.4);
}
.big-ai-text {
flex: 1;
height: calc(72rpx + 2vh);
border-radius: 14rpx;
border: 1px solid rgba(116, 216, 255, 0.18);
background: rgba(7, 13, 28, 0.78);
padding: 0 14rpx;
font-size: var(--font-sm);
color: rgba(242, 252, 255, 0.92);
box-sizing: border-box;
}
.big-ai-send {
width: 120rpx;
height: calc(72rpx + 2vh);
border-radius: 14rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-sm);
font-weight: 900;
color: rgba(242, 252, 255, 0.92);
border: 1px solid rgba(0, 240, 255, 0.22);
background: linear-gradient(180deg, rgba(0, 240, 255, 0.16) 0%, rgba(2, 8, 22, 0.92) 100%);
box-sizing: border-box;
}
.big-ai-send.disabled {
opacity: 0.5;
}
.big-ring {
position: relative;
z-index: 1;
height: calc(360rpx + 2.5vh);
}
.big-ring:before {
content: '';
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: calc(360rpx + 2.5vh);
height: calc(360rpx + 2.5vh);
border-radius: 999rpx;
pointer-events: none;
z-index: 0;
background-image: url('/static/hud-ring.png');
background-repeat: no-repeat;
background-position: center;
background-size: contain;
opacity: 0.38;
filter: drop-shadow(0 0 18rpx rgba(0, 240, 255, 0.22));
animation: bigRingSpin 22s linear infinite;
}
@keyframes bigRingSpin {
0% { transform: translate(-50%, -50%) rotate(0deg); }
100% { transform: translate(-50%, -50%) rotate(360deg); }
}
.big-ring-center {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: calc(210rpx + 5vh);
height: calc(210rpx + 5vh);
border-radius: 999rpx;
background: rgba(7, 13, 28, 0.72);
z-index: 5;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6rpx;
box-sizing: border-box;
}
.big-ring-center-main {
font-size: var(--font-lg);
font-weight: 900;
color: rgba(242, 252, 255, 0.96);
}
.big-ring-center-sub {
font-size: var(--font-xs);
color: rgba(242, 252, 255, 0.84);
text-align: center;
line-height: 26rpx;
padding: 0 12rpx;
}
.big-chart {
position: relative;
z-index: 1;
height: calc(340rpx + 2.5vh);
}
.big-portrait-cloud {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
.big-portrait-word {
position: absolute;
left: 50%;
top: 50%;
font-weight: 800;
letter-spacing: 1rpx;
white-space: nowrap;
text-shadow:
0 2rpx 10rpx rgba(0, 0, 0, 0.55),
0 0 14rpx rgba(0, 240, 255, 0.08);
filter: saturate(1.08);
will-change: transform;
}
@keyframes portraitFloat1 {
0% { transform: translate(-50%, -50%) translate(0, 0); }
50% { transform: translate(-50%, -50%) translate(0, -10rpx); }
100% { transform: translate(-50%, -50%) translate(0, 0); }
}
@keyframes portraitFloat2 {
0% { transform: translate(-50%, -50%) translate(0, 0); }
50% { transform: translate(-50%, -50%) translate(10rpx, 0); }
100% { transform: translate(-50%, -50%) translate(0, 0); }
}
@keyframes portraitFloat3 {
0% { transform: translate(-50%, -50%) translate(0, 0); }
50% { transform: translate(-50%, -50%) translate(-8rpx, 8rpx); }
100% { transform: translate(-50%, -50%) translate(0, 0); }
}
@keyframes portraitFloat4 {
0% { transform: translate(-50%, -50%) translate(0, 0); }
50% { transform: translate(-50%, -50%) translate(8rpx, -6rpx); }
100% { transform: translate(-50%, -50%) translate(0, 0); }
}
.big-chart-xl {
height: calc(400rpx + 3vh);
}
.big-kpis {
position: relative;
z-index: 1;
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.big-kpi {
width: calc(50% - 6rpx);
border-radius: 14rpx;
padding: 14rpx 12rpx;
border: 1px solid rgba(116, 216, 255, 0.18);
background: rgba(7, 13, 28, 0.78);
box-sizing: border-box;
}
.big-kpi-num-row {
display: flex;
align-items: center;
gap: 10rpx;
}
.big-kpi-ico {
width: calc(34rpx + 1vh);
height: calc(34rpx + 1vh);
filter: drop-shadow(0 0 10rpx rgba(0, 200, 255, 0.22));
}
.big-kpi-num {
font-size: var(--font-xl);
font-weight: 900;
color: #74d8ff;
text-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.65);
}
.big-kpi-lab {
margin-top: 6rpx;
font-size: var(--font-sm);
color: rgba(242, 252, 255, 0.84);
font-weight: 800;
}
.big-tools {
position: relative;
z-index: 1;
display: flex;
gap: 12rpx;
margin-bottom: 10rpx;
}
.big-action-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 18rpx;
padding: 10rpx 2rpx 2rpx;
}
.big-panel-actions {
min-height: calc(380rpx + 3vh);
}
.big-action-item {
position: relative;
z-index: 1;
height: calc(150rpx + 1.5vh);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10rpx;
padding: 14rpx 10rpx;
border-radius: 16rpx;
border: 1px solid rgba(0, 188, 255, 0.22);
background: linear-gradient(180deg, rgba(0, 188, 255, 0.10) 0%, rgba(2, 8, 22, 0.82) 70%, rgba(2, 8, 22, 0.92) 100%);
box-shadow: 0 0 18rpx rgba(0, 166, 255, 0.10);
box-sizing: border-box;
overflow: hidden;
}
.big-action-item:active { transform: scale(0.98); }
.big-action-ico {
width: calc(68rpx + 2vh);
height: calc(68rpx + 2vh);
flex: 0 0 auto;
filter: drop-shadow(0 0 10rpx rgba(0, 200, 255, 0.22));
}
.big-action-text {
font-size: var(--font-base);
font-weight: 900;
color: rgba(242, 252, 255, 0.92);
line-height: 1.1;
text-align: center;
}
.big-action-item.c-blue { border-color: rgba(0, 188, 255, 0.40); box-shadow: 0 0 20rpx rgba(0, 188, 255, 0.14); }
.big-action-item.c-blue2 { border-color: rgba(39, 120, 255, 0.40); box-shadow: 0 0 20rpx rgba(39, 120, 255, 0.14); }
.big-action-item.c-red { border-color: rgba(255, 72, 92, 0.40); box-shadow: 0 0 20rpx rgba(255, 72, 92, 0.12); }
.big-action-item.c-cyan { border-color: rgba(0, 240, 255, 0.40); box-shadow: 0 0 20rpx rgba(0, 240, 255, 0.12); }
.big-action-item.c-yellow { border-color: rgba(255, 196, 45, 0.40); box-shadow: 0 0 20rpx rgba(255, 196, 45, 0.12); }
.big-action-item.c-purple { border-color: rgba(165, 90, 255, 0.40); box-shadow: 0 0 20rpx rgba(165, 90, 255, 0.12); }
.big-tool {
flex: 1;
border-radius: 14rpx;
border: 1px solid rgba(116, 216, 255, 0.18);
background: rgba(7, 13, 28, 0.78);
padding: 12rpx 12rpx;
display: flex;
align-items: center;
gap: 10rpx;
}
.big-tool-icon {
width: calc(50rpx + 1.5vh);
height: calc(50rpx + 1.5vh);
border-radius: 12rpx;
border: 1px solid rgba(116, 216, 255, 0.22);
background: rgba(10, 18, 38, 0.55);
display: flex;
align-items: center;
justify-content: center;
}
.big-tool-text {
font-size: var(--font-sm);
font-weight: 900;
color: rgba(242, 252, 255, 0.92);
}
.big-nav {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.big-nav-item {
width: calc(33.33% - 8rpx);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 10rpx 0;
}
.big-hex {
width: calc(96rpx + 3vh);
height: calc(86rpx + 2.7vh);
clip-path: polygon(25% 6.7%, 75% 6.7%, 100% 50%, 75% 93.3%, 25% 93.3%, 0% 50%);
border: 1px solid rgba(116, 216, 255, 0.28);
background: radial-gradient(circle at 50% 30%, rgba(116, 216, 255, 0.22) 0%, rgba(10, 18, 38, 0.55) 70%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 18rpx rgba(116, 216, 255, 0.18);
}
.big-nav-text {
font-size: var(--font-xs);
font-weight: 900;
color: rgba(242, 252, 255, 0.86);
}
.hex-img { display: none; }
@media (max-width: 980px) {
.big-grid {
flex-direction: column;
}
.big-col {
width: 100%;
}
}
/* 登录弹窗显示时禁用页面交互 */
.page-disabled {
pointer-events: none;
user-select: none;
}
</style>